## 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

```bash
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)

```bash
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
```

```bash
# 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

```nginx
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

```nginx
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)

```nginx
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

```nginx
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

```nginx
# 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

```nginx
# 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;
}
```

```bash
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:

```bash
# 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:

```bash
# 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
```

```nginx
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:

```bash
# 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.

```nginx
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:

```nginx
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;
    }
}
```

```bash
# 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:

```nginx
# 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:

```nginx
# 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:

```nginx
# 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 \| grep ':80'` to find the PID, stop it (`systemctl stop apache2`), then start Nginx |
| `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 \| grep 3000`); confirm `proxy_pass` host:port matches; for FPM check the socket path in `fastcgi_pass` exists |
| `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 \| md5sum` vs `openssl rsa -noout -modulus -in key.pem \| md5sum` — they must match |
| Reload "succeeds" but changes don't apply | Edited a file in `sites-available` that isn't symlinked into `sites-enabled` | `nginx -T \| grep -A3 server_name` to see what's actually loaded; add the missing symlink |
| `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](https://www.deployhq.com/features/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](https://www.deployhq.com/features/build-pipelines) so a broken config aborts the release instead of shipping it. Pair it with the [deploy from GitHub guide](https://www.deployhq.com/deploy-from-github) for the end-to-end Git → build → release flow.

[Start a free DeployHQ trial](https://www.deployhq.com/signup) to wire a zero-downtime Nginx reload into your deploy pipeline.

---

## Related cheatsheets

- [systemd cheatsheet](https://www.deployhq.com/cheatsheets/systemd) — for managing the `nginx` service unit, timers, and the `LimitNOFILE` bump referenced above.
- [SSH cheatsheet](https://www.deployhq.com/cheatsheets/ssh) — for the server access and deploy-key patterns behind every remote `nginx -s reload`.
- [curl cheatsheet](https://www.deployhq.com/cheatsheets/curl) — for smoke-testing endpoints and health checks the moment a reload lands.
- [rsync cheatsheet](https://www.deployhq.com/cheatsheets/rsync) — for the atomic release-directory transfers that feed the symlinked `current/` root.
- [Docker cheatsheet](https://www.deployhq.com/cheatsheets/docker) — for running Nginx as a container fronting a containerised app.
- [Cheatsheets hub](https://www.deployhq.com/cheatsheets) — every DeployHQ cheatsheet in one place.

---

Need help? Email [support@deployhq.com](mailto:support@deployhq.com) or follow [@deployhq on X](https://x.com/deployhq).