# Deploying behind a reverse proxy

For anything beyond a single user on `localhost`, run LDR behind a reverse proxy
(nginx, Caddy, Traefik, Nginx Proxy Manager, …). The proxy terminates TLS and
handles **HTTPS, response compression, and static-asset caching** for you — so
LDR itself does none of those (see
[ADR-0005](../decisions/0005-reject-inapp-response-compression.md) on why
compression is intentionally the proxy's job, not the app's).

> **Bind LDR to loopback and expose only the proxy.** LDR's default
> `LDR_WEB_HOST` is `0.0.0.0` (all interfaces) and it always serves **plain
> HTTP**. Set `LDR_WEB_HOST=127.0.0.1` (or, for Docker, publish to loopback:
> `-p 127.0.0.1:5000:5000`, or use an internal network). This matters for
> security — see the next section.

The config below is **illustrative**; directive syntax evolves, so follow the
linked upstream docs for the current spelling. `5000` is LDR's default port
(`LDR_WEB_PORT`); substitute yours if you changed it.

## What LDR expects from the proxy

LDR uses Werkzeug's
[`ProxyFix`](https://werkzeug.palletsprojects.com/en/stable/middleware/proxy_fix/)
with `x_for=1, x_proto=1` (and `x_host=0, x_port=0`). This makes it read the
**right-most value** of each forwarded header — the one appended by the single
proxy directly in front of LDR. `ProxyFix` does not count or validate hops; the
count just has to equal the number of trusted proxies.

| Forwarded header | Used by LDR? | For |
|---|---|---|
| `X-Forwarded-For` | yes | client IP — rate limiting, logging |
| `X-Forwarded-Proto` | yes | http/https detection → secure cookies, HSTS, the WebSocket same-origin check |
| `X-Forwarded-Host` | ignored | — |
| `X-Forwarded-Port` | ignored | — |

This means:

- **Your proxy must set `X-Forwarded-Proto`.** Without it, a TLS-terminating
  proxy makes LDR think the request is plain HTTP — secure cookies and HSTS are
  withheld and the same-origin WebSocket check rejects the browser's `https`
  origin.
- **Set `X-Forwarded-For`** so client IPs (and rate limiting) are correct;
  otherwise every request is attributed to the proxy.
- **`ProxyFix` is always on and trusts these headers unconditionally.** If LDR
  is reachable directly (not only via the proxy), a client can forge
  `X-Forwarded-For` (to spoof its IP and evade rate limiting) or
  `X-Forwarded-Proto: https` (to force secure cookies over plaintext). This is
  why LDR must be bound to loopback / an internal network. See Flask's
  [Tell Flask it is behind a proxy](https://flask.palletsprojects.com/en/stable/deploying/proxy_fix/).
- **Exactly one proxy hop is supported.** The count is fixed at `1` in the app
  and has no env/setting knob, so a multi-proxy chain or a CDN/Cloudflare Tunnel
  *in addition* to your proxy is not supported without a code change (LDR would
  read the inner proxy's address as the client IP).
- **HSTS and HTTPS redirect:** LDR sends `Strict-Transport-Security`
  (`max-age=31536000; includeSubDomains`, no `preload`) itself on HTTPS
  requests, so don't add a duplicate at the proxy. Do add an HTTP→HTTPS redirect
  at the proxy (shown below).

## nginx

```nginx
# --- in the http { } context (e.g. conf.d/), shared by all servers ---
# Map for the WebSocket upgrade; also lets Socket.IO's long-polling fallback
# (which sends no Upgrade header) keep the connection alive.
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name ldr.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;                       # `listen ... http2` is deprecated since nginx 1.25.1
    server_name ldr.example.com;

    # Example paths from certbot/Let's Encrypt; see https://certbot.eff.org/instructions
    ssl_certificate     /etc/letsencrypt/live/ldr.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ldr.example.com/privkey.pem;

    # nginx's default is 1 MB, which would 413 every research-library upload.
    # Size this to your upload cap (LDR_SECURITY_UPLOAD_MAX_FILE_SIZE_MB,
    # default 3072 MB per file). Lower both together to tighten the limit.
    client_max_body_size 3072m;

    # Compression LDR no longer does in-process (ADR-0005). For Brotli, add the
    # ngx_brotli module (https://github.com/google/ngx_brotli). nginx ALWAYS
    # compresses text/html regardless of gzip_types — that's fine here because
    # LDR's CSRF token is masked per render (see ADR-0005). application/json is
    # left out so secret-bearing API responses aren't compressed.
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_types text/css text/javascript application/javascript image/svg+xml;

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;   # required for HTTPS/cookies/HSTS

        # LDR streams live progress as Server-Sent Events on these routes;
        # don't buffer or time them out during a long research run.
        proxy_buffering    off;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }

    # WebSocket (live research progress) needs the HTTP/1.1 upgrade headers.
    location /socket.io {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;
        proxy_buffering    off;
        proxy_read_timeout 3600s;        # generous; keeps idle WebSockets open
    }
}
```

Content-hashed bundles under `/static/dist/` are sent with
`Cache-Control: public, max-age=31536000, immutable` (the cache key is the
content hash in the *filename*, e.g. `app.<hash>.js`), so browsers cache them
without revalidating. Don't add `proxy_cache` to `location /`: LDR is a
multi-user app with per-user encrypted databases, and caching authenticated
pages there would leak one user's data to another.

References:
[proxy module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) ·
[`client_max_body_size`](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size) ·
[gzip module](https://nginx.org/en/docs/http/ngx_http_gzip_module.html) ·
[WebSocket proxying](https://nginx.org/en/docs/http/websocket.html) ·
[`http2`](https://nginx.org/en/docs/http/ngx_http_v2_module.html) ·
[Flask-SocketIO deployment](https://flask-socketio.readthedocs.io/en/latest/deployment.html)

## Caddy

Caddy auto-provisions TLS, redirects HTTP→HTTPS, sets `X-Forwarded-For`/`-Proto`
on [`reverse_proxy`](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
automatically, and upgrades WebSockets transparently — so the whole deployment
is a few lines:

```caddy
ldr.example.com {
    encode gzip               # compression LDR no longer does (ADR-0005)
    reverse_proxy 127.0.0.1:5000
}
```

[Automatic HTTPS](https://caddyserver.com/docs/automatic-https) requires
`ldr.example.com` to be a real public domain whose DNS points at the host, with
inbound ports 80 and 443 reachable for the ACME challenge and a writable data
directory. For an internal/non-public hostname Caddy falls back to its
locally-trusted internal CA (browsers warn unless you trust its root).

## Notes

- **Single backend only.** LDR doesn't support horizontal scaling — multiple
  replicas would need Socket.IO sticky sessions and a shared message queue.
- **Lock down registration** if you expose LDR publicly: it ships its own auth,
  but `LDR_APP_ALLOW_REGISTRATIONS` defaults to **true** (open self-signup). Set
  `LDR_APP_ALLOW_REGISTRATIONS=false` after creating your accounts. Proxy-level
  basic-auth is usually unnecessary given the built-in auth.
- **Other proxies:** Traefik (set its forwarded-headers trusted IPs),
  [Nginx Proxy Manager](unraid.md), or a tunnel. Note **Cloudflare Tunnel adds a
  second hop**, which conflicts with the fixed one-proxy trust model above.

## Related

- [ADR-0005: Reject in-app response compression](../decisions/0005-reject-inapp-response-compression.md)
- WebSocket / Socket.IO behind a proxy, CSRF/cookie issues: [troubleshooting](../troubleshooting.md)
- Cross-origin front-ends, allowed WebSocket/CORS origins: [env configuration](../env_configuration.md)
- [Unraid + Nginx Proxy Manager](unraid.md)
