Skip to content

Instantly share code, notes, and snippets.

@michabbb
Last active May 5, 2026 23:41
Show Gist options
  • Select an option

  • Save michabbb/91747cd6f3a700fcc7be072d6817433f to your computer and use it in GitHub Desktop.

Select an option

Save michabbb/91747cd6f3a700fcc7be072d6817433f to your computer and use it in GitHub Desktop.
PHP-FPM: route a long-running endpoint into a separate pool — without touching application code. Setup with nginx map + dedicated FPM pool.

PHP-FPM: Route a long-running endpoint into a separate pool — without touching application code

Problem

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.

Solution

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.

How it works — two example URLs, two pools

Same domain, same /api.php, only the query parameter decides the pool:

🟢 URL 1 — normal request → fast 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

🔴 URL 2 — long-running endpoint → slow pool

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 map block shown below. Nothing else in the request path inspects the URL. The PHP application is unaware that two different pools even exist.


Where exactly is the filter? (TL;DR before the configs)

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.confhttp {}upstream php_slow { … } Defines the second backend (port 9001) ❌ just a name → IP:port mapping
nginx.confhttp {}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.


Configuration (3 files)

1) PHP-FPM pool config — append a second [slow] pool

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] = on

2) nginx http context — upstream + routing map

In 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_action you can use any nginx variable:

  • $uri for path-based routing (/exports/heavy → slow)
  • $http_x_pool for header-based routing (caller sets X-Pool: slow)
  • $host for hostname-based routing (slow.api.example.com → slow)

3) nginx location block — use the variable in fastcgi_pass

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;
}

Reload

# 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

Verification

Are both pools listening?

# 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]

Which pool is currently serving which request?

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 working

On timeouts: the pool name shows up in the log

PHP-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).


FAQ

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.


TL;DR

  1. Append a second [pool] block in php-fpm.conf with its own port and a higher timeout.
  2. Add upstream php_slow { server …:9001; } plus a map directive to nginx http {}.
  3. Replace the fixed pool name in the location with fastcgi_pass $php_upstream;.
  4. Restart PHP-FPM, reload nginx — done. Not a single line of application code touched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment