From soft limits through limits.conf to the systemd override
When PHP-FPM or Nginx crashes under load with Too many open files, the cause is almost always a ulimit value set too low, not a genuine resource shortage. This article explains the difference between soft and hard limits, shows how to configure file descriptor and process boundaries persistently and correctly through limits.conf and systemd overrides, and how to reliably diagnose the error.
Table of Contents
- 1. What ulimit actually limits: soft and hard limits
- 2. The ulimit command: syntax, shell scope, and key flags
- 3. Why PHP and web server workloads are especially affected
- 4. Setting persistent limits via /etc/security/limits.conf
- 5. systemd unit overrides: LimitNOFILE and LimitNPROC
- 6. Diagnosing and fixing Too many open files
- 7. Sizing Nginx, PHP-FPM, and container workloads correctly
- 8. System-wide boundaries: fs.file-max and PAM limits
- 9. ulimit configuration compared
- 10. Summary
- 11. FAQ
1. What ulimit actually limits: soft and hard limits
ulimit is the shell's interface to the resource boundaries the Linux kernel imposes on every process: the maximum number of open files, the maximum number of concurrent processes for a user, the maximum stack size, and several other quotas. Each of these boundaries exists in two forms. The soft limit is the currently effective value that a process can raise on its own without special privileges, as long as it does not exceed the hard limit. The hard limit is the absolute ceiling that only root can lower or raise, and it acts as a cap on the soft limit.
This split exists deliberately: applications should be able to adjust themselves to their actual needs within a safe boundary, without every process requiring root privileges to request more resources. In practice this means a PHP-FPM worker could raise its soft limit for open files at runtime to as much as 65536, provided the hard limit allows it, but it never does so on its own if nobody has explicitly configured the limits. That is exactly where the most common source of failure originates: the default on many distributions is 1024 open files per process, a value that is far too low for a web server handling a high number of concurrent connections.
2. The ulimit command: syntax, shell scope, and key flags
The command ulimit -a shows all limits currently set for the running shell, each reported as the soft value unless stated otherwise. With -S and -H you can specifically distinguish between the soft and hard limit, for example ulimit -Sn for the current soft limit on open files and ulimit -Hn for the corresponding hard limit. The scope matters here: ulimit set in an interactive shell applies only to that shell and any child processes it spawns, not to already running processes and not to processes started by systemd or a display manager independently of the shell.
The -n flag controls RLIMIT_NOFILE, the maximum number of open file descriptors, and the -u flag controls RLIMIT_NPROC, the maximum number of processes and threads for a user. Both values matter most for web server workloads, because every TCP connection, every open file, and every Unix socket consumes a file descriptor, and every PHP-FPM worker as well as every thread of a process counts against the process limit. A limit set manually in the shell survives neither a service restart nor a server reboot, which makes it useful only as a temporary test on production systems.
# Show all current limits of the running shell
$ ulimit -a
core file size (blocks, -c) 0
open files (-n) 1024
max user processes (-u) 15938
stack size (kbytes, -s) 8192
# Inspect soft vs. hard limit for open file descriptors
$ ulimit -Sn
1024
$ ulimit -Hn
1048576
# Raise the soft limit within the hard limit ceiling (session-scoped only)
$ ulimit -n 65536
$ ulimit -Sn
65536
3. Why PHP and web server workloads are especially affected
PHP-FPM starts one dedicated process per configured worker, and each of these workers simultaneously holds database connections, cache connections to Redis or Memcached, open log files, and the client connection itself. With pm.max_children = 50 and several open connections per worker, the demand for file descriptors quickly adds up to several thousand, far beyond the default limit of 1024. As soon as a worker hits its limit, every further fopen(), fsockopen(), or database connection fails with an EMFILE error, which PHP reports as a warning or exception.
The process limit is equally critical for applications that spawn threads or subprocesses internally, such as queue workers processing parallel jobs, or build pipelines with many concurrent child processes. Nginx acting as a reverse proxy in front also holds its own file descriptors for every client connection, every upstream socket to PHP-FPM, and every open log file. Under high concurrent user counts, Nginx and PHP-FPM therefore often hit their respective limits at the same time, which complicates troubleshooting unless both services are checked independently.
4. Setting persistent limits via /etc/security/limits.conf
For a limit to survive a restart, it must be configured through the PAM module pam_limits, whose central configuration file is /etc/security/limits.conf. Each line consists of four fields: domain (user, group with an @ prefix, or * for everyone), type (soft, hard, or - for both at once), resource (nofile, nproc, and others), and the actual value. This file, however, only applies to processes started through a PAM-driven login path, meaning interactive SSH sessions or su/login-based processes.
This exact restriction is a common source of confusion: an administrator sets generous values for the www-data user in limits.conf, logs in via SSH as www-data to test, and correctly sees the new limit there. The actual PHP-FPM process, started directly by systemd at boot, never goes through the PAM login path though, and therefore ignores limits.conf entirely. In addition, the line session required pam_limits.so must be present in /etc/pam.d/common-session, otherwise the configuration does not take effect even for genuine logins.
# /etc/security/limits.conf: persistent limits for the www-data user
# domain type item value
www-data soft nofile 65536
www-data hard nofile 131072
www-data soft nproc 4096
www-data hard nproc 8192
# Group-wide limit for all members of the docker group
@docker soft nofile 65536
@docker hard nofile 131072
# Applies only to PAM-driven login paths (ssh, su, login)
# NOT applied to services started directly by systemd
5. systemd unit overrides: LimitNOFILE and LimitNPROC
Because most production services today are started directly by systemd rather than through a PAM login path, limits.conf is effectively ineffective for them. The correct place for resource boundaries of services such as PHP-FPM or Nginx is the unit file itself, specifically the LimitNOFILE and LimitNPROC directives in the [Service] section. Instead of editing the package-managed unit file directly, which would be overwritten on a package update, you run systemctl edit php8.4-fpm.service to create an override fragment that systemd automatically loads alongside the original unit.
After every change to a unit file or an override, systemctl daemon-reload must be run so systemd picks up the new configuration, followed by a restart of the affected service. Running systemctl show php8.4-fpm.service -p LimitNOFILE lets you verify which value is actually active, and cat /proc/$(pgrep -f 'php-fpm: master')/limits shows the limit directly from the perspective of the running process, which is the most reliable proof that the configuration actually takes effect.
# /etc/systemd/system/php8.4-fpm.service.d/override.conf
# Created via: systemctl edit php8.4-fpm.service
[Service]
LimitNOFILE=65536
LimitNPROC=4096
# Same pattern for Nginx
# /etc/systemd/system/nginx.service.d/override.conf
[Service]
LimitNOFILE=65536
# Apply the change:
# systemctl daemon-reload
# systemctl restart php8.4-fpm nginx
# systemctl show php8.4-fpm.service -p LimitNOFILE
6. Diagnosing and fixing Too many open files
The error message Too many open files, or the kernel-side errno code EMFILE, means that a process has hit its individual file descriptor limit, not that the system itself has run out of resources. The first diagnostic step is to identify the affected process ID, for example from the PHP-FPM or Nginx error log, and then read the actual active soft and hard limit for Max open files with cat /proc/PID/limits. Running ls /proc/PID/fd | wc -l counts the file descriptors this process currently has open, and you compare that value directly against the limit.
In addition, lsof -p PID shows in detail which files, sockets, and pipes a process is holding open, which often reveals a file descriptor leak where a database connection or a socket is never closed. System-wide, lsof | wc -l shows the total number of open descriptors across all processes and can be compared against the kernel-wide limit from cat /proc/sys/fs/file-nr. If the number of open descriptors of a single process keeps rising over time without ever dropping, it almost always points to a leak in the application code, which raising the limit only delays rather than fixes.
#!/usr/bin/env bash
# Diagnose an EMFILE / Too many open files incident
set -euo pipefail
PID=$(pgrep -f 'php-fpm: pool www' | head -n1)
echo "--- Configured limits for PID $PID ---"
grep -i "open files" "/proc/${PID}/limits"
echo "--- Currently open file descriptors ---"
ls "/proc/${PID}/fd" | wc -l
echo "--- Top 10 open file/socket types ---"
lsof -p "$PID" | awk '{print $5}' | sort | uniq -c | sort -rn | head -10
echo "--- System-wide open file handles vs. kernel limit ---"
cat /proc/sys/fs/file-nr
7. Sizing Nginx, PHP-FPM, and container workloads correctly
Nginx ships with its own configuration directive, worker_rlimit_nofile, which overrides the process limit as long as the value does not exceed the systemd hard limit. This directive is useful because it documents the boundary directly in the application context, but it does not replace the systemd configuration, it complements it: Nginx can only raise its soft limit up to the hard limit set by the operating system. For PHP-FPM an additional rule of thumb applies: the number of workers from pm.max_children multiplied by the average number of connections open per worker should never max out the configured LimitNOFILE, but stay well below it with a clear margin.
In containerized environments the same kernel mechanisms apply, but configuration happens through the container runtime instead of systemd on the host. Without an explicit setting, Docker often defaults to too low a limit, which is why limits should be set either in docker-compose.yml via the ulimits key or directly on the docker run call with --ulimit nofile=65536:65536. It is important that a limit set inside a container can never exceed the host's hard limit, because container processes remain, ultimately, processes of the host kernel.
# docker-compose.yml: raise file descriptor and process limits per service
services:
php-fpm:
image: mironsoft/php:8.4-fpm
ulimits:
nofile:
soft: 65536
hard: 131072
nproc: 4096
nginx:
condition: service_healthy
nginx:
image: nginx:1.27
ulimits:
nofile:
soft: 65536
hard: 65536
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
8. System-wide boundaries: fs.file-max and PAM limits
Above all process-specific ulimit values sits a system-wide ceiling defined by the kernel parameter fs.file-max: the maximum number of files open simultaneously across all processes on the entire system. This value is usually set generously by default on modern systems with sufficient RAM, but it can come in too low on smaller cloud instances or containers with limited memory. Running cat /proc/sys/fs/file-max checks the current value, and cat /proc/sys/fs/file-nr shows the currently used, allocated, and maximum count side by side.
A persistent change is made via /etc/sysctl.conf or a dedicated file under /etc/sysctl.d/, followed by sysctl -p to activate the change without a reboot. The order of precedence matters: a single process can never open more files than its own RLIMIT_NOFILE allows, even if fs.file-max theoretically still has capacity, and conversely, a fs.file-max set too low throttles all processes together, regardless of their individually set, higher ulimit values. Both layers must therefore be kept consistent with one another, or a generously set process boundary remains ineffective.
9. ulimit configuration compared
Depending on how a service is started and which tool is used for limit configuration, a boundary either actually takes effect or is silently ignored. The following overview summarizes the most common misconfigurations and the correct approach for each.
| Scenario | Problem | Recommended action | Effect |
|---|---|---|---|
| Manually via ulimit in the shell | Applies only to the current session | limits.conf or a systemd override | Survives restarts and reboots |
| Service started by systemd | limits.conf is ignored entirely | LimitNOFILE in a unit override |
Limit applies right at process start |
| pm.max_children exceeds ulimit -u | Worker spawning fails intermittently | Align nproc limit with worker count | No missing processes under load |
| Docker container without ulimits set | Default FD limit too low | --ulimit nofile=65536:65536 |
Consistent limit across all containers |
| Monitoring CPU and RAM only | FD exhaustion goes unnoticed | /proc/PID/limits as an alert metric | EMFILE errors caught before an outage |
It is striking that almost every row in the table traces back to the same underlying mistake: a limit is set in the wrong place and therefore never read by the process actually running. Anyone who consistently separates service limits via systemd overrides from interactive login limits via limits.conf, and regularly verifies both layers with /proc/PID/limits, avoids the entire catalog of failures listed above.
Mironsoft
Server administration, capacity planning, and Magento infrastructure
Ready to get resource limits configured correctly for good?
We analyze your PHP-FPM and Nginx configuration, set file descriptor and process limits cleanly and persistently through systemd overrides, and set up monitoring so Too many open files never blindsides production again.
Limit audit
Review and document soft and hard limits for all production services
systemd configuration
Set up LimitNOFILE and LimitNPROC as clean unit overrides
FD monitoring
Early warning for EMFILE errors in Grafana and Alertmanager
10. Summary
The key insight about configuring system resource limits with ulimit is this: soft and hard limits are two separate values, and where a limit is set determines whether it actually takes effect at process start. ulimit set in the shell applies only to the current session, /etc/security/limits.conf applies only to PAM-driven logins, and services started directly by systemd ignore both completely unless LimitNOFILE or LimitNPROC is set in the unit file. This exact confusion is the most common reason a seemingly correctly set limit still fails to reach production.
The error message Too many open files can be reliably diagnosed within minutes using /proc/PID/limits, lsof -p PID, and a comparison against fs.file-max, instead of guessing whether it is a capacity issue or a configuration issue. Anyone who consistently uses systemd overrides for all production services, aligns PHP-FPM and Nginx limits with the actual worker count, and establishes file descriptor usage as its own monitoring metric alongside CPU and RAM prevents this error message from ever occurring under load in the first place.
Configuring system resource limits with ulimit, the essentials at a glance
Soft vs. hard
The soft limit is the active value, the hard limit is the ceiling. Processes may raise the soft limit themselves up to the hard limit.
limits.conf vs. systemd
limits.conf only applies to PAM logins. Services started by systemd require LimitNOFILE and LimitNPROC in a unit override.
Too many open files
Diagnosable in minutes with /proc/PID/limits and lsof -p PID. A steadily rising FD count points to a leak in the code.
System-wide limit
fs.file-max caps all processes together and must stay consistent with the combined demand of all services.