PHP-FPM has three different timeouts that can kill a request:
| Layer | Directive | Overridable per-request from PHP? |
|---|---|---|
| nginx | fastcgi_read_timeout |
❌ No (the connection is dropped on the nginx side) |
| PHP | max_execution_time (php.ini) |
✅ Yes, via set_time_limit() / ini_set() |
| PHP-FPM | request_terminate_timeout |
❌ No — the master process kills the worker from outside via SIGTERM |
Because request_terminate_timeout is enforced from outside the worker, it's the safety
net that prevents a single hung request from blocking all your FPM workers. A typical
value is 60–120 seconds.
But: every now and then you have a single endpoint that may take several minutes in the worst case (report export, bulk recalculation, calls to slow third-party APIs). Bumping the global timeout for every request is dangerous — if a bug ever causes real hangs, workers pile up and the whole service stalls.
A second FPM pool with its own request_terminate_timeout, plus nginx routing based
on a query parameter via the map directive.
Incoming request
│
▼
┌─────────┐ map $arg_action $php_upstream {
│ nginx │ ─▶ default php; ──▶ FPM pool [www] port 9000 90s timeout
│ │ "heavy_export" php_slow; ──▶ FPM pool [slow] port 9001 360s timeout
└─────────┘ }
Benefits:
- 🟢 No application code change — the URL stays exactly the same.
- 🟢 Fast endpoints keep their strict 90s killer as a safety net.
- 🟢 Add another long-running endpoint? One extra line in the
map. Done. - 🟢 Pool sizing (
max_children) can be tuned independently per pool.
Same domain, same /api.php, only the query parameter decides the pool:
GET https://api.example.com/api.php?action=list_users
─────────────────
│
▼
nginx reads $arg_action = "list_users"
│
▼
map: "list_users" not listed → falls through to default
│
▼
$php_upstream = "php" (the default upstream)
│
▼
fastcgi_pass $php_upstream; → php-fpm:9000
│
▼
[www] pool · killed at 90s → HTTP 200/504
GET https://api.example.com/api.php?action=heavy_export&format=csv
────────────────────
│
▼
nginx reads $arg_action = "heavy_export"
│
▼
map: "heavy_export" matches the explicit line
│
▼
$php_upstream = "php_slow" (the slow upstream)
│
▼
fastcgi_pass $php_upstream; → php-fpm:9001
│
▼
[slow] pool · killed at 360s → HTTP 200/504
The entire routing decision is one nginx directive — the
mapblock shown below. Nothing else in the request path inspects the URL. The PHP application is unaware that two different pools even exist.
Three places in the nginx config matter, and only one of them is "the filter":
| File / Block | What it does | Is this "the filter"? |
|---|---|---|
nginx.conf → http {} → upstream php_slow { … } |
Defines the second backend (port 9001) | ❌ just a name → IP:port mapping |
nginx.conf → http {} → map $arg_action $php_upstream { … } |
Reads the query parameter and picks an upstream | ✅ THIS is the filter |
php.conf → inside location ~ \.php$ → fastcgi_pass $php_upstream; |
Uses the result of the map for actual routing | ❌ just consumes the variable |
Two pieces, one shared variable name ($php_upstream). That's the whole mechanism.
File is typically /etc/php/8.x/fpm/pool.d/www.conf, or the mounted pool file in your
container. Just append the [slow] block at the bottom — the existing [www]
pool stays untouched.
; ============================================================================
; Slow pool for long-running endpoints
; nginx routes specific requests via map $arg_xxx -> upstream php_slow to here.
; Normal requests stay in [www] with the short timeout.
; ============================================================================
[slow]
user = www-data
group = www-data
; A separate TCP port (must differ from [www]).
; Reachable on the same container/host — no public exposure required.
listen = 0.0.0.0:9001
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
; ondemand: workers spawn ONLY when needed.
; Slow endpoints are typically rare, so this saves RAM the rest of the time.
pm = ondemand
; Caps how many slow requests can run in parallel. Going much higher rarely makes
; sense — if you regularly need >10 concurrent slow calls, consider an async job queue.
pm.max_children = 10
; Idle workers terminate after 30s -> memory is reclaimed.
pm.process_idle_timeout = 30s
; Worker respawns after 100 requests -> protects against memory leaks in long scripts.
pm.max_requests = 100
; >>> THIS is the whole point of the exercise <<<
; Hard-kills a request after 360s (6 min). Pick a value covering your worst case + buffer.
request_terminate_timeout = 360s
; Pipe worker stdout/stderr into the FPM log (debugging-friendly).
catch_workers_output = yes
; Errors go to the container log (or a file, depending on your setup).
php_admin_value[error_log] = /proc/self/fd/2
php_admin_flag[log_errors] = onIn the http {} block of nginx.conf (or in an included file). Right next to your
existing upstream definition.
# Existing upstream for normal requests (the container/service is named "php-fpm" here)
upstream php {
server php-fpm:9000;
}
# NEW: a second upstream pointing at the slow pool on port 9001
upstream php_slow {
server php-fpm:9001;
}
# ============================================================================
# >>> THIS BLOCK IS THE ACTUAL FILTER <<<
# Everything else in this gist is just plumbing around it.
# ============================================================================
#
# Syntax: map <input variable> <output variable> { rules }
#
# ┌── INPUT: nginx automatically exposes every GET parameter as
# │ $arg_<name>. So $arg_action == the value of ?action=...
# │ in the request URL. No code, no parsing — built in.
# │
# │ ┌── OUTPUT: a new variable that downstream
# │ │ directives (fastcgi_pass) can use.
# ▼ ▼
map $arg_action $php_upstream {
# Rule 1: fallback. If no other rule matches, $php_upstream becomes "php"
# (the name of the FAST upstream defined above). This is the safe default.
default php;
# Rule 2: if ?action=heavy_export, $php_upstream becomes "php_slow"
# (the name of the SLOW upstream defined above). Exact, case-sensitive match.
"heavy_export" php_slow;
# Rule 3: add one line like this per long-running endpoint. No other change
# in the entire stack is needed.
"bulk_recalc" php_slow;
}
#
# To match more flexibly, prefix patterns with ~ (regex) or ~* (case-insensitive
# regex). Example:
# ~*^report_ php_slow; # any action starting with "report_"💡 Instead of
$arg_actionyou can use any nginx variable:
$urifor path-based routing (/exports/heavy→ slow)$http_x_poolfor header-based routing (caller setsX-Pool: slow)$hostfor hostname-based routing (slow.api.example.com→ slow)
File is typically /etc/nginx/conf.d/default.conf or an included php.conf:
location ~ \.php$ {
root /var/www/html;
try_files $uri =404;
# nginx-side ceiling must cover the slow pool (otherwise you get a 504 before PHP
# is killed). For the fast pool this is harmless — FPM kills there at 90s anyway.
fastcgi_read_timeout 360;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include /etc/nginx/fastcgi_params;
# >>> The actual magic: a variable instead of a fixed "fastcgi_pass php;".
# nginx evaluates $php_upstream from the map directive and routes accordingly.
fastcgi_pass $php_upstream;
}# 1. Validate nginx config
nginx -t
# 2. Restart PHP-FPM -> picks up the new [slow] pool
systemctl restart php-fpm
# or in Docker: docker restart <php-fpm-container>
# 3. Reload nginx without downtime
nginx -s reload
# or in Docker: docker exec <nginx-container> nginx -s reload# Inside the FPM container:
netstat -tln | grep -E ':(9000|9001)'
# tcp 0.0.0.0:9000 LISTEN <- [www]
# tcp 0.0.0.0:9001 LISTEN <- [slow]ps -eo pid,etime,time,stat,args | grep -E 'pool (www|slow)'
# 7 23:14 0:42 S php-fpm: pool www
# 45 4:17 0:38 R php-fpm: pool slow <- R = running, actively workingPHP-FPM logs the pool name explicitly in every termination warning:
WARNING: [pool www] child 230058 ... execution timed out (96.50 sec), terminating
WARNING: [pool slow] child 45 ... execution timed out (361.20 sec), terminating
→ If [pool slow] ever shows up here, even your 360s ceiling is too low — bump the pool
timeout (or move that endpoint into a real async job queue).
Can I have more than two pools?
Yes, as many as you want. One port + one upstream + one extra map entry per pool.
Can I override request_terminate_timeout from PHP code?
No — by design. The FPM master kills the worker from outside via SIGTERM, and PHP code
has no influence on it. set_time_limit() only affects max_execution_time from php.ini.
Shouldn't I be using a job queue instead? If an endpoint regularly takes minutes: yes, async + status polling is cleaner. This solution is meant for cases where you need to stabilize an existing synchronous API without changing code — e.g. when the caller (client / cron / third-party system) cannot be touched.
Do I need expose: 9001 in docker-compose?
If the nginx and php-fpm containers share a user-defined network: no, they can talk on
any port. expose is purely documentation in that case.
- Append a second
[pool]block inphp-fpm.confwith its own port and a higher timeout. - Add
upstream php_slow { server …:9001; }plus amapdirective to nginxhttp {}. - Replace the fixed pool name in the location with
fastcgi_pass $php_upstream;. - Restart PHP-FPM, reload nginx — done. Not a single line of application code touched.