Files
learningcircuit--local-deep…/docs/deployment/reverse-proxy.md
T
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

8.5 KiB

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 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 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.
  • 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

# --- 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 · client_max_body_size · gzip module · WebSocket proxying · http2 · Flask-SocketIO deployment

Caddy

Caddy auto-provisions TLS, redirects HTTP→HTTPS, sets X-Forwarded-For/-Proto on reverse_proxy automatically, and upgrades WebSockets transparently — so the whole deployment is a few lines:

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

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, or a tunnel. Note Cloudflare Tunnel adds a second hop, which conflicts with the fixed one-proxy trust model above.