
Nginx is often the control plane where an application meets the public internet. It terminates TLS, selects an upstream, decides whether a response can be cached, adds security headers, and produces the evidence you need when a request fails. A reliable configuration is therefore more than a working proxy_pass: it makes routing, freshness, trust boundaries, and failure behavior explicit.
This guide assumes you already know the basics of editing a server block. The examples are deliberately small; adapt hostnames, certificates, application ports, and timeouts to your service, then validate with nginx -t before reloading.
Start with a correct reverse proxy
A minimal proxy should pass the original host and a trustworthy request context to the application. X-Forwarded-For must be built from the address Nginx sees; do not blindly accept a client-supplied forwarding header unless the request came through a proxy you control.
location / {
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;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
The Connection "" setting allows upstream keepalive connections to be reused instead of forwarding a hop-by-hop Connection header. The distinction between proxy_pass http://app_backend; and proxy_pass http://app_backend/; is important: when a URI is supplied, Nginx replaces the part of the normalized request URI that matched the location. A trailing slash can consequently remove a path prefix and turn an expected /api/users into /users. Test both the application route and the resulting upstream route when changing this line.
For more than one application instance, define the upstream and choose a balancing method that matches the workload:
upstream app_backend {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3010 backup;
keepalive 32;
}
location / {
proxy_pass http://app_backend;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
}
Round-robin is the default; NGINX Open Source also supports least_conn, ip_hash, and hash ... consistent. The ip_hash method can provide simple session persistence, but it is not a replacement for shared session storage. The least_time and random methods have version or product constraints, so check the current Nginx documentation before copying an example marketed as a universal feature. The max_fails and fail_timeout settings are passive checks: they react to failed proxied requests rather than actively polling an endpoint. The keepalive 32 setting is a per-worker cache of idle upstream connections, not a limit on total connections.
Cache deliberately: revalidation is not invalidation
A cache is safe only when its key represents every dimension that changes the response. Never cache a personalized response under a key shared by all users or tenants. Bypass private requests explicitly, and include a tenant or authorization dimension in a carefully reviewed key when caching is genuinely appropriate.
proxy_cache_path /var/cache/nginx/app
levels=1:2
keys_zone=app_cache:20m
max_size=2g
inactive=30m
use_temp_path=off;
map $http_authorization $skip_private_cache {
default 1;
"" 0;
}
server {
location /assets/ {
proxy_cache app_cache;
proxy_cache_key "$scheme$proxy_host$request_uri";
proxy_cache_valid 200 10m;
proxy_cache_min_uses 2;
proxy_cache_revalidate on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_bypass $skip_private_cache;
proxy_no_cache $skip_private_cache;
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass http://app_backend;
}
}
proxy_cache_revalidate on performs a conditional request, allowing the upstream to return 304 Not Modified when the stored representation remains current. That is revalidation: the entry stays in the cache while its freshness is checked. Invalidation is different: it explicitly removes or makes an entry unusable. Nginx Open Source has no built-in purge directive. Prefer versioned asset URLs or a content version in the cache key when possible. If hard purge is required, use a carefully restricted third-party module such as ngx_cache_purge, or NGINX Plus’s built-in proxy_cache_purge; never expose a public PURGE endpoint.
proxy_cache_use_stale ... updating supports a stale-while-revalidate pattern during an upstream refresh, while the error and timeout options can keep a service available during a short backend incident. Treat that as a resilience trade-off: define how stale the response may be and ensure users never receive private data from a shared cache. $upstream_cache_status reports states such as MISS, HIT, EXPIRED, UPDATING, and BYPASS, making cache behavior testable rather than anecdotal.
TLS termination and security headers
A typical TLS edge redirects cleartext traffic and applies headers at a scope where they are not accidentally lost:
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
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_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://app_backend;
}
}
The certificate chain should contain the server certificate followed by its intermediates; the private key must be readable only by the intended service account. TLS 1.2 and 1.3 are the current practical baseline in Nginx documentation. For a tailored cipher and protocol profile, use the TLSRef Configurator: its Intermediate profile favors broad compatibility, while Modern is appropriate only when all clients support TLS 1.3. HSTS is difficult to undo for clients that have cached it; add preload only after verifying every subdomain is HTTPS-ready.
always applies these headers to error responses too. Nginx add_header inheritance is easy to miss: a nested location that defines its own add_header can replace inherited headers, so audit the effective configuration with nginx -T. Prefer a carefully designed Content-Security-Policy, including frame-ancestors, over relying only on X-Frame-Options. Do not add the obsolete X-XSS-Protection; current OWASP guidance recommends against it.
Harden the upstream boundary
Set limits based on the application rather than hiding overload behind long queues. Useful controls include proxy_send_timeout, proxy_read_timeout, proxy_buffering, proxy_buffer_size, proxy_buffers, and client_max_body_size. A small request-rate limit can protect login or expensive API endpoints:
http {
limit_req_zone $binary_remote_addr zone=api_rate:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=client_conn:10m;
server {
location /api/ {
limit_req zone=api_rate burst=20 nodelay;
limit_conn client_conn 20;
client_max_body_size 2m;
proxy_pass http://app_backend;
}
}
}
burst absorbs short spikes; nodelay rejects excess requests immediately instead of queueing them. Combine these controls with application authentication and upstream timeouts. They are not a complete DDoS defense, and an overly strict limit can deny legitimate users behind a shared NAT address.
Make failures observable
The default access log is rarely enough to explain a slow or intermittent request. Add upstream timings and cache state to a custom format, then centralize the resulting logs:
http {
log_format proxy_timing '$remote_addr $request $status '
'request_time=$request_time upstream_status=$upstream_status '
'connect=$upstream_connect_time header=$upstream_header_time '
'response=$upstream_response_time cache=$upstream_cache_status';
access_log /var/log/nginx/access.log proxy_timing;
error_log /var/log/nginx/error.log warn;
}
$request_time shows the client-visible duration; the upstream timing variables help separate connection, application, and response delays. A map can conditionally log only errors or slow requests when volume is high. NGINX Open Source can use a custom log_format or syslog for structured collection; JSON output for error_log is an NGINX Plus feature, not an assumption to make about OSS.
Verification and common failure modes
Run nginx -t and inspect nginx -T before a reload. Then test the public and backend-facing behavior:
curl -I https://example.com/health
curl -I https://example.com/assets/app.css
curl -kI https://example.com/
A 404 after proxying often means a proxy_pass path rewrite. Incorrect application routing can come from a missing Host header, while intermittent 502 responses can indicate dead upstreams, incompatible keepalive behavior, or timeouts. Missing security headers usually means a nested add_header scope or a missing always. Unexpected cache hits can indicate a key collision; inspect X-Cache-Status and exclude private responses before increasing cache duration.
For related deployment context, see serving a static Astro site with Nginx, Linux web-workload performance tuning, and the practical security-headers guide. A good Nginx configuration is one you can explain, measure, and roll back—not one that merely passes a syntax check.
Sources and further reading
- NGINX Admin Guide: Reverse Proxy
- NGINX Admin Guide: Content Caching
- NGINX Admin Guide: HTTP Load Balancing
- NGINX Admin Guide: SSL Termination
- NGINX Admin Guide: Logging
- ngx_http_proxy_module and ngx_http_upstream_module
- TLSRef Configurator
- OWASP HTTP Headers Cheat Sheet
- MDN: Strict-Transport-Security
Related areas
Related What I Do
These What I Do pages are matched from the subject matter of this article, creating a cleaner path from educational content to implementation work.
Continue reading
Related articles
Based on shared categories first, then the strongest overlap in tags.


