Nginx 502 Bad Gateway Fix: A Diagnostic Order That Works
Fix nginx 502 Bad Gateway step by step: read the error log, check the upstream is up, match the socket or port, then rule out killed workers and buffers.
To fix an nginx 502 Bad Gateway, read the last lines of /var/log/nginx/error.log first, then follow a fixed order. Check that the upstream (PHP-FPM, Node, Gunicorn, a container) is running. Check that nginx points at the exact socket or port it listens on. Check whether the backend is killing slow requests. Last, check header buffers. We work through it in a DockLab split with the log tailing next to a shell, but any SSH session will do.
Quick answer
- Run
sudo tail -n 30 /var/log/nginx/error.log. The line for your request names the upstream and the errno. Connection refusedorNo such file or directory: the backend is down or listening somewhere else. Checksystemctl status, then comparess -ltnp/ss -lxpwith yourproxy_pass/fastcgi_pass.Permission deniedon a.sock: fixlisten.owner,listen.groupandlisten.modein the PHP-FPM pool so the nginx user (www-data) can write to it.upstream prematurely closed connectionorConnection reset by peer: the backend crashed, was OOM-killed or hit its own timeout. Check its logs andjournalctl -k | grep -i oom.upstream sent too big header: raiseproxy_buffer_size(orfastcgi_buffer_size), then runsudo nginx -t && sudo systemctl reload nginx.
Read the one log line that matters
A 502 means nginx reached its upstream step and got a broken answer or no answer at all. In almost every case nginx writes the reason to its error log. Start there, because each message points to a different fix:
sudo tail -n 30 /var/log/nginx/error.log
# or follow it while you reload the failing page
sudo tail -f /var/log/nginx/error.log
If a site has its own error_log directive, check that file instead. sudo nginx -T | grep -n error_log shows every location in use.
| Error log message (abridged) | What it usually means | Go to |
|---|---|---|
connect() failed (111: Connection refused) while connecting to upstream | Nothing listens on that IP and port | Step 1, then Step 2 |
connect() to unix:/run/php/php8.3-fpm.sock failed (2: No such file or directory) | Socket path is wrong or PHP-FPM is down | Step 1, then Step 2 |
connect() to unix:... failed (13: Permission denied) | nginx user cannot write to the socket | Step 2 |
connect() to unix:... failed (11: Resource temporarily unavailable) | Socket backlog full, all workers busy | Step 3 |
upstream prematurely closed connection while reading response header | Backend died or closed mid-request | Step 3 |
recv() failed (104: Connection reset by peer) | Backend reset the connection (crash, kill, keepalive race) | Step 3 |
upstream sent too big header while reading response header | Response headers larger than the buffer | Step 4 |
no live upstreams while connecting to upstream | Every server in the upstream block is marked failed | Step 1 |
If the log says upstream timed out (110: Connection timed out), the browser almost certainly shows 504 rather than 502. A 504 points at a slow backend, not a missing one.
Step 1: Is the upstream actually running?
Check this first because it is the most common cause and the quickest to rule out. Use the service name your stack runs under. On Ubuntu 24.04 LTS the PHP package is php8.3-fpm:
systemctl status php8.3-fpm --no-pager
systemctl status myapp --no-pager # your Node/Python/Go unit
sudo journalctl -u myapp -n 100 --no-pager
If the service restarts in a loop, the journal shows why: a missing env var, a port already in use, or a failed migration. Fix that before you touch nginx.
Next, check whether the kernel killed the process. An OOM kill looks like a clean crash from nginx's side:
sudo journalctl -k --since "1 hour ago" | grep -iE "out of memory|killed process"
For containers, look at the container, not the host service:
docker compose ps
docker compose logs --tail 100 app
no live upstreams is a special case. In an upstream block with several servers, nginx marks a server unavailable after max_fails failed attempts within fail_timeout (defaults 1 and 10 seconds). Once every server is marked down, requests fail at once with no new connection attempt. The upstream module docs also note that a group with a single server is never marked unavailable, so with one server you will see the underlying connect error instead.
Step 2: Does the socket or port match?
A running backend still returns 502 if nginx calls it at the wrong address. Compare what nginx is configured to call with what is actually listening.
TCP backends behind proxy_pass
# What nginx calls (full merged config, all included files)
sudo nginx -T 2>/dev/null | grep -nE "proxy_pass|fastcgi_pass|server .*:[0-9]+"
# What is listening
sudo ss -ltnp | grep -E ":3000|:8000|:8080"
# Talk to the backend directly, bypassing nginx
curl -sv http://127.0.0.1:3000/ -o /dev/null
Watch for these mismatches:
- The app listens on
127.0.0.1:3000and nginx callslocalhost:3000, which resolves to::1first. The fix is to use127.0.0.1explicitly inproxy_pass. - The app moved to a new port after a deploy and the vhost still has the old one.
curlto the backend works but nginx gets refused. On RHEL-family systems this is often SELinux blocking nginx from making network connections (setsebool -P httpd_can_network_connect 1). Stock Ubuntu does not ship SELinux, so there you can skip it.
Unix sockets (PHP-FPM)
The path in fastcgi_pass unix:... has to match the pool's listen value exactly. Per the PHP-FPM configuration reference, on Linux the web server needs read and write permission on that socket, which listen.owner, listen.group and listen.mode (default 0660) control.
grep -E "^listen" /etc/php/8.3/fpm/pool.d/www.conf
sudo ss -lxp | grep php
namei -l /run/php/php8.3-fpm.sock
sudo -u www-data test -w /run/php/php8.3-fpm.sock && echo "nginx can write"
The pool settings Ubuntu expects:
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
A common trap after a PHP upgrade: the vhost still points at php8.1-fpm.sock, but that service is gone. The log line says No such file or directory, so update the path.
Docker
If nginx runs in a container, 127.0.0.1 means the nginx container itself, not the host or the app container. Use the Compose service name (proxy_pass http://app:3000;) on a shared network. Also check that the app inside its container binds to 0.0.0.0. A process bound to 127.0.0.1 inside a container cannot be reached through a published port.
nginx resolves a hostname in proxy_pass when it loads its configuration. If you recreate the app container and it gets a new IP, nginx keeps calling the old one until you reload it.
Step 3: Killed workers, saturated pools and keepalive
If the backend is up and the address is right but 502s come and go, the backend is usually ending requests itself. This is where timeouts matter, but probably not the nginx timeout you're thinking of.
nginx's own proxy_read_timeout and fastcgi_read_timeout default to 60 seconds, and they apply between two successive reads, not to the whole response (proxy module docs, FastCGI module docs). When they expire, the client gets a 504. A 502 that shows up after a long request means something on the backend side gave up first:
- PHP-FPM
request_terminate_timeoutkills the worker after the set time. nginx then logsupstream prematurely closed connection. - Gunicorn's worker
--timeout(30 seconds by default) kills and restarts a worker that stays silent too long, with the same result in nginx. - OOM kills under load, covered in Step 1.
Raising nginx timeouts won't help with any of these. Find the slow request in the backend log, then either make it faster or raise the backend's limit and nginx's read timeout together.
PHP-FPM pool exhaustion
Ubuntu's default www pool ships with pm.max_children = 5, which a small WordPress site under a crawler burst can use up. When every child is busy, new connections queue on the socket. Once the backlog fills, nginx logs (11: Resource temporarily unavailable) and returns 502. PHP-FPM records the cause in its own log:
sudo grep -i "max_children" /var/log/php8.3-fpm.log | tail -n 5
# WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
Raise pm.max_children according to memory, not by guessing. Divide the RAM you can give PHP by the resident size of one worker (ps -o rss= -C php-fpm8.3 | sort -n | tail -n 1 gives the largest, in KB). Then reload with sudo systemctl reload php8.3-fpm.
The keepalive race (newer nginx)
The nginx changelog for 1.29.7 (March 2026) turned on upstream keepalive by default and made proxy_http_version 1.1 the default. If you run 1.29.7 or later (for example from the nginx.org mainline repository; check with nginx -v), nginx now reuses idle connections to your backend. If the backend closes idle connections sooner than nginx expects, nginx can send a request down a socket the backend just closed. The result is a scattered Connection reset by peer or prematurely closed error. POST requests suffer most, because proxy_next_upstream does not retry non-idempotent requests unless you add non_idempotent.
The fix is to make nginx give up on idle upstream connections before the backend does:
upstream app_backend {
server 127.0.0.1:3000;
keepalive 32;
keepalive_timeout 4s; # shorter than the backend's idle keep-alive timeout
}
Alternatively, raise the backend's idle keep-alive timeout above nginx's (in Node.js, that is server.keepAliveTimeout).
Step 4: Header size and buffers
upstream sent too big header means the first part of the response, which holds the headers, didn't fit in proxy_buffer_size. That is one memory page by default, 4k or 8k. nginx then treats the response as invalid. Large Set-Cookie headers, big JWTs and long Link headers are the usual causes.
location / {
proxy_pass http://app_backend;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
}
Raise the three values together. If you raise only proxy_buffer_size, nginx -t usually fails, because proxy_busy_buffers_size must be at least as large as the buffer size and a single proxy_buffers entry. For PHP-FPM, the matching directives are fastcgi_buffer_size, fastcgi_buffers and fastcgi_busy_buffers_size.
Make the next 502 faster to diagnose
The default combined access log doesn't say which upstream answered or how long it took. Add a format in the http block:
log_format upstreamlog '$remote_addr [$time_local] "$request" $status '
'upstream=$upstream_addr ustatus=$upstream_status '
'uconnect=$upstream_connect_time urt=$upstream_response_time rt=$request_time';
access_log /var/log/nginx/access_upstream.log upstreamlog;
Now grep ' 502 ' /var/log/nginx/access_upstream.log shows which backend failed, whether it failed while connecting (empty or - connect time) or later, and whether failures cluster at a round number like 30 seconds. A cluster at a round number points straight at a worker timeout.
The whole order as a checklist you can keep next to the terminal:
Read the error log
sudo tail -n 30 /var/log/nginx/error.logand match the message to the table above.Confirm the upstream is up
systemctl status,journalctl -u,docker compose ps, and a check for OOM kills.Match the address
Compare
nginx -Twithss -ltnp/ss -lxp, thencurlthe backend directly.Check backend limits
Look at worker timeouts,
pm.max_children, and keepalive idle timeouts on both sides.Check header buffers
Raise
proxy_buffer_sizeand its two companion directives, then runnginx -tand reload.
Doing this across several servers
Most of the time spent on a 502 goes to switching contexts: one shell for the nginx log, one for the backend journal, an editor for the vhost, and another for the pool config. We built DockLab to keep those side by side. A split runs tail -f on the error log next to a shell where you run curl against the backend. The Monaco editor opens /etc/nginx/sites-available/... over SFTP. The Nginx Manager plugin shows enabled sites, their proxy targets, nginx -t results and the recent error log in one panel. The Systemd Services plugin covers the status and journal side, and Log Viewer surfaces error counts from system log locations.
DockLab does not replace knowing the commands above, and on a single box a plain terminal with ssh and tmux does the job fine. It pays off when you jump between many hosts. If you use per-server aliases from your SSH config, you can download DockLab and keep a saved workspace per server. If you look after many clients' servers, check pricing for the plan that fits.
Frequently Asked Questions
What is the difference between a 502 and a 504 in nginx?
A 502 means nginx got an invalid response or a refused or reset connection from the upstream. A 504 means the upstream did not answer within nginx's timeout, for example proxy_read_timeout. Raising timeouts can fix a 504, but rarely fixes a 502.
Why does restarting PHP-FPM fix the 502 only for a while?
A restart frees every worker, so the pool works again until it fills up. If the PHP-FPM log shows server reached pm.max_children, size the pool to your memory and find the slow requests holding workers. If the journal shows OOM kills, the server needs more memory or fewer workers.
Can Cloudflare or a load balancer cause the 502 instead of nginx?
Yes. Each proxy layer can return its own 502, and the error page usually tells you which one did. Run curl -sv against the origin server directly, or against 127.0.0.1 on the server. If that works, the problem is between the CDN or load balancer and nginx, not behind nginx.
Do I need to restart nginx after changing the config?
No, a reload is enough and avoids dropping connections: sudo nginx -t && sudo systemctl reload nginx. Always run nginx -t first, so a typo doesn't stop the reload and leave you thinking the fix didn't work.
Why do I get a 502 only on some requests?
Intermittent 502s usually come from a backend limit, not from a wrong address. Look for a full PHP-FPM pool, a worker timeout killing long requests, or a keepalive race where the backend closes idle connections before nginx does. The upstream log format above shows which one by recording the upstream status and response time for each failed request.
SSH, SFTP, a remote editor, a browser and AI in one desktop workspace.
DockLab runs on macOS, Windows and Linux. Start free and keep your whole server workflow in one window.