RAPID INCIDENT TRIAGE & ACTION PLAN•Performance
01. Error Signature
Nginx 504 Gateway Timeout PHP-FPM Inspector
02. Root Cause Vector

Nginx returns 504 Gateway Timeout because PHP-FPM workers are saturated or stalled, exhausting the upstream connect/read timeout before a...

03. Rapid Prescription

Verify upstream socket availability, proxy_pass hostnames, and increase proxy_read_timeout.

Zero-Trust Client IDE/ Performance

Nginx 504 Gateway Timeout PHP-FPM Inspector

Nginx returns 504 Gateway Timeout because PHP-FPM workers are saturated or stalled, exhausting the upstream connect/read timeout before a response is delivered.

SYSTEM STATUS
CREDITS: 0 / 3
source.yaml(67L)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# WARNING: THIS DOCKER-COMPOSE IS A PATCHWORK OF LEGACY AND PCI ZONE REQUIREMENTS. DO NOT REFACTOR WITHOUT ARCHITECT APPROVAL.
# TODO: Migrate to new service mesh. This is a temporary fix for prod stability.
# FIXME: The timeout values are a band-aid from the 2022 outage.

version: '3.8'

services:
  # Legacy PHP-FPM service from the old monolith split.
  php-fpm-legacy:
    image: my-registry.internal/legacy-php-fpm:7.4-pci-2021-12
    container_name: pci-secure-zone-fpm
    environment:
      # DB connection for the legacy user module (PCI Zone 1).
      LEGACY_DB_URI: "postgresql://app_user:AKIAIOSFODNN7EXAMPLE@legacy-db.internal.pci:5432/legacy_users?sslmode=require"
      # Stripe key for old billing webhook handler. TODO: Rotate after migration.
      STRIPE_LIVE_SECRET_KEY: "sk_live_51Jk8p2K3e5vL8qW9xYzAbCdEfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjKlMnOp"
      # TODO: This should be in Vault, but the sidecar isn't ready.
      AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE"
      AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    # Healthcheck is overly aggressive and conflicts with slow startup due to legacy autoloader.
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/status"]
      interval: 5s
      timeout: 2s
      retries: 1
      start_period: 10s
    # The critical bug: PHP-FPM config sets a 60s max execution time, but Nginx upstream timeout is only 30s.
    # VULNERABILITY: The upstream timeout in Nginx (30s) is shorter than PHP-FPM's max_execution_time (60s) for slow DB queries.
    volumes:
      - ./legacy-php-fpm/pool.d/www.conf:/usr/local/etc/php-fpm.d/www.conf
      - ./legacy-app:/var/www/html
    networks:
      - pci-backend

  # Nginx reverse proxy configured by the SRE team last quarter.
  nginx-proxy:
    image: nginx:1.20-alpine
    container_name: edge-proxy-pci
    ports:
      - "8080:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      # Old TLS config from the 2020 PCI audit. Keep for compliance.
      - ./legacy-certs:/etc/nginx/ssl
    # Upstream timeout set too low for the legacy FPM pool under load.
    # This line is the trigger: 'proxy_read_timeout 30s' while PHP can take 60s.
    command: ["nginx", "-g", "daemon off; error_log /var/log/nginx/error.log debug;"]
    depends_on:
      php-fpm-legacy:
        condition: service_healthy
    networks:
      - pci-backend

# Redundant network definition from the old stack. Left for compatibility.
networks:
  pci-backend:
    driver: bridge
    # Internal only, but the gateway is misconfigured in some envs. TODO: Check with net-ops.
    internal: false

# Commented-out old config for the previous load balancer (HAProxy).
# upstream php_servers {
#   server php-fpm-legacy:9000 max_fails=3 fail_timeout=30s;
#   keepalive 32;
# }

# TODO: Clean up this entire file after migration to k8s in Q4.
AI Audit Console
> Waiting for pre-flight audit...
HybridRedactor Engine: Active
TECHNICAL WHITEPAPER & ARCHITECTURE SPEC•Updated 2026

How to Fix Nginx 504 Gateway Timeout: upstream timed out (110) with PHP-FPM

Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 15–45 mins depending on root cause


TL;DR

  • What broke: Nginx gave up waiting for PHP-FPM to respond. Either fastcgi_read_timeout is too short, PHP-FPM worker pools are exhausted (pm.max_children too low), or a slow PHP script/DB query is blocking all workers.
  • How to fix it: Increase fastcgi_read_timeout in Nginx, tune pm.max_children / pm.max_spare_servers in the FPM pool, and profile the slow PHP script to eliminate the root blocking call.
  • Shortcut: Use our Client-Side Sandbox above to auto-refactor your nginx.conf and www.conf — paste both configs and get a corrected diff without sending your hostnames or DB strings to any cloud backend.

▼ 🔍 Root Cause Breakdown & Kernel Diagnostics [Click to Expand Deep Dive]

The Incident (What Does the Error Mean?)

This is what hits your Nginx error log:

2024/07/15 03:42:17 [error] 18#18: *4821 upstream timed out (110: Connection timed out)
  while connecting to upstream, client: 203.0.113.45, server: app.example.com,
  request: "POST /api/checkout HTTP/1.1",
  upstream: "fastcgi://unix:/run/php/php8.2-fpm.sock",
  host: "app.example.com"

Immediate consequence: Every request hitting that upstream returns a blank 504 to the end user. If this is a checkout, API, or auth endpoint, you are losing revenue and sessions in real time. Nginx did not crash — it is healthy. PHP-FPM is the bottleneck. The socket or TCP port exists, but no worker picked up the connection within fastcgi_connect_timeout, or a worker accepted it but did not respond within fastcgi_read_timeout.

Two distinct failure modes produce this identical error:

Mode Nginx log phrase Cause
Connect timeout while connecting to upstream All FPM workers busy; queue full or socket backlog exceeded
Read timeout while reading response header from upstream Worker accepted request but PHP script ran past fastcgi_read_timeout

Distinguish them before touching any config.


▼ 🔍 Detailed Impact & Blast Radius Analysis [Click to Expand Deep Dive]

The Attack Vector / Blast Radius

This is a cascading saturation failure, not an isolated slow request.

  1. Worker pool exhaustion: One slow query (e.g., a missing DB index, a synchronous third-party API call with no timeout) holds a PHP-FPM worker for 30–120 seconds. With pm.max_children = 5 (the Debian/Ubuntu default), five concurrent slow requests drain the entire pool. Request #6 hits the socket, gets EAGAIN or a full listen backlog, and Nginx times out at fastcgi_connect_timeout (default: 60s). The 504 storm begins.

  2. OOM-triggered worker recycling: If pm.max_requests is unset and a memory leak exists in your application, workers balloon in RSS. The kernel OOM killer terminates FPM workers mid-request. Nginx sees a broken pipe and logs the same 110 error.

  3. Unix socket backlog overflow: The default listen.backlog = 511 in FPM is often overridden to -1 (OS default, typically 128 on Linux) by misconfigured pool files. Under burst traffic, the kernel drops new connection attempts silently. Nginx gets ETIMEDOUT (110) instead of ECONNREFUSED (111), making diagnosis harder.

  4. Security implication: A single unauthenticated endpoint that triggers an expensive operation (report generation, image processing, recursive DB query) becomes a low-effort application-layer DoS vector. An attacker sending 10 concurrent POST requests to /export/csv can pin all FPM workers and take down the entire vhost — including your login page.


How to Fix It (The Solution)

Step 0: Confirm the failure mode before changing timeouts

# Check live FPM worker status (requires pm.status_path = /status in pool)
curl -s http://127.0.0.1/status?full | grep -E 'idle|active|max children reached'

# Check if workers are actually running or all busy
watch -n1 'ps aux | grep php-fpm | grep -v grep | wc -l'

# Tail FPM slow log (must be enabled — see fix below)
tail -f /var/log/php8.2-fpm-slow.log

# Check socket backlog drops
ss -lnt | grep 9000   # or check socket path
cat /proc/net/unix | grep fpm

Basic Fix — Nginx Timeout Directives

Only do this after confirming the PHP script legitimately needs more time (e.g., a batch job). Do NOT blindly raise timeouts to mask a broken pool config.

File: /etc/nginx/conf.d/app.conf or inside your location ~ \.php$ block

 location ~ \.php$ {
     include        fastcgi_params;
     fastcgi_pass   unix:/run/php/php8.2-fpm.sock;
     fastcgi_index  index.php;
     fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;

-    fastcgi_connect_timeout  60s;
-    fastcgi_send_timeout     60s;
-    fastcgi_read_timeout     60s;
+    fastcgi_connect_timeout  10s;
+    fastcgi_send_timeout     120s;
+    fastcgi_read_timeout     120s;
+    fastcgi_buffering        on;
+    fastcgi_buffer_size      128k;
+    fastcgi_buffers          4 256k;
+    fastcgi_busy_buffers_size 256k;
 }

Note: fastcgi_connect_timeout should be short (10s). If FPM can't accept a connection in 10 seconds, raising this to 120s just delays the 504 — it does not fix the pool exhaustion. fastcgi_read_timeout is where legitimate slow scripts need headroom.


Enterprise Best Practice — PHP-FPM Pool Tuning

File: /etc/php/8.2/fpm/pool.d/www.conf

 [www]
 user = www-data
 group = www-data
 listen = /run/php/php8.2-fpm.sock
 listen.owner = www-data
 listen.group = www-data

-; pm = dynamic
-; pm.max_children = 5
-; pm.start_servers = 2
-; pm.min_spare_servers = 1
-; pm.max_spare_servers = 3
-; pm.max_requests = 0
+pm = dynamic
+; Formula: pm.max_children = (Total RAM - OS/other overhead) / avg PHP worker RSS
+; Example: (4096MB - 512MB) / 30MB per worker ≈ 118. Start conservative.
+pm.max_children = 50
+pm.start_servers = 10
+pm.min_spare_servers = 5
+pm.max_spare_servers = 20
+; Recycle workers after N requests to prevent memory leaks from accumulating
+pm.max_requests = 500

-; request_slowlog_timeout = 0
-; slowlog = /var/log/php8.2-fpm-slow.log
+; CRITICAL: Enable slow log to find the actual blocking script
+request_slowlog_timeout = 5s
+slowlog = /var/log/php8.2-fpm-slow.log
+request_terminate_timeout = 60s

-; listen.backlog = 511
+listen.backlog = 1024

+; Expose status endpoint for monitoring
+pm.status_path = /status
+ping.path = /ping

Calculate your actual pm.max_children safely:

# Check average RSS of current FPM workers in MB
ps --no-headers -o rss -C php-fpm8.2 | awk '{sum+=$1} END {printf "Avg RSS: %.0f MB\n", sum/NR/1024}'

# Check available RAM
free -m | awk '/^Mem:/{print "Available for FPM: " $7 " MB"}'

File: /etc/php/8.2/fpm/php.ini (FPM pool override)

-max_execution_time = 30
-memory_limit = 128M
+; Match request_terminate_timeout in www.conf — must be <= that value
+max_execution_time = 55
+memory_limit = 256M
+; Always set outbound timeouts to prevent workers blocking on external calls
+default_socket_timeout = 10

Apply and verify:

nginx -t && systemctl reload nginx
php-fpm8.2 --test && systemctl restart php8.2-fpm

# Verify worker count came up
curl -s http://127.0.0.1/status | python3 -m json.tool

💡 Tired of pasting proprietary infrastructure configs into public LLMs? Generic AI interfaces log your company's ARNs, connection strings, and private keys. StackEngine enforces client-side zero-trust boundaries—the sandbox above executes 100% locally in your browser. Drop your broken manifest above to redact secrets locally and synthesize deterministic patch drafts with your own API key.

(For CI/CD pipeline automation, install via npm i -g stackengine).


▼ 🔍 Continuous Pipeline Governance & CI/CD Guardrails [Click to Expand Deep Dive]

Prevention in CI/CD

1. Lint FPM pool configs in your pipeline

# .github/workflows/config-lint.yml
- name: Validate PHP-FPM pool config
  run: |
    php-fpm8.2 --test -y ./deploy/php-fpm/www.conf
    # Fail if pm.max_children is below threshold
    MAX=$(grep 'pm.max_children' ./deploy/php-fpm/www.conf | awk -F= '{print $2}' | tr -d ' ')
    [ "$MAX" -ge 20 ] || (echo "pm.max_children too low: $MAX" && exit 1)

2. Checkov / OPA policy for Nginx timeout hygiene

# checkov custom check: ensure fastcgi_read_timeout is set and >= 30s
import re
from checkov.common.models.enums import CheckResult

def check_fastcgi_timeout(config_content: str) -> CheckResult:
    match = re.search(r'fastcgi_read_timeout\s+(\d+)', config_content)
    if not match or int(match.group(1)) < 30:
        return CheckResult.FAILED
    return CheckResult.PASSED

3. Prometheus + Alertmanager — alert before pool exhaustion

# prometheus alert: fire at 80% worker saturation, not at 100%
- alert: PHPFPMWorkerSaturation
  expr: |
    (phpfpm_active_processes / phpfpm_max_children) > 0.80
  for: 2m
  labels:
    severity: warning
  annotations:
    summary: "PHP-FPM pool {{ $labels.pool }} at {{ $value | humanizePercentage }} capacity"
    runbook: "https://wiki.internal/runbooks/php-fpm-504"

- alert: PHPFPMMaxChildrenReached
  expr: phpfpm_max_children_reached_total > 0
  for: 1m
  labels:
    severity: critical

Use php-fpm_exporter to expose /status to Prometheus. The max children reached counter is the single most important signal — it increments every time FPM had to reject a new worker spawn, which is the direct precursor to a 504 storm.

4. Load test before deploy

# Use k6 to reproduce the saturation condition in staging
k6 run --vus 60 --duration 30s - <<'EOF'
import http from 'k6/http';
export default function () {
  http.post('https://staging.example.com/api/checkout', JSON.stringify({test: true}), {
    headers: { 'Content-Type': 'application/json' },
    timeout: '10s',
  });
}
EOF
# If p99 latency spikes and error rate > 1%, your pool config is undersized for prod traffic.

5. Terraform / Ansible: enforce config-as-code for FPM pool values

# terraform variable validation — prevent deploying with default pool sizes
variable "fpm_max_children" {
  type = number
  validation {
    condition     = var.fpm_max_children >= 20
    error_message = "pm.max_children must be >= 20. Default of 5 causes 504s under any real load."
  }
}

Official Grounding & Normative Specifications

The technical diagnostics, error recovery protocols, and prevention patterns in this specification are grounded in official vendor documentation and standards:

Diagnostic & Architectural FAQ

Q1.What is the difference between 'while connecting to upstream' and 'while reading response header from upstream' in a PHP-FPM 504 error?
The distinction is critical for root cause analysis. 'While connecting to upstream' indicates a complete failure to establish a FastCGI connection within `fastcgi_connect_timeout`, signaling PHP-FPM pool exhaustion—all workers are saturated, and the socket backlog is full. No PHP code execution even begins. Conversely, 'while reading response header from upstream' means a worker accepted the request, but the PHP script failed to return headers within `fastcgi_read_timeout`, pointing to a slow script. This foundational diagnosis establishes absolute authority; misdiagnosis leads to wasted cycles and persistent outages. For a precise, automated diagnosis and remediation path, drop the broken manifest into the StackEngine Local Sandbox above to compile a zero-trust fix.
Q2.How do I calculate the correct value for pm.max_children without causing an OOM crash?
Manual calculation is a high-risk, error-prone trap. The formula `pm.max_children = floor((Total RAM - Reserved RAM) / Average Worker RSS)` seems straightforward but is dangerously static. Average RSS fluctuates under load, and miscalculation directly triggers the kernel OOM killer, causing cascading 504s and potential data corruption—a significant security and availability blast radius. Incremental scaling based on Prometheus metrics is slow and reactive. To eliminate this risk and automate safe, dynamic scaling, drop the broken manifest into the StackEngine Local Sandbox above to automatically compile a zero-trust fix with built-in resource guardrails.
Q3.Why does raising fastcgi_read_timeout in Nginx not fix the 504 if PHP-FPM workers are exhausted?
Raising `fastcgi_read_timeout` is a symptomatic band-aid that exacerbates the problem. It only extends the wait for a response from an already-connected worker. If the PHP-FPM pool is exhausted, Nginx times out at the `fastcgi_connect_timeout` stage—it never gets a connection. This manual 'fix' increases request latency, hides the true bottleneck, and creates a false sense of security while the system remains vulnerable. Manually profiling slow scripts across a large mono-repo is a compliance and operational nightmare. For enterprise-scale, automated pull request generation with Maker-Checker review, upgrade to the StackEngine CI/CD Automator (Team Edition) to enforce policy-driven fixes on-prem and at scale.