RAPID INCIDENT TRIAGE & ACTION PLAN•Performance
01. Error Signature
Nginx PHP-FPM Pool Exhaustion 111 Refused
02. Root Cause Vector

PHP-FPM's process pool is fully saturated, causing Nginx to receive connection refused errors on localhost:9000 and return 502 Bad Gatewa...

03. Rapid Prescription

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

Zero-Trust Client IDE/ Performance

Nginx PHP-FPM Pool Exhaustion 111 Refused

PHP-FPM's process pool is fully saturated, causing Nginx to receive connection refused errors on localhost:9000 and return 502 Bad Gateway to all users.

SYSTEM STATUS
CREDITS: 0 / 3
source.yaml(111L)
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 is a legacy deployment for the PCI-secure-zone. DO NOT MODIFY without approval from the legacy-infra team.
# TODO: Migrate to new microservice architecture (JIRA: LEG-4452) - "scheduled for Q4"

apiVersion: apps/v1
kind: Deployment
metadata:
  name: legacy-php-app
  namespace: pci-secure-zone
  labels:
    app: legacy-php-app
    tier: backend
    # OLD LABEL: version: v1-deprecated
spec:
  replicas: 10  # Increased for Black Friday load, never scaled back. TEMP FIX.
  selector:
    matchLabels:
      app: legacy-php-app
  template:
    metadata:
      labels:
        app: legacy-php-app
    spec:
      containers:
      - name: php-fpm
        image: my-registry.internal/legacy/php-fpm:5.6-deprecated
        # VULNERABILITY: The pool configuration (pm.max_children=5) is static and far lower than the number of pod replicas (10) * concurrent requests per pod, leading to pool exhaustion and connection refusals to Nginx.
        env:
        - name: DB_CONNECTION_STRING
          value: "postgresql://app_user:SuperSecretDBPassword123!@pci-db.internal:5432/legacy_db?sslmode=require"
        - name: AWS_ACCESS_KEY_ID
          value: AKIAIOSFODNN7EXAMPLE
        - name: AWS_SECRET_ACCESS_KEY
          value: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
        # STRIPE_LIVE_KEY commented out after PCI audit, but left in env block by mistake.
        # - name: STRIPE_LIVE_KEY
        #   value: sk_live_51H8zF2Kk3NqL8xYw9vB4cD7aA6sF5gH2jL1mN3bV4cX6zQ8
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        lifecycle:
          postStart:
            exec:
              command: ["/bin/sh", "-c", "echo 'Pool tuning for high load...' && sed -i 's/pm.max_children = 5/pm.max_children = 50/' /usr/local/etc/php-fpm.d/www.conf"]
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 30; echo 'Graceful stop...' || true"]  # TODO: Fix this sleep hack.
        volumeMounts:
        - name: php-config
          mountPath: /usr/local/etc/php-fpm.d
      - name: nginx
        image: nginx:1.18-alpine
        ports:
        - containerPort: 80
        volumeMounts:
        - name: nginx-config
          mountPath: /etc/nginx/conf.d
        - name: php-config
          mountPath: /etc/nginx/php_backend  # Misconfigured mount, not used.
        env:
        - name: NGINX_BACKEND
          value: "http://localhost:9000"  # Hardcoded to localhost, relies on shared network namespace.
        # Embedded RSA key for legacy SFTP backup (should be a secret, but it's here).
        - name: SFTP_PRIVATE_KEY
          value: |
            -----BEGIN RSA PRIVATE KEY-----
            MIIEowIBAAKCAQEAu5o6J8rS4zX7q8pLmKjHvNtTwQwGp8sQkLmZxV7bJwK9
            ... (truncated for brevity) ...
            n4K8wIDAQABAoIBAQC/2p7zJ8l1Zq9K8XvL6WkQmG5sT7vY5V2nJwK9LmZx
            -----END RSA PRIVATE KEY-----
      volumes:
      - name: php-config
        configMap:
          name: php-fpm-config
          items:
          - key: www.conf
            path: www.conf
      - name: nginx-config
        configMap:
          name: nginx-config
          items:
          - key: default.conf
            path: default.conf
---
# ConfigMap for PHP-FPM (legacy tuning from 2018)
apiVersion: v1
kind: ConfigMap
metadata:
  name: php-fpm-config
  namespace: pci-secure-zone
data:
  www.conf: |
    [www]
    user = www-data
    group = www-data
    listen = 9000
    listen.allowed_clients = 127.0.0.1
    pm = static
    pm.max_children = 5  # CRITICAL: This is the bottleneck. Set artificially low due to memory concerns in old docs.
    pm.start_servers = 2
    pm.min_spare_servers = 1
    pm.max_spare_servers = 3
    ; pm.max_requests = 500  # Commented out after causing memory leaks in v5.6
    ; env[HOSTNAME] = $HOSTNAME  # Old debugging line
    rlimit_files = 1024  # Too low for modern loads
    # TODO: Adjust pool size based on load (automation script broken)
AI Audit Console
> Waiting for pre-flight audit...
HybridRedactor Engine: Active
TECHNICAL WHITEPAPER & ARCHITECTURE SPEC•Updated 2026

How to Fix Nginx 'upstream failed (111: Connection refused)' Caused by PHP-FPM Pool Exhaustion

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15–30 mins


TL;DR

  • What broke: PHP-FPM exhausted every worker process in the pool. Nginx attempted to proxy to localhost:9000, got ECONNREFUSED, and started returning 502 Bad Gateway site-wide.
  • How to fix it: Tune pm.max_children based on available RAM, switch to pm = dynamic or pm = ondemand, and set request_terminate_timeout to prevent zombie workers from holding slots.
  • Fast path: Use our Client-Side Sandbox above to paste your www.conf and auto-refactor the pool config without sending your server details anywhere.

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

The Incident (What Does the Error Mean?)

Your Nginx error log is printing this:

2024/01/15 03:42:17 [error] 18#18: *58231 connect() failed (111: Connection refused)
  while connecting to upstream,
  client: 203.0.113.47, server: example.com,
  request: "POST /api/checkout HTTP/1.1",
  upstream: "http://127.0.0.1:9000",
  host: "example.com"

Error 111 (ECONNREFUSED) means the TCP connection was actively refused — the port is bound but nothing is accepting the connection, or the listen backlog queue is overflowing and the kernel is dropping SYNs.

PHP-FPM's process manager has hit pm.max_children. Every worker slot is occupied by a running (or hung) PHP request. The socket/port has no capacity to accept the next connection. Nginx has nowhere to send the request and immediately 502s the user.

Immediate consequence: 100% of PHP-dependent traffic returns HTTP 502. Static assets served directly by Nginx still work, which is why your homepage may load but every dynamic request fails.


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

The Attack Vector / Blast Radius

Pool exhaustion is a cascading failure, not a single-point event:

  1. Slow upstream dependency (database, Redis, external API) causes PHP workers to block waiting for I/O. Response times climb from 200ms to 8s+.
  2. New requests queue behind the blocked workers. pm.max_children is hit. The FPM listen backlog (listen.backlog) fills.
  3. Nginx upstream timeout (fastcgi_read_timeout) hasn't fired yet, so Nginx keeps the connection open, consuming worker slots on the Nginx side too.
  4. The kernel starts refusing new TCP connections to port 9000 / the UNIX socket — this is the 111: Connection refused you see.
  5. Nginx logs the error, returns 502. If you have no circuit breaker, every retry from the client hammers the already-exhausted pool harder.
  6. OOM risk: If you attempt to fix this in-flight by restarting PHP-FPM under load, the master process spawns pm.start_servers children simultaneously, potentially spiking RAM and triggering the OOM killer on the PHP-FPM master itself.

Secondary blast radius: Any health check endpoint backed by PHP will also fail, causing your load balancer to mark the instance unhealthy and potentially drain it from the pool — turning a performance incident into a full availability incident.


How to Fix It (The Solution)

Step 1 — Diagnose the live pool state first

Before changing anything, confirm exhaustion is the actual cause:

# Check live pool status (requires pm.status_path = /status in www.conf)
curl http://127.0.0.1/fpm-status?full

# Or inspect the process table directly
ps aux | grep php-fpm | wc -l

# Check the socket/port backlog
ss -tlnp | grep 9000

# Tail the FPM slow log for hung workers
tail -f /var/log/php-fpm/www-slow.log

If active processes == max_children, you are pool-exhausted. Proceed.


Basic Fix — Increase `pm.max_children` correctly

The formula: pm.max_children = floor(Available RAM for PHP / Average RAM per worker)

# Find average worker RAM (run under load, not idle)
ps --no-headers -o rss -C php-fpm | awk '{sum+=$1} END {print sum/NR/1024 " MB per worker"}'

If each worker uses ~80 MB and you have 4 GB available for PHP: 4096 / 80 = 51 max_children.

; /etc/php/8.2/fpm/pool.d/www.conf

- pm = static
- pm.max_children = 5
+ pm = dynamic
+ pm.max_children = 50
+ pm.start_servers = 10
+ pm.min_spare_servers = 5
+ pm.max_spare_servers = 20
+ pm.max_spawn_rate = 4

- request_terminate_timeout = 0
+ request_terminate_timeout = 60s

- pm.max_requests = 0
+ pm.max_requests = 500

Reload, do not restart (avoids dropping in-flight requests):

systemctl reload php8.2-fpm

Enterprise Best Practice — Full hardened pool config

For high-traffic production: separate pools per application, UNIX sockets instead of TCP (lower syscall overhead, no TCP handshake), and strict resource limits.

; /etc/php/8.2/fpm/pool.d/www.conf

  [www]
  user = www-data
  group = www-data

- listen = 127.0.0.1:9000
+ listen = /run/php/php8.2-fpm-www.sock
+ listen.owner = www-data
+ listen.group = www-data
+ listen.mode = 0660
+ listen.backlog = 65535

- pm = static
- pm.max_children = 5
+ pm = dynamic
+ pm.max_children = 50
+ pm.start_servers = 10
+ pm.min_spare_servers = 5
+ pm.max_spare_servers = 20
+ pm.max_spawn_rate = 4
+ pm.process_idle_timeout = 30s

- request_terminate_timeout = 0
+ request_terminate_timeout = 60s
+ request_slowlog_timeout = 5s
+ slowlog = /var/log/php-fpm/$pool-slow.log

- pm.max_requests = 0
+ pm.max_requests = 500

+ pm.status_path = /fpm-status
+ ping.path = /fpm-ping
+ ping.response = pong

+ ; Emergency valve: if pool is exhausted, FPM logs WARN instead of silent drop
+ catch_workers_output = yes
+ decorate_workers_output = no

+ ; Per-worker memory ceiling — kills runaway scripts before OOM killer does
+ php_admin_value[memory_limit] = 256M
+ php_admin_value[max_execution_time] = 60

Update Nginx to use the UNIX socket:

# /etc/nginx/conf.d/app.conf

  location ~ \.php$ {
-   fastcgi_pass 127.0.0.1:9000;
+   fastcgi_pass unix:/run/php/php8.2-fpm-www.sock;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+   fastcgi_read_timeout 65;
+   fastcgi_connect_timeout 5;
+   fastcgi_send_timeout 65;
  }

💡 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. Load test before deploy — catch exhaustion in staging

# k6 smoke test — run in CI pipeline before promoting to prod
k6 run --vus 60 --duration 30s load-test.js
# Fail the pipeline if p95 latency > 2s or error rate > 1%

2. Ansible / Terraform — enforce `pm.max_children` is calculated, not hardcoded

# terraform/modules/php_fpm/variables.tf
variable "worker_ram_mb" { default = 80 }
variable "available_ram_mb" {}

locals {
  max_children = floor(var.available_ram_mb / var.worker_ram_mb)
}

# Fail fast if someone sets max_children below a safe floor
resource "null_resource" "validate_pool" {
  triggers = { max_children = local.max_children }
  provisioner "local-exec" {
    command = "[ ${local.max_children} -ge 10 ] || (echo 'pm.max_children too low' && exit 1)"
  }
}

3. Checkov / OPA policy — reject static pools in IaC

# checkov custom check: no static PHP-FPM pools in prod
from checkov.common.models.enums import CheckResult
from checkov.ansible.checks.task.base_ansible_check import BaseAnsibleCheck

class CheckFPMPoolMode(BaseAnsibleCheck):
    def __init__(self):
        super().__init__(
            name="Ensure PHP-FPM pool uses dynamic or ondemand pm",
            id="CKV_ANSIBLE_PHPFPM_1",
            supported_tasks=["ini_file", "template"]
        )
    def get_resource_id(self, conf):
        return conf.get("name", "unknown")
    def scan_resource_conf(self, conf):
        # Flag any config setting pm = static
        if "static" in str(conf):
            return CheckResult.FAILED
        return CheckResult.PASSED

4. Prometheus alerting — fire before users see 502s

# prometheus/alerts/phpfpm.yml
groups:
  - name: phpfpm_pool
    rules:
      - alert: PHPFPMPoolNearExhaustion
        expr: |
          phpfpm_active_processes / phpfpm_max_active_processes > 0.80
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "PHP-FPM pool {{ $labels.pool }} at {{ $value | humanizePercentage }} capacity"
          runbook: "https://wiki.internal/runbooks/phpfpm-exhaustion"

      - alert: PHPFPMPoolExhausted
        expr: |
          phpfpm_active_processes >= phpfpm_max_active_processes
        for: 30s
        labels:
          severity: critical
        annotations:
          summary: "PHP-FPM pool EXHAUSTED — 502s imminent"

5. `request_terminate_timeout` is your last line of defense

Never leave it at 0. A single slow DB query or infinite loop will permanently occupy a worker slot until FPM or the server is restarted. Set it to 1.5× your fastcgi_read_timeout in Nginx so FPM kills the worker before Nginx gives up and logs the error.

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 exact cause of Nginx error 111 Connection refused on upstream localhost:9000?
Error 111 (ECONNREFUSED) is a definitive signal of PHP-FPM pool exhaustion. Nginx's FastCGI proxy attempts a TCP connection to PHP-FPM on port 9000, but the OS kernel actively refuses it because every `pm.max_children` worker process is occupied and the listen backlog queue is full. This is not a network issue; it's a process-level resource exhaustion on the same host, indicating your application's concurrency model has hit a hard, unmanaged ceiling.
Q2.How do I calculate the correct value for pm.max_children without causing OOM?
Manually calculating `pm.max_children` using the formula `floor(RAM reserved / average RSS)` is a high-risk, error-prone trap. A miscalculation leads directly to OOM kills, cascading service failure, and a significant security blast radius as exhausted resources become a denial-of-service vector. Stop guessing with `ps` and `awk`. Instead, drop the broken manifest into the StackEngine Local Sandbox above to automatically compile a zero-trust fix that dynamically sizes pools based on real-time telemetry and enforces hard resource boundaries.
Q3.Should I use pm = static, dynamic, or ondemand for PHP-FPM in production?
Choosing between static, dynamic, or ondemand modes manually creates a compliance nightmare at scale. Managing these configurations across large mono-repos or hundreds of services is unsustainable and guarantees drift, leading to unpredictable performance and audit failures. For automated pull request generation with Maker-Checker review that enforces policy-as-code, upgrade to the StackEngine CI/CD Automator (Team Edition). It provides deterministic, secure provisioning of FPM pools across your entire estate, eliminating manual tuning risks.