Nginx troubleshooting
Nginx won't start or reload
Run nginx -t for the exact file and line of syntax errors.
Check journalctl -u nginx -n 50. Common causes: duplicate
listen directives, missing semicolons, or invalid paths in
include files.
Reload succeeded but behavior didn't change
nginx -t and systemctl reload nginx both succeed, but
the site still behaves like the old config. The running process may not be using
the file you edited, or your request may never reach the block you changed.
Checks:
nginx -T | grep -n 'your-change'
ps aux | grep nginx
nginx -T dumps the effective config the master
loaded — not the file you think you edited. Confirm your change appears there.
ps aux shows which binary and config path the running workers use;
multiple Nginx instances or a container vs host install are easy to miss.
Common causes:
- Editing the wrong config file — distro defaults under
/etc/nginx/sites-available/vs a customnginx.conf; always verify withnginx -T - Config included from another file — your edit is overridden
by a later
includeor a duplicateserverblock inconf.d/ - Traffic hitting another server — load balancer, CDN, or
wrong
server_nameblock; see Wrong site or default page served below. Test locally:curl -v -H "Host: your.domain" http://127.0.0.1/ - Stale container image — config baked into the image or a volume mount pointing elsewhere; reload inside the container does not pick up host-side edits
If nginx -T shows the new config but behavior is unchanged, the
problem is upstream of Nginx (cache, proxy, DNS) or you are testing a different
host than the one you reloaded.
403 Forbidden
File permissions, missing index file when autoindex off; is set, or no directory listing enabled.
Nginx user (usually www-data or nginx) needs
execute permission on every parent directory and read on the file.
SELinux on RHEL may block access — check audit.log and: ausearch -m avc or sealert -a /var/log/audit/audit.log. See
worker process permissions below — checks as root can be misleading.
Worker process permissions
The Nginx master process runs as root to bind privileged
ports (80, 443). Worker processes — the ones that read files
and proxy requests — run as www-data, nginx, or
whatever user is set in nginx.conf.
File access errors are always the worker user's permissions,
not root's. A common trap: you verify paths as root (cat
/var/www/app/index.html works) but Nginx still returns
403 because the worker cannot traverse or read the path.
grep -E '^(user|group)' /etc/nginx/nginx.conf
sudo -u www-data test -r /var/www/app/index.html && echo ok
sudo -u www-data ls -la /var/www/app/
Test as the worker user, not root. Every directory from / to the
file needs execute (x) for others or matching ownership/group.
Unix socket upstreams use the same worker identity — see Unix socket backend
above.
502 Bad Gateway / 504 Gateway Timeout
Upstream is down or not responding. Test from the Nginx host:
curl -v http://127.0.0.1:8000.
Check error_log
for "connect() failed" or timeout messages, most common ones are:
connect() failed (111: Connection refused)
upstream timed out
no live upstreams
Verify proxy_pass URL and that the backend listens on the expected
socket.
Unix socket backend
When proxy_pass targets a Unix socket
(proxy_pass http://unix:/run/app.sock), the socket file must
exist, be writable by the Nginx worker user (often www-data or
nginx), and the backend must be running. A missing or
wrong-permission socket produces 502 with a different
error_log message than a TCP connection failure — often
"No such file or directory" or "Permission denied" on the socket path.
ls -la /run/app.sock
grep -E '^(user|group)' /etc/nginx/nginx.conf
systemctl status php8.2-fpm # or gunicorn, puma
Common with PHP-FPM, Gunicorn, and Puma. Fix socket path in both Nginx and
the app config, align group membership or socket mode (0660),
and ensure the upstream service starts before Nginx proxies to it.
Wrong site or default page served
The default server for a port handles requests with no matching
server_name. Disable or fix the default site. Test which
block matches: curl -v -H "Host: api.example.com" http://127.0.0.1/.
See server_name matching order below for how Nginx picks a
server block (if multiple server blocks match, Nginx selects the most specific name match. If no server block matches, Nginx selects the default server for that port).
server_name matching order and default server
Nginx chooses a server block for each request using a fixed
priority on the Host header (and SNI on HTTPS):
- Exact match —
server_name api.example.com; - Leading wildcard —
server_name *.example.com; - Trailing wildcard —
server_name example.*; - Regex —
server_name ~^api\.; - Default server — first
listenon that port withdefault_server, or the first block if none is marked
A common bug: a broad regex server_name unintentionally
wins over a more specific block you expected to match. Another: traffic lands on
the default server because the Host header does not match any name you defined.
nginx -T | grep -E 'server_name|listen|default_server'
curl -v -H "Host: api.example.com" http://127.0.0.1/
Dump the effective config with nginx -T, list every
server_name and listen on the port, then test with
curl -H "Host: ...". Mark the intentional catch-all with
listen 443 ssl default_server; so it does not steal traffic by
load order alone.
413 Request Entity Too Large
Upload exceeds client_max_body_size (default 1m). Increase in
the server or http block and reload.
Address already in use
Port 80 or 443 is held by Apache, Caddy, or another Nginx instance.
Find the process: ss -tlnp | grep ':80 '. Remove duplicate
listen directives or stop the conflicting service.
root vs alias confusion
root and alias both map URLs to filesystem paths, but
they append the location prefix differently. Mixing them up — or using the wrong
one — causes a huge number of mysterious 404s. Config tests
pass; the path looks right when you ls the directory as root.
With root, Nginx appends the full request URI to
the directive value:
location /images/ {
root /data;
}
# GET /images/file.jpg → /data/images/file.jpg
With alias, Nginx replaces the matched location prefix with the
alias path — the location segment is not repeated:
location /images/ {
alias /data/;
}
# GET /images/file.jpg → /data/file.jpg
Common traps: using root /data/images; when files live directly
under /data/ (you need alias /data/; or
root /data; with location /images/). Trailing slashes
matter — location /images without a trailing slash paired with
alias is a frequent misconfiguration. alias inside
regex locations needs a capture group.
nginx -T | grep -A5 'location /images'
# Confirm the resolved path matches where the file actually is
ls -la /data/file.jpg
ls -la /data/images/file.jpg
Debug: dump the block with nginx -T, then check which filesystem
path Nginx would use for a sample URI. See try_files
misconfiguration below — fallback paths are resolved the same way.
try_files misconfiguration
A very common source of 404s and odd routing —
try_files checks paths in order and uses the last argument as
fallback. The standard static pattern:
try_files $uri $uri/ =404;Common misconfigurations:
- Wrong last argument —
=404when you meant SPA fallback (/index.html) or a named location (@backend) - Missing
index—$uri/looks forindex.htmlvia theindexdirective; without it, directory URLs 404 even when the file exists - Named location fallthrough — last arg
@proxymust exist; typos or wronglocation @proxyblock silently fail or hit the wrong handler rootvsalias— see above;try_fileschecks paths relative to the same directive; a mismatch makes every fallback miss
# SPA — serve index.html for client routes
try_files $uri $uri/ /index.html;
# Named location fallback
try_files $uri @app;
Debug: nginx -T for the effective location,
confirm root, index, and that the fallback path or
named location exists. Test with curl -v http://127.0.0.1/path —
unexpected 404 often means the last try_files argument is wrong.
proxy_pass URI rewriting surprises
A trailing slash on proxy_pass strips the matched location prefix:
# location /api/ + proxy_pass http://backend/
# /api/users → http://backend/users
# location /api/ + proxy_pass http://backend/api/
# /api/users → http://backend/api/users
When proxy_pass has no trailing slash and the
location has no trailing slash either, the full URI — including
the location prefix — is passed through unchanged:
# location /api + proxy_pass http://backend
# /api/users → http://backend/api/users
rewrite adds another layer: a rule before proxy_pass
changes what gets forwarded regardless of the trailing-slash rule. Trace with
error_log debug or test with curl -v and compare
the upstream request path to what the backend expects.
TLS / certificate issues
ssl_certificate should contain the full chain
(leaf certificate + intermediates) in modern Nginx. Omitting intermediates is
a very common cause of TLS errors — nginx -t passes, but browsers
and mobile clients fail with incomplete chain warnings.
# Full chain — certificate + intermediates concatenated
ssl_certificate /etc/ssl/certs/example_fullchain.crt;
ssl_certificate_key /etc/ssl/private/example.key;
openssl s_client -connect example.com:443 -servername example.com
# Check certificate expiry
openssl x509 -in cert.pem -noout -dates
Verify the chain with openssl s_client — look for
"Verify return code: 0 (ok)". Let's Encrypt and similar CAs often provide
fullchain.pem; use that for ssl_certificate, not
cert.pem alone.
Debugging workflow
1. Config test and dump
nginx -t
nginx -T | less2. Error log while reproducing
tail -f /var/log/nginx/error.log
curl -v http://127.0.0.1/problem-url , journalctl -u nginx -f3. Test upstream independently
curl -v http://127.0.0.1:8000/health
ss -tlnp | grep 8000Practice scenarios
Hands-on Nginx scenarios on live Linux VMs: nginx