Platform
Learn
Developer docs User guide Quickstart Blog
Company
Services About Contact Links Get started

Running behind a proxy

Updated

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 the Strict-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 setupSetting
Railway, Azure App Service, or one nginx or Caddy in front of the appLeave 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 serverTRUSTED_PROXIES=all and TRUSTED_PROXY_HOPS=2
Docker network, sidecar proxy, or a tunnel such as cloudflaredTRUSTED_PROXIES=private
Proxies with known addressesTRUSTED_PROXIES=10.20.0.0/16,203.0.113.10 (IPv4 and IPv6 addresses and CIDR ranges)
Kestrel reachable directly from the internet, no proxyTRUSTED_PROXIES=none

How the values work

  • all (the default) believes any peer. It then counts back TRUSTED_PROXY_HOPS entries from the end of X-Forwarded-For, one per proxy. With one hop, the last entry is the client. With two, it is the one before.
  • none ignores both headers. The connecting peer is the client.
  • private trusts only peers in 127.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/10 and fc00::/7.
  • A list trusts only those addresses and ranges, and follows X-Forwarded-For backwards until the first address that is not on the list. That address is the client. private may be mixed into a list. Because the walk stops by itself, a list takes no hop count.
  • all and none must be the only value. TRUSTED_PROXY_HOPS is accepted only with all or an unset TRUSTED_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 of 10.20.0.0/16), and shorthand such as 10.

Warning The default trusts any peer. If a client can reach Raytha without going through your proxy, for example because the Docker port 5001 is published to the internet, it can send its own X-Forwarded-For and pick its own IP address, which defeats the per-IP rate limit. Close the direct path, or use none, 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 use TRUSTED_PROXIES=all with TRUSTED_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: all with 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-For before 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 private instead of using all.

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:

  1. Terminate TLS at the proxy and send X-Forwarded-Proto: https to Raytha. Raytha then treats the request as HTTPS and does not redirect.
  2. 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 redirect and serves the request over HTTP.
  3. Set ASPNETCORE_ENVIRONMENT=Production (the default when unset). Development switches 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 5001 on 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_PROXIES set to an empty string is treated as unset. An empty TRUSTED_PROXY_HOPS is 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_MINUTE if that bites.

Next steps