# One origin, two halves: static frontend at /, the API proxied behind it. # # This is what makes a generated app a single container on a single port. The # browser sees one origin, so there is no CORS to configure, no second hostname # to route, and no preflight on every request. The backend never learns it is # behind anything. # # This is a COMPLETE nginx.conf, not a conf.d snippet, because the websocket # `Connection` header needs a `map` and a map is only legal at http level. A # drop-in under conf.d cannot declare one, and the usual workaround — passing # the client's Connection header straight through — is wrong for a proxy that # also keeps upstream connections alive. worker_processes auto; error_log /dev/stderr warn; pid /tmp/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # Logs go to the container's streams. A generated app that logs to a file # inside its own filesystem is logging into a volume nobody reads and # filling a disk nobody is watching. access_log /dev/stdout; sendfile on; tcp_nopush on; keepalive_timeout 65; server_tokens off; gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; # "upgrade" when the client is opening a websocket, "close" otherwise. # Forwarding the client's own Connection header instead would tear down the # keepalive to the upstream on every ordinary request. map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream app_backend { # The application server, bound to loopback inside this container. It is # NOT published: the only way in is through this nginx, which is what # makes "the exposed port is 8000" true rather than conventional. server 127.0.0.1:8001; keepalive 16; } server { listen 8000; server_name _; root /usr/share/nginx/html; index index.html; # An upload limit belongs here rather than in the app: nginx refuses an # oversized body before it is buffered, where the app would read it # first and then complain. client_max_body_size 25m; # --- the API ----------------------------------------------------- # Longest-prefix wins in nginx, so this is matched before the SPA # fallback regardless of the order the blocks appear in. location /api { # Whether the prefix is STRIPPED before the backend sees it depends # on the framework, so the recipe supplies the rewrite (or none): # # Django keeps /api/... and strips it itself via # FORCE_SCRIPT_NAME, which is also what makes its # redirects and admin URLs carry the prefix. # FastAPI needs it stripped. uvicorn's --root-path does NOT # strip; it only affects URL generation. # # An explicit `rewrite`, not `proxy_pass http://app_backend/`. The # trailing-slash form looks equivalent and is not: with a prefix # location that has no trailing slash, nginx replaces the matched # "/api" with "/" and the backend receives "//ping" — a double # slash that matches no route, so every endpoint 404s while nginx, # the frontend and the healthcheck all report perfectly fine. proxy_pass http://app_backend; proxy_http_version 1.1; # FastAPI recipes: a 404 with the prefix kept is retried with it # stripped (@api_stripped below), so an app that mounts at the root # and one that mounts under /api both answer. Django: empty. proxy_intercept_errors on; error_page 404 = @api_stripped; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Without this a Django or FastAPI app behind TLS builds http:// # URLs in redirects and password-reset links, and the browser # refuses them as mixed content — a failure that only shows up once # there is TLS in front, which is to say in production. proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; # Upgrade is hop-by-hop, so it has to be forwarded explicitly or a # websocket handshake degrades to a plain request that the app # answers 200 with no socket, and the client waits forever. proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 120s; proxy_send_timeout 120s; # Streaming responses — server-sent events, a slow export — must # not be held in a buffer until they finish, which is exactly what # makes them look like a hang. proxy_buffering off; } # --- the frontend ------------------------------------------------- # The retry target for proxy_intercept_errors on; error_page 404 = @api_stripped;: the same upstream, prefix # stripped. Internal only — a client cannot request it by name. location @api_stripped { rewrite ^/api/?(.*)$ /$1 break; proxy_pass http://app_backend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location / { try_files $uri $uri/ /index.html; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } # index.html must NOT be cached: it names the hashed asset files, so a # cached copy keeps pointing at the previous deploy's bundles. location = /index.html { add_header Cache-Control "no-store, must-revalidate"; } # nginx's own liveness, answered without waking the app. The container # healthcheck uses this, so a backend that is slow to start reads as # starting rather than as a dead container. location = /_nginx_health { access_log off; return 200 "ok\n"; } } }