02. Server blocks, location blocks, proxy_pass — the configuration surface¶
~13 min read. The day-to-day nginx surface is the config file. server blocks, location blocks, proxy_pass directives, headers. This chapter is the catalogue of patterns developers write daily and the rules that decide which block matches.
Builds on: 01-event-loop-workers-internals.md.
The previous chapter explained the runtime. This chapter is what you actually write — the configuration that tells nginx how to route, transform, and respond.
1) The hierarchy: http → server → location¶
# /etc/nginx/nginx.conf — top-level directives
user nginx;
worker_processes auto;
events {
worker_connections 10240;
}
http {
# http-level directives shared by all servers
include mime.types;
default_type application/octet-stream;
sendfile on;
gzip on;
upstream gunicorn {
server 127.0.0.1:8000;
keepalive 32;
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location /static/ {
alias /var/www/static/;
expires 30d;
}
location /api/ {
proxy_pass http://gunicorn;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
proxy_pass http://gunicorn;
proxy_set_header Host $host;
}
}
}
Three nesting levels: http (global), server (per virtual host), location (per URL pattern within a server). Directives inherit downward unless overridden.
2) The server block — virtual hosts¶
server blocks match incoming requests by listener + Host header.
server {
listen 443 ssl http2;
server_name api.example.com;
# ...
}
server {
listen 443 ssl http2;
server_name www.example.com example.com;
# ...
}
server {
listen 443 ssl http2 default_server;
server_name _;
return 444; # close without response — defence against host-header attacks
}
The default server catches requests that don't match any server_name. Best practice: a default server that returns 444 (or 421) so unmatched hosts don't fall through to one of the real servers by accident.
The listen directive accepts modifiers:
default_server— this server handles requests that don't match server_name.ssl— terminate TLS on this listener.http2— enable HTTP/2.reuseport— kernel-level load balancing across workers (Linux 3.9+).
3) The location block — matching URL patterns¶
location blocks match by URL path. The matching rules are surprisingly subtle.
location = /healthz { ... } # exact match — highest priority
location ^~ /static/ { ... } # prefix match, stops regex search
location ~ \.php$ { ... } # regex (case-sensitive)
location ~* \.(jpg|png|gif)$ { ... } # regex (case-insensitive)
location /api/ { ... } # prefix match
location / { ... } # default / fallback
The matching algorithm:
- Check for exact match (
=). If found, use it. - Check prefix matches (no modifier or
^~). Remember the longest match. - If the longest prefix match used
^~, use it. Stop here. - Check regex matches (
~and~*) in config order. First match wins. - If no regex matched, use the longest prefix match from step 2.
The pattern in practice: use = for hot paths (/healthz, /ping), ^~ for static-file prefixes, ~ for extension-based matches, plain prefix for application routes.
Common mistake: assuming order matters for prefix matches (it doesn't — longest prefix wins). Order only matters within the regex set.
4) proxy_pass — sending requests to upstreams¶
upstream gunicorn {
server 127.0.0.1:8000;
server 127.0.0.1:8001;
server 127.0.0.1:8002;
keepalive 32;
}
location /api/ {
proxy_pass http://gunicorn;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_read_timeout 60s;
proxy_connect_timeout 5s;
}
Key points:
proxy_http_version 1.1+Connection ""— required for upstream keepalive to work. Without these, every proxied request opens a new TCP connection.Host $host— forwards the original Host header so the upstream knows which domain it was hit for.X-Forwarded-For— appends the client IP to the chain. The upstream reads the first non-trusted IP as the real client.proxy_read_timeout— how long to wait for upstream response. Default 60s. Tune per workload.
The slash trap:
location /api/ {
proxy_pass http://gunicorn; # forwards /api/orders/ → /api/orders/
}
location /api/ {
proxy_pass http://gunicorn/; # forwards /api/orders/ → /orders/
}
If proxy_pass URL has a trailing slash, nginx strips the matched location prefix. If no slash, it forwards the full URI. This catches every nginx beginner.
5) Headers — request and response¶
Setting request headers (to upstream):
Adding response headers (to client):
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
The always flag adds the header even on error responses (4xx, 5xx). Without it, add_header skips error responses. The always is almost always desired.
A subtle inheritance rule: add_header inside a location block replaces (not adds to) add_header from the outer server block. To get both, repeat the outer headers inside the inner block. This pattern catches teams when security headers disappear from one location.
6) Static file serving¶
location /static/ {
alias /var/www/static/;
expires 30d;
add_header Cache-Control "public, immutable";
location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|woff2)$ {
expires 365d;
add_header Cache-Control "public, immutable";
}
}
alias vs. root is a common confusion:
alias /var/www/static/;— strips the location prefix. A request for/static/css/main.cssreads/var/www/static/css/main.css.root /var/www;— prepends to the full URI. Same request reads/var/www/static/css/main.css.
For prefix-stripping behaviour, use alias. For straight serving, use root. Most production configs use alias for clarity.
Cache-Control with hashed filenames. Modern build tools hash filenames (main.a3b2c1.css). The hash changes on content change. With Cache-Control: immutable, browsers cache forever; new content forces a new filename. This is the cleanest cache strategy.
7) Conditional logic — if is evil (mostly)¶
# DO NOT do this
if ($request_method = POST) {
proxy_pass http://upstream;
}
# DO use this instead
location / {
limit_except GET HEAD POST { deny all; }
proxy_pass http://upstream;
}
The if directive is famously dangerous in nginx; the wiki documents it as "if is evil." It works correctly for limited cases (redirects, return statements) and produces undefined behaviour in others. The pattern: use if only for return, rewrite, or set directives. For routing logic, use multiple location blocks or map.
8) map — config-time lookup tables¶
map $request_uri $rate_limit_zone {
default general;
~^/api/heavy/ heavy;
~^/api/admin/ admin;
}
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=heavy:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=admin:10m rate=5r/s;
location / {
limit_req zone=$rate_limit_zone burst=20 nodelay;
# ...
}
map lets you derive a variable from another. Cleaner than nested if. Useful for routing, rate-limit selection, CORS allowlists, A/B traffic splits.
9) Upstream load balancing¶
upstream backend {
# algorithm — default is round-robin
least_conn; # forwards to least-loaded
# or: ip_hash; # same client IP → same upstream (sticky)
# or: hash $request_uri consistent; # consistent hashing on URI
server backend-1.local:8080 weight=3;
server backend-2.local:8080 weight=1;
server backend-3.local:8080 backup; # used only if others fail
keepalive 32;
keepalive_timeout 60s;
keepalive_requests 1000;
}
Failover is automatic: if backend-1 returns 502 or times out within proxy_next_upstream settings, nginx retries on the next upstream. Configure carefully — automatic retry can mask issues and amplify load.
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
The trade-off: retry on transient failures improves availability; aggressive retry under failure amplifies the failing upstream's load.
10) The threaded example — a full production config¶
worker_processes auto;
worker_rlimit_nofile 65536;
events {
worker_connections 10240;
use epoll;
multi_accept on;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1024;
log_format json escape=json
'{"time":"$time_iso8601","method":"$request_method","host":"$host",'
'"uri":"$request_uri","status":$status,"rt":$request_time,'
'"urt":"$upstream_response_time","client":"$remote_addr",'
'"reqid":"$request_id","ua":"$http_user_agent"}';
access_log /var/log/nginx/access.log json;
upstream app {
server 127.0.0.1:8000;
keepalive 32;
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
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;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
client_max_body_size 100m;
location = /healthz {
access_log off;
return 200 "ok\n";
}
location /static/ {
alias /var/www/static/;
expires 30d;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "public, immutable" always;
}
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
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-Request-Id $request_id;
proxy_read_timeout 60s;
proxy_connect_timeout 5s;
}
}
}
This config gets a small Django/Rails/Node app shipped. TLS terminated. Static files cached. Application proxied with keepalive and forwarded headers. Health check. Security headers. Structured logs.
Operational signals¶
Healthy. nginx -t passes; reload completes cleanly; access log shows expected paths and statuses; $upstream_response_time is the dominant component of $request_time.
First degrading metric. $request_time - $upstream_response_time (nginx-side latency) climbing. Either nginx is CPU-bound (rare, usually means SSL handshakes spiking) or worker_connections is saturating.
Misleading metric. Aggregate request rate. A misconfigured location block can route traffic incorrectly while the aggregate looks fine; the affected endpoint's users see errors.
Expert graph. Per-location latency and status distribution. The cell that lights up is the cell to investigate.
Where this appears in production¶
- Stripe API edge — nginx + custom modules; well-documented Host-header validation.
- Cloudflare — extensive use of
mapfor routing logic at edge. - GitHub Enterprise — nginx for the application edge;
proxy_bufferingtuned for large diffs. - Slack — nginx in the edge for HTTP API; WebSocket routed to dedicated upstream.
- Wikipedia — nginx + Lua (OpenResty) for cache logic at scale.
- A Mumbai e-commerce site —
^~for static prefix to skip regex evaluation on every request. - A Pune SaaS —
map-based rate-limit zone selection; clean separation by endpoint class. - A Bengaluru fintech — security headers consistently re-added per location to avoid the inheritance trap.
Recall / checkpoint¶
- What is the location matching priority order?
- What is the difference between
aliasandroot? - Why is upstream keepalive load-bearing?
- What is the
proxy_passslash trap? - When is
ifsafe and when is it dangerous? - What is
mapand when does it replaceif? - What is the
add_headerinheritance quirk?
Interview Q&A¶
Q1. A team's new endpoint matches the wrong location block. Walk through the diagnosis.
Re-derive nginx's matching priority: exact (=) → longest prefix (with ^~ short-circuit) → regex in config order → longest prefix. The team likely added a regex ~ that catches the new endpoint before the intended prefix. Fix: either use ^~ on the intended prefix to short-circuit regex evaluation, or reorder regex blocks so the more-specific one comes first. Validate with curl -v and access logs. Common wrong answer to avoid: "order is read top to bottom" — only for regex within their set.
Q2. The upstream keepalive isn't working — every request opens a new TCP connection. Walk through the fix.
Verify three things. keepalive 32; (or similar) is in the upstream block. proxy_http_version 1.1; is in the location block. proxy_set_header Connection ""; is in the location block. Without all three, keepalive doesn't kick in: HTTP/1.0 doesn't support it, and the default Connection: close header forces tear-down. Confirm by checking netstat on the upstream — you should see fewer connections in TIME_WAIT after the fix. Common wrong answer to avoid: "keepalive is automatic" — it's three explicit settings.
Q3. The team's security headers are missing on 404 responses. Walk through the fix.
add_header doesn't apply to error responses unless you use the always flag. The fix is to audit every add_header directive and add always. Then verify with curl -I example.com/some-404-path that the headers are present on the 404 response. The inheritance rule still applies — headers in inner location blocks replace outer ones, so they may need to be re-declared. Common wrong answer to avoid: "errors don't need security headers" — they do; a 404 is still HTML rendered to the client.
Q4. A proxy_pass is forwarding /api/orders/ as /orders/. Walk through what happened.
The proxy_pass URL has a trailing slash, which tells nginx to strip the location prefix. The location is /api/; the upstream URL is http://backend/, so /api/orders/ becomes /orders/. Fix depends on intent: if the upstream expects /api/orders/, remove the trailing slash from proxy_pass. If the upstream expects /orders/, leave it. The slash trap is the single most common nginx config bug at this layer. Common wrong answer to avoid: "this is a bug in nginx" — it's documented behaviour, deliberate, and frequently desired.
Q5. A senior engineer wants to use if for routing logic in nginx. Walk through the pushback.
if in nginx is documented as dangerous outside narrow cases (return, rewrite, set). For routing logic, the alternatives are: multiple location blocks (cleaner, faster, well-supported); map directives for variable derivation; limit_except for method-based access control. The pushback: show the wiki page on "if is evil"; show the specific failure modes (location contexts with if produce subtle bugs around try_files and proxy_pass); offer the structural alternative. Common wrong answer to avoid: "if works fine for this case" — it might, but the pattern teaches a habit that breaks elsewhere.
Q6. A new location is added but reloading fails because the test suite catches it. Walk through the CI integration.
nginx has a -t flag that validates the config without applying. CI should run nginx -t -c /path/to/test/config on every commit that touches nginx config. For more thorough testing, spin up the new config against a fixture upstream and run curl-based assertions: curl /healthz expects 200, curl /static/foo.css expects 200 and Cache-Control header, curl /api/test expects upstream response. Treat nginx config as code — review, test, version. Common wrong answer to avoid: "test in staging" — staging catches some issues; CI catches syntax and basic routing at PR time.
Operational memory¶
This chapter explained the nginx configuration surface: server blocks, location blocks, proxy_pass, headers, static files, conditional logic, upstream load balancing, and a full production config. The important idea is that nginx config has subtle rules (location matching order, alias-vs-root, slash trap, if evil) that catch every team; learning the rules is the difference between three days of debugging and three minutes.
You learned to write location blocks with the right matching mode, configure proxy_pass with the right slash and headers, serve static files with appropriate caching, avoid if, use map for routing, and lay out upstream blocks with keepalive. That solves the day-to-day surface; production gotchas come next.
Carry this diagnostic forward: when nginx config behaves unexpectedly, ask which rule was violated — location order, slash trap, header inheritance, or if semantics. Each has a known structural fix.
Remember:
- Location priority: exact →
^~prefix → regex (in order) → plain prefix. aliasstrips the location prefix;rootprepends to the URI.- Upstream keepalive needs three settings, not one.
proxy_passtrailing slash strips the matched prefix.ifis evil except forreturn/rewrite/set.add_header alwaysto cover error responses.
Bridge. The config surface is set. Production has its own catalogue: TLS, caching, rate limiting, the gotchas every team eventually hits. The next chapter is that surface. → 03-tls-caching-prod-gotchas.md