Mirra
Function Client (self-host)

Reverse proxy

The compose stack already has one. The edge container (Caddy, configured by apps/web/Caddyfile) is what people reach. It serves two origins:

OriginServes
FUNCTION_SCHEME://FUNCTION_DOMAINthe web client; the API (/api/*, /health, /mcp) and its WebSockets (/ws, /socket/ws) from the server; the media bucket (/mirra-media/*) from object storage
FUNCTION_SCHEME://pages.FUNCTION_DOMAINonly what a rendered page needs, so a page never runs on an origin that holds a session

With FUNCTION_SCHEME=https it obtains and renews certificates for both itself. Most servers need nothing more.

Your own proxy in front of the edge

For example, when a load balancer or an existing nginx terminates TLS. Let the edge speak plain http on a local port, and give the server its public addresses yourself, since compose would otherwise derive them from the edge's scheme and port. In .env.selfhost:

FUNCTION_DOMAIN=client.example.com
FUNCTION_SCHEME=http
EDGE_BIND=127.0.0.1
EDGE_HTTP_PORT=8080
EDGE_HTTPS_PORT=8443
EDGE_TRUSTED_PROXIES=private_ranges
TRUST_PROXY=2
PUBLIC_API_URL=https://client.example.com
PAGES_RENDER_ORIGIN=https://pages.client.example.com
S3_PUBLIC_BASE_URL=https://client.example.com/mirra-media
S3_PRESIGN_ENDPOINT=https://client.example.com

Your proxy then sends both hosts to 127.0.0.1:8080. (EDGE_HTTPS_PORT moves the edge's unused https port off 443, which your proxy holds.) It must:

  • pass the Host header through unchanged. The edge picks the origin by it, and a browser's presigned upload is signed over it;
  • add the client's address to X-Forwarded-For, so each client keeps its own rate limit (below);
  • pass WebSocket upgrades on /ws and /socket/ws, and allow them to stay open for hours;
  • accept large request bodies: uploads go through it;
  • leave HTTP/3 off (below).

An nginx example:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
 
server {
    listen 443 ssl;
    http2 on;
    server_name client.example.com pages.client.example.com;
    # ssl_certificate and ssl_certificate_key for both names
 
    client_max_body_size 200m;
 
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 1h;
    }
}

Client addresses

The server limits sign-in and registration attempts per client address. The edge replaces the X-Forwarded-For header of every request with the address that connected to it, unless that address is in EDGE_TRUSTED_PROXIES. From a trusted address it keeps the header and adds the proxy's address after it. TRUST_PROXY tells the server how many proxies stand in front of it: 1, the edge, by default; 2 with yours in front of the edge.

VariableDefaultDescription
EDGE_TRUSTED_PROXIESempty: trust noneAddresses or CIDR ranges the edge takes X-Forwarded-For from, separated by spaces. private_ranges is Caddy's name for the private and loopback ranges.
TRUST_PROXY1Proxy hops in front of the server. The boot log reads mirra.trust_proxy=<hops>.

A proxy on the same machine reaches the edge's published port through Docker, so the edge sees it connect from the compose network's gateway, a private address. With EDGE_BIND=127.0.0.1 nothing else reaches that port, and private_ranges covers the gateway whatever address Docker gives the network. For a proxy on another machine, list its address instead, and let nothing but that proxy reach the edge's port.

[!WARNING] Anyone who reaches the edge from a trusted address can name any client address in X-Forwarded-For and skip the rate limits. Never trust private_ranges while EDGE_BIND=0.0.0.0.

Without these two settings, the server sees every request as coming from your proxy, and all clients share one set of rate limits: 20 wrong passwords lock your whole team out of signing in for 15 minutes, and the whole server gets 5 registration attempts an hour, failed ones included.

HTTP/3 stays off

Safari uses WebKit, whose WebSockets fail over HTTP/3: the client connects, then drops the socket. The edge serves HTTP/1.1 and HTTP/2 only. A proxy or CDN in front of it must not offer HTTP/3 either: in Caddy, servers { protocols h1 h2 }; in nginx, no quic listener; on Cloudflare, turn HTTP/3 off for the zone.

The client on another host

To serve the web client from a CDN while the API stays here, see Getting the client to your team. The server must then list both origins in CORS_ORIGINS.

Inside the app, pages render on pages.FUNCTION_DOMAIN, and they work. A page's shareable link is different: it is built as https://<name>.<PUBLIC_PAGES_HOST>/<path>, a wildcard host that serves published pages to people outside the app. This repository doesn't include a server for those hosts, so shared page and meeting links don't open. While PUBLIC_PAGES_HOST is unset they are built on localhost (https://<name>.localhost/<path>), and the boot log warns about it. Setting it only changes where they point.

On this page