# nginx and networking The nginx front door, how it serves each route, and the production-specific rules it enforces. See also [Production overview](/docs/production.html), [Deploy and update](/docs/production-deploy.html), and [Static asset caching and versioning](/docs/static-caching.html). ## Public hostnames: two front doors, one application The platform answers on **two** public hostnames that reach the same application by completely different routes. Knowing which is which is the difference between a five minute diagnosis and an hour of chasing the wrong edge. | | `pravda.education` | `devplace.net` | |---|---|---| | DNS | `95.216.15.238`, `2a01:4f9:2a:100e::2` | `88.198.21.243`, `2a01:4f8:222:2c45::2` | | Machine | the production host | a separate front host | | Path in | molohttp on port 443, proxying to `127.0.0.1:10500` | its own proxy, then an **SSH tunnel** to `127.0.0.1:10500` on production | | Passes through molohttp | yes | **no** | `devplace.net` runs on its own machine and holds a persistent SSH session into the production host, forwarding through it to `127.0.0.1:10500` - the `docker-proxy` socket for the nginx container. The forwarded listener lives on the **front** host, so the production host shows no sshd listening socket for it. That absence is expected. **molohttp deliberately has no `devplace.net` site.** Its sites are `mail`, `smtp` and `imap.molodetz.nl`, `pravda.education`, and the workspace tunnel wildcard `*.tunnel.pravda.education`. Traffic for `devplace.net` enters underneath molohttp, so it needs no site there and adding one would achieve nothing - the hostname does not resolve to the production host, so such a site could never match. **Triage rule.** Run the same authenticated request against both hostnames and compare: - Fails on **both** - the fault is in the application or the database. Neither edge is involved. - Fails on **devplace.net only** - the fault is in the front host's proxy. WebSocket `Upgrade` and `Connection` headers are the usual cause, exactly as documented for the nginx locations below. - Fails on **pravda.education only** - the fault is in molohttp or its site configuration. **Never test a hostname by forcing it onto an IP it does not resolve to.** Using `curl --resolve devplace.net:443:<production ip>` sends `Host: devplace.net` to molohttp, which correctly answers `404 No site configured for host: devplace.net`. That result says nothing about the real path and reads convincingly like a total outage. Always fetch each hostname over the public internet as it genuinely resolves. ## Build and configuration The nginx image (`nginx/Dockerfile`) renders `nginx/nginx.conf.template` at start through `nginx/start.sh`, which substitutes a small allow-list of variables (`NGINX_CACHE_CONFIG`, `NGINX_CACHE_MAX_SIZE`, `NGINX_MAX_BODY_SIZE`) and leaves nginx runtime variables such as `$http_upgrade` untouched. The host's `devplacepy/static` directory is bind-mounted read-only at `/app/static` for package assets, and the consolidated `<DEVPLACE_DATA_DIR>/uploads` directory is bind-mounted read-only at `/data/uploads` (the `/static/uploads/` location aliases it), so both served assets and uploads always match the running code and data without an image rebuild. ## Route map | Location | Behavior | |----------|----------| | `/static/uploads/` | Served from disk with `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`. | | `/static/v<version>/` | Versioned assets served from disk, `public, immutable, max-age=31536000` (1 year). The `<version>` is the server boot timestamp, so a deploy changes every asset URL. See [Static asset caching](/docs/static-caching.html). | | `/static/` | Unversioned fallback served from disk with a short `max-age=3600` (1 hour), not immutable. | | `/devii/ws` | Proxied as a WebSocket (`Upgrade`/`Connection` headers, 1h read/send timeout). | | `/tools/seo/<uid>/ws` | SEO Diagnostics live-progress WebSocket; matched by `location ~ ^/tools/seo/[^/]+/ws$` with the same `Upgrade`/`Connection` headers and 1h timeout. | | `/avatar/` | Proxied to the app (generated SVG avatars). | | `/` | Proxied to the app; optional micro-cache. | ## Uploads are forced downloads The application mounts `/static/uploads` through `UploadStaticFiles`, which sets `Content-Disposition: attachment` so a user-supplied file downloads instead of rendering inline - a stored-XSS defense against uploaded SVG or HTML on the same origin. In production nginx serves those files directly from disk, bypassing that header, so the `/static/uploads/` location **re-applies** `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`. Keep this rule whenever the static handling changes. ## WebSockets The Devii terminal connects to `/devii/ws`. A plain reverse-proxy block drops the WebSocket handshake, so nginx maps the upgrade header: ``` map $http_upgrade $connection_upgrade { default upgrade; '' close; } ``` and the `/devii/ws` location forwards `Upgrade $http_upgrade` and `Connection $connection_upgrade` with a long read timeout. The app serves `/devii/ws` only from the worker holding the service lock; other workers close with code 1013 and the auto-reconnecting client lands on the owner, so a connection may take a brief reconnect to settle. See [Multi-worker and concurrency](/docs/production-concurrency.html) for the lock-owner model. **Every WebSocket path needs its own location block.** The catch-all `location /` deliberately sets `Connection ""` (no upgrade) and a short 60s read timeout, so any route that falls through to it cannot complete a WebSocket handshake - the browser reports `WebSocket connection failed` with no close code (the upgrade never happens). When you add a new WebSocket endpoint, add a dedicated `location` (exact path or a `~` regex) that forwards `Upgrade $http_upgrade` and `Connection $connection_upgrade` with long timeouts, **above** `location /`. The existing examples are `/devii/ws`, the SEO Diagnostics `location ~ ^/tools/seo/[^/]+/ws$`, and the container ingress `/p/` block. Like Devii, the SEO progress socket is served only by the service-lock owner; a non-owner closes with code 4013 and the client fast-retries until it lands on the owner. ## Upload size ceiling `client_max_body_size` is set from `NGINX_MAX_BODY_SIZE` (default `50m`). It must be **>= the admin-configurable `max_upload_size_mb`** (Admin -> Settings). If the app limit exceeds the nginx limit, nginx rejects the upload with **HTTP 413** before it ever reaches the app. After raising `max_upload_size_mb`, raise `NGINX_MAX_BODY_SIZE` to match and restart nginx. ## IPv6 reachability App stores require a client to work on an **IPv6-only** network, so the public edge must answer over IPv6. The nginx server block listens dual-stack: ``` listen 80; listen [::]:80; ``` That is the part this repository controls. Two things sit outside it and must be checked on the host before an IPv6-only client can connect: 1. The domain needs an **AAAA record** pointing at the host's IPv6 address, alongside the A record. 2. The published port must be bound on an IPv6 address. `docker-compose.yml` publishes nginx on `127.0.0.1:${PORT}:80`, which is IPv4 loopback only, on the assumption that a host-level reverse proxy or tunnel terminates the public connection. Whatever terminates it must itself listen on `[::]` and forward to that loopback port. The application container is only ever reached by nginx over the compose network, so uvicorn binding `0.0.0.0` is not a limitation - no IPv6 client ever talks to it directly. Verify from an IPv6-only vantage point (or force the family): `curl -6 -I https://your-domain/` must return a `200`, and `curl -6 -I https://your-domain/feed` must render. A failure here is a DNS or host-binding problem, not an application one. ## Security headers and caching The server block sets `X-Content-Type-Options`, `X-XSS-Protection`, and `Referrer-Policy`, inherited only by locations that declare no `add_header` of their own. It deliberately sets no `X-Frame-Options`: every location without its own `add_header` is proxied to the app, which owns framing policy via the CSP `frame-ancestors` directive, and an nginx-level header would be re-added on top of the app's response - that is what previously defeated the `/p/` ingress exemption, since `location /p/` declares no `add_header` and the app intentionally sends no framing headers there. gzip is enabled for text, JSON, JS, CSS, and SVG. The micro-cache is off by default; set `NGINX_CACHE_ENABLED=true` to cache proxied 200s for one minute with `X-Cache-Status` reporting. ## Troubleshooting - **Devii terminal will not connect** - confirm the `map` block and the `/devii/ws` location are present in the rendered config (`docker compose exec nginx cat /etc/nginx/conf.d/default.conf`); a connection that closes immediately with 1013 is the lock-owner reconnect, not a failure. - **A WebSocket reports `connection failed` with no close code** - the route has no dedicated upgrade `location` and fell through to `location /`, which strips the upgrade headers. Add a matching `location` block (see WebSockets above) and reload nginx. This is what breaks the SEO Diagnostics live progress if the `location ~ ^/tools/seo/[^/]+/ws$` block is missing. - **Uploads fail with 413** - `NGINX_MAX_BODY_SIZE` is below `max_upload_size_mb`; raise it and restart nginx. - **Uploaded file renders inline instead of downloading** - the `/static/uploads/` location lost its `Content-Disposition` rule. - **Static assets stale after deploy** - every app-owned asset URL carries the version path segment (`/static/v-/...`), so a restart busts the cache automatically; if assets still look stale, confirm the app actually restarted (the `` value in page source changed). See [Static asset caching](/docs/static-caching.html).