Software Engineering WikiSE Wiki

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.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Check syntax before touching the running processnginx -t
Dump the full effective configuration, includes resolvednginx -T
Reload without dropping connectionsnginx -s reload or systemctl reload nginx
Version and compiled-in modulesnginx -V
Which config file is in usenginx -V 2>&1 | grep -o -- '--conf-path=[^ ]*'
Follow the error logtail -f /var/log/nginx/error.log
Find the server that handles a namenginx -T | grep -n 'server_name'
Is it listening where I thinkss -ltnp | grep nginx
Test a name without DNScurl -sv --resolve example.com:443:127.0.0.1 https://example.com/
Test the upstream directlycurl -sv http://127.0.0.1:8080/
Requests per status code todayawk '{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 countscurl -s http://127.0.0.1/nginx_status (needs stub_status)
Worker processes and their stateps -o pid,user,stat,cmd -C nginx
Open files of a workerls /proc/$(pgrep -f 'nginx: worker' | head -1)/fd | wc -l
Generate a basic-auth filehtpasswd -B -c /etc/nginx/.htpasswd alice
Increase the upload limitclient_max_body_size 100m;
Increase the upstream timeoutproxy_read_timeout 300s;
Debug one client onlydebug_connection 192.0.2.10; inside events {}
Redirect everything to HTTPSreturn 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.

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

ModifierMeaningPrecedence
= /pathExact matchWins immediately
^~ /path/Prefix match that stops regex evaluationTaken if it is the longest prefix
~ regexCase-sensitive regexFirst matching regex in file order
~* regexCase-insensitive regexSame as ~
/path/Prefix matchLongest 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$.

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.

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:

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:

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#

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 page to verify the chain the server presents.

Static files and caching#

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:

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#

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#

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#

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

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.

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#

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

#!/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.

#!/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.

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

SymptomCauseFix
502 Bad Gateway, error log says connect() failed (111)Nothing listening at the upstream address, or wrong portcurl -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 upstreamSELinux blocks the worker from making network connectionssetsebool -P httpd_can_network_connect 1; for a Unix socket, fix its file mode and the user directive
502, upstream sent too big headerApplication sets large cookies or headersproxy_buffer_size 16k; proxy_buffers 4 32k; in the location
502, upstream prematurely closed connectionBackend crashed or closed mid-response, often a worker timeout in the appRead the application log; raise its request timeout
504 Gateway Time-outBackend took longer than proxy_read_timeout (60s default)Raise proxy_read_timeout for that location; fix the slow endpoint
413 Request Entity Too LargeBody 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 HTTPSreturn 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-ProtoRedirect on $http_x_forwarded_proto != https instead; pass X-Forwarded-Proto and configure the app to trust it
Wrong location handles the requestA regex location matched before the intended prefixnginx -T, add ^~ to the prefix or = for an exact URI, or move the regex
Wrong server handles the nameNo default_server, or Host not in any server_nameAdd a catch-all default_server that returns 444; check server_name spelling
nginx -t passes but reload does nothingReload sent to the wrong master, or a second Nginx instanceps -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 portss -ltnp 'sport = :80'
bind() ... (13: Permission denied) on a high portSELinux http_port_t does not include the portsemanage port -a -t http_port_t -p tcp 8443
Headers set in server vanish in a locationadd_header or proxy_set_header in the child replaces the parent’s whole setRepeat the headers, or include a snippet in each location
Static file returns 403Worker user cannot traverse the directory, or SELinux label is not httpd_sys_content_tsudo -u nginx stat /srv/www/...; restorecon -Rv /srv/www
Too many open files in the error logworker_connections exceeds the file descriptor limitworker_rlimit_nofile 65536; in main
WebSocket closes after 60 secondsproxy_read_timeout on an idle socketRaise 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 portabsolute_redirect off; or port_in_redirect off;

Further reading#