AI Software Factory — Nginx Reverse Proxy
nginx.conf in this directory is a hardened reverse proxy for the
docker-compose app stack. It is the single public entry point: TLS
termination, security headers, rate limiting, WebSocket support, and path
routing to the three app services.
internet ──► nginx :443 ──┬─► / web (Next.js, :3000)
├─► /api/ api (NestJS, :4000)
├─► /ws realtime (ws node, :4001)
└─► /health 200 ok (LB probe, no app hit)
How to use
Option A — nginx as a compose service (recommended for self-host)
Add to infrastructure/docker/docker-compose.yml (or an override file):
nginx:
image: nginx:1.27-alpine
container_name: factory-nginx
profiles: ["apps"]
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro # your fullchain + privkey
- ./nginx/certbot:/var/www/certbot:ro # ACME HTTP-01 webroot
depends_on:
- web
- api
- realtime
networks:
- factory-net
Because the nginx container joins factory-net, the upstream names
(web:3000, api:4000, realtime:4001) resolve to the compose service
names as-is — no edits to nginx.conf needed.
Option B — host-level nginx in front of published ports
If nginx runs on the host (outside compose) against the published ports,
change the three upstream servers in nginx.conf to:
upstream web_upstream { server 127.0.0.1:3000; keepalive 32; }
upstream api_upstream { server 127.0.0.1:4000; keepalive 32; }
upstream realtime_upstream { server 127.0.0.1:4001; keepalive 16; }
Everything else is identical.
TLS certificate
The ssl_certificate / ssl_certificate_key paths are placeholders:
/etc/nginx/ssl/factory.example.com.fullchain.pem / ...privkey.pem.
- certbot (Let's Encrypt): the 80 server already serves
/.well-known/acme-challenge/from/var/www/certbot. Get a cert withcertbot certonly --webroot -w /var/www/certbot -d factory.example.com, then either symlink the issued files to the expected paths or edit the twossl_certificate*lines. Usecertbot renew --deploy-hook "nginx -s reload". - ACM / self-signed / internal CA: drop the fullchain + key at those paths.
- For a quick local smoke test, generate a self-signed pair:
openssl req -x509 -nodes -newkey rsa:2048 -days 30 -keyout privkey.pem \ -out fullchain.pem -subj "/CN=factory.example.com"(browsers will warn).
Routing & behavior notes
| Path | Upstream | Behavior |
|---|---|---|
/ |
web:3000 | Next.js; nginx gzip OFF (Next compresses itself) |
/api/ |
api:4000 | limit_req 30 r/s/IP (burst 60); client_max_body_size 512m; proxy_request_buffering off for streaming uploads; 300s read timeout for bulk ops |
/api/v1/auth/ |
api:4000 | tighter edge limit: 10 req/min/IP (burst 3) — registration/login/reset/refresh |
/ws |
realtime:4001 | WebSocket upgrade (Upgrade/Connection headers), 3600s read+send timeout, buffering OFF, 50 concurrent connections/IP cap |
/health |
— | 200 ok, no upstream, access_log off — use it as your LB target health check |
/api/v1/auth/nested limit: requests under the auth prefix are limited by theauthzone only (nginx does not stacklimit_reqfrom enclosing locations). The API applies its own, stricter, in-app limits — the nginx zones are the edge tripwire (DDoS/credential-stuffing), not the business rule.- Why
proxy_request_buffering offon/api/: large multipart uploads are streamed straight to the API instead of being buffered to disk by nginx; the API (Phase 9 files) then hands the body to presigned S3 flow. - Why the
/healthendpoint: load balancers (cloud LBs, k8s ingress fronting this box) can probe nginx directly — cheap, no app dependency, and it validates the proxy itself.
Why these security headers
The header block on the 443 server (full text in nginx.conf with inline
rationale) sets:
- CSP —
default-src 'self'baseline: no third-party scripts/styles.style-src 'unsafe-inline'is required by Next.js (inline<style>for initial theme/CSS-in-JS).connect-srcallowsws:/wss:for the realtime channel.frame-ancestors 'none'+X-Frame-Options DENYprevent clickjacking (we are never an embeddable frame). Extend the CSP-srclists before adding any third-party widget — the browser will silently block it otherwise (check the console for CSP violations). - HSTS — 2-year max-age,
includeSubDomains,preload(only enablepreloadonce you intend to be in the preload list). X-Content-Type-Options: nosniff— MIME-sniffing off (XSS defense).Referrer-Policy: strict-origin-when-cross-origin— full URL stays on-origin; only the origin is leaked cross-origin (search links don't carry page titles in the referrer).Permissions-Policy— no camera/mic/geolocation/payment/USB/etc. (the product uses none of them today;fullscreen=(self)allows the editor's fullscreen mode).server_tokens off— no nginx version in headers/404 pages.- TLS — 1.2 + 1.3 only,
HIGH:!aNULL:!MD5, session cache on, tickets off (forward secrecy hygiene).http2 on;requires nginx ≥ 1.25.1.
Tuning notes
worker_processes auto+worker_connections 4096is right for a 1-4 vCPU box; for a dedicated edge box, raiseworker_connectionsto 8192-16384 and addworker_rlimit_nofile.limit_reqburst values: the 30 r/s zone's burst of 60 absorbs editor autosave bursts (10s of rapid saves) without 429s. If you see legitimate 429s underapi, raiseburstbefore raisingrate./wsconnection cap (limit_conn connperip 50): one user should not hold 50 sockets; raise it if you support many concurrent tabs + offline retry queues per user.- OpenSearch/MinIO are NOT proxied here — they are data-plane services the API talks to over the compose network, and exposing them publicly would be a vulnerability. If you need admin access, use SSH port-forward.
- Logging: the
mainlog format adds upstream timing (uct/uht/urt) — use it to spot slow-upstream vs slow-nginx issues (error.log warnis the right verbosity; raise toinfowhen debugging). - Compression: JSON responses ≥ 1 KB are gzipped at level 5; HTML/CSS/ JS from the API too. The web app path disables nginx gzip (double-compress avoidance) — do not re-enable it there.
Smoke test
# with a local self-signed cert and Option B:
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost/health # 200
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost/ # 200/302 (web)
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost/api/v1/health # 200 (api)
curl -skI https://localhost/ | grep -Ei 'strict-transport|content-security|x-frame'