# Nginx

> Configure Nginx as a reverse proxy, TLS terminator and static file server, understand which server and location handle a request, and read the logs when it returns 502.

Canonical: https://www.wiki.jodisand.me/nginx/
Reviewed: 2026-09-24
Related: [Caddy](https://www.wiki.jodisand.me/caddy/index.md), [Traefik](https://www.wiki.jodisand.me/traefik/index.md), [TLS and certificates](https://www.wiki.jodisand.me/tls/index.md), [HTTP and curl](https://www.wiki.jodisand.me/http/index.md), [DNS](https://www.wiki.jodisand.me/dns/index.md), [systemd](https://www.wiki.jodisand.me/systemd/index.md)


## Cheatsheet

| Task | Command |
| --- | --- |
| Check syntax before touching the running process | `nginx -t` |
| Dump the full effective configuration, includes resolved | `nginx -T` |
| Reload without dropping connections | `nginx -s reload` or `systemctl reload nginx` |
| Version and compiled-in modules | `nginx -V` |
| Which config file is in use | `nginx -V 2>&1 \| grep -o -- '--conf-path=[^ ]*'` |
| Follow the error log | `tail -f /var/log/nginx/error.log` |
| Find the server that handles a name | `nginx -T \| grep -n 'server_name'` |
| Is it listening where I think | `ss -ltnp \| grep nginx` |
| Test a name without DNS | `curl -sv --resolve example.com:443:127.0.0.1 https://example.com/` |
| Test the upstream directly | `curl -sv http://127.0.0.1:8080/` |
| Requests per status code today | `awk '{print $9}' /var/log/nginx/access.log \| sort \| uniq -c \| sort -rn` |
| Slowest requests (with `rt=$request_time` logged) | `grep -oE 'rt=[0-9.]+ .*' access.log \| sort -t= -k2 -rn \| head` |
| Live connection counts | `curl -s http://127.0.0.1/nginx_status` (needs `stub_status`) |
| Worker processes and their state | `ps -o pid,user,stat,cmd -C nginx` |
| Open files of a worker | `ls /proc/$(pgrep -f 'nginx: worker' \| head -1)/fd \| wc -l` |
| Generate a basic-auth file | `htpasswd -B -c /etc/nginx/.htpasswd alice` |
| Increase the upload limit | `client_max_body_size 100m;` |
| Increase the upstream timeout | `proxy_read_timeout 300s;` |
| Debug one client only | `debug_connection 192.0.2.10;` inside `events {}` |
| Redirect everything to HTTPS | `return 301 https://$host$request_uri;` |

Directives below are for Nginx 1.28 (stable) and 1.29 (mainline); `http2 on` needs 1.25.1 and HTTP/3 needs 1.25.0 built with a QUIC-capable TLS library. Reference: the [Nginx directive index](https://nginx.org/en/docs/dirindex.html).

## How a request is handled

A master process reads the configuration and forks workers. Workers accept connections, pick the `server` block that matches the listen socket and `Host`, pick the `location` that matches the URI, then run the phases for that location: rewrite, access (auth, `allow`/`deny`, `limit_req`), content (`proxy_pass`, `try_files`, `root`), log. Configuration is a tree of contexts (`main`, `events`, `http`, `server`, `location`) and most directives are inherited downward unless a child sets the same directive, in which case the child's value replaces the parent's completely.

```
/etc/nginx/nginx.conf            # main, events, http; includes the rest
/etc/nginx/conf.d/*.conf         # one server block per file (Debian also uses sites-available and sites-enabled)
/etc/nginx/snippets/*.conf       # reusable fragments pulled in with include
/etc/nginx/mime.types
/var/log/nginx/{access,error}.log
/var/cache/nginx/                # proxy_cache_path and temp files
```

```nginx
user  nginx;
worker_processes  auto;            # one per CPU
error_log  /var/log/nginx/error.log warn;
pid        /run/nginx.pid;

events {
    worker_connections  4096;      # per worker; each proxied request uses two
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile      on;
    tcp_nopush    on;
    keepalive_timeout  65;
    server_tokens off;             # drop the version from headers and error pages
    include /etc/nginx/conf.d/*.conf;
}
```

`nginx -t` parses the whole tree and opens the files it references, so it also catches unreadable certificates and missing log directories. `nginx -s reload` sends `SIGHUP` to the master, which tests the configuration, starts new workers with it and asks the old workers to exit once their connections finish. A reload that fails validation leaves the old workers running and writes the error to the error log; the exit status of `systemctl reload nginx` reflects it.

## Server selection

Nginx first narrows to the `server` blocks whose `listen` matches the address and port the connection arrived on. Among those it compares `Host` (or the TLS SNI name, then `Host`) against `server_name` in this order:

1. Exact name (`example.com`).
2. Longest wildcard starting with `*` (`*.example.com`).
3. Longest wildcard ending with `*` (`api.*`).
4. Regular expressions (`~^(?<tenant>.+)\.example\.com$`), in the order they appear; first match wins.
5. The `default_server` for that listen socket, or the first server block defined for it when none is marked.

```nginx
server {                                   # catch-all: refuse unknown names instead of serving the first vhost
    listen 80 default_server;
    listen 443 ssl default_server;
    ssl_reject_handshake on;               # 1.19.4+: abort TLS for names you do not serve
    return 444;                            # close the connection without a response
}

server {
    listen 443 ssl;
    http2 on;
    server_name example.com www.example.com;
    ...
}
```

A missing `default_server` is the usual reason an IP-address scan hits your first site with the wrong certificate. `listen 443 ssl` and `listen 443` cannot coexist for the same address without a `ssl` mismatch error; put HTTP and HTTPS in separate server blocks.

## Location matching

| Modifier | Meaning | Precedence |
| --- | --- | --- |
| `= /path` | Exact match | Wins immediately |
| `^~ /path/` | Prefix match that stops regex evaluation | Taken if it is the longest prefix |
| `~ regex` | Case-sensitive regex | First matching regex in file order |
| `~* regex` | Case-insensitive regex | Same as `~` |
| `/path/` | Prefix match | Longest prefix, used only if no regex matches |

The algorithm: check exact matches, then find the longest prefix match and remember it; if that prefix has `^~`, use it; otherwise test every regex location in order and use the first that matches; if none matches, use the remembered prefix. A regex therefore beats a longer plain prefix, which surprises people who expect `location /images/` to win over `location ~ \.png$`.

```nginx
location = / { ... }                       # only the root URI
location / { ... }                         # everything not matched more specifically
location ^~ /static/ { ... }               # static files, and no regex gets a look-in
location ~* \.(png|jpg|css|js)$ { ... }    # any other path with these extensions
location /api/ { ... }                     # would lose to the regex above for /api/logo.png
```

`nginx -T` prints the effective order, and `add_header X-Location "$uri -> static" always;` inside a candidate block is the fastest way to confirm which one fired. Nested locations are allowed inside prefix locations, and named locations (`@fallback`) are reachable only from `try_files` and `error_page`.

## Reverse proxy

`proxy_pass` needs the upstream address, the headers the application relies on and, for anything long-lived, the WebSocket upgrade handling. Nginx speaks HTTP/1.0 to upstreams unless `proxy_http_version 1.1` is set, and it does not forward hop-by-hop headers such as `Upgrade`.

```nginx
map $http_upgrade $connection_upgrade {    # in the http context
    default upgrade;
    ''      close;
}

upstream my-app {
    server 127.0.0.1:8080;
    keepalive 32;                          # idle connections to keep per worker
}

server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;

    location / {
        proxy_pass http://my-app;
        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;   # appends to an existing header
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header Upgrade           $http_upgrade;               # WebSocket
        proxy_set_header Connection        $connection_upgrade;         # "upgrade" for WS, "" (keepalive) otherwise
        proxy_read_timeout  300s;          # idle WebSocket connections are closed after this
        proxy_send_timeout  300s;
        proxy_connect_timeout 5s;
    }

    location /events/ {                    # server-sent events or streaming responses
        proxy_pass http://my-app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffering off;               # deliver each chunk as it arrives
        proxy_read_timeout 1h;
    }
}
```

Setting `proxy_set_header` in a location discards every `proxy_set_header` inherited from the server block, so keep the full set together in a snippet and `include` it. The `map` with `''` mapped to `close` matters: it keeps `Connection: upgrade` for WebSocket and an empty header (keepalive) for normal requests, which is what `keepalive` in the upstream needs.

The URI handed to the upstream depends on whether `proxy_pass` carries a path:

```nginx
location /api/ { proxy_pass http://my-app; }        # /api/users -> /api/users
location /api/ { proxy_pass http://my-app/; }       # /api/users -> /users (the matched prefix is replaced)
location /api/ { proxy_pass http://my-app/v2/; }    # /api/users -> /v2/users
location ~ ^/api/ { proxy_pass http://my-app/; }    # error: a URI part is not allowed with a regex location
```

Upstream names are resolved once at startup unless a `resolver` is set and the name is held in a variable. In containers, where the backend IP changes on restart, use the variable form:

```nginx
resolver 127.0.0.11 valid=30s ipv6=off;    # Docker's embedded DNS; use the host resolver elsewhere
set $backend http://my-app:8080;
proxy_pass $backend$request_uri;           # with a variable, Nginx no longer rewrites the URI for you
```

## TLS, HTTP/2 and HTTP/3

```nginx
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;             # HTTP/3; reuseport is required with several workers
    listen [::]:443 quic reuseport;
    http2 on;
    http3 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;   # leaf plus intermediates
    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:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;         # let modern clients choose; the list above has no weak entries
    ssl_ecdh_curve X25519:prime256v1:secp384r1;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;      # about 40k sessions
    ssl_session_tickets off;

    add_header Alt-Svc 'h3=":443"; ma=86400';                 # tell browsers HTTP/3 is available
    add_header Strict-Transport-Security "max-age=63072000" always;

    location / { proxy_pass http://my-app; ... }
}

server {
    listen 80;
    listen [::]:80;
    server_name example.com;
    location /.well-known/acme-challenge/ { root /var/www/acme; }   # keep HTTP-01 working
    location / { return 301 https://$host$request_uri; }
}
```

`ssl_ciphers` only affects TLS 1.2; TLS 1.3 cipher suites are fixed by the library. `reuseport` on the QUIC listener is mandatory when `worker_processes` is more than one, and UDP 443 must be open in the firewall. Check `nginx -V` for `--with-http_v3_module` before enabling `http3`. OCSP stapling (`ssl_stapling on`) is harmless but Let's Encrypt stopped serving OCSP responses in 2025, so it adds nothing for those certificates. Use `openssl s_client -connect example.com:443 -servername example.com` and the [TLS](https://www.wiki.jodisand.me/tls/#testing-a-server) page to verify the chain the server presents.

## Static files and caching

```nginx
server {
    listen 443 ssl;
    http2 on;
    server_name www.example.com;
    root /srv/www/example.com;             # request /docs/a.html -> /srv/www/example.com/docs/a.html
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;  # single-page app: unknown paths get the shell
    }

    location /downloads/ {
        alias /srv/files/;                 # request /downloads/a.zip -> /srv/files/a.zip; trailing slashes must match
    }

    location ~* \.(css|js|woff2|png|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    gzip on;
    gzip_types text/plain text/css application/json application/javascript image/svg+xml;
    gzip_min_length 1024;
}
```

`root` appends the full URI to the path; `alias` replaces the matched prefix. Using `alias` in a regex location requires captures in the target, and `alias` with `try_files` has long-standing bugs; prefer `root` wherever the directory layout allows it.

A proxy cache stores upstream responses on disk keyed by URI:

```nginx
proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=app:10m max_size=2g inactive=60m use_temp_path=off;

location /api/catalogue/ {
    proxy_pass http://my-app;
    proxy_cache app;
    proxy_cache_key $scheme$host$request_uri;
    proxy_cache_valid 200 301 10m;         # only when the upstream sends no Cache-Control
    proxy_cache_valid 404 1m;
    proxy_cache_use_stale error timeout updating http_502 http_503;   # serve stale while the backend is down
    proxy_cache_lock on;                   # one request fills the cache; others wait
    proxy_cache_bypass $http_cache_control;
    add_header X-Cache-Status $upstream_cache_status;   # HIT, MISS, EXPIRED, STALE, BYPASS
}
```

Responses with `Set-Cookie`, `Cache-Control: private` or `no-store` are not cached. There is no purge in open-source Nginx; delete files under the cache path or change `proxy_cache_key`.

## Rate limiting, access control and basic auth

```nginx
limit_req_zone  $binary_remote_addr zone=per_ip:10m rate=10r/s;   # 10m holds about 160k addresses
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;

server {
    location /api/ {
        limit_req zone=per_ip burst=20 nodelay;   # allow bursts of 20, reject beyond that immediately
        limit_req_status 429;                      # default is 503
        limit_conn conn_per_ip 20;
        proxy_pass http://my-app;
    }

    location /admin/ {
        allow 192.0.2.0/24;
        deny  all;                                 # evaluated in order; first match wins
        auth_basic           "Restricted";
        auth_basic_user_file /etc/nginx/.htpasswd; # htpasswd -B for bcrypt; readable by the worker user
        proxy_pass http://my-app;
    }

    location /internal/ { internal; }              # only reachable via rewrite, error_page or X-Accel-Redirect
}
```

Without `nodelay`, requests within the burst are queued and released at `rate`, which looks like latency rather than errors. Behind a load balancer, key on the client address from `X-Forwarded-For` instead, using `set_real_ip_from` and `real_ip_header` from the `realip` module; otherwise every client shares the balancer's address. `satisfy any;` lets either the address allow-list or the password pass a request.

## Redirects and rewrites

```nginx
return 301 https://$host$request_uri;                     # cheapest redirect; no regex engine
return 302 /maintenance.html;
rewrite ^/old/(.*)$ /new/$1 permanent;                     # regex redirect (301); "redirect" gives 302
rewrite ^/blog/(\d+)$ /posts?id=$1 break;                  # internal rewrite; stop processing, stay in this location
rewrite ^/blog/(\d+)$ /posts?id=$1 last;                   # internal rewrite; re-run location matching
location = /health { return 200 'ok'; default_type text/plain; }
absolute_redirect off;                                     # emit relative Location headers behind a proxy
```

`return` inside a `server` block runs before location matching; inside `if` it is the one safe use of `if`. Redirects that Nginx generates itself (adding a trailing slash to a directory) use `$host` and the listen port, so behind a TLS-terminating balancer set `port_in_redirect off` or `absolute_redirect off` to avoid `Location: http://example.com:8080/`.

## Upstreams and load balancing

```nginx
upstream my-app {
    least_conn;                            # default is weighted round-robin; also ip_hash, hash $key [consistent], random
    zone my-app 64k;                       # shared state across workers so failure counts are cluster-wide
    server 10.0.0.11:8080 weight=2 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.13:8080 backup;          # used only when every primary is marked failed
    server 10.0.0.14:8080 down;            # kept for ip_hash stability, never used
    keepalive 64;
}

location / {
    proxy_pass http://my-app;
    proxy_next_upstream error timeout http_502 http_503;   # retry another server on these; not for POST unless idempotent
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 10s;
}
```

Health checking in open-source Nginx is passive: a server that fails `max_fails` times within `fail_timeout` is skipped for `fail_timeout`, then tried again with a single request. `health_check`, `slow_start`, `resolve` on `server` lines and the `/api` module are NGINX Plus features and produce `unknown directive` on the open-source build. For active checks use [Caddy](https://www.wiki.jodisand.me/caddy/#load-balancing-and-health-checks), [Traefik](https://www.wiki.jodisand.me/traefik/#services) or the load balancer in front.

`$upstream_addr`, `$upstream_status` and `$upstream_response_time` in the access log show which server answered and whether a retry happened (comma-separated values mean several attempts).

## Logs

```nginx
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                'rt=$request_time urt=$upstream_response_time ua=$upstream_addr us=$upstream_status '
                'cs=$upstream_cache_status h=$host';
log_format json escape=json '{"ts":"$time_iso8601","host":"$host","remote":"$remote_addr","method":"$request_method","uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,"rt":$request_time,"urt":"$upstream_response_time","ua":"$upstream_addr","ref":"$http_referer","agent":"$http_user_agent"}';

access_log /var/log/nginx/access.log json buffer=64k flush=5s;   # buffered: lines appear up to 5s late
error_log  /var/log/nginx/error.log warn;                         # debug needs --with-debug; info is enough to see upstream retries
```

The default `combined` format has no timing, so add `$request_time` (whole request, client included) and `$upstream_response_time` (backend only). A large gap between them is a slow client or slow network, not a slow application. Error-log lines name the client, the request, the upstream and the reason, for example `upstream prematurely closed connection while reading response header from upstream` or `connect() failed (111: Connection refused) while connecting to upstream`, which point directly at the backend. Log `$http3` or `$server_protocol` while rolling out HTTP/3 to see who negotiates it.

```sh
journalctl -u nginx --since -1h                   # startup and reload failures land here, not in error.log
tail -f /var/log/nginx/error.log | grep -v 'No such file'
awk '$9 >= 500' /var/log/nginx/access.log | tail  # combined format; column 9 is $status
```

## Oneliners

```sh
# Validate and reload only if valid
nginx -t && systemctl reload nginx

# Effective configuration with includes expanded, comments stripped
nginx -T 2>/dev/null | grep -Ev '^\s*(#|$)'

# Every server_name and its listen ports
nginx -T 2>/dev/null | grep -E '^\s*(server_name|listen)' | sed 's/^\s*//'

# Which config file defines a location
grep -rn 'location /api/' /etc/nginx/

# Modules compiled in
nginx -V 2>&1 | tr ' ' '\n' | grep -- '--with'

# Confirm HTTP/2 and HTTP/3 negotiate
curl -sI --http2 https://example.com/ | head -1; curl -sI --http3 https://example.com/ | head -1

# Requests per minute over the last hour (combined format)
awk -v d="$(date -d '-1 hour' '+%d/%b/%Y:%H')" '$4 ~ d {print substr($4,2,17)}' /var/log/nginx/access.log | sort | uniq -c

# Top 20 client addresses
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

# Top URIs returning 5xx
awk '$9 ~ /^5/ {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

# Status code distribution for one path
grep '"GET /api/' /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c

# Upstream errors by reason, from the error log
grep -oE '(upstream [a-z ]+|connect\(\) failed[^,]*|no live upstreams)' /var/log/nginx/error.log | sort | uniq -c | sort -rn

# Bytes served per host today (json format with jq)
jq -r 'select(.ts | startswith("'"$(date +%F)"'")) | "\(.host) \(.bytes)"' /var/log/nginx/access.log | awk '{s[$1]+=$2} END {for (h in s) print s[h], h}' | sort -rn

# Live worker connections (stub_status on 127.0.0.1)
curl -s http://127.0.0.1/nginx_status

# Established connections per upstream
ss -tn state established '( dport = :8080 )' | tail -n +2 | wc -l

# Certificate expiry of every ssl_certificate in the config
nginx -T 2>/dev/null | awk '$1=="ssl_certificate" {gsub(";","",$2); print $2}' | sort -u | xargs -I{} sh -c 'printf "%s " {}; openssl x509 -enddate -noout -in {}'

# Hash a password for auth_basic without the apache2-utils package
openssl passwd -apr1 >> /etc/nginx/.htpasswd   # prompts; prepend "alice:" to the line afterwards

# Rotate logs without a restart (reopen after moving the files)
mv /var/log/nginx/access.log /var/log/nginx/access.log.1 && nginx -s reopen

# Trace what a worker is doing right now
strace -p "$(pgrep -f 'nginx: worker' | head -1)" -f -e trace=network -s 80

# Purge one URL from the proxy cache: the file name is the MD5 of proxy_cache_key ($scheme$host$request_uri above)
find /var/cache/nginx/app -type f -name "$(printf 'httpsexample.com/api/catalogue/' | md5sum | cut -c1-32)" -delete
```

## Scripts

Reload only when the certificate on disk is newer than the one the running server presents, for use after a renewal hook.

```sh
#!/usr/bin/env bash
set -euo pipefail
host=${1:?hostname}
cert=/etc/letsencrypt/live/$host/fullchain.pem
served=$(openssl s_client -connect 127.0.0.1:443 -servername "$host" </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256)
disk=$(openssl x509 -noout -fingerprint -sha256 -in "$cert")
if [[ "$served" == "$disk" ]]; then
  printf '%s: certificate already live\n' "$host"
  exit 0
fi
nginx -t
systemctl reload nginx
printf '%s: reloaded\n' "$host"
```

Summarise the last N lines of a JSON access log by status class and p95 request time per URI prefix.

```python
#!/usr/bin/env python3
import json, sys, collections, statistics
n = int(sys.argv[1]) if len(sys.argv) > 1 else 10000
lines = collections.deque(open("/var/log/nginx/access.log"), maxlen=n)
by_prefix = collections.defaultdict(list)
status = collections.Counter()
for line in lines:
    try:
        r = json.loads(line)
    except ValueError:
        continue
    prefix = "/" + r["uri"].split("/")[1].split("?")[0]
    by_prefix[prefix].append(float(r["rt"]))
    status[f"{prefix} {str(r['status'])[0]}xx"] += 1
for prefix, times in sorted(by_prefix.items(), key=lambda kv: -len(kv[1])):
    times.sort()
    p95 = times[int(len(times) * 0.95) - 1] if len(times) > 1 else times[0]
    errs = status.get(f"{prefix} 5xx", 0)
    print(f"{prefix:30} n={len(times):6} p50={statistics.median(times):.3f}s p95={p95:.3f}s 5xx={errs}")
```

Check every upstream server in the configuration with a direct HTTP request, for a cron job or a pre-deploy gate.

```sh
#!/usr/bin/env bash
set -euo pipefail
path=${1:-/healthz}
rc=0
while read -r addr; do
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "http://$addr$path" || echo 000)
  if [[ $code == 2* ]]; then
    printf 'ok   %s %s\n' "$addr" "$code"
  else
    printf 'FAIL %s %s\n' "$addr" "$code" >&2
    rc=1
  fi
done < <(nginx -T 2>/dev/null | awk '/^upstream/,/}/ {if ($1=="server") {gsub(";","",$2); print $2}}' | grep -v ':unix' | sort -u)
exit "$rc"
```

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `502 Bad Gateway`, error log says `connect() failed (111)` | Nothing listening at the upstream address, or wrong port | `curl -v http://127.0.0.1:8080/` from the Nginx host; check the app's bind address is not `127.0.0.1` inside a container |
| `502`, error log says `(13: Permission denied) while connecting to upstream` | SELinux blocks the worker from making network connections | `setsebool -P httpd_can_network_connect 1`; for a Unix socket, fix its file mode and the `user` directive |
| `502`, `upstream sent too big header` | Application sets large cookies or headers | `proxy_buffer_size 16k; proxy_buffers 4 32k;` in the location |
| `502`, `upstream prematurely closed connection` | Backend crashed or closed mid-response, often a worker timeout in the app | Read the application log; raise its request timeout |
| `504 Gateway Time-out` | Backend took longer than `proxy_read_timeout` (60s default) | Raise `proxy_read_timeout` for that location; fix the slow endpoint |
| `413 Request Entity Too Large` | Body exceeds `client_max_body_size` (1m default) | `client_max_body_size 100m;` in `http`, `server` or `location`; the value must be in the block that matches |
| Redirect loop between HTTP and HTTPS | `return 301 https://` in a server that also receives plain HTTP from a TLS-terminating balancer, or the app redirects based on a missing `X-Forwarded-Proto` | Redirect on `$http_x_forwarded_proto != https` instead; pass `X-Forwarded-Proto` and configure the app to trust it |
| Wrong location handles the request | A regex location matched before the intended prefix | `nginx -T`, add `^~` to the prefix or `=` for an exact URI, or move the regex |
| Wrong server handles the name | No `default_server`, or `Host` not in any `server_name` | Add a catch-all `default_server` that returns 444; check `server_name` spelling |
| `nginx -t` passes but reload does nothing | Reload sent to the wrong master, or a second Nginx instance | `ps -o pid,cmd -C nginx`; compare `--conf-path` from `nginx -V` with the file edited |
| `bind() to 0.0.0.0:80 failed (98: Address already in use)` | Another process (or old Nginx) holds the port | `ss -ltnp 'sport = :80'` |
| `bind() ... (13: Permission denied)` on a high port | SELinux `http_port_t` does not include the port | `semanage port -a -t http_port_t -p tcp 8443` |
| Headers set in `server` vanish in a `location` | `add_header` or `proxy_set_header` in the child replaces the parent's whole set | Repeat the headers, or `include` a snippet in each location |
| Static file returns 403 | Worker user cannot traverse the directory, or SELinux label is not `httpd_sys_content_t` | `sudo -u nginx stat /srv/www/...`; `restorecon -Rv /srv/www` |
| `Too many open files` in the error log | `worker_connections` exceeds the file descriptor limit | `worker_rlimit_nofile 65536;` in `main` |
| WebSocket closes after 60 seconds | `proxy_read_timeout` on an idle socket | Raise it on that location or send pings from the application |
| Client sees `Location: http://host:8080/` | Nginx built an absolute redirect from its own listen port | `absolute_redirect off;` or `port_in_redirect off;` |

## Further reading

- [Nginx directive index](https://nginx.org/en/docs/dirindex.html)
- [How nginx processes a request](https://nginx.org/en/docs/http/request_processing.html)
- [ngx_http_core_module (listen, location, root, alias)](https://nginx.org/en/docs/http/ngx_http_core_module.html)
- [ngx_http_proxy_module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [ngx_http_upstream_module](https://nginx.org/en/docs/http/ngx_http_upstream_module.html)
- [Support for QUIC and HTTP/3](https://nginx.org/en/docs/quic.html)


