Nginx Cheatsheet
What it is
Nginx is a high-performance web server, reverse proxy, and load balancer that fronts most production web applications. It terminates TLS, serves static assets, and proxies dynamic requests back to an app server (a PHP-FPM pool, a Node process, a Rails/Puma worker) while handling far more concurrent connections than the app behind it.
This sheet covers the config layout and directives you reach for day to day, plus the deployment-specific patterns generic Nginx references skip: the zero-downtime reload that swaps config without dropping a connection, atomic release swaps behind a symlinked document root, reverse-proxy blocks with health checks, and rate-limiting for deploy webhooks.
Quick reference
The nginx binary
nginx -t # test config syntax — ALWAYS run before reload
nginx -T # test AND dump the full resolved config to stdout
nginx -s reload # graceful reload — reread config, no dropped connections
nginx -s reopen # reopen log files (after logrotate)
nginx -s quit # graceful shutdown (finish in-flight requests)
nginx -s stop # fast shutdown (kill workers now)
nginx -v # version
nginx -V # version + compile flags + configured modules
nginx -c /etc/nginx/nginx.conf # run with a specific config file
nginx -p /path/to/prefix # set prefix (useful for testing non-root installs)
nginx -t is the single most important command in a deploy pipeline — it validates the config before you reload, so a typo aborts the deploy instead of taking the site down. nginx -T (capital) is the same test but also prints every effective directive across all included files, which is how you find where a setting actually comes from.
Service management (systemd)
systemctl reload nginx # graceful reload (preferred — sends SIGHUP)
systemctl restart nginx # full stop + start (drops connections — avoid on live traffic)
systemctl start nginx
systemctl stop nginx
systemctl status nginx # running? last reload time? recent errors?
systemctl enable nginx # start on boot
journalctl -u nginx --since "10 min ago" # service-level logs (start/stop/reload failures)
reload (SIGHUP) keeps existing worker processes serving in-flight requests while new workers pick up the new config — no dropped connections. restart tears the master process down and back up, closing every connection. On a live site, reach for reload unless you changed something a reload can't apply (e.g. user, worker_processes in some builds, or the listen socket itself).
Config file layout
/etc/nginx/
├── nginx.conf # main config: events{}, http{}, includes
├── conf.d/*.conf # http-level drop-ins (auto-included by nginx.conf)
├── sites-available/ # all server blocks (Debian/Ubuntu convention)
├── sites-enabled/ # symlinks to the ones actually active
├── snippets/ # reusable fragments (ssl params, fastcgi params)
├── mime.types
├── fastcgi_params
└── modules-enabled/ # dynamic module load directives
# Debian/Ubuntu: enable a site by symlinking available → enabled
ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default # remove the default welcome page
nginx -t && systemctl reload nginx
# RHEL/Fedora/Amazon Linux: no sites-* convention — drop files in conf.d/ directly
Debian and its derivatives use the sites-available / sites-enabled split; RHEL-family distros just use conf.d/*.conf. Both are conventions layered on top of the same include directive in nginx.conf — nothing magic.
Core directives
server {
listen 80; # IPv4 :80
listen [::]:80; # IPv6 :80
listen 443 ssl; # HTTPS (add `http2` on for HTTP/2)
server_name example.com www.example.com;
root /var/www/example/current/public; # document root
index index.html index.php;
# location matching, in priority order:
location = /favicon.ico { ... } # exact match (highest priority)
location ^~ /assets/ { ... } # prefix match, stops regex search
location ~ \.php$ { ... } # case-sensitive regex
location ~* \.(jpg|png|css)$ { ... } # case-insensitive regex
location /api/ { ... } # plain prefix match (lowest priority)
# try_files: attempt each path, fall back to the last (named location or =code)
location / {
try_files $uri $uri/ /index.php?$query_string;
}
return 301 https://$host$request_uri; # redirect (301 permanent, 302 temp)
rewrite ^/old/(.*)$ /new/$1 permanent; # rewrite with regex capture
}
Location matching priority (Nginx picks ONE): exact = wins first, then the longest ^~ prefix, then the first matching regex (~ / ~*) in file order, then the longest plain prefix. This ordering is the single most common source of "why is the wrong block handling my request" confusion.
Reverse proxy
upstream app_backend {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
# keepalive 32; # reuse upstream connections (needs http/1.1 below)
}
server {
listen 80;
server_name app.example.com;
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 X-Forwarded-Host $host;
proxy_set_header Connection ""; # required with keepalive upstreams
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_next_upstream error timeout http_502 http_503;
}
}
The four X-Forwarded-* / X-Real-IP headers are non-negotiable behind a proxy — without them the app sees every request as coming from 127.0.0.1 over http, which breaks IP logging, rate limiting, and https-only redirects. X-Forwarded-Proto $scheme is what lets the app know the original request was HTTPS even though the proxy hop is plain HTTP.
PHP-FPM (FastCGI)
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock; # or 127.0.0.1:9000
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_index index.php;
fastcgi_read_timeout 60s;
}
TLS
server {
listen 443 ssl;
http2 on; # separate directive since Nginx 1.25.1
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 HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers off; # let modern clients pick (TLS 1.3)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
# redirect all HTTP to HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
gzip and static caching
# http{} block — applies site-wide
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
gzip_min_length 1024;
# long cache for fingerprinted assets, no-cache for the HTML entry point
location ~* \.(css|js|woff2|png|jpg|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
Logging
# http{} block
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" rt=$request_time uct=$upstream_connect_time '
'urt=$upstream_response_time';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn; # levels: debug info notice warn error crit
# per-server override
server {
access_log /var/log/nginx/myapp.access.log main;
error_log /var/log/nginx/myapp.error.log;
}
tail -f /var/log/nginx/error.log # watch errors live during a deploy
tail -f /var/log/nginx/access.log | grep ' 5[0-9][0-9] ' # watch for 5xx after a reload
Adding rt=$request_time and urt=$upstream_response_time to the log format is the cheapest way to see whether slowness is Nginx or the upstream app — rt is the total, urt is the time the backend took.
Deployment workflows (the moat)
1. Zero-downtime config reload
The core distinction: reload is graceful, restart is not. A reload sends the master process a SIGHUP. The master rereads the config, spins up new worker processes with the new config, and tells the old workers to stop accepting new connections while they finish the requests already in flight. No connection is ever dropped. A restart kills the master and every worker, then starts fresh — every open connection dies.
The only safe reload sequence tests the config first, so a syntax error aborts before anything changes:
# The canonical deploy-time reload — test, then reload only if the test passes
nginx -t && systemctl reload nginx
# Equivalent without systemd (send SIGHUP directly to the master PID)
nginx -t && nginx -s reload
Because the old workers drain gracefully, you can watch a reload complete:
# Before reload: note the worker PIDs
ps -o pid,ppid,cmd --ppid "$(cat /run/nginx.pid)"
# After `systemctl reload nginx`: old workers linger in "worker process is shutting down"
# state until their last request finishes, then exit. New workers have new PIDs.
ps aux | grep 'nginx: worker'
If nginx -t fails, the reload never runs and the currently-serving config stays live. This is why the && matters: nginx -t; systemctl reload nginx (with a semicolon) would reload even after a failed test.
2. Atomic release swaps with a symlinked current/
The release-directory + current symlink pattern is what makes zero-downtime deploys work with Nginx. The document root points at a symlink, and the deploy swaps that symlink in one atomic filesystem operation — Nginx starts serving the new release on the very next request with no reload needed at all (for pure static/asset changes).
/var/www/myapp/
├── releases/
│ ├── 20260527T140312/ # a git checkout, built and ready
│ ├── 20260527T151901/
│ └── 20260527T163044/ # newest
├── current -> releases/20260527T163044 # the atomic pointer
└── shared/ # .env, uploads, logs — symlinked into each release
server {
listen 80;
server_name myapp.example.com;
root /var/www/myapp/current/public; # points AT the symlink
# ...
}
The swap itself — ln -sfn writes a new symlink and renames it over the old one atomically:
# In the deploy hook, after the new release is built and ready:
ln -sfn /var/www/myapp/releases/20260527T163044 /var/www/myapp/current
# For a proxied app (not just static files), reload so workers reopen file handles
# and any config that references the release path is re-resolved:
nginx -t && systemctl reload nginx
ln -sfn matters: -f forces overwrite, -n treats an existing symlink-to-directory as a file so you replace the link instead of creating one inside the old target. Plain ln -sf into an existing current/ dir creates current/163044 and breaks everything. For a proxied app you still reload so PHP-FPM/opcache and Nginx pick up the new path; for purely static assets the symlink swap alone is enough.
3. Reverse proxy to an app upstream with health-based failover
An upstream block with multiple servers plus proxy_next_upstream gives you request-level failover during a rolling deploy: while one app instance is restarting, Nginx retries the request against a healthy one.
upstream app {
server 127.0.0.1:3000 max_fails=3 fail_timeout=15s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=15s;
server 127.0.0.1:3002 backup; # only used when the others are down
keepalive 32;
}
server {
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# retry the NEXT upstream on connection errors, timeouts, or 502/503
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
}
# a lightweight health endpoint the app answers cheaply
location = /healthz {
proxy_pass http://app;
access_log off;
}
}
max_fails=3 fail_timeout=15s means: after 3 failed attempts, take that server out of rotation for 15 seconds, then try it again. Combined with proxy_next_upstream, a client whose request hits a mid-restart instance is transparently retried against a live one — so a rolling deploy that restarts app processes one at a time never surfaces a 502 to a user. (Passive health checks like this ship with open-source Nginx; active checks are an Nginx Plus feature.)
4. Maintenance mode and canary releases
For a hard maintenance window, short-circuit everything with a 503 and a static page — before the app is even touched:
server {
listen 80;
server_name myapp.example.com;
root /var/www/myapp/current/public;
# If this file exists, everyone gets the maintenance page.
if (-f $document_root/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
root /var/www/myapp/shared/maintenance;
rewrite ^ /maintenance.html break;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
# Enable / disable maintenance without a reload — the `if -f` check is per-request
touch /var/www/myapp/current/public/maintenance.on # site now returns 503
rm /var/www/myapp/current/public/maintenance.on # back to normal
For a weighted canary — send a small slice of traffic to a new release running on a separate upstream — use split_clients to bucket requests deterministically by a hash:
# http{} block — 10% of clients (hashed by IP) go to the canary
split_clients "$remote_addr" $release_pool {
10% canary;
* stable;
}
upstream stable { server 127.0.0.1:3000; }
upstream canary { server 127.0.0.1:4000; }
server {
location / {
proxy_pass http://$release_pool; # variable in proxy_pass
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Bump the 10% up as confidence grows, reload, and the split shifts. Because the bucket is hashed by $remote_addr, a given client stays on the same pool between requests rather than flapping.
5. Trusting X-Forwarded-* behind a load balancer
When Nginx itself sits behind an ELB/ALB or Cloudflare, $remote_addr is the load balancer's IP, not the real client. real_ip fixes this so logs, rate limits, and the app all see the true client address:
# http{} block — trust the forwarded header only from known proxy ranges
set_real_ip_from 10.0.0.0/8; # your LB subnet
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on; # walk the XFF chain past trusted hops
Only list proxy ranges you actually control in set_real_ip_from — trusting X-Forwarded-For from arbitrary clients lets anyone spoof their source IP, which defeats IP allowlists and rate limits.
6. Rate-limiting deploy webhooks and abusive clients
Deploy webhook endpoints and login forms are the two places a rate limit earns its keep. Define a zone once, then apply it per-location with a small burst:
# http{} block — 10 MB zone tracks ~160k IPs; 1 request/sec sustained
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=1r/s;
server {
location = /deploy/webhook {
limit_req zone=webhooks burst=5 nodelay; # allow short bursts of 5, then 503
limit_req_status 429; # return 429 not the default 503
proxy_pass http://app;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
burst=5 nodelay allows a short spike of 5 queued requests to pass immediately, then enforces the 1r/s rate — the nodelay serves the burst without artificially spacing it out. Set limit_req_status 429 so clients get the correct "Too Many Requests" status instead of Nginx's default 503, which retry logic often misreads as a server outage.
Common errors and fixes
| Error / symptom | Cause | Fix |
|---|---|---|
nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use) |
Another process (often Apache, or a stale Nginx master) holds port 80 | `sudo ss -ltnp \ |
nginx: [emerg] "server" directive is not allowed here |
A server {} block is outside the http {} context (e.g. pasted into conf.d but the file isn't included under http, or nested wrong) |
Ensure server blocks live inside http {}; on Debian confirm the file is in sites-enabled and nginx.conf includes it |
502 Bad Gateway |
Upstream is down, wrong proxy_pass address/port, or the app socket doesn't exist |
Check the app is listening (`ss -ltnp \ |
504 Gateway Timeout |
Upstream accepted the connection but didn't respond in time | Raise proxy_read_timeout / fastcgi_read_timeout; but first check why the app is slow — a 504 usually means a slow query or hung worker, not an Nginx problem |
connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied) |
Nginx worker user can't access the FPM socket | Match FPM pool listen.owner/listen.group to the Nginx user (www-data), or set socket mode 0660 |
[emerg] socket() ... failed (24: Too many open files) |
Worker hit the file-descriptor limit under load | Raise worker_rlimit_nofile 65535; in the main context AND the systemd LimitNOFILE=65535 for the service |
413 Request Entity Too Large |
Upload exceeds the default 1 MB body limit | Set client_max_body_size 50m; in http, server, or location (must also raise the app/FPM limit) |
502 only intermittently, (13: Permission denied) while connecting to upstream on RHEL |
SELinux blocks Nginx from making network connections | sudo setsebool -P httpd_can_network_connect 1 (and httpd_can_network_relay 1 for proxying) |
SSL_CTX_use_PrivateKey ... key values mismatch |
Cert and key don't belong together | Compare moduli: `openssl x509 -noout -modulus -in cert.pem \ |
| Reload "succeeds" but changes don't apply | Edited a file in sites-available that isn't symlinked into sites-enabled |
`nginx -T \ |
worker_connections are not enough in error log |
Concurrent connections exceeded worker_connections (default 512/768) |
Raise worker_connections 4096; in the events {} block; ensure the OS fd limit is high enough too |
| Redirect loop after adding HTTPS | App behind proxy redirects to HTTPS but only sees http from the proxy hop |
Send proxy_set_header X-Forwarded-Proto $scheme; and configure the app to trust it |
Companion: full DeployHQ deploy workflow
Nginx is the front door a deploy has to swing without slamming it on live traffic. The release-directory pattern (workflow 2) plus a graceful reload (workflow 1) is exactly what makes a deploy invisible to the user hitting the site mid-release.
For DeployHQ deploys, point your document root at /var/www/myapp/current/public and let DeployHQ handle the release-directory checkout and symlink swap through its zero-downtime deployments — each release lands in a fresh directory and goes live atomically. Run nginx -t && systemctl reload nginx as a post-deploy SSH command inside your build pipeline so a broken config aborts the release instead of shipping it. Pair it with the deploy from GitHub guide for the end-to-end Git → build → release flow.
Start a free DeployHQ trial to wire a zero-downtime Nginx reload into your deploy pipeline.
Related cheatsheets
- systemd cheatsheet — for managing the
nginxservice unit, timers, and theLimitNOFILEbump referenced above. - SSH cheatsheet — for the server access and deploy-key patterns behind every remote
nginx -s reload. - curl cheatsheet — for smoke-testing endpoints and health checks the moment a reload lands.
- rsync cheatsheet — for the atomic release-directory transfers that feed the symlinked
current/root. - Docker cheatsheet — for running Nginx as a container fronting a containerised app.
- Cheatsheets hub — every DeployHQ cheatsheet in one place.
Need help? Email support@deployhq.com or follow @deployhq on X.