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.
Why you need HTTPS
Section titled “Why you need HTTPS”- 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
Section titled “Set ORIGIN”Set ORIGIN in your .env to the public address:
ORIGIN=https://lumastra.example.comUse 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.
What the proxy must do
Section titled “What the proxy must do”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 asexample.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)/jand/j/for Jellyfin clients, which usehttps://<your domain>/jas the server address/subsonic/for Subsonic music apps, which usehttps://<your domain>/subsonic/api/plugin-webhooks/for webhooks that other services send to plugins/api/healthfor uptime monitors, and/metricsif you scrape it with Prometheus/manifest.jsonand 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
Hostheader through, and setX-Forwarded-ForandX-Forwarded-Proto. -
Remove the sign-in headers
X-Forwarded-User,X-Forwarded-GroupsandX-Authentik-Namefrom 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.
Sign-in headers
Section titled “Sign-in headers”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
Section titled “TRUSTED_PROXY_IPS”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-Forfrom 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_IPSempty. Lumastra sees the proxy’s requests coming from the compose network’s gateway (for example172.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.
Before you start
Section titled “Before you start”- Point a DNS name such as
lumastra.example.comat your server and open ports 80 and 443 for certificate issuance and HTTPS. - Set
ORIGINin.envas 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 thatports:block. When the proxy runs in the same compose file, remove theports:block from thelumastraservice and send traffic tolumastra:3000over the compose network. - The example compose file ships Traefik labels for the
lumastraservice. They do nothing without Traefik. The forward-auth middleware line (traefik.http.routers.lumastra.middlewares=...) and theFORWARD_AUTH_ENABLEDandTRUSTED_PROXY_IPSsettings 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.
Setups
Section titled “Setups”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/25ip_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.
This is a complete config for nginx installed on the same host. It works with nginx 1.18 and newer, so also with the versions in Debian 12 and Ubuntu 22.04 and 24.04.
Get the certificate first, because nginx refuses to start with a ssl_certificate file
that doesn’t exist yet. With Certbot and its nginx plugin, while only the default site
is enabled:
sudo certbot certonly --nginx -d lumastra.example.comCertbot also sets up renewal. Then put this file in /etc/nginx/conf.d/lumastra.conf
or your sites-available folder:
server { listen 80; listen [::]:80; server_name lumastra.example.com; return 301 https://$host$request_uri;}
server { # nginx 1.25.1 or newer also accepts "listen 443 ssl;" plus "http2 on;". listen 443 ssl http2; listen [::]:443 ssl http2; server_name lumastra.example.com;
ssl_certificate /etc/letsencrypt/live/lumastra.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/lumastra.example.com/privkey.pem;
# Match the app's BODY_SIZE_LIMIT. nginx defaults to 1 MB. client_max_body_size 50m;
# A paused player stops reading. Keep its stream open. send_timeout 1h;
location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme;
# Lumastra reads sign-in headers from trusted proxies. Never pass a client's own. proxy_set_header X-Forwarded-User ""; proxy_set_header X-Forwarded-Groups ""; proxy_set_header X-Authentik-Name "";
# Live updates and video must not be buffered. proxy_buffering off;
# Streams and event connections stay open for a long time. proxy_read_timeout 1h; proxy_send_timeout 1h; }}Check and reload:
sudo nginx -t && sudo systemctl reload nginxnginx 1.25.1 and newer print a warning that listen ... http2 is deprecated. It still
works. To silence it, use listen 443 ssl; and add http2 on; on its own line.
X-Forwarded-For $remote_addr sends only the address nginx sees.
A CDN or a second proxy in front of nginx. With Cloudflare or another proxy in front of nginx, nginx sees that proxy’s address. Let
nginx restore the client’s address with its realip module, which the Debian, Ubuntu and
nginx.org packages include. Keep X-Forwarded-For $remote_addr: it then carries the
restored address. For Cloudflare, add one set_real_ip_from line per range listed at
cloudflare.com/ips, IPv4 and IPv6, to the server
block:
# Cloudflare's ranges, from https://www.cloudflare.com/ips/ set_real_ip_from 173.245.48.0/20; set_real_ip_from 2400:cb00::/32; # ... the remaining ranges ... real_ip_header CF-Connecting-IP;For another proxy, list its address and use real_ip_header X-Forwarded-For; with
real_ip_recursive on;. Lumastra then gets one address per request and needs only nginx
in TRUSTED_PROXY_IPS, if it needs anything (see TRUSTED_PROXY_IPS).
Update the Cloudflare ranges when Cloudflare changes them.
With nginx installed on the host, leave TRUSTED_PROXY_IPS empty (see
TRUSTED_PROXY_IPS).
nginx writes the full request URI, query string included, to its default access log.
Add the lumastra_no_query log format from
Access logs: keep credentials out
and use it in this server block.
If you enable gzip, select content types explicitly as described under
Compression, and leave out text/event-stream,
video, audio and HLS.
Traefik reads its routing from Docker labels. Put them on the lumastra service and
remove the ports: block. Traefik and Lumastra share a Docker network called proxy.
Create it with a fixed subnet, so Traefik can have an address of its own:
docker network create --subnet 172.30.0.0/24 --ip-range 172.30.0.128/25 proxy--ip-range keeps Docker’s automatic addresses away from the one Traefik gets below.
If 172.30.0.0/24 is already used on your host, pick another private range and change
the addresses on this page to match.
Replace the Traefik labels in the example compose file with these, add the proxy
network to the service, and add proxy to the top-level networks::
services: lumastra: labels: - traefik.enable=true - traefik.docker.network=proxy - traefik.http.routers.lumastra.rule=Host(`lumastra.example.com`) - traefik.http.routers.lumastra.entrypoints=websecure - traefik.http.routers.lumastra.tls=true - traefik.http.routers.lumastra.tls.certresolver=letsencrypt - traefik.http.routers.lumastra.middlewares=lumastra-headers # Lumastra reads sign-in headers from trusted proxies. Never pass a client's own. - traefik.http.middlewares.lumastra-headers.headers.customrequestheaders.X-Forwarded-User= - traefik.http.middlewares.lumastra-headers.headers.customrequestheaders.X-Forwarded-Groups= - traefik.http.middlewares.lumastra-headers.headers.customrequestheaders.X-Authentik-Name= - traefik.http.services.lumastra.loadbalancer.server.port=3000 networks: - lumastra-network - proxy
networks: proxy: external: trueAn empty value in customrequestheaders removes that header from the request.
The router refers to an entrypoint websecure and a certificate resolver letsencrypt.
Both come from Traefik’s own configuration. A minimal Traefik service that defines them,
in its own compose file or in the same one:
services: traefik: image: traefik:v3 restart: unless-stopped command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.scheme=https - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.email=you@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web ports: - '80:80' - '443:443' volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - traefik_letsencrypt:/letsencrypt networks: proxy: ipv4_address: 172.30.0.2
volumes: traefik_letsencrypt:
networks: proxy: external: trueThen set TRUSTED_PROXY_IPS=172.30.0.2 in Lumastra’s .env.
Traefik forwards Host, sets X-Forwarded-For and X-Forwarded-Proto, drops those
headers when a client sends its own, doesn’t buffer responses, and doesn’t limit request
bodies. It does close a request that takes longer than the entrypoint’s readTimeout
(60 seconds by default) to arrive, which can cut off a large upload from a slow
connection. To lift that limit, add this to the Traefik command: list:
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=0Traefik writes no access log by default. If you turn it on, drop query parameters so credentials stay out of it:
- --accesslog=true - --accesslog.fields.queryparameters.defaultmode=dropNginx Proxy Manager (NPM) is nginx with a web UI.
If you run it as a container, the simplest layout is to add it to the same compose file as Lumastra, so it can reach the app by service name. Give it a fixed address:
services: npm: image: jc21/nginx-proxy-manager:latest restart: unless-stopped ports: - '80:80' - '443:443' - '81:81' # admin UI: don't expose this to the internet volumes: - npm_data:/data - npm_letsencrypt:/etc/letsencrypt networks: lumastra-network: ipv4_address: 172.28.0.10
volumes: npm_data: npm_letsencrypt: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/25Set TRUSTED_PROXY_IPS=172.28.0.10 in .env. If 172.28.0.0/24 is already used on
your host, pick another private range and change all three addresses. A changed network
takes effect after docker compose down and docker compose up -d.
Remove the ports: block from lumastra, run docker compose up -d, and open the NPM
admin UI on port 81. On first start it asks you to create the admin account. Then add a
proxy host.
On the Details tab:
| Field | Value |
|---|---|
| Domain Names | lumastra.example.com |
| Scheme | http |
| Forward Hostname / IP | lumastra (the service name). If NPM runs outside this compose file, use the host’s address and publish port 3000 on it instead. |
| Forward Port | 3000 |
| Cache Assets | Off |
| Block Common Exploits | Off, unless you have tested it with your apps |
| Websockets Support | Enable for Jellyfin-compatible clients (/j/socket). |
On the SSL tab, request a new certificate for the domain and turn on Force SSL and HTTP/2 Support.
On the Advanced tab, paste this into Custom Nginx Configuration:
# Lumastra reads sign-in headers from trusted proxies. Never pass a client's own.more_clear_input_headers X-Forwarded-User X-Forwarded-Groups X-Authentik-Name;
# Live updates and video must not be buffered.proxy_buffering off;
# Streams and event connections stay open for a long time.proxy_read_timeout 1h;proxy_send_timeout 1h;send_timeout 1h;
# Match the app's BODY_SIZE_LIMIT.client_max_body_size 50m;
# NPM logs the full request URI, which can hold tokens. Turn its log off.access_log off;NPM’s proxy hosts already pass Host, X-Forwarded-For and X-Forwarded-Proto to the
upstream. Its per-host access log records the query string, and the Advanced tab can’t
change its format, so the snippet turns it off. If you need an access log, see
Access logs: keep credentials out.
Check it works
Section titled “Check it works”-
Check the health endpoint through the proxy:
Terminal window curl -i https://lumastra.example.com/api/healthYou should see
HTTP/2 200(orHTTP/1.1 200) and the body{"status":"pass"}.curlfails with a certificate error if the certificate is missing or untrusted. Fix that before you try an app. -
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/meA
200means anyone can sign in as any user. Stop the stack and fix the proxy config before you go on. -
Open
https://lumastra.example.comin a browser. The address bar should show a valid certificate. -
Sign in. If you are still on first setup, finish the wizard at
/setup. A login that works and stays signed in meansORIGINand the forwarded headers are right. -
Open the browser’s network tools and look at the event stream request (
/api/events/user). It should stay open and show a: pingline about every 10 seconds. -
Sign in from the iPhone, iPad or Apple TV app using your public HTTPS address.
Common problems
Section titled “Common problems”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.1points at the proxy container itself. Use the service namelumastraon a shared network, or the host’s address. - If the proxy runs on the host, make sure the
ports:mapping onlumastrais still there. - Run
docker compose psand check thatlumastrais 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.
- Deploy with Docker: compression, access logs and request body size in detail
- Authentication: forward auth, OIDC and proxy trust
- Setting up Authentik: single sign-on through the proxy
- Known limitations