PHP-FPM Worker Exhaustion — When a DDoS Kills Your Database

· 6 min read

The site was down. MariaDB wasn't just struggling — it had been killed outright by the Linux OOM (Out of Memory) killer. The server had 3.7GB of RAM, and every last byte had been consumed. Not by the database. By PHP-FPM.

The Symptom

The client's WordPress site was returning 502 Bad Gateway errors. SSH into the server showed MariaDB was not running. When I tried to start it:

sudo systemctl start mariadb

It started, then was killed again within minutes. The system logs confirmed what was happening:

dmesg | grep -i "out of memory"
# Out of memory: Killed process 1234 (mariadbd), UID 27, total-vm:2458732kB

The OOM killer was selecting MariaDB as the process to sacrifice because it was the easiest way to free a large block of memory. But MariaDB wasn't the problem. It was the victim.

The Investigation

I checked the PHP-FPM pool configuration:

grep -E "^pm\." /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20

50 max children on a 3.7GB server. I checked how much memory each PHP-FPM worker was actually consuming:

ps -C php-fpm8.2 -o rss= | awk '{ sum += $1 } END { printf "Average: %.0f MB\n", sum/NR/1024 }'
# Average: 90 MB

The math was straightforward:

50 workers x 90 MB = 4,500 MB (4.5 GB)
Server total RAM  = 3,700 MB (3.7 GB)

PHP-FPM alone could consume more memory than the entire server had available. Add MariaDB (needing at least 500MB for InnoDB buffers), the operating system, and any other services, and there was simply nowhere for it all to fit.

Under normal traffic, only 10-15 workers would be active at any time, so the problem stayed hidden. But something had changed.

The Trigger

I pulled the access logs for the past hour:

awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

One IP address had made over 12,000 requests in the past hour — an aggressive bot or a targeted DDoS. Every request was hitting uncached WordPress pages, meaning every request spawned a PHP-FPM worker. With pm.max_children = 50, all 50 slots filled up, each holding 90MB of RAM, and the server ran out of memory.

I blocked the IP immediately in Cloudflare and at the server level:

# Block at server firewall
sudo ufw deny from 203.0.113.42

# Also added to Cloudflare WAF for edge-level blocking

The Fix

Blocking the abusive IP stopped the immediate bleeding, but the underlying problem remained: the PHP-FPM configuration was a ticking time bomb. Any traffic spike — legitimate or malicious — could trigger the same cascade.

I calculated the correct pm.max_children value using a formula I apply to every server I manage:

max_children = (Available RAM - MySQL - OS overhead) / per-worker memory

For this server:

Total RAM:         3,700 MB
MySQL allocation:    800 MB
OS + buffers:        400 MB
Available for PHP: 2,500 MB
Per worker:           90 MB

max_children = 2,500 / 90 = ~27

Conservative setting: 15 (leaving headroom for spikes)

I always round down significantly. Running at 100% theoretical capacity means any unexpected memory allocation — a plugin that leaks memory, a large file upload, a WordPress cron job — pushes you back into OOM territory.

Here's the updated PHP-FPM configuration:

; /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 15
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500

The pm.max_requests = 500 setting is important too — it recycles workers after 500 requests, preventing memory leaks from accumulating over time.

Recommended Settings by Server Size

Here's a reference table I use when onboarding new servers. These assume a standard WordPress + MariaDB stack with ~90MB per PHP worker:

Server RAM MySQL Allocation OS Overhead Available for PHP max_children
1 GB 300 MB 300 MB 400 MB 4
2 GB 500 MB 400 MB 1,100 MB 8
4 GB 1,000 MB 400 MB 2,600 MB 15
8 GB 2,000 MB 500 MB 5,500 MB 30
16 GB 4,000 MB 500 MB 11,500 MB 60
32 GB 8,000 MB 1,000 MB 23,000 MB 100

These are conservative starting points. The actual per-worker memory depends on your plugins — WooCommerce sites with heavy plugins can easily reach 120-150MB per worker, which changes the calculation significantly. Always measure actual usage with ps before setting these values.

Why Default Configs Are Almost Always Wrong

Most server control panels — cPanel, Plesk, CloudPanel — ship PHP-FPM with defaults designed for shared hosting environments or generic use cases. pm.max_children = 50 is a common default that works fine on a 64GB server but is dangerous on anything under 8GB.

The insidious part is that it works perfectly under normal load. You could run for months without hitting the ceiling. Then one bot, one traffic spike, one viral social media post, and your database is killed by the OOM killer. The site goes down, and the hosting provider's generic advice is "upgrade your server" — when the real fix is a 30-second config change.

This is one of the first things I check when onboarding a new site for server management. Properly tuning PHP-FPM, MySQL memory allocation, and setting up rate limiting at the edge are foundational. They cost nothing and prevent the most common category of WordPress downtime I see: resource exhaustion from misconfiguration.

Need help with something similar? Check out my maintenance plans.

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for UK agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs