Linux Performance Tuning for Web Servers (Nginx/PHP-FPM)
AI generated
$
/etc
Performance Tuning · Nginx · PHP-FPM · Linux
Linux Performance Tuning for Web Servers (Nginx/PHP-FPM)
from kernel limits to the right pm.max_children

An overloaded web server is rarely down to application code alone. More often, it comes down to mismatched operating system limits and undersized PHP-FPM pools that collapse under real traffic. This article shows, in practical terms, how to correctly size Nginx worker processes, file descriptor limits, the TCP backlog, and pm.max_children based on the memory actually available, and how to back up every single change with real benchmark numbers instead of relying on guesswork.

15 min read worker_connections · ulimit · somaxconn · pm.max_children Nginx 1.24+ · PHP-FPM 8.2+ · systemd · Ubuntu · Debian

1. Why Performance Tuning Starts at the Operating System

Many tuning guides jump straight to opcode caches, query optimization, or CDN configuration. On a heavily loaded web server, though, the system often hits a wall much earlier: the number of simultaneously open file descriptors, the size of the TCP accept queue, or the number of PHP-FPM workers running at once. These operating system limits ship with conservative defaults meant for desktop systems or small test environments, not for a production server handling thousands of concurrent connections. Optimizing only the application, without adjusting the layer underneath it, still runs into hard limits as soon as traffic increases.

The Nginx-plus-PHP-FPM stack consists of several layers, each with its own limits that all have to work together: Nginx accepts TCP connections and manages worker processes with their own connection limits, the kernel caps the queue for incoming connections and the number of open file descriptors per process, and PHP-FPM manages its own pool of worker processes, each of which occupies a fixed amount of memory. A bottleneck in just one of these layers slows down the entire chain, no matter how well the other layers are configured. The following sections work through that chain systematically, from the bottom up.

2. Nginx Worker Processes and Connection Limits

Nginx handles connections through a fixed number of worker processes, each of which can serve up to worker_connections simultaneous connections. The theoretical ceiling for concurrent clients is worker_processes × worker_connections, and each connection consumes a file descriptor on both the client side and the upstream side whenever Nginx acts as a reverse proxy in front of PHP-FPM. Setting worker_processes auto pins the worker count to the number of available CPU cores, which is the sensible default for most workloads, since running more workers than cores only adds context-switching overhead without increasing throughput.

The default worker_connections value on most distributions is 512 or 1024, which quickly becomes a bottleneck for a high-traffic shop. Importantly, Nginx can only raise this value up to the limit allowed by the underlying operating system's open file descriptor cap, controlled via worker_rlimit_nofile. If this value is set higher than the actual system limit, Nginx either throws an error when the configuration is reloaded or silently ignores the value, depending on the distribution and systemd configuration.


# /etc/nginx/nginx.conf: worker sizing for a busy reverse proxy
worker_processes auto;
worker_rlimit_nofile 65536;

events {
    worker_connections 8192;
    multi_accept on;
    use epoll;
}

# Test config, then reload without dropping active connections
sudo nginx -t && sudo systemctl reload nginx

3. Setting File Descriptor Limits Correctly

Every TCP connection, every open log file, and every connection to PHP-FPM over a Unix socket or TCP port consumes a file descriptor. The default of 1024 open files per process, as shown by ulimit -n on most systems, falls far short of what a production web server needs under load. Once the limit is hit, the kernel rejects new connection attempts with the Too many open files error, which shows up in the Nginx or PHP-FPM error logs, often only during traffic spikes, which is exactly the worst possible moment.

A common mistake is raising the limit only in /etc/security/limits.conf. That file only applies to login shells via PAM, not to services that systemd starts directly. For systemd-managed services like Nginx and PHP-FPM, the limit instead needs to be set via LimitNOFILE in the service unit or an override fragment. The limit actually in effect for a running process can always be checked with cat /proc/PID/limits, which is the most reliable way to catch configuration mismatches between limits.conf, systemd, and application configuration before they turn into a problem in production.


# Raise the file descriptor limit for a systemd-managed service
sudo systemctl edit nginx
# Add inside the editor:
#   [Service]
#   LimitNOFILE=65536

sudo systemctl edit php8.3-fpm
# Add inside the editor:
#   [Service]
#   LimitNOFILE=65536

sudo systemctl daemon-reload
sudo systemctl restart nginx php8.3-fpm

# Verify the limit actually applied to the running process
cat /proc/"$(pgrep -o nginx)"/limits | grep 'Max open files'

4. TCP Backlog and Network Stack Tuning

Before a connection ever reaches Nginx as an application, it passes through the kernel's accept queue. Its maximum size is capped by the kernel parameter net.core.somaxconn, which defaults to just 128 on many distributions, a value left over from an era of much lower connection counts. When concurrent connections suddenly spike, say from a marketing campaign or a social media post going viral, this queue fills up and the kernel drops further connection attempts before Nginx even gets a chance to respond. From the client's point of view, that looks like a timeout or a connection-refused error, even though the server itself is not actually overloaded.

The backlog parameter set in Nginx's listen directive must not exceed the kernel's somaxconn value, otherwise it gets silently capped to the kernel value with no warning at all. On top of that, net.ipv4.tcp_max_syn_backlog limits the queue for half-open connections during the TCP handshake, which matters most under a high rate of new connections. Both values need to be adjusted together, since raising just one parameter in isolation only shifts the bottleneck instead of removing it.


# /etc/sysctl.d/99-webserver-network.conf
# Accept queue depth: must be >= nginx "listen ... backlog=N"
net.core.somaxconn = 65535

# Half-open connection queue during the TCP handshake
net.ipv4.tcp_max_syn_backlog = 8192

# Incoming packet queue at the NIC driver level under high PPS
net.core.netdev_max_backlog = 16384

# Reuse TIME_WAIT sockets for new outgoing connections (safe for IPv4)
net.ipv4.tcp_tw_reuse = 1

# Apply without a reboot
sudo sysctl --system

5. Understanding PHP-FPM Process Manager Modes

PHP-FPM offers three process manager modes, each handling memory and response time in fundamentally different ways. In static mode, PHP-FPM immediately starts exactly pm.max_children worker processes and keeps them alive permanently, regardless of current load. That gives predictable memory usage and no delay from process spawning, but wastes RAM during quiet periods when not all workers are needed. In dynamic mode, the number of running workers fluctuates between pm.min_spare_servers and pm.max_spare_servers, with pm.start_servers as the initial value, which strikes a good balance between memory usage and responsiveness.

ondemand mode starts workers only for incoming requests and shuts them down again after pm.process_idle_timeout, which saves memory on servers with many rarely used pools, for example shared hosting environments with dozens of customer domains. For a single, consistently high-traffic shop, though, ondemand introduces noticeable latency on the first request after an idle period, since each new worker process has to be started and initialized from scratch. For production Nginx-plus-PHP-FPM setups with a steady baseline load, dynamic is usually the right choice; static suits systems with a known, even peak load and plenty of free RAM.

6. Calculating pm.max_children from Available RAM

The most common mistake in PHP-FPM configuration is setting pm.max_children by gut feeling or copying it from a tutorial, instead of calculating it from the memory that is actually available. Set it too high, and the server can spin up more PHP-FPM workers under load than the available memory can handle, triggering the out-of-memory killer, which terminates processes indiscriminately, often including the database or Nginx itself. Set it too low, and incoming requests start queuing up as soon as all workers are busy, showing up as rising response times and as upstream timed out in the Nginx log.

The correct formula is: available RAM minus a reserve for the operating system, database, and other services, divided by the average memory footprint of a single PHP-FPM worker process. The average RSS value per worker can be measured under load with ps --no-headers -o rss -C php-fpm8.3. What matters is measuring the average under real load, not right after startup, since memory usage climbs as Opcache and autoloader classes get loaded.


# Measure average RSS per worker under real load, not right after startup
ps --no-headers -o rss -C php-fpm8.3 | \
  awk '{sum+=$1; n++} END {printf "avg %.1f MB across %d workers\n", sum/n/1024, n}'

# Example calculation: 8 GB RAM server, 2 GB reserved for OS/MySQL/Redis
# average worker size ~55 MB -> (8192 - 2048) / 55 =~ 111
echo $(( (8192 - 2048) / 55 ))

; /etc/php/8.3/fpm/pool.d/www.conf
; Sized for the 8 GB example above with a safety margin below the
; theoretical ceiling of 111 workers
pm = dynamic
pm.max_children = 100
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
pm.max_requests = 1000

; Recycle workers periodically to bound memory leaks from extensions
pm.process_idle_timeout = 10s

; Expose a status endpoint for monitoring (see section 7)
pm.status_path = /fpm-status

7. Measuring a Baseline Before Changing Anything

Tuning without measurement is just guessing. Before touching even a single parameter, you need a solid baseline: current throughput in requests per second, response time percentiles at p95 and p99, error rate under load, and how busy the PHP-FPM workers actually are. The Nginx status module endpoint stub_status reports active connections and the number of waiting requests, while the PHP-FPM status endpoint shows active, idle, and the peak number of simultaneously active processes.

For the actual load test, wrk is a better choice than the older ab, since it runs multiple threads in parallel and produces latency histograms instead of just averages, which are far more informative when response times are unevenly distributed. It's important to test against a realistic page, such as a typical product detail page with database access, rather than a static test page that barely stresses PHP-FPM and therefore gives a completely misleading picture of behavior under real load.


# Nginx status endpoint (requires ngx_http_stub_status_module)
curl -s http://127.0.0.1/nginx-status
#   Active connections: 842
#   server accepts handled requests
#    189234 189234 512310
#   Reading: 3 Writing: 121 Waiting: 718

# PHP-FPM status endpoint (pm.status_path from section 6)
curl -s "http://127.0.0.1/fpm-status?json"

# Realistic load test against a real product page, 4 threads, 200 connections, 60s
wrk -t4 -c200 -d60s --latency https://shop.example.com/product/example-sku

8. Measuring and Validating After the Change

After every configuration change, the exact same load test with the exact same parameters gets repeated, since that is the only way to compare before-and-after numbers fairly. It's important to change only one parameter between two test runs, otherwise it becomes impossible to tell afterward which adjustment was actually responsible for the improvement. Alongside that, it's worth checking journalctl -u php8.3-fpm for the warning server reached pm.max_children, consider raising it, a clear sign that the pool is still undersized despite the change.

All configuration files belong under version control, so that a change with negative effects can be traced with git diff and reliably rolled back with git checkout, followed by a clean systemctl reload. Anyone working without version control quickly loses track, after several consecutive changes, of which state was actually the last one that worked. The combination of a reproducible load test, a strict one-parameter-per-test-run rule, and versioned configuration turns performance tuning into something traceable rather than a guessing game.


{
  "test": "product-page-load",
  "tool": "wrk -t4 -c200 -d60s",
  "before": {
    "requests_per_sec": 412,
    "latency_p95_ms": 890,
    "latency_p99_ms": 2140,
    "errors": 37,
    "fpm_max_children_reached": true
  },
  "after": {
    "change": "pm.max_children 24 -> 100, somaxconn 128 -> 65535",
    "requests_per_sec": 1350,
    "latency_p95_ms": 145,
    "latency_p99_ms": 310,
    "errors": 0,
    "fpm_max_children_reached": false
  }
}

9. Tuning Parameters Compared Side by Side

The individual levers from the previous sections only work correctly together. The table below sets typical default values side by side with production-ready settings and shows the concrete effect each adjustment has.

Parameter Risky / Default Production-Ready Effect
worker_connections 512 (distribution default) 8192, tied to worker_rlimit_nofile More concurrent connections per worker
File descriptor limit Only limits.conf adjusted LimitNOFILE in a systemd override Limit actually takes effect for the service
net.core.somaxconn 128 65535 No dropped connections during traffic spikes
pm.max_children Copied from a tutorial Calculated from RAM / average RSS No OOM kills, no queue wait time
Tuning approach Guessing multiple values at once One parameter per test run, with a baseline Cause and effect clearly attributable

Without reproducible measurement, any claim that a change helped remains unproven. Sticking consistently to the sequence of baseline, one change, retest yields solid numbers instead of gut feeling, and lets you justify tuning decisions to the team in a traceable way.

Mironsoft

Server tuning, kernel configuration, and performance monitoring

Web servers that don't buckle under traffic spikes?

We analyze your Nginx and PHP-FPM configuration, size pm.max_children based on actual memory requirements, and set up monitoring that surfaces bottlenecks before your customers do.

Performance Audit

Systematically review Nginx, PHP-FPM, and kernel limits with baseline measurements

Pool Sizing

Configure pm.max_children and the process manager mode to match available RAM

Monitoring Setup

Integrate stub_status, FPM status, and load tests into reliable monitoring

10. Summary

An Nginx-plus-PHP-FPM stack under load consists of several layers with their own limits that all need to work together. worker_connections and worker_rlimit_nofile cap how many connections Nginx can accept at once; the system-wide file descriptor limit for that needs to be set via LimitNOFILE in systemd, not just in limits.conf. net.core.somaxconn and net.ipv4.tcp_max_syn_backlog determine how many connections the kernel can buffer before it starts rejecting new connection attempts. pm.max_children has to be calculated from the RAM actually available and the measured average memory usage per worker, not copied from a tutorial example.

The one principle that matters across every parameter: first measure a solid baseline with wrk, stub_status, and the PHP-FPM status endpoint, then change exactly one parameter, then repeat the same test and compare the numbers. Only this approach makes it possible to trace which change actually drove the improvement, instead of making several adjustments at once and guessing afterward which one helped. Versioned configuration files also make it possible to cleanly roll back any change when needed.

Linux Performance Tuning for Web Servers: The Key Takeaways

Nginx Workers

Tie worker_processes auto and worker_connections to worker_rlimit_nofile, otherwise the value gets silently capped.

File Descriptors

For systemd services, LimitNOFILE in the unit is what counts, not /etc/security/limits.conf. Verify it with /proc/PID/limits.

TCP Backlog

net.core.somaxconn and the Nginx backlog parameter need to match, otherwise connections get dropped under load.

pm.max_children

Calculate it from available RAM and measured RSS per worker, and validate it with wrk and the FPM status endpoint before and after every change.

11. FAQ: Linux Performance Tuning for Web Servers

1Why isn't code optimization alone enough?
The stack often hits operating system limits, like file descriptors, TCP backlog, or worker count, before code optimizations can even take effect.
2How do you correctly calculate worker_connections?
worker_processes times worker_connections gives the ceiling, capped by worker_rlimit_nofile, otherwise the value gets truncated.
3Why doesn't limits.conf work for systemd services?
limits.conf only applies to PAM login shells. systemd services need LimitNOFILE directly in the unit, or via systemctl edit.
4What does net.core.somaxconn do?
It caps the kernel's accept queue. The Nginx backlog parameter must not exceed this value, otherwise it gets capped automatically.
5How do I correctly calculate pm.max_children?
Available RAM minus a reserve, divided by the average RSS value per worker measured under load, not the value right after startup.
6static, dynamic, or ondemand: what's the difference?
static always keeps max_children workers alive, dynamic fluctuates between the spare values, ondemand starts workers only on demand. For shops, dynamic is usually the choice.
7What does server reached pm.max_children mean?
The pool has hit its ceiling and further requests are waiting. A signal that max_children is set too low or that more RAM is needed.
8ab or wrk for load testing?
wrk works multi-threaded and produces latency histograms, far more informative than the older, single-threaded ab.
9Why change parameters one at a time instead of all at once?
It's the only way to clearly attribute which change was actually responsible for an improvement or a regression.
10How do I check the actual file descriptor limit?
Use cat /proc/PID/limits to see the limit actually in effect for a running process, independent of limits.conf or systemd configuration.