# 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). ## 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 `/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/` | Versioned assets served from disk, `public, immutable, max-age=31536000` (1 year). The `` 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//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-Frame-Options: DENY`, `X-XSS-Protection`, and `Referrer-Policy`, inherited only by locations that declare no `add_header` of their own. 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 boot-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).