Fixing nginx 502 Bad Gateway: causes and solutions

Published on 17 min read

502 Bad Gateway means nginx got no valid response from the backend. The five most common causes, the matching line in the error log, and how to prove that the fix really works.

What "502 Bad Gateway" really means

A 502 does not come from your application, it comes from nginx. nginx accepted the request, passed it on to a backend (PHP-FPM, Node, Python, another web server) and never got a usable response back. That is why the error page tells you nothing useful.

Telling it apart from the adjacent status codes saves a lot of time when things go wrong:

  • 500 Internal Server Error: the backend did answer, and the answer was an error. The cause sits in the application code. Read the log of the application, not the one from nginx.
  • 502 Bad Gateway: the connection to the backend never came up, or it broke before a complete response had arrived.
  • 504 Gateway Time-out: the connection was there, the backend simply stayed silent for too long, and nginx ran out of patience.

This distinction is the biggest lever when you are dealing with timeouts, because the same slow page shows up as a 502 or as a 504 depending on which side gives up first. More on that further down.

Package versions on Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04

nginx behaves identically on all four systems when a 502 occurs, and the directives carry the same names. The differences sit almost entirely on the PHP side, and that is exactly where most 502 errors after a distribution upgrade come from.

SystemnginxPHPService nameSocket
Debian 13 (Trixie)1.26.38.4php8.4-fpm/run/php/php8.4-fpm.sock
Debian 12 (Bookworm)1.22.18.2php8.2-fpm/run/php/php8.2-fpm.sock
Ubuntu 24.04 LTS1.24.08.3php8.3-fpm/run/php/php8.3-fpm.sock
Ubuntu 22.04 LTS1.18.08.1php8.1-fpm/run/php/php8.1-fpm.sock

In all of the commands below, replace the version number with the one your system uses. Every example assumes a root shell, otherwise put sudo in front. A look at the FPM binaries tells you which version is installed, even when the service refuses to start:

ls /usr/sbin/php-fpm*
ls /etc/php/

Start with the error log: finding the right line

The most common mistake during diagnosis is searching in the wrong log. nginx has one global error log and often a separate one per virtual host. The configuration tells you which file applies:

grep -Rn "error_log" /etc/nginx/nginx.conf /etc/nginx/sites-enabled/

Watch out for the uppercase -R. On Debian and Ubuntu, /etc/nginx/sites-enabled/ holds nothing but symlinks into sites-available, and GNU grep with the lowercase -r follows no symlink while it descends recursively. With -rn you therefore only get the hits from nginx.conf, while the vhost's own error_log line stays invisible: exactly the line you are looking for in a 502 case, because the global log does not contain the FastCGI error as soon as the vhost redirects it. If you prefer to stay with -r, grep the source directories directly:

grep -rn "error_log" /etc/nginx/nginx.conf /etc/nginx/sites-available/ /etc/nginx/conf.d/

Without an entry of its own in the server block, everything ends up in /var/log/nginx/error.log. The most reliable route to the matching line is a live capture: keep the log open in one terminal, trigger the request in a second one, and look at the lines that appear while you do it.

tail -f /var/log/nginx/error.log

Alternatively, filter on the timestamp. nginx writes local time in the format 2026/07/26 09:14:22, not UTC. Comparing that against a clock in a different time zone goes wrong on a regular basis.

A 502 line always follows the same pattern. Example:

2026/07/26 09:14:22 [error] 812#812: *3 connect() to unix:/run/php/php8.2-fpm.sock
failed (2: No such file or directory) while connecting to upstream,
client: 203.0.113.7, server: example.com,
request: "GET /index.php HTTP/1.1",
upstream: "fastcgi://unix:/run/php/php8.2-fpm.sock:", host: "example.com"

Four elements carry the entire information:

  1. The system call: connect(), recv(), send(). connect() means a connection was never established. recv() means the connection was up and then broke.
  2. The error number in brackets, see the table below. That is the actual diagnosis.
  3. The phase: while connecting to upstream versus while reading response header from upstream. The first one is a reachability problem, the second a runtime or crash problem.
  4. The upstream: field. It shows the path or the address nginx really used. Not what you assume the configuration contains, but what is actively loaded.
MessageMeaningSection
2: No such file or directorySocket file does not existService dead or wrong path
13: Permission deniedSocket exists, nginx is not allowed to access itPermissions
111: Connection refusedNothing is listening on that address and portBackend unreachable
110: Connection timed outNo response within the time limitTimeouts
104: Connection reset by peerBackend process died in the middle of the requestCrashes and limits
11: Resource temporarily unavailableListen queue of the socket is fullCrashes and limits

In nginx, the line for 13: Permission denied frequently carries the level [crit] instead of [error]. Anyone filtering for [error] alone will miss it. Better to filter on the text:

grep -n "upstream" /var/log/nginx/error.log

The second half of the truth lives in the PHP-FPM log, by default at /var/log/php8.2-fpm.log. For crashes and limits, the reason is written down there, while nginx only ever sees the symptom.

Cause 1: PHP-FPM is not running

The classic error number 2. Check the state of the service first:

systemctl is-active php8.2-fpm
systemctl status php8.2-fpm --no-pager -l

is-active answers with a single word, which is enough for a script. If you get inactive or failed, pull the reason out of the journal, using a time window rather than the last ten lines:

journalctl -u php8.2-fpm --since "30 min ago" --no-pager

Very often the cause is a broken pool configuration that was left behind after a reload. FPM has a syntax test of its own that runs without a restart:

php-fpm8.2 -t

Typical startup errors word for word, and what they mean:

  • ERROR: [pool www] cannot get uid for user 'webuser': the system user configured in user = no longer exists, for example after a migration.
  • ERROR: unable to bind listening socket for address '/run/php/php8.2-fpm.sock': No such file or directory (2): the directory /run/php is missing. It sits on a tmpfs and gets created when the service starts. If you point listen at a path outside of it, you have to arrange for that directory to be created yourself.
  • ERROR: An another FPM instance seems to already listen on ...: a process from a failed restart is still attached to the socket.

How you know it is really solved: not by the fact that systemctl restart ran without any output. An FPM master starts even when not a single worker process is able to accept requests. What is meaningful is that the socket is visible on the system and that FPM answers on it.

ss -lx | grep php

For a real response test, enable the line ping.path = /ping in /etc/php/8.2/fpm/pool.d/www.conf, reload FPM and query the socket directly, bypassing nginx completely:

apt-get install -y libfcgi-bin
SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping REQUEST_METHOD=GET \
  cgi-fcgi -bind -connect /run/php/php8.2-fpm.sock

If pong comes back, the PHP side is fine and the fault sits between nginx and the socket. If nothing comes back, there is no point in searching any further on the nginx side.

Cause 2: wrong socket path

On Debian and Ubuntu this is by far the most frequent cause, because the socket carries the PHP version in its name, the nginx configuration hard-codes that name, and a distribution upgrade pulls the two apart.

In concrete terms: an upgrade from Debian 12 to Debian 13 lifts PHP from 8.2 to 8.4. The old socket /run/php/php8.2-fpm.sock disappears, while the vhost file still refers to it. The result is a 502 on every single PHP page, immediately after the reboot. The same thing happens going from Ubuntu 22.04 to 24.04 (8.1 to 8.3).

A second trap hides in the example configuration that ships with the package. /etc/nginx/sites-available/default contains a commented-out block whose fastcgi_pass points at a PHP version that has not been current for years. Simply uncommenting those lines is what creates the 502 in the first place.

Compare both sides. What nginx intends to use:

grep -Rn "fastcgi_pass" /etc/nginx/

What FPM actually offers:

grep -n "^listen *=" /etc/php/*/fpm/pool.d/*.conf

The *= in the search pattern is deliberate: it requires any number of spaces after listen and then an equals sign. A bare ^listen would also match listen.owner, listen.group and listen.mode, and the line you actually want drowns among the hits. The wildcard /etc/php/*/ on the other hand is correct as it stands, because it covers every installed PHP version. And what exists on the running system:

ls -l /run/php/

All three outputs have to show the same path. Important: use grep -R across /etc/nginx/ rather than just the one file you suspect. Snippets pulled in via include are a popular hiding place, and so are old files in sites-available that are still active through a forgotten symlink in sites-enabled. The same rule applies here: only the uppercase -R follows those symlinks and therefore shows you which file is really active.

ls -l /etc/nginx/sites-enabled/

Before you change anything, make a copy. It costs two seconds and, in case of doubt, saves you a restore from backup:

mkdir -p /root/backups
cp -a /etc/nginx/sites-available/default /root/backups/default.bak

If you intervene more than once, append a timestamp (default.bak.$(date +%F-%H%M)), because cp -a overwrites an existing .bak without a word.

After the correction, always test first and load afterwards. Use reload instead of restart so that existing connections are not dropped:

nginx -t
systemctl reload nginx

Keep in mind: nginx -t checks the syntax and nothing else. A socket path that does not exist counts as a perfectly valid configuration. A green syntax is ok is therefore no evidence that the 502 is gone.

Cause 3: permissions on the socket

Error number 13. The socket is there, nginx is just not allowed to open it. On Debian and Ubuntu, nginx runs as the user www-data, and the default FPM pool creates the socket to match. You can see that in the pool file:

grep -n "listen.owner\|listen.group\|listen.mode" /etc/php/*/fpm/pool.d/*.conf

With listen.owner = www-data, listen.group = www-data and mode 0660, the two sides work together without any help. It can go wrong in three situations:

  • A separate pool per project. If user and group are set to a project user, listen.group still has to be a group that nginx belongs to. The usual combination is listen.owner = projektuser together with listen.group = www-data.
  • Socket outside of /run. It is not only the socket file that has to be accessible: every directory along the way to it needs the execute permission for nginx. A socket in a home directory with mode 0700 is reachable for nobody except the owner.
  • nginx running with a changed user in /etc/nginx/nginx.conf.

You can confirm the suspicion without guessing, by attempting the access as exactly the user that needs it in production:

id www-data
sudo -u www-data test -w /run/php/php8.2-fpm.sock && echo "access ok" || echo "no access"

The cgi-fcgi call from the previous section is even more telling, again with sudo -u www-data in front of it. If FPM answers as root but not as www-data, the diagnosis is unambiguous.

Set the values in the pool file, not with chmod on the socket file. A chmod 666 holds exactly until the next restart of FPM, then FPM recreates the socket with the configured permissions and the error is back, usually at the most inconvenient moment.

AppArmor is active on Debian and Ubuntu. The nginx profile that ships with it is not enforced in the default state, but a hardening template may have switched it on. If a 13 message persists despite correct permissions, it is worth looking at aa-status and at journalctl -k | grep DENIED.

Cause 4: timeouts on long requests

This is where a clean approach parts ways with guesswork, because the nginx timeout expiring on its own produces a 504, not a 502. When a long request ends in a 502, PHP-FPM has almost always cleared the worker process away first, and all nginx saw was a broken connection. The log then typically says:

recv() failed (104: Connection reset by peer) while reading response header from upstream

Three timeouts are in effect at the same time, and their order decides the status code:

  1. max_execution_time in php.ini, 30 seconds by default under FPM. It counts the runtime of the script only. Time spent waiting inside system calls, on a hanging database query for instance, does not count on Linux. That is why this value does not save you in exactly the case where you would expect it to.
  2. request_terminate_timeout in the pool file, off in the default state. It terminates the worker process hard, no matter what it is stuck on. This is the value that produces 502 errors.
  3. fastcgi_read_timeout in nginx, 60 seconds by default. When it expires, you get a 504.

The useful order rises from the inside out, so that the layer which can still produce a comprehensible error message always fires first. For example 60, then 75, then 90 seconds. Sorted the other way round, you get 502 errors instead of readable PHP errors.

grep -rn "request_terminate_timeout" /etc/php/*/fpm/pool.d/*.conf

Whatever FPM cleared away is stated in plain text in its own log:

WARNING: [pool www] child 1234, script '/var/www/html/import.php'
(request: "POST /import.php") execution timed out (76.271849 sec), terminating

Before you raise any timeouts, have the server show you where the time goes. FPM brings a dedicated log for that, which writes a complete PHP call stack whenever the limit is exceeded. Enable it in the pool file:

slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s

After a systemctl reload php8.2-fpm, the next slow request writes the function and the line number that is hanging. In practice, four out of five times that turns out to be a database query without an index or a call to a third-party API without a timeout of its own. Turning the limits up then only stretches the time until the error appears, and it blocks worker processes on top of that.

Cause 5: backend unreachable

This affects every backend that is addressed over TCP: FPM on port 9000, a Node application, a Java service, a container. The leading message is error number 111.

connect() to 127.0.0.1:3000 failed (111: Connection refused) while connecting to upstream

First check whether anything is listening at all, and above all on what:

ss -ltnp

This command lists TCP sockets exclusively, and that is the source of a widespread misconception: a PHP-FPM pool in its default state listens on a Unix socket under /run/php/ and does not appear in this list at all, even though it is running perfectly. You only make it visible like this:

ss -lxn | grep php-fpm
ls -l /run/php/

For FPM, ss -ltnp is therefore only meaningful if the pool was deliberately switched to TCP with listen = 127.0.0.1:9000. For Node, Java or container backends it is exactly the right command.

Three pitfalls that rarely appear in tutorials:

  • localhost resolves to ::1 first. If nginx contains proxy_pass http://localhost:3000; but the application only listens on 127.0.0.1, nginx tries the IPv6 address and gets "Connection refused". The service is running, the port is open, and you still get a 502. The fix: write 127.0.0.1 out in nginx, or bind the application to both address families.
  • nginx resolves names once, at load time. If proxy_pass contains a host name, nginx remembers the address. If the backend changes its IP address, a freshly started container for instance, the requests run into nothing until the next reload.
  • A firewall on the return path. With a backend on another server you see 113: No route to host or a timeout instead of "Connection refused". Check with ufw status and with a direct connection test from the nginx server.

If an upstream block with several targets is in use, one more message joins the list:

no live upstreams while connecting to upstream

It means that nginx has taken all targets out of rotation for the duration of fail_timeout after repeated failed attempts. Even once the backend is repaired, it still takes until that period expires before requests get through again. A systemctl reload nginx resets the state immediately.

The 502 that fits none of the five causes

Two cases look like an outage without being one, and they cost an above-average amount of time because of it.

Response header too large. The application runs perfectly, only individual requests return a 502:

upstream sent too big header while reading response header from upstream

The trigger is large cookies or session attributes in the headers, and the nginx buffer is too small. In the server or location block:

fastcgi_buffer_size 32k;
fastcgi_buffers 8 16k;
fastcgi_busy_buffers_size 64k;

With proxy_pass the directives are called proxy_buffer_size and proxy_buffers. Typically only logged-in users are affected, while the home page loads flawlessly.

Out of worker processes. Under load, the FPM log shows:

WARNING: [pool www] server reached pm.max_children setting (5), consider raising it

New requests then wait in the listen queue of the socket. Once that is full as well, nginx reports 11: Resource temporarily unavailable. Before you raise pm.max_children, do the arithmetic: available RAM divided by the actual consumption of one worker process. A value that is too high trades 502 errors for a system state in which memory runs out, and that hits the database as well.

Crashes. Lines containing exited on signal 11 (SIGSEGV) point to a broken PHP extension, frequently after a PHP version change with leftover modules from the previous version.

When the intervention does not help: the way back

Two rules keep the damage small. First: one change at a time, with a copy of the original file under /root/backups, never inside the web directory. Second: verify after every step, instead of changing three things at once and then not knowing which one helped.

If nginx falls over completely after a change, put the copy back and reload:

cp -a /root/backups/default.bak /etc/nginx/sites-available/default
nginx -t
systemctl reload nginx

If nginx no longer starts after a restart, systemctl status nginx rarely says enough. More informative:

journalctl -u nginx --since "10 min ago" --no-pager

The most frequent reason for a failed restart with a syntactically flawless configuration is port 80 or 443 already being occupied, usually a process from the previous run. ss -ltnp | grep ':80' shows the culprit.

How you know it is really fixed

A command without an error message proves nothing at all. systemctl reload stays silent even when nothing has actually changed, and nginx -t only checks syntax. These four pieces of evidence hold up:

  1. Query the status code directly on the server, so that neither a cache nor a service sitting in front of it distorts the result:
    curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1/
    Expect 200, not 502. With several virtual hosts, pass the name along: curl -H "Host: example.com" ...
  2. The error log stays silent. Truncate it before the test with truncate -s 0 /var/log/nginx/error.log, trigger several requests, then look at it again. An empty file is the actual proof.
  3. FPM answers on the socket, past nginx, via cgi-fcgi and as www-data. That settles the permission question and the path question in one step.
  4. A reboot changes nothing. The most important point, and the one most often skipped. Many quick fixes (permissions set by hand, directories created manually under /run, a service that was started but never enabled) do not survive a reboot. Check systemctl is-enabled php8.2-fpm nginx and reboot the server once in a controlled way, while you are still watching, instead of leaving it to the next maintenance window.

With the KVM root servers and dedicated servers from KernelHost, you carry out that reboot including console access in the customer panel, even while the web service is unreachable. The servers sit in the maincubes datacenter in Frankfurt am Main (TÜV TIER3+), connected to our own network with DDoS protection. Further reading: Fixing nginx 504 Gateway Time-out and Calculating PHP-FPM pm.max_children correctly.

Quick checklist for an emergency

  1. tail -f /var/log/nginx/error.log, trigger the request, note the error number.
  2. Number 2 or 111: is the service running, is the path correct. ss -lx | grep php against grep -Rn "fastcgi_pass" /etc/nginx/.
  3. Number 13: permissions in the pool file, not via chmod.
  4. Number 104 or 110: read the FPM log and the slowlog, and only then talk about timeouts.
  5. Message "too big header": raise the buffer sizes.
  6. After the fix: truncate the log, test again, reboot the server once.

Frequently asked questions

Why do I see 502 and not 504 when the page is simply slow?
When the nginx timeout expires (fastcgi_read_timeout, 60 seconds by default), nginx answers with 504 Gateway Time-out. On long requests, a 502 typically appears when PHP-FPM terminates the worker process hard before that, usually through request_terminate_timeout. All nginx sees then is a broken connection, and it logs 'recv() failed (104: Connection reset by peer)'. Order the timeouts so they rise from the inside out, and you get readable PHP errors instead of 502.
After the upgrade to Debian 13 every PHP page returns 502. What do I have to change?
The socket carries the PHP version in its name. Debian 13 ships PHP 8.4, so the socket is called /run/php/php8.4-fpm.sock, while the nginx configuration still holds the Debian 12 path with 8.2. Adjust fastcgi_pass in all active vhost files, check with 'grep -Rn "fastcgi_pass" /etc/nginx/' and 'ls -l /run/php/' that both sides show the same path, then reload nginx. Going from Ubuntu 22.04 to 24.04 has the same effect (8.1 to 8.3).
Is 'nginx -t' enough to prove that the error is fixed?
No. 'nginx -t' checks the syntax of the configuration and nothing else. A socket path that does not exist on the system at all is syntactically perfectly correct and still returns a 502 on every request. Only a status code test on the server itself holds up, for example 'curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1/', together with an error log that stays empty while you run it.
How do I find the line in the error log that belongs to my 502?
Keep 'tail -f /var/log/nginx/error.log' open in one terminal and trigger the request in a second one. The lines that appear while you do it are the right ones. Bear in mind that many vhosts have an error_log of their own, which you can check with 'grep -Rn "error_log" /etc/nginx/nginx.conf /etc/nginx/sites-enabled/'. The uppercase -R is decisive here, because sites-enabled contains only symlinks, the lowercase -r does not follow them, and then exactly the vhost specific error_log line stays invisible. Messages about 'Permission denied' often carry the level [crit] instead of [error], so it is better to filter on the word 'upstream'.
The 502 only happens for logged-in users, the home page loads normally. What causes that?
That is almost always the message 'upstream sent too big header while reading response header from upstream'. Logged-in sessions bring larger cookies and additional headers with them, and those blow past the response buffer of nginx. Raise fastcgi_buffer_size and fastcgi_buffers in the server or location block, and proxy_buffer_size and proxy_buffers accordingly when you use proxy_pass.
I corrected the socket permissions with chmod, and after a reboot the error is back. Why?
PHP-FPM creates the socket file anew on every start and applies the values from listen.owner, listen.group and listen.mode in the pool file while doing so. A chmod by hand therefore only holds until the next start of the service. Enter the permissions in /etc/php/<version>/fpm/pool.d/www.conf and reload FPM.

nginx PHP-FPM 502 Bad Gateway Debian Ubuntu Troubleshooting Web server Linux administration