Bali, Indonesia
Insights

Raised max_execution_time but Still Getting 504? Match Nginx fastcgi_read_timeout

November 2, 2026
0:00
Someone needs to run a big product import or a year-end report. It dies with 504 Gateway Time-out, so they raise max_execution_time in PHP to five minutes, restart PHP, and try again. The same 504 shows up at almost exactly the same moment as before. PHP was rarely the part giving up. Nginx was, because it has its own timeout for waiting on PHP, and nobody touched it. This write-up uses a post-mortem shape, but it describes a common pattern, not one specific incident. The timeline is the order of investigation that usually plays out at a support desk.
The symptom is consistent. A long admin action (CSV import, bulk image regeneration, a report that joins several large tables) runs for a while, then the browser shows a plain Nginx 504 Gateway Time-out page. Short pages on the same site load fine. Nothing looks broken except the one slow task. The side effect causes more damage than the error page. When Nginx gives up, PHP often keeps working in the background, because PHP usually only notices the connection is gone when it tries to send output. So the import may partly or fully finish after the user has already seen a failure. The user then clicks the button again, and you end up with duplicate orders, double-imported products, or two copies of a report job fighting over the same rows. Before you retry a timed-out job, check whether the first attempt actually stopped.
The first move is almost always PHP. That makes sense: "execution time" sounds like exactly the knob for "this script takes too long." The value gets raised in php.ini, in a .user.ini, through a control panel's PHP settings, or with set_time_limit() in the code. The change often fails for two separate reasons. The first is that the change may not have landed where PHP-FPM reads it. PHP-FPM (the FastCGI Process Manager, the service that runs PHP for Nginx) uses its own php.ini and pool files. Those differ from the CLI's. Running php -i in a terminal shows the CLI configuration, not what the website gets. A pool file can also force the value with php_admin_value, which scripts and .user.ini files cannot override. The reliable check is a temporary phpinfo() page served through the website, removed right after. The second reason matters more. Even with the PHP value correctly applied, PHP was never the layer timing out. On Linux, max_execution_time counts the script's own execution time. It does not count time spent waiting on database queries, stream operations, or external calls. A report that spends most of its life waiting on MySQL can run far past its PHP limit in wall-clock terms without PHP stopping it. Nginx, on the other hand, counts real seconds.
The Nginx error log usually gives the answer in one line. A useful clue is timing. If the 504 always arrives around the same mark (a round minute is typical) no matter what you set in PHP, a fixed timeout somewhere else is involved.
BASH
# Find upstream timeouts in the Nginx error log (path varies by distro and panel)
sudo grep -n 'upstream timed out' /var/log/nginx/error.log | tail -n 5

# Show the timeout values Nginx actually loaded, including included files
sudo nginx -T 2>/dev/null | grep -n -E 'fastcgi_(read|send|connect)_timeout'
The matching log entry typically reads like upstream timed out (110: Connection timed out) while reading response header from upstream, followed by the client, server, request, and the FastCGI upstream address. "Upstream" means PHP-FPM here, and "while reading response header" means Nginx was still waiting for PHP to send anything at all. If the second command prints nothing, Nginx is running on its defaults. fastcgi_read_timeout defaults to 60 seconds. One detail about it gets misread a lot: the timeout applies between two successive reads, not to the whole response. A script that flushes some output every few seconds keeps resetting the clock. A script that stays silent until it's done, which is how most imports and reports behave, gets cut off at 60 seconds of silence. The status code also tells you which layer acted:
LayerSettingWhat it measuresWhat the user typically sees
PHPmax_execution_timeScript execution time (not DB or I/O wait on Linux)A PHP fatal error, or a 500 if errors are hidden
PHP-FPM poolrequest_terminate_timeoutWall-clock time per request; disabled by default502, since the worker is killed mid-request
Nginxfastcgi_read_timeoutSilence between reads from PHP-FPM504
CDN or load balancerProvider-specificIts own idle or response limitThe provider's own error page and code
A plain Nginx 504 points at fastcgi_read_timeout. A 502 with "upstream prematurely closed connection" in the log points at PHP-FPM killing the worker. Raising the wrong one changes nothing.
The quick fix is to give Nginx a longer read timeout. It is tempting to set fastcgi_read_timeout 300s; once in the http block and move on. That works, but every PHP request on the server now gets five minutes to hang, including ones stuck on a dead external API. During a traffic spike, those slow requests tie up PHP-FPM workers for much longer, and the rest of the site starts queuing behind them. A narrower option is to raise it only for the path that needs it:
NGINX
# Exact-match location for the long-running endpoint only (illustrative path)
location = /admin/import.php {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME document_rootfastcgi_script_name;
    # Use the same socket or address as your main PHP location
    fastcgi_pass unix:/run/php/php-fpm.sock;
    fastcgi_read_timeout 300s;
}
An exact-match location replaces the general PHP location for that path. It needs the full FastCGI setup, not just the timeout line, so copy whatever your main location ~ \.php$ block contains. Run sudo nginx -t before reloading. On panel-managed servers, the generated Nginx config may get rewritten on the next rebuild, so use the panel's supported include or override mechanism instead of editing generated files directly. Then line up the PHP side so the layers fail in a sensible order:
INI
; PHP-FPM pool file (location varies by distro, PHP version, and panel)
; Applies to every script in this pool, not just the import
php_admin_value[max_execution_time] = 280
; Hard wall-clock backstop for stuck workers; 0 means disabled
request_terminate_timeout = 290s
The rule of thumb is that inner layers end slightly before outer ones. PHP stops first and can log a clear error. PHP-FPM catches anything PHP's own timer misses, such as long database waits. Nginx sits just above both. If a CDN or load balancer sits in front, its limit has to be the largest of all. Otherwise it returns its own error while everything behind it is still working, and you're back to chasing the wrong layer. The numbers above are an illustrative example, not a recommendation. Reload PHP-FPM after changing the pool file.
Raising timeouts treats the symptom. The underlying issue is that a job lasting minutes is running inside an HTTP request, which was designed to finish in seconds. Each layer in that path (browser, CDN, Nginx, PHP-FPM) has a reasonable reason to give up on a silent connection. The durable fixes take the job out of that path:
  • Run it from the command line, where PHP's default max_execution_time is unlimited and no web server is waiting. WP-CLI for WordPress, a framework's console command, or a plain php script started over SSH all work.
  • Hand it to a background worker or a cron-driven queue. The web request only creates the job and returns right away. The page then polls for progress.
  • Split it into batches. An import that handles a few hundred rows per request, with the browser calling the next batch, never comes close to any timeout. A failure only costs one batch.
  • Make the job idempotent where possible (safe to run twice with the same result), so a retry after a timeout cannot double-insert data.
None of these need exotic tools. Most applications that handle large imports already offer a CLI or batched mode. It's worth checking the plugin or framework docs before raising any limit. The habit worth building is simple. Whenever one timeout changes, check the rest of the chain in the same sitting: PHP's effective value through the web, the PHP-FPM pool, Nginx's fastcgi_read_timeout, and anything in front of Nginx. Read the status code and the error log line first, because they tell you which layer actually acted. For the Indonesian-language version of the core misconception, see Mengira Naikkan max_execution_time Cukup untuk Hilangkan Error 504 di Nginx.