Fixing nginx 504 Gateway Time-out: find the cause instead of raising the timeout
With a 504 the backend was reachable, it only answered too slowly. How to use a timing log to pin down where the time goes, which of the many timeouts really applies, and why a higher timeout usually only postpones the outage.
A 504 Gateway Time-out is the most patient of all error pages. nginx accepted the request, passed it on to the backend and then waited until an internally set timeout ran out. The backend was reachable the whole time, it just did not answer in time. That is precisely what separates it from the adjacent status code: with a 502 Bad Gateway the backend answers wrongly or not at all, with a 504 it answers too slowly. This guide shows you how to measure where the time is really spent, which of the many timeouts actually applies, and why raising that timeout is almost always the worst of the available answers.
Everything here applies to Debian 13 (trixie), Debian 12 (bookworm), Ubuntu 24.04 LTS and Ubuntu 22.04 LTS. The commands are written for a root shell, as a normal user put sudo in front. The examples use PHP 8.4, replace the version number with the one your system runs:
| System | PHP | Service | Configuration |
|---|---|---|---|
| Debian 13 (trixie) | 8.4 | php8.4-fpm | /etc/php/8.4/fpm/ |
| Debian 12 (bookworm) | 8.2 | php8.2-fpm | /etc/php/8.2/fpm/ |
| Ubuntu 24.04 LTS | 8.3 | php8.3-fpm | /etc/php/8.3/fpm/ |
| Ubuntu 22.04 LTS | 8.1 | php8.1-fpm | /etc/php/8.1/fpm/ |
ls /etc/php/
The nginx directives carry the same names on all four systems. The differences sit on the PHP side and on the database side, and they are noted where they matter.
The layer that gave up decides where you look
Before you open a single file, answer one question: which layer gave up? The status code already tells you.
| Code | What happened | Where to look |
|---|---|---|
| 500 Internal Server Error | The backend answered, and the answer was an error | Log of the application |
| 502 Bad Gateway | The connection never came up, or it broke off | Service, socket, permissions, crashes |
| 504 Gateway Time-out | The connection was up, the answer did not arrive within the timeout | Runtime in the backend |
| 408 Request Timeout | The visitor did not finish sending their own request in time | Uploads, slow connections |
| 499 (log only) | The visitor gave up before nginx was done | Too slow, but still below the timeout |
The 499 row is the most underrated one. It is not an error, it is an early warning system: the visitor closed the tab because the page took too long. If a 504 disappears after you raise the timeout and 499s show up in its place, nothing has been solved.
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
Field 9 holds for the default format combined. Many 504s next to few 499s point at a handful of heavy pages, the opposite picture points at an application that is sluggish throughout.
Before you change anything: the way back
The diagnosis in the next two sections is read-only. It only gets risky where you start touching configurations, and that can stop the site in three ways: a broken nginx configuration keeps the web server from starting, a broken pool file keeps PHP-FPM from starting, and generously raised limits can eat up the RAM. The last case is the nastiest, because the kernel then starts killing processes, and it does not necessarily hit the one that caused the trouble. If it hits the SSH service, the server can no longer be operated over the network.
So make copies first, below /root and never in the web directory. The timestamp in the name matters, because you rarely intervene only once and cp -a overwrites an existing copy without a word:
mkdir -p /root/backups
cp -a /etc/nginx/nginx.conf /root/backups/nginx.conf.$(date +%F-%H%M)
cp -a /etc/nginx/sites-available/example.com /root/backups/example.com.$(date +%F-%H%M)
cp -a /etc/php/8.4/fpm/php.ini /root/backups/php.ini.$(date +%F-%H%M)
cp -a /etc/php/8.4/fpm/pool.d/www.conf /root/backups/www.conf.$(date +%F-%H%M)
The way back is three lines, and the order is deliberate:
cp -a /root/backups/example.com.2026-09-03-1030 /etc/nginx/sites-available/example.com
nginx -t
systemctl reload nginx
Use reload instead of restart for as long as you can. reload only takes the new configuration over when it is free of errors. A restart stops the running process first and leaves you without a web server if something is wrong.
If the server stops answering altogether, the KVM root servers and dedicated servers from KernelHost give you the VNC console in the customer panel. It hangs off the virtualization layer, or off the uplink itself, and not off the network stack of the guest system, so it still works when no service is reachable any more. Log in there once beforehand and make sure you know the root password. Check command after every change:
systemctl is-active nginx php8.4-fpm
free -m
The line in the error log that decides the case
A 504 always leaves a trace:
2026/09/03 10:12:33 [error] 812#812: *5 upstream timed out (110: Connection timed out)
while reading response header from upstream, client: 203.0.113.7, server: example.com,
request: "GET /report.php HTTP/1.1", upstream: "fastcgi://unix:/run/php/php8.4-fpm.sock:"
What matters is not the error number 110, which reads the same for every 504, but the phase behind it:
while connecting to upstream: the connection was never established. With a remote backend this is almost always a packet filter that drops packets instead of rejecting them, because a rejection would come back immediately and produce a 502.while sending request to upstream: nginx could not get the request body out, which is typical for large uploads.while reading response header from upstream: the normal case. The backend received everything and is still computing, without sending even the first header line.while reading upstream: the headers arrived, then the body stalled. You see this with streaming responses and exports.
grep -n "upstream timed out" /var/log/nginx/error.log | tail -20
Many virtual hosts write into an error log of their own. Where that file lives, and why the uppercase -R is needed when you search in sites-enabled, is covered in the article on the 502. If the search stays empty although the browser shows a 504, the error does not come from this nginx.
Where the time goes: measure instead of guess
nginx can log per request how long the backend needed. This is the most important step of the whole diagnosis, because it answers the question "application or network" without any guessing. In the http block of /etc/nginx/nginx.conf:
log_format kh_timing '$time_iso8601 $status rt=$request_time '
'uct=$upstream_connect_time uht=$upstream_header_time '
'urt=$upstream_response_time "$request"';
In the affected server block, add a second log line. The existing access log stays untouched, nginx writes both:
access_log /var/log/nginx/timing.log kh_timing;
nginx -t
systemctl reload nginx
tail -n 5 /var/log/nginx/timing.log
After a few minutes of traffic, pull the slowest requests to the top:
awk '{ t=$3; sub(/^rt=/, "", t); print t, $0 }' /var/log/nginx/timing.log | sort -rn | head -20
| Observation | Interpretation | Next step |
|---|---|---|
| uct high with a local backend | The connection setup is stalling | Full listen queue on the socket, slow name resolution |
| uht and urt almost equal, both high | The backend computes before it sends the first header line | Application, database, external API |
| uht small, urt high | The header came fast, the body trickles | Streaming, exports, loops over many records |
| urt small, rt high | The backend was fast, the time was lost afterwards | The visitor's connection, very large response |
| A hyphen instead of a number | No backend was involved at all | Static file, or an abort before the request was passed on |
Two details save a lot of time. Several comma separated values in one field mean the request went to more than one target, so there was a retry. And the sharpest hint of all: if the measured duration matches the configured value to the second, say 60.001 seconds with a timeout of 60, then the timeout fired and the backend did not give up on its own. Odd values such as 43.7 seconds show that something else was the brake.
Which timeout actually applies
nginx has a good dozen directives for timeouts, and the most commonly wasted hour comes from somebody touching the wrong one. Which one applies depends on the module that handles the location block.
| Directive | Default | Applies in blocks with | Effect when it expires |
|---|---|---|---|
| proxy_connect_timeout | 60s | proxy_pass | 504, "while connecting to upstream" |
| proxy_send_timeout | 60s | proxy_pass | 504, "while sending request to upstream" |
| proxy_read_timeout | 60s | proxy_pass | 504, the decisive timeout for proxy backends |
| fastcgi_connect_timeout | 60s | fastcgi_pass | 504, as above, for PHP-FPM |
| fastcgi_send_timeout | 60s | fastcgi_pass | 504, as above |
| fastcgi_read_timeout | 60s | fastcgi_pass | 504, the decisive timeout for PHP |
| send_timeout | 60s | everywhere | no 504, the connection to the visitor is closed |
| client_body_timeout | 60s | everywhere | 408, not 504 |
The timeout applies between two read operations, not to the whole response. A download that takes ten minutes and delivers data without a break runs through. A backend that stays silent for 61 seconds gets thrown out. With stalling exports it therefore often helps more to have the application output something at regular intervals than to raise the timeout.
send_timeout does nothing about a 504. That timeout covers the transfer to the visitor. When it expires there is no error page, only an aborted download. It becomes relevant once you switch buffering off with proxy_buffering off;, because a slow visitor then drags the backend down as well.
nginx also accepts directives that have no effect in the block they sit in. A proxy_read_timeout 300s; inside a PHP block with fastcgi_pass is syntactically flawless, nginx -t reports syntax is ok, and the page still breaks off after 60 seconds. The reverse holds just as well. This is the most common reason why a raised timeout stays without effect. What really applies shows up in the assembled configuration:
nginx -T | grep -E "read_timeout|send_timeout|fastcgi_pass|proxy_pass"
If one single path really has to run longer, set the timeout exactly there and nowhere else. The equals sign turns it into an exact match, and that wins against the general block location ~ \.php$ which handles the remaining PHP files:
location = /admin/export.php {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_read_timeout 300s;
}
nginx -t
systemctl reload nginx
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" -H "Host: example.com" http://127.0.0.1/admin/export.php
PHP: why max_execution_time rarely applies here
The obvious assumption is that PHP terminates a hanging script by itself. In exactly this case that is usually wrong. First the measuring trap: php -i queries the command line variant. That one uses a configuration of its own under /etc/php/8.4/cli/ and runs without a runtime limit anyway, so it says nothing about FPM. These two places are the ones that count:
grep -n "^max_execution_time" /etc/php/8.4/fpm/php.ini
grep -rn "max_execution_time" /etc/php/8.4/fpm/pool.d/
In the pool file the value can be overridden with php_value[max_execution_time] or php_admin_value[max_execution_time]. A value set through php_admin_value can no longer be changed from inside the application with ini_set(). If your framework raises the runtime itself and that suddenly has no effect, this is why.
And now the actual point: on Linux, time spent waiting inside system calls does not count. The clock only runs while the script itself is computing. When it waits for a database query, for an external API or for the file system, the clock stands still. A script can therefore stick to a hanging query for ten minutes without the runtime limit ever firing. What terminates it in the end is the nginx timeout, and the result is the 504.
An uncomfortable rule follows from that: max_execution_time protects you against endless loops in your own code, not against waiting. The only hard limit on the PHP side is request_terminate_timeout in the pool file, which clears the worker process away no matter what it is stuck on. It produces a 502 though, not a 504. Order the timeouts so they rise from the inside out, so that the layer which can still produce a readable message fires first. For computing scripts that works, for waiting ones it does not, for the reason just given. There, the only option left is to limit the waiting itself.
Why raising the timeout is usually the wrong answer
Do the math for a moment. A pool with pm.max_children = 10 has ten worker processes. One page needs 90 seconds. Ten simultaneous calls therefore occupy every single one of them for a minute and a half. During that time nobody gets a PHP page delivered any more, not even the home page. A slow subpage has turned into an outage. Three mechanisms make it worse:
- The visitor reloads. That creates an extra request without releasing the old one. PHP only notices that a visitor is gone the next time the script outputs something, and a computing script outputs nothing for a long while.
- nginx retries on its own. With an
upstreamblock that has several targets,proxy_next_upstreamdefaults toerror timeout. A request that ran into the timeout goes to the next server, and the expensive query runs a second time. Writing requests are excluded, reading ones are not. Switch it off withproxy_next_upstream error;. - Monitoring retries as well. A check interval of 60 seconds against a page that needs 90 seconds creates a permanent load that never gets worked off.
On top of that, nobody waits five minutes for a web page. A timeout of 300 seconds turns a one minute problem into a five minute problem, and the visitor is long gone while the worker process keeps computing.
How full it really is shows on the status page of PHP-FPM. Set pm.status_path = /fpm-status in the pool file and create an entry point in the server block that is reachable locally only:
location = /fpm-status {
allow 127.0.0.1;
deny all;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
systemctl reload php8.4-fpm
nginx -t
systemctl reload nginx
curl -s -H "Host: example.com" http://127.0.0.1/fpm-status
Watch active processes, listen queue and max active processes. If listen queue stays above zero permanently, the number of worker processes does not cover the current runtime of the pages. That is the moment to bring the runtime down, not to raise the timeout.
The three usual time sinks
Database
In the majority of cases the time sits here. Look at what is running right now first. On all four systems the root access goes through the Unix socket by default, so the command works without a password:
mysql -e "SHOW FULL PROCESSLIST;"
The interesting columns are Time and State. Values such as Sending data or Waiting for table metadata lock next to double digit seconds are your case. For a systematic search use the slow query log, which can be switched on while the database keeps running:
mysql -e "SET GLOBAL slow_query_log = 1; SET GLOBAL long_query_time = 1;"
mysql -e "SHOW VARIABLES LIKE 'slow_query_log_file';"
Read the file name from the second output, because it differs: Debian usually goes with MariaDB and writes to /var/log/mysql/mariadb-slow.log, while on Ubuntu the file is named differently depending on the installed server. Evaluate it after a few minutes and switch it off again, the log costs write load:
mysqldumpslow -s t /var/log/mysql/mariadb-slow.log | head -30
mysql -e "SET GLOBAL slow_query_log = 0;"
The switch -s t sorts by total time. Check the most expensive query with EXPLAIN, and in most cases an index is missing on exactly the column that gets filtered or sorted on. SET GLOBAL takes effect immediately, but it does not survive a restart of the database. There is an upper limit per query on the database side as well: MariaDB has max_statement_time in seconds, MySQL has max_execution_time in milliseconds, the latter for reading queries only. That way your application gets a clean error instead of an occupied worker process.
External APIs
If your site queries a payment provider or a license server on every call, their outage becomes your 504. Measure that call separately, from the server:
curl -o /dev/null -s -w "dns=%{time_namelookup} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" https://api.example.com/status
If dns already stands out, the cause is name resolution and not the remote side, cross-check with time getent hosts api.example.com. In the code, every external call needs a tight timeout of its own: with cURL those are CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT. Anyone who reaches for file_get_contents() on a URL instead ends up with default_socket_timeout from the php.ini, and that one defaults to 60 seconds:
grep -n "default_socket_timeout" /etc/php/8.4/fpm/php.ini
The rule of thumb: the sum of all external timeouts within one request has to stay below fastcgi_read_timeout. Otherwise nginx cuts things off before your code can output a readable error page.
File system and RAM
A full disk makes writes slow or impossible, and that hits session files, caches and logs at the same time. Check both, the space and the inodes:
df -h
df -i
The second command is the one people forget. A partition can sit at 40% usage and still refuse to take another file once the inodes are used up. The second candidate is a shortage of memory: as soon as the system starts swapping, every request turns sluggish without any particular query being at fault.
vmstat 1 5
If the columns si and so stay above zero permanently, the kernel is swapping out and in without a break, and you have a memory problem, not a timing problem. The clean way to deal with that is described in setting up swap and avoiding out-of-memory. The third candidate is network drives: a hanging NFS mount point blocks every process that touches it, and that includes your diagnosis command. So put a time limit in front of it:
timeout 5 df -h
Long tasks do not belong in the request
Some tasks simply take their time: an annual report, an import with 200,000 rows, an image conversion. The mistake is not that they run long, it is that a web server waits for them. The clean shape has three parts:
- The request creates a job, in a table or in a queue, and answers immediately. The fitting status code is 202, together with a URL where the state can be polled.
- A worker process outside nginx picks the jobs up and gets them done. It has no timeout breathing down its neck, because nobody is waiting for it.
- The interface polls the state. That query is always fast, no matter how long the task takes.
Run the worker process as a service of its own, so that it comes back by itself after a crash and after a reboot. What such a unit looks like is shown in creating a systemd service. For tasks at fixed intervals a cron job is enough, and there you need a lock so that two runs do not overtake each other. With -n the second run aborts immediately instead of waiting:
flock -n /run/lock/kh-worker.lock /usr/bin/php /var/www/html/worker.php
The common frameworks ship the queue ready made, it only has to be run: with Laravel that is php artisan queue:work, with Symfony php bin/console messenger:consume plus the name of your transport. Both belong in a systemd unit, not in a terminal window.
One special case deserves an explicit mention: WordPress starts scheduled tasks inside visitor requests by default. A visitor therefore pays with waiting time so that an update check can run in the background. The line define('DISABLE_WP_CRON', true); in wp-config.php, above the reference to wp-settings.php, switches that off. After that you call the due tasks yourself at regular intervals:
wp cron event run --due-now --path=/var/www/html
Whatever has to stay synchronous gets a location block of its own with a timeout of its own, plus a cap so that this one path cannot occupy every worker process: limit_conn_zone $binary_remote_addr zone=export:10m; in the http block and limit_conn export 1; in the block concerned. Further attempts then get a 503 instead of an occupied server.
Common errors and solutions
| Message, word for word | Meaning and remedy |
|---|---|
upstream timed out (110: Connection timed out) while reading response header from upstream | The normal case. The backend takes too long to compute. Evaluate the timing log and identify the time sink before you touch the timeout. |
upstream timed out (110: Connection timed out) while connecting to upstream | The connection setup ran into the timeout. With a remote backend this is almost always a packet filter that drops instead of rejecting, with local PHP-FPM it is a full listen queue on the socket. |
upstream timed out (110: Connection timed out) while reading upstream | The headers arrived, then the body stalled for longer than the timeout. Typical for exports that compute for a long stretch in between. |
nginx: [emerg] "fastcgi_read_timeout" directive is not allowed here in /etc/nginx/nginx.conf:12 | The directive sits outside of http, server or location, usually by accident right at the top of the file. |
nginx: [emerg] unknown directive "proxy_read_timout" in /etc/nginx/sites-enabled/example.com:31 | A typo. nginx checks names, not intentions. The line number is in the message. |
PHP Fatal error: Maximum execution time of 30 seconds exceeded in /var/www/html/export.php on line 42 | Here the PHP runtime limit did fire for once, so the script was computing and not waiting. Produces a 500 or a blank page, not a 504. |
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction | Another transaction holds the row, and the limit is 50 seconds by default. The cause is almost always a transaction that stays open for too long. |
cURL error 28: Operation timed out after 60000 milliseconds | An external API is not answering. Set a shorter timeout of your own in the code so that your application keeps control. |
504 in the browser, but the search for upstream timed out stays empty | The 504 does not come from this nginx, it comes from a service in front of it such as a load balancer or a second proxy. Some of them report a status code of their own for it. |
| Still breaking off after exactly 60 seconds although the timeout is set to 300 | The changed directive belongs to the wrong module, or another block wins. nginx -T shows what really applies. |
How you can tell that it is fixed
A command without an error message proves nothing, and a single successful call proves nothing either. Four pieces of evidence that hold up together:
- Status code and duration directly on the server, so that no cache prettifies the result. What you expect is a 200 and a duration clearly below the timeout. A 200 after 58 seconds with a timeout of 60 is not a success, it is the next outage as soon as the load rises a little. What counts here is the worst of twenty calls:
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} %{time_total}\n" -H "Host: example.com" http://127.0.0.1/report.php; done | sort -k2 -n | tail -3 - The error log stays quiet. Truncate it before the test with
truncate -s 0 /var/log/nginx/error.log, trigger the calls, then look inside again. An empty file is the actual proof. - The queue of PHP-FPM sits at zero. As long as something is waiting under
listen queueon the status page, the cause has only been moved. - A reboot changes nothing. The point that gets skipped most often. Values from
SET GLOBAL, processes started by hand and directories you created yourself below/rundo not survive it. Checksystemctl is-enabled nginx php8.4-fpmand reboot the server once in a controlled way, while you are still watching.
With the KVM root servers and dedicated servers from KernelHost you handle that reboot, console access included, in the customer panel, even while the web service is delivering nothing at all. The servers sit in the maincubes datacenter in Frankfurt am Main (TÜV TIER3+ certified), with filtering in the network in front of them.
Quick checklist for an emergency
- Count the status codes in the access log. 504, 499 and 502 side by side say more than any single one of them.
grep "upstream timed out" /var/log/nginx/error.log, note the phase in the message.- Switch the timing log on, compare
uct,uhtandurt. If the duration matches the configured timeout to the second, the timeout fired. - Identify the time sink: database, external API, file system or memory.
- Use
nginx -Tto check which timeout applies in that block before you change one. - Only then decide: fix it, move it into a queue or, as a last resort, raise the timeout for exactly this one path.
- After the fix: truncate the log, measure twenty calls, reboot the server once in a controlled way.
Frequently asked questions
What is the difference between 502 Bad Gateway and 504 Gateway Time-out?
I set fastcgi_read_timeout to 300 seconds and the page still breaks off after 60 seconds. What causes that?
Does a higher timeout solve the problem?
Which line in the error log belongs to a 504?
Why does max_execution_time not terminate my hanging PHP script?
How do I tell whether the timeout fired or the backend gave up on its own?
The browser shows a 504 but the search for "upstream timed out" stays empty. Where does the error come from?
What do I do with tasks that naturally take longer than any sensible timeout?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

