Servers
Nginx explained
Server blocks, location order, reverse proxies, caching, gzip, Let's Encrypt, redirects, rate limits, and how to test and reload nginx safely.
9 min read
Nginx reads a configuration, starts worker processes, and serves HTTP from that configuration until you reload it. There is no per-request programming language in a stock build. Behaviour comes from directives, and the surprising part is which directive wins when several could match. This note walks the pieces you actually edit: the server block, the location, a reverse proxy, files on disk, compression, TLS, redirects, and a rate limit. Paths below follow the Debian and Ubuntu layout. The binary is the same elsewhere. The directories may not be. Test with nginx -t before every reload. A reload that skips the test is how a one-character mistake takes the site down.
Server blocks
A server block is one site on one or more listeners. It lives inside http. listen chooses the address and port. server_name chooses the hostname.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
return 200 "ok\n";
}
}Nginx picks a server for the address and port, then a location inside it. Names match in this order: exact server_name, longest *.example.com wildcard, longest mail.* wildcard, then the first matching regular expression. If nothing matches, the default_server for that listener is used. With no default marked, the first block on that address and port wins, which is why a new file in sites-enabled sometimes answers the wrong host.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}return 444 closes the connection with no response. Use a 404 if a person might hit the address.
server_name is not a location. It does not match the path. Two server blocks can share a port only if they differ by name or if one is the default. A duplicate listen plus the same server_name is a configuration you will fight with later. nginx -T (capital T) prints the configuration after all the include files are pulled in, which is the way to see that a forgotten symlink in sites-enabled is still loaded. Do not leave editor backups (site.save, site.bak) in sites-enabled. Nginx parses every file in that directory.
Location matching order
A location matches the request URI path, not the query string. Nginx chooses exactly one location. The order in the file is not the order of preference, except among regular expressions. The real order is:
- Exact match (
location = /path). If it hits, the search stops. - Longest prefix match among the remaining locations. Prefix means "the URI starts with this string". If that longest prefix is marked
^~, the search stops and regular expressions are not considered. - Regular expressions,
~for case-sensitive and~*for case-insensitive, in the order they appear. The first one that matches wins, even if a longer prefix also matched. - If no regular expression matched, the longest prefix from step 2 is used.
location / matches every URI, because every path starts with /. It is the fallback, not a special "homepage" block. location /api also matches /apiv2 and /apiary, because it is a prefix, not a directory. location /api/ does not match /apiv2. The trailing slash is the difference between "this tree" and "this string".
location = / {
# only the URI "/", and nothing under it
}
location ^~ /images/ {
# prefix, and regex locations will not override it
}
location ~* \.(gif|jpg|jpeg|png)$ {
# first matching regex wins, unless a ^~ prefix already stopped the search
}
location / {
# everything else
}A request for /images/logo.png is served by ^~ /images/ even though the regex also matches. A request for /photos/logo.png is served by the regex, because the longest ordinary prefix is / and the regex is allowed to override it. A request for / uses the exact match and never looks further.
try_files checks paths in order. The last entry is an internal redirect if it is a URI, or a status if it is =404. Named locations (@fallback) are only reached that way. They are not part of the search above.
location / {
root /var/www/example;
try_files $uri $uri/ /index.html;
}The last entry, /index.html, is an internal redirect. It is matched against locations again. If that location also try_files back to /index.html, you have a loop. For an API, prefer try_files $uri =404 or no try_files at all. A single-page app is the case that wants the index.html fallback. A proxied application is not.
root appends the URI to a directory. location /assets/ { root /var/www/example; } looks for /var/www/example/assets/app.js. alias replaces the matched prefix. location /assets/ { alias /var/www/files/; } looks for /var/www/files/app.js. The trailing slashes must agree. Prefer root unless you are deliberately stripping a prefix.
Reverse proxy
proxy_pass sends the request to another server. Whether the URI is rewritten depends on a slash.
location /api/ {
proxy_pass http://127.0.0.1:3000;
}With no URI path on proxy_pass, /api/users is forwarded as /api/users. With a URI path, the part of the request that matched the location is replaced:
location /api/ {
proxy_pass http://127.0.0.1:3000/;
}/api/users is now forwarded as /users. The trailing slash on proxy_pass is the entire difference. A regex location, or a proxy_pass that contains a variable, does not do this replacement. Variables also force you to declare a resolver. Stay on the literal form until you need otherwise.
The upstream sees nginx as the client unless you pass the original headers. Nginx does not forward Host as the browser sent it unless you say so. Set the headers in the location that proxies.
location / {
proxy_pass http://127.0.0.1:3000;
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;
}$host is the hostname in lower case, without a port when the port is the default. $http_host is the raw Host header, port included. Most applications want $host. An application that generates redirects also has to trust the proxy, or it will ignore X-Forwarded-Proto, emit http:// redirects, and loop. In Express that switch is trust proxy. The header alone does nothing if the app does not read it.
$proxy_add_x_forwarded_for appends $remote_addr to any X-Forwarded-For already on the request. That is correct when nginx is the edge. It is wrong when you copy the header from an untrusted client and then make access-control decisions from it. Only the last address you appended yourself is yours.
WebSocket upgrades need HTTP/1.1 and two headers. map is valid in the http block, not inside server.
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 1h;
}
}The empty Upgrade maps to close so ordinary requests are not treated as a tunnel. Long-lived streams also need a higher proxy_read_timeout than the 60-second default, and proxy_buffering off. Leave buffering on for ordinary HTML and JSON.
client_max_body_size defaults to 1 megabyte. An upload that dies at 1 MB is this directive. Set it on the location that should accept the body.
Static files and caching
A static site is root, index, and try_files. Hashed assets can be cached for a long time. HTML should not.
server {
listen 80;
server_name example.com;
root /var/www/example;
index index.html;
location /assets/ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}expires 30d sets Expires and a Cache-Control max-age. no-cache does not mean "do not store". It means "revalidate before reuse", which is what you want for index.html after a deploy. Long caching belongs on filenames that change when the bytes change.
add_header does not inherit once a location defines any add_header of its own. A HSTS line on the server vanishes inside a location that only adds Cache-Control. Repeat the headers, or keep them at one level. Without always, add_header is also skipped on error responses:
add_header Strict-Transport-Security "max-age=31536000" always;gzip
gzip on compresses responses when the client sends Accept-Encoding: gzip. text/html is included automatically. You list the other text types you serve.
gzip on;
gzip_types text/css application/javascript application/json application/xml image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_vary on;
gzip_proxied any;gzip_min_length skips tiny bodies. Level 5 is a reasonable balance. The default is 1. Do not gzip JPEG, PNG, MP4, or WOFF2. They are already compressed. gzip_vary on adds Vary: Accept-Encoding, so a cache does not serve a gzip body to a client that did not ask. gzip_proxied any matters when nginx is proxying and the upstream did not compress.
Brotli is not in the stock nginx binary. brotli on is an unknown directive unless you installed the module, and nginx -t will say so.
HTTPS with Let's Encrypt
A public certificate from Let's Encrypt lasts 90 days. Certbot renews it when about 30 days remain, via a systemd timer or a cron job, if the install set one up. Confirm the timer rather than discovering the lapse from a browser warning.
The nginx plugin writes the TLS server block for you:
sudo certbot --nginx -d example.com -d www.example.comIf you would rather own the configuration, use webroot. The challenge has to be served over HTTP at /.well-known/acme-challenge/ before nginx redirects everything else to HTTPS.
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}^~ matters. A regex location for files would otherwise steal the challenge. Then:
sudo certbot certonly --webroot -w /var/www/certbot \
-d example.com -d www.example.comPoint nginx at the full chain, not the leaf alone. cert.pem is the leaf. Clients that do not already have the intermediate will fail the handshake.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
}http2 on is the directive added in nginx 1.25.1. On nginx 1.18 and 1.24, which Ubuntu 22.04 and 24.04 ship, that line is an unknown directive. Use listen 443 ssl http2; there instead. The older parameter still works on newer nginx. It is deprecated, not removed.
Allow TLS 1.2 and 1.3 only. For a cipher list, use the Mozilla intermediate profile rather than inventing one. Skip ssl_stapling for Let's Encrypt. Certificates issued from 7 May 2025 have no OCSP URL, and the OCSP service was shut down on 6 August 2025. Browsers take revocation from certificate revocation lists.
Keep the private key out of the web root and out of git. A renewal hook should reload nginx only after a successful test:
#!/bin/sh
nginx -t && systemctl reload nginxPut that in /etc/letsencrypt/renewal-hooks/deploy/ and mark it executable. sudo certbot renew --dry-run checks the renewal path without replacing the live certificate.
The HTTPS server and any www server that browsers will open with https:// both need a certificate that lists that name. A port-80 redirect does not cover a user who already typed https://www. Issue the name on the certificate, then redirect.
Redirects
return stops processing and sends the response. Prefer it to rewrite for a straight redirect. Use 302 while you are testing. Browsers cache 301 aggressively, and a mistaken 301 will stick in a client you cannot easily refresh.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}$request_uri keeps the path and the query string. $uri drops the query, so a redirect built from it loses ?id=1.
A loop is usually the app redirecting to HTTP because it ignored X-Forwarded-Proto, two server blocks redirecting at each other, or www and the apex doing the same. curl -sI should show a single hop. For one moved path, location = /old { return 301 /new; } is easier to read than rewrite.
Rate limiting
limit_req_zone is defined in the http block. It allocates shared memory and a rate. The location opts in.
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
}$binary_remote_addr is the client address in a compact form, so the zone holds more keys than it would with the string $remote_addr. rate=10r/s is ten requests per second per key. r/m is per minute. burst=20 allows a short queue above the rate. Without nodelay, those burst requests are delayed to smooth the rate. With nodelay, they are handled immediately and still count against the burst. The default rejection status is 503. limit_req_status 429 is the code clients already treat as "slow down".
Behind a CDN, $remote_addr is the CDN, and every visitor shares one bucket. The real-IP module can replace it, but only from addresses you trust:
set_real_ip_from 203.0.113.0/24;
real_ip_header CF-Connecting-IP;Use the CDN's published ranges, not 0.0.0.0/0. If any client can set the header, they can dodge the limit or pin it on someone else. limit_conn_zone is the sibling for concurrent connections rather than request rate.
Testing and reloading
nginx -t parses the configuration and opens the files it references, including certificates. A missing semicolon, an unknown directive, or a certificate path that does not exist fails here, with a line number. The running workers keep the old configuration until a reload succeeds.
sudo nginx -t && sudo systemctl reload nginxsystemctl reload nginx and nginx -s reload are graceful. Workers finish current requests and exit. New workers take the new configuration. systemctl restart nginx drops connections. Prefer reload. If the test fails, do not reload. If a reload fails, the old workers are still serving, and the error is in error.log as well as on the terminal.
sudo nginx -T prints the configuration after includes, which is how you find the file that overrode a header. Then curl -sI the public URL and check that HSTS survived and the redirect is a single hop.
if inside a location is not a normal conditional. It runs in the rewrite phase and interacts badly with return and rewrite. Prefer two server blocks, a map, or another location. The location algorithm and the proxy_pass slash are the two rules worth memorising.