Running behind a proxy
In production, Raytha usually sits behind something that terminates TLS: Caddy, nginx, Cloudflare, a load balancer or a platform such as Railway. That proxy passes the visitor's IP address and scheme in headers, and Raytha must know which senders it can believe.
What Raytha reads from a proxy
X-Forwarded-For: the client's IP address. It decides which bucket the per-IP sign-in rate limit uses.X-Forwarded-Proto: whether the visitor used HTTPS. Raytha sends theStrict-Transport-Security(HSTS) header only on requests it believes are HTTPS.
Raytha never reads X-Forwarded-Host. Links in emails and absolute media URLs come from the Website URL in Settings, Configuration, not from the request's host.
Two environment variables control the trust. They are read once at start-up, and the app logs what it decided, for example Trusting forwarded headers from any peer, 1 hop. Make sure this app is only reachable through your proxy.
Pick a setting
| Your setup | Setting |
|---|---|
| Railway, Azure App Service, or one nginx or Caddy in front of the app | Leave TRUSTED_PROXIES unset (same as all, one hop). |
| Cloudflare in front of Railway or Azure App Service (two proxies) | TRUSTED_PROXIES=all and TRUSTED_PROXY_HOPS=2 |
| Cloudflare in front of nginx on your own server | TRUSTED_PROXIES=all and TRUSTED_PROXY_HOPS=2 |
Docker network, sidecar proxy, or a tunnel such as cloudflared | TRUSTED_PROXIES=private |
| Proxies with known addresses | TRUSTED_PROXIES=10.20.0.0/16,203.0.113.10 (IPv4 and IPv6 addresses and CIDR ranges) |
| Kestrel reachable directly from the internet, no proxy | TRUSTED_PROXIES=none |
How the values work
all(the default) believes any peer. It then counts backTRUSTED_PROXY_HOPSentries from the end ofX-Forwarded-For, one per proxy. With one hop, the last entry is the client. With two, it is the one before.noneignores both headers. The connecting peer is the client.privatetrusts only peers in127.0.0.0/8,::1,169.254.0.0/16,fe80::/10,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10andfc00::/7.- A list trusts only those addresses and ranges, and follows
X-Forwarded-Forbackwards until the first address that is not on the list. That address is the client.privatemay be mixed into a list. Because the walk stops by itself, a list takes no hop count. allandnonemust be the only value.TRUSTED_PROXY_HOPSis accepted only withallor an unsetTRUSTED_PROXIES.- A bad entry stops start-up with a message, including a range with host bits set such as
10.20.1.0/16(it would silently trust all of10.20.0.0/16), and shorthand such as10.
Warning The default trusts any peer. If a client can reach Raytha without going through your proxy, for example because the Docker port
5001is published to the internet, it can send its ownX-Forwarded-Forand pick its own IP address, which defeats the per-IP rate limit. Close the direct path, or usenone, or list your proxies.
Example: Caddy
Caddy sets X-Forwarded-For and X-Forwarded-Proto itself, replaces any copy a client sends, and gets certificates automatically. This is a complete Caddyfile:
raytha.example.com {
reverse_proxy 127.0.0.1:5001
}
With Caddy on the same host, both the default and TRUSTED_PROXIES=private work. Prefer private: a client then cannot choose its IP address even if it reaches the app directly from outside the private ranges. Run Raytha with ASPNETCORE_ENVIRONMENT=Production, and the response through Caddy carries Strict-Transport-Security.
Example: nginx
This is a standard nginx server block, not a Raytha-specific one. Certificates are up to you (for example certbot).
server {
listen 80;
server_name raytha.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name raytha.example.com;
ssl_certificate /etc/letsencrypt/live/raytha.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/raytha.example.com/privkey.pem;
# Keep this above FILE_STORAGE_MAX_FILE_SIZE, or uploads fail at nginx first.
client_max_body_size 25m;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Example: Cloudflare
- Cloudflare, then Railway or another platform proxy: two proxies append to
X-Forwarded-For, so useTRUSTED_PROXIES=allwithTRUSTED_PROXY_HOPS=2. With one hop, every visitor looks like a Cloudflare address. - Cloudflare, then your own server with a firewall that admits only Cloudflare:
allwith one hop is correct, because Cloudflare is the only proxy. - Cloudflare, then nginx, then Raytha: two hops, because nginx appends Cloudflare's address to the header Cloudflare sent. Check what your own proxy does with an incoming
X-Forwarded-Forbefore you count hops; some proxies discard it from clients they do not trust. - Without the firewall rule, anyone can reach the origin and forge headers. List Cloudflare's published address ranges plus
privateinstead of usingall.
HTTPS
Outside Development, Raytha redirects HTTP to HTTPS (when ENFORCE_HTTPS is true, the default), sends HSTS, and marks the sign-in cookie Secure. Behind a proxy:
- Terminate TLS at the proxy and send
X-Forwarded-Proto: httpsto Raytha. Raytha then treats the request as HTTPS and does not redirect. - Do the HTTP to HTTPS redirect in the proxy. Raytha's own redirect needs to know an HTTPS port, and with a single plain-HTTP listener it does not. In that situation it logs
Failed to determine the https port for redirectand serves the request over HTTP. - Set
ASPNETCORE_ENVIRONMENT=Production(the default when unset).Developmentswitches all of this off and is for local work only.
If the proxy cannot send X-Forwarded-Proto, or you set TRUSTED_PROXIES=none, Raytha sees plain HTTP and omits HSTS. Pages still load; the visible difference is the missing HSTS header. Do not set an HTTPS port for Raytha in this case, or its redirect will loop.
Set ENFORCE_HTTPS=false only if something else already guarantees HTTPS and you want Raytha to stay out of it.
The Website URL
The Website URL under Settings, Configuration must be the public address, with the scheme: https://raytha.example.com. Raytha uses it for password-recovery links, sign-in emails, the base of absolute media URLs in the REST API, and as the origin the admin app suggests during first-run setup. A wrong value does not break page rendering, but links in emails and API responses point at the wrong place.
Serving under a sub-path
Set PATHBASE=/mywebsite to serve the site below a prefix, and keep the prefix in the proxy's forwarded path. Public pages then link to /mywebsite/.... In 2.0.0 the admin app's own asset URLs begin with /raytha/ without the prefix, so check the admin through your proxy before relying on it, and consider a host name of its own instead.
Check it
Look for the start-up line, then confirm the client IP logic. The sign-in rate limit makes a good probe: with AUTH_RATE_LIMIT_PER_MINUTE=3, send wrong passwords with a different forged X-Forwarded-For each time, directly to the app.
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "%{http_code} " -X POST "$RAYTHA_URL/raytha/api/auth/login" \
-H 'Content-Type: application/json' -H "X-Forwarded-For: 203.0.113.$i" \
-d '{"email":"[email protected]","password":"wrong-password"}'
done; echo
401 401 401 401 401 401: the forged addresses were believed, so each got its own limit. This is correct only when the request comes through a trusted proxy.401 401 401 429 429 429: the forged header was ignored and all requests counted as one client. This is what you want when you hit the app from outside the proxy.
Repeat the test through the public proxy address. Caddy replaces a forged header, so the result should be 401 401 401 429 429 429 there too.
Gotchas
- Docker Compose in the repository publishes port
5001on all interfaces and trusts any peer. Behind a proxy on the same host, bind it to loopback (127.0.0.1:5001:8080) or use the production file in Deploy with Docker, where the app has no published port. - Counting hops wrongly in either direction makes every visitor share one rate-limit bucket (too few hops, all appear as the proxy) or lets a visitor choose their own address (too many).
TRUSTED_PROXIESset to an empty string is treated as unset. An emptyTRUSTED_PROXY_HOPSis treated as not given.- The sign-in rate limit counts per client IP, so many people behind one office NAT share a bucket. Raise
AUTH_RATE_LIMIT_PER_MINUTEif that bites.
Next steps
- Deploy with Docker: a Compose file with Caddy already in place.
- Deploy with Railway.
- Health checks for load balancers.