What Nginx actually is
Nginx (pronounced “engine-x”) is a web server built around an event-driven, asynchronous architecture. Instead of one thread or process per connection, a small number of worker processes handle thousands of connections concurrently by reacting to events. That design is why it can sit in front of a slow application without falling over, and why it became the default front door for the modern web.
It plays three roles that are easy to blur together. It is a static web server, happily returning files from disk. It is a reverse proxy, forwarding requests to another server and relaying the response. And it is a load balancer, spreading requests across a pool of backends. In front of a Node, Python, Go or PHP app, you will use all three. Nginx is not part of your application: it is a separate process with its own configuration, lifecycle and logs.
Why put it in front of your app
You could bind Node directly to port 80. People do it in tutorials, and it works until it does not. Putting Nginx in front buys you a set of capabilities that are tedious or dangerous to build into an application.
TLS termination. Nginx holds the certificate, performs the handshake and decrypts. Your app speaks plain HTTP on localhost and never manages certificate renewal. Certbot can even rewrite the config for you.
Static files, compression and caching. Nginx serves files with sendfile and kernel caching, compresses with gzip or brotli, and can replay a stored response without touching the upstream at all.
Rate limiting and request limits. Abuse is stopped at the edge, before it consumes a connection pool or a database query.
A single entry point. One public address and certificate can front many services, routed by hostname or path. You can move an app to another port or host without changing DNS.
Zero-downtime deploys. An upstream can drain one backend while it restarts, and a config reload is graceful. Directly exposed Node drops every in-flight connection on restart.
The configuration model
Nginx configuration is a tree of contexts and directives. A directive is a setting ending in a semicolon; a context is a block enclosed in braces that contains more directives. Directives are inherited downward, and the most specific context wins.
# /etc/nginx/nginx.conf (main context)
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/css application/javascript application/json;
# site configs are pulled in here
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
The contexts you will work in are:
- main — global settings such as
userandworker_processes. - events — how connections are handled.
- http — everything HTTP: MIME types, logging, compression, and the includes that pull in site files.
- server — one virtual host, selected by
listenandserver_name. - location — a path inside a server, where requests are actually handled.
On Debian and Ubuntu the convention is to keep one file per site in /etc/nginx/sites-available/ and symlink the enabled ones into /etc/nginx/sites-enabled/. That gives you an easy way to disable a site without deleting it.
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo rm /etc/nginx/sites-enabled/app
Directives are not a scripting language. There are no loops or variables in the usual sense — only map, if (used sparingly) and includes. If a problem feels like it needs control flow, the answer is usually a map or a change to the application.
Your first server block
A server block is a virtual host. It listens on a port, answers for one or more names, and contains location blocks.
server {
listen 80;
server_name app.example.com www.app.example.com;
root /var/www/app/dist;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
server_name is matched against the Host header. The first server block for a port is the default server and catches requests whose host matches nothing else, so it is worth making that one an explicit catch-all that returns 444 or a maintenance page rather than leaking an unintended site.
Matching order matters and trips people up. location blocks are tried as exact matches (=), then the longest prefix match, then regexes (~ and ~*). A ^~ prefix tells Nginx to stop and use that prefix without checking regexes. When behaviour seems wrong, this ordering is usually why.
location = /favicon.ico { log_not_found off; access_log off; }
location ^~ /assets/ { expires 1y; }
location ~* \.(jpg|png|css|js)$ { expires 30d; }
location / { try_files $uri /index.html; }
Proxying to a Node app
The core directive is proxy_pass. Point it at your application and Nginx becomes a reverse proxy.
upstream app {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 30s;
proxy_connect_timeout 5s;
}
}
Defining an upstream block rather than inlining the address is not just for multiple servers. It is what lets you set keepalive, so Nginx reuses connections to the backend instead of opening a new TCP connection for every request. proxy_http_version 1.1 is required for keepalive and for WebSockets.
Timeouts deserve thought. proxy_connect_timeout bounds how long Nginx waits to establish the connection; proxy_read_timeout bounds how long it waits for the next byte from the upstream. The read timeout must exceed your slowest legitimate response, or Nginx will return a 504 while the app is still working.
The trailing-slash rule is the classic gotcha. proxy_pass http://app; forwards the full path unchanged. proxy_pass http://app/; replaces the matched location prefix with /. Inside location /api/, the first sends /api/users and the second sends /users. Pick deliberately and check the upstream log.
Forwarding headers and why your app cares
By default the upstream sees the request as coming from Nginx: the client address is 127.0.0.1, the scheme is http, and the Host header may be missing. That breaks logging, redirects, cookies and rate limiting inside the app.
location / {
proxy_pass http://app;
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;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
Each header earns its place:
- Host preserves the original domain, so the app can build correct absolute URLs and virtual hosts.
- X-Real-IP is the immediate client address.
- X-Forwarded-For appends to a chain;
$proxy_add_x_forwarded_forkeeps any existing entries and adds the current client. Use the first entry as the original client, and never trust it blindly. - X-Forwarded-Proto tells the app whether the user connected over HTTPS, which fixes redirect loops and secure-cookie bugs.
- X-Forwarded-Host and X-Forwarded-Port help when Nginx listens on a non-standard port.
Your framework must be told to trust this proxy. In Express, app.set("trust proxy", 1) makes req.ip and req.protocol read the forwarded values. Skip that and every request appears to come from localhost, which quietly breaks per-IP rate limiting and geolocation.
Serving static files and the SPA fallback
Nginx is excellent at static files, so let it do that job. A common split is to serve the built frontend from disk and proxy only the API.
server {
listen 80;
server_name app.example.com;
root /var/www/app/dist;
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://app;
proxy_set_header Host $host;
}
location / {
try_files $uri /index.html;
}
}
For a single-page app, try_files $uri /index.html is the fallback that makes client-side routing work: if the requested path is not a file, return index.html and let the router handle it. For an API, the same pattern with =404 is better, because returning HTML for a missing endpoint hides bugs.
Assets with content hashes in their names can be cached forever — immutable and a one-year expiry are safe because a change produces a new filename. index.html must never be cached that way, or users keep loading an old app.
TLS with Let’s Encrypt and certbot
Certbot obtains free certificates from Let’s Encrypt and can configure Nginx automatically. Install the plugin, run it once per domain, and it handles the rest.
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com -d www.app.example.com
sudo certbot renew --dry-run
Certbot writes the certificate paths into your server block and installs a renewal timer. Certificates last 90 days; renewal is automatic but only works if the renewal check can reach your server on port 80, so do not firewall it off entirely.
The resulting block looks like this, with an HTTP server that redirects and an HTTPS server that terminates.
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
TLSv1.2 and TLSv1.3 are the only protocols worth enabling today. HSTS tells browsers to refuse plain HTTP for a year, so enable it only once you are confident HTTPS works everywhere — it is hard to undo.
HTTP/2 and HTTP/3
HTTP/2 multiplexes many requests over one connection and is enabled per server. On modern Nginx (1.25.1 and later) the directive is http2 on;. On older versions it is a flag on the listen line: listen 443 ssl http2;.
server {
listen 443 ssl;
http2 on;
# ...
}
HTTP/3 runs over QUIC and UDP. It needs a build with the QUIC module, a separate listen 443 quic reuseport; line and an Alt-Svc: h3=":443"; ma=86400 header to advertise it. Treat it as an optimisation to add once HTTP/2 is solid, not a first step. Browsers negotiate both over TLS automatically, so there is nothing to change in your app.
Load balancing methods
An upstream block defines a pool, and the balancing method decides which server gets the next request. The default is round-robin.
upstream app {
least_conn;
server 10.0.0.11:3000 weight=2 max_fails=3 fail_timeout=15s;
server 10.0.0.12:3000;
server 10.0.0.13:3000 backup;
keepalive 64;
}
- Round-robin (default) cycles through servers. Simple and even when requests cost roughly the same.
least_connsends to the server with the fewest active connections. Better when request durations vary widely, which is common for APIs.ip_hashpins a client to one server by hashing the address. Useful for in-memory sessions, at the cost of uneven load and lost stickiness when a server fails.weightbiases traffic toward a stronger machine or during a canary.backupmarks a server that only receives traffic when the primaries are down.
max_fails and fail_timeout give you passive health checking: after max_fails failures within fail_timeout, Nginx marks the server down for that window. proxy_next_upstream then decides which failures are retried on another backend.
proxy_next_upstream error timeout http_502 http_503 http_504;
Retrying is powerful but not free: it only makes sense for idempotent requests. Do not blindly retry a POST that may have already changed state unless the upstream is idempotent by design.
Health checks and failure handling
Nginx OSS has passive checks; active health checks (health_check with match) require Nginx Plus. Passive checks plus an application-level endpoint get most teams a long way: expose /healthz that checks the dependencies it needs — database, cache — and returns quickly, then point your platform’s liveness probe at it. Use it to gate deploys by starting a new backend, waiting until it is healthy, then removing the old one from the upstream.
Because config reloads are graceful, the zero-downtime pattern is to edit the upstream to point at the new instance, nginx -t, reload, then drain and stop the old one.
Caching with proxy_cache
proxy_cache stores upstream responses on disk and serves them without contacting the backend. For read-heavy endpoints that change slowly, this is the largest single win available at the edge.
proxy_cache_path /var/cache/nginx levels=1:2
keys_zone=app:10m max_size=1g inactive=60m;
server {
listen 80;
server_name app.example.com;
location /api/public/ {
proxy_pass http://app;
proxy_cache app;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 10s;
proxy_cache_valid 404 1m;
proxy_cache_bypass $http_authorization $cookie_session;
proxy_no_cache $http_authorization;
add_header X-Cache-Status $upstream_cache_status;
}
}
proxy_cache_valid sets how long each status code is cached. proxy_cache_bypass skips the cache for requests carrying a session or authorization header; proxy_no_cache prevents storing their responses, so one user’s private data is never served to another. The X-Cache-Status header (HIT, MISS, BYPASS, EXPIRED) is the fastest way to prove caching works.
Two cautions. Cache only responses that are safe to share — public, non-personalised data — and make sure the upstream sends a Cache-Control header your config agrees with. A cache key that ignores a variant (a language header, a query parameter) will happily serve the wrong content.
Rate limiting with limit_req
Rate limiting protects login endpoints, search and public APIs from abuse and accidental floods. It is defined in the http context and applied in a location.
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_status 429;
server {
listen 80;
server_name app.example.com;
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app;
}
location = /api/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://app;
}
}
The zone key is usually $binary_remote_addr (the client IP), and the rate is requests per second or per minute. burst allows short spikes, and nodelay processes the burst immediately rather than spacing it out — which is what you want for interactive endpoints. Without nodelay, excess requests are delayed, which can make a UI feel sluggish instead of simply returning 429.
Behind another proxy or a CDN, $binary_remote_addr may be the proxy’s address, so the limiter treats everyone as one client. Fix that by configuring set_real_ip_from and real_ip_header so Nginx sees the real client. Also return 429 rather than the default 503 so clients can distinguish rate limiting from an outage.
WebSocket upgrade
A WebSocket connection starts as an HTTP request with an Upgrade header, then becomes a long-lived bidirectional stream. Proxying it needs the upgrade headers and a Connection value that depends on the request, so a map is used.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
The long read timeout matters: an idle WebSocket with a short timeout is closed by Nginx, and the client sees a mysterious disconnect. Note that HTTP/2 does not carry WebSocket in the same way; browsers open a separate HTTP/1.1 connection for the upgrade, which Nginx handles automatically on the same port.
Running Nginx in Docker
The official nginx image is a convenient way to ship config and static files with your app. The one rule is that the container’s main process must stay in the foreground, so the command ends with daemon off;.
# Dockerfile
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/app.conf
COPY dist/ /usr/share/nginx/html/
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
In Compose, the app service is reachable by its service name, so the upstream host is simply app. Nginx starts before the app is ready, so either add a healthcheck and depend on it, or let Nginx retry.
# docker-compose.yml
services:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/app.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
app:
condition: service_healthy
app:
build: .
expose:
- "3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 5s
retries: 10
Note expose rather than ports on the app: it should only be reachable inside the Compose network, never directly from the host. The nginx config’s upstream becomes server app:3000;.
Testing, reloading and zero-downtime config changes
Every config change should follow the same loop: validate, reload, verify. A reload is graceful; a restart is not.
sudo nginx -t # test the configuration
sudo nginx -T # test and dump the full effective config
sudo systemctl reload nginx # graceful, no dropped connections
sudo systemctl status nginx
curl -I https://app.example.com
nginx -T is underused and extremely helpful: it prints the merged configuration exactly as Nginx sees it, which settles arguments about inheritance and includes. If -t fails, the error names the file and line.
Reloading keeps a shell open in a second session, so a bad config cannot lock you out. In a container, the equivalent is sending SIGHUP to the master process or running nginx -s reload. Never restart a busy production Nginx to apply a config change when a reload will do.
Best practices
- Run
nginx -tbefore every reload; never edit and restart blindly. - Keep one file per site and enable it with a symlink so changes are reversible.
- Define an
upstreamblock withkeepaliveinstead of inlining the backend address. - Always forward
Host,X-Real-IP,X-Forwarded-ForandX-Forwarded-Proto, and settrust proxyin the app. - Serve static assets and hashed bundles from disk with long, immutable cache headers.
- Redirect HTTP to HTTPS and enable HSTS only after HTTPS is proven.
- Cache only public, non-personalised responses, and never key a cache without its variants.
- Rate limit login and public endpoints, and return 429 rather than 503.
- Long timeouts for WebSocket locations, short ones for health checks.
- Keep secrets and certificates out of images; mount them read-only at runtime.
Common mistakes
- Skipping
nginx -tand taking the site down with a syntax error. - Forgetting
X-Forwarded-Proto, then debugging an infinite HTTPS redirect loop. - Not enabling
trust proxyin the app, so every request looks like it came from localhost. - Confusing
proxy_passwith and without a trailing slash and getting 404s. - Setting
proxy_read_timeoutshorter than a legitimate slow response and returning 504s. - Caching personalised responses and serving one user’s data to another.
- Proxying WebSockets without the upgrade
map, then seeing connections drop immediately. - Leaving the default server as the first site block and exposing an unintended app.
- Enabling HSTS before HTTPS works everywhere and locking users out for a year.
- Exposing the app service with
portsin Compose instead ofexpose.
Where to go next
Nginx is the front door, and the HTTP / HTTPS guide explains the protocol it terminates and forwards. To run it beside your app reproducibly, read Docker & Deployment, and to keep the host it runs on healthy, revisit Linux. When you are ready to put it on the public internet with certificates, DNS and autoscaling, the Cloud Deployment guide ties the pieces together.