Skip to content

Reverse proxy

Lumastra listens on port 3000 and speaks plain HTTP. A reverse proxy in front of it terminates TLS, so your browser and apps connect over HTTPS. This page lists what the proxy has to do and gives a working setup for the common ones.

  • The Apple apps require it. The iPhone, iPad and Apple TV apps only connect to a server on HTTPS with a certificate the device trusts. Plain HTTP and self-signed certificates don’t work.
  • Your password and session travel encrypted. On HTTPS the session cookie is marked Secure, so browsers never send it over plain HTTP.

Set ORIGIN in your .env to the public address:

ORIGIN=https://lumastra.example.com

Use the exact public address: scheme, host and port (leave the port out if it is 443). Restart the stack after changing it.

ORIGIN is required behind a reverse proxy and on every HTTPS install. Lumastra uses it for every address it builds and for its same-origin check on sign-in and other form posts, and an https address in ORIGIN marks the session cookie Secure on every request. Without it, Lumastra guesses the protocol from the request. A sign-in from an app that sends no Origin header, through an inner proxy that reports http (for example nginx behind Cloudflare), can then get a session cookie without Secure on an HTTPS site.

The host and port must match the address in your browser. A wrong host, or a leftover :3000, makes sign-in fail with 403. Because the cookie is Secure, sign-in only works over HTTPS at that address. See Authentication for the details.

Every proxy below is set up for this list. If you use another one, check each line.

  • Forward to Lumastra on port 3000 over plain http://.

  • Give Lumastra its own domain or subdomain, such as media.example.com. Serving it under a subpath such as example.com/lumastra/ is not supported yet.

  • Forward every path on that domain or subdomain to Lumastra. Besides the web app, the apps and clients use these paths, and none of them may be blocked or sent to a login page:

    • /api/v1/ for the public API, the iPhone, iPad and Apple TV apps, personal access tokens, and casting to AirPlay and Chromecast
    • /oauth/ for signing in on a TV or app with a code (device sign-in)
    • /j and /j/ for Jellyfin clients, which use https://<your domain>/j as the server address
    • /subsonic/ for Subsonic music apps, which use https://<your domain>/subsonic
    • /api/plugin-webhooks/ for webhooks that other services send to plugins
    • /api/health for uptime monitors, and /metrics if you scrape it with Prometheus
    • /manifest.json and the app icons, which browsers fetch without the session cookie when you install the web app

    Each of these checks its own credentials (tokens, app sign-in or the client’s password). The setups below forward everything, so they all work without extra lines. If you put a sign-in layer such as Authentik in front, these paths must skip it; see Authentik.

  • Pass the Host header through, and set X-Forwarded-For and X-Forwarded-Proto.

  • Remove the sign-in headers X-Forwarded-User, X-Forwarded-Groups and X-Authentik-Name from every request it forwards. Lumastra reads them for forward auth. See Sign-in headers below for why this matters.

  • Don’t buffer streaming responses. Live updates (notifications, watch-state sync, Watch Together) use Server-Sent Events, and video is served as HLS segments and byte ranges. Enable WebSocket upgrades for Jellyfin-compatible clients at /j/socket.

  • Allow long timeouts in both directions. A stream or an event connection can stay open for hours, a transcode can take a while to produce its first segment, and a paused player can stop reading for minutes. Lumastra sends a keepalive comment about every 10 seconds on event streams.

  • Accept request bodies up to the biggest upload you want, at least the app’s BODY_SIZE_LIMIT (50 MB in the example compose file). The app’s own limits are 10 MB for artwork, 2 MiB for avatars and 3 MiB for diagnostics bundles from the iOS and tvOS apps. nginx defaults to 1 MB, which is too small. The full table is in Request body size.

  • Don’t compress media or streams. If you enable compression, pick content types explicitly. See Compression.

  • Keep playback grants and API keys out of the access log. Some apps send them in the query string. See Access logs: keep credentials out.

With forward auth, your proxy signs the user in and tells Lumastra who it is in the headers X-Forwarded-User, X-Forwarded-Groups and X-Authentik-Name (the defaults of USERHEADER, GROUPHEADER and NAMEHEADER). Lumastra reads these headers only when forward auth is turned on (FORWARD_AUTH_ENABLED=true, off by default), and then believes them from every address in TRUSTED_PROXY_IPS.

A proxy passes headers it doesn’t know straight through. If it isn’t set up to remove them, forward auth is on and its address is in TRUSTED_PROXY_IPS, anyone can send X-Forwarded-User: admin and is signed in as that user. With forward auth off, Lumastra ignores the headers and logs that it did; remove them anyway, so that turning forward auth on later is safe. Every setup below removes the three headers. Keep those lines when you adapt a setup, and add them if you use another proxy. If you changed USERHEADER, GROUPHEADER or NAMEHEADER, remove those header names instead.

With forward auth, the forward-auth step sets these headers after the proxy has removed the client’s copies. Follow Authentication and Setting up Authentik for that part.

TRUSTED_PROXY_IPS lists the addresses of proxies that Lumastra believes. It does two things:

  • With FORWARD_AUTH_ENABLED=true, it lets those proxies send the sign-in headers above. Forward auth needs this. With forward auth off, no proxy can sign anyone in.
  • It makes Lumastra believe X-Forwarded-For from those proxies, so it knows each client’s real IP address.

With an empty list, most per-IP rate limits trust the first X-Forwarded-For entry from any peer. A client can change that header to bypass those limits. Sign-in limits, personal-access-token IP allowlists and IP-pinned playback grants use the direct peer instead, so they see the proxy’s address. Set TRUSTED_PROXY_IPS when two things are true:

  • Your proxy removes the sign-in headers, as every setup on this page does.
  • The address you list belongs to your proxy alone, if you turn forward auth on.

List only the addresses of your proxies. Every address in the list can choose the client IP Lumastra sees, and with FORWARD_AUTH_ENABLED=true it can also send the sign-in headers itself. A range such as 192.168.0.0/16 hands that to every device on your network: any of them could sign in as any user, including an admin. npm run doctor, the admin config check and the Security Self-Check warn when the list contains an IPv4 range wider than /24. On Kubernetes the warning is informational, because the documented value there is the router’s pod range behind a NetworkPolicy. IPv6 ranges are not supported and match no address; list IPv6 proxies as exact addresses.

When a request passes through more than one proxy, for example Cloudflare, then nginx, then Lumastra, every hop that adds an address to X-Forwarded-For has to be in the list. Lumastra takes the rightmost address in X-Forwarded-For that isn’t a listed proxy. If the outer proxy is missing, its address counts as the client: everyone shares one budget for failed sign-ins, and playback grants pinned to an IP address stop working when the outer proxy’s address changes. The simpler way is to let the inner proxy restore the real client address and pass on only that address. Then only the inner proxy belongs in the list. For Cloudflare in front of nginx, see the nginx setup under Setups. Don’t list Cloudflare’s own ranges: they are wider than /24, its IPv6 ranges match nothing, and with forward auth on, any connection that reaches Lumastra from a Cloudflare address could send sign-in headers.

Which address to use depends on where the proxy runs:

  • Proxy in the same compose file (the Caddy service variant, Traefik and Nginx Proxy Manager below): give the proxy a fixed address and list only that address. Each setup below shows how.
  • Proxy installed on the Docker host (Caddy or nginx from your package manager): leave TRUSTED_PROXY_IPS empty. Lumastra sees the proxy’s requests coming from the compose network’s gateway (for example 172.18.0.1), and every other program on the host connects from that same address. Trusting the gateway lets any local program send sign-in headers. The details are in Deploy with Docker.

See Known limitations.

  • Point a DNS name such as lumastra.example.com at your server and open ports 80 and 443 for certificate issuance and HTTPS.
  • Set ORIGIN in .env as above.
  • When the proxy runs on the same host, the example compose file already publishes 127.0.0.1:3000:3000, which is all it needs. Keep that ports: block. When the proxy runs in the same compose file, remove the ports: block from the lumastra service and send traffic to lumastra:3000 over the compose network.
  • The example compose file ships Traefik labels for the lumastra service. They do nothing without Traefik. The forward-auth middleware line (traefik.http.routers.lumastra.middlewares=...) and the FORWARD_AUTH_ENABLED and TRUSTED_PROXY_IPS settings are commented out by default, so a fresh install uses built-in login or OIDC. Enable them only if you want your proxy to sign users in (forward auth), and then set all three together. If you use Traefik, adapt the labels as shown in the Traefik setup below.
  • The example compose file indents with 4 spaces, and so do the snippets below. Keep the indentation when you paste, or Docker Compose rejects the file.

Each setup covers a plain proxy. For single sign-on through the proxy, add forward auth as described in Authentication and Setting up Authentik, or use OIDC, which needs no proxy support at all.

Caddy gets and renews certificates by itself. It passes Host through, sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host, flushes event streams immediately, has no timeouts that cut off a stream, and doesn’t limit request body size by default. The whole config is:

lumastra.example.com {
reverse_proxy 127.0.0.1:3000 {
# Lumastra reads sign-in headers from trusted proxies. Never pass a client's own.
header_up -X-Forwarded-User
header_up -X-Forwarded-Groups
header_up -X-Authentik-Name
}
}

With Caddy installed on the host, leave TRUSTED_PROXY_IPS empty (see TRUSTED_PROXY_IPS).

If Caddy runs in the same compose file, point it at the service name instead:

lumastra.example.com {
reverse_proxy lumastra:3000 {
header_up -X-Forwarded-User
header_up -X-Forwarded-Groups
header_up -X-Authentik-Name
}
}

Add Caddy as a service next to lumastra with a fixed address, remove the ports: block from lumastra, and add the two volumes at the bottom of the file:

services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '443:443/udp'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
lumastra-network:
ipv4_address: 172.28.0.10
volumes:
caddy_data:
caddy_config:

A fixed address needs a subnet on the network. Replace the lumastra-network entry under the top-level networks: with:

networks:
lumastra-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
ip_range: 172.28.0.128/25

ip_range keeps Docker’s automatic addresses away from the one you picked. Then set TRUSTED_PROXY_IPS=172.28.0.10 in .env. A changed network takes effect after docker compose down and docker compose up -d. If 172.28.0.0/24 is already used on your host, pick another private range and change all three addresses.

Caddy writes no access log unless you add a log block. If you do, filter the query string as shown in Access logs: keep credentials out.

  1. Check the health endpoint through the proxy:

    Terminal window
    curl -i https://lumastra.example.com/api/health

    You should see HTTP/2 200 (or HTTP/1.1 200) and the body {"status":"pass"}. curl fails with a certificate error if the certificate is missing or untrusted. Fix that before you try an app.

  2. Check that the proxy removes the sign-in headers. This must answer 401:

    Terminal window
    curl -s -o /dev/null -w '%{http_code}\n' -H 'X-Forwarded-User: admin' \
    https://lumastra.example.com/api/me

    A 200 means anyone can sign in as any user. Stop the stack and fix the proxy config before you go on.

  3. Open https://lumastra.example.com in a browser. The address bar should show a valid certificate.

  4. Sign in. If you are still on first setup, finish the wizard at /setup. A login that works and stays signed in means ORIGIN and the forwarded headers are right.

  5. Open the browser’s network tools and look at the event stream request (/api/events/user). It should stay open and show a : ping line about every 10 seconds.

  6. Sign in from the iPhone, iPad or Apple TV app using your public HTTPS address.

502 Bad Gateway. The proxy can’t reach Lumastra. Check these in order:

  • The upstream uses http:// and port 3000. Lumastra itself speaks no HTTPS.
  • If the proxy runs in a container, 127.0.0.1 points at the proxy container itself. Use the service name lumastra on a shared network, or the host’s address.
  • If the proxy runs on the host, make sure the ports: mapping on lumastra is still there.
  • Run docker compose ps and check that lumastra is healthy.

504 Gateway Timeout on Traefik. The lumastra service is on two networks and Traefik picked the one it can’t reach. Add the traefik.docker.network=proxy label.

Login fails with 403. ORIGIN doesn’t match the address in your browser. Compare the host and port character by character, remove any :3000, then restart Lumastra. Also check that your proxy passes the Host header through.

Sign-in succeeds, but you land on the sign-in page again. The browser dropped the session cookie because the cookie is marked Secure and you opened the page over plain HTTP. This happens when ORIGIN is an https address and you open the same host over http://; another host or port gets the 403 above instead. Use the https address. For plain-HTTP access on your local network, remove ORIGIN from .env and restart Lumastra.

Live updates arrive late or never. The proxy is buffering or compressing event streams. Turn off buffering for the Lumastra location (proxy_buffering off in nginx), and exclude text/event-stream from compression.

Playback stops after a pause, or after about a minute. A timeout is too short. nginx closes a connection when the player hasn’t read from it for 60 seconds (send_timeout), and when Lumastra hasn’t sent anything for 60 seconds (proxy_read_timeout). Raise both. On Traefik, check the entrypoint timeouts.

Uploads fail with 413. The proxy’s body limit is smaller than the upload. Raise client_max_body_size in nginx (or its equivalent), to at least BODY_SIZE_LIMIT.

Everyone shares the sign-in limit, or a token with allowed_ips is refused. These checks see the proxy’s address when proxy trust is unset. Most other per-IP limits then trust a spoofable forwarded header. Set TRUSTED_PROXY_IPS as described above.

The Apple app can’t connect. Open your address in Safari on the same device. If it shows a certificate warning, the app fails too. Use a certificate from a public authority such as Let’s Encrypt.