Files
template-tutoring-admin-das…/nginx.conf
T

160 lines
6.6 KiB
Nginx Configuration File

# 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";
}
}
}