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:
| Origin | Serves |
|---|---|
FUNCTION_SCHEME://FUNCTION_DOMAIN | the 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_DOMAIN | only 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:
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
Hostheader 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
/wsand/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:
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.
| Variable | Default | Description |
|---|---|---|
EDGE_TRUSTED_PROXIES | empty: trust none | Addresses 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_PROXY | 1 | Proxy 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-Forand skip the rate limits. Never trustprivate_rangeswhileEDGE_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.
Shared page links
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.