Nginx doesn’t process PHP on its own — it has no built-in interpreter for it, unlike Apache with mod_php. Instead, Nginx hands PHP requests off to PHP-FPM (FastCGI Process Manager), a separate service that actually runs the PHP code and hands the result back. This separation is a big part of why Nginx + PHP-FPM tends to outperform Apache + mod_php under load — each piece does one job well, and PHP-FPM’s process pool can be tuned independently from the web server itself.
I’ve set this combination up more times than I can count, and this guide covers exactly how I do it, from installation through tuning the process pool for real traffic.
How Nginx and PHP-FPM Work Together
The flow for a PHP request looks like this:
- A browser requests
example.com/index.php. - Nginx matches the request to a
locationblock for.phpfiles. - Nginx passes the request to PHP-FPM using the FastCGI protocol, either over a Unix socket or a TCP port.
- PHP-FPM’s worker pool executes the PHP script and returns the output.
- Nginx sends that output back to the browser as an HTTP response.
Nginx itself never executes PHP code — it’s purely a proxy for these requests, which is exactly why the configuration involves the fastcgi_pass directive rather than anything PHP-specific.
Prerequisites
- A server with Nginx already installed.
- Root or sudo access.
- Ubuntu/Debian examples shown here (package names differ slightly on RHEL/CentOS — I’ll note that where relevant).
Step 1: Install PHP-FPM
sudo apt update
sudo apt install php-fpm php-mysql php-cli php-curl php-xml php-mbstring -y
Adjust the extension list based on what your application actually needs — php-mysql for MySQL/MariaDB, php-pgsql for PostgreSQL, php-gd for image processing, and so on. On RHEL/CentOS/AlmaLinux, this would be:
sudo dnf install php-fpm php-mysqlnd php-cli php-curl php-xml php-mbstring -y
Check the installed version:
php -v
Step 2: Confirm PHP-FPM Is Running
sudo systemctl status php8.3-fpm
(Adjust the version number to match what got installed — php8.1-fpm, php8.2-fpm, etc. Run ls /etc/init.d/ | grep php if you’re not sure of the exact service name.)
Enable it to start on boot:
sudo systemctl enable php8.3-fpm
Step 3: Locate the PHP-FPM Socket
By default, PHP-FPM listens on a Unix socket rather than a TCP port, which is slightly faster since it avoids the TCP/IP stack overhead entirely. Check the pool config:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Look for the listen directive:
listen = /run/php/php8.3-fpm.sock
This path is what you’ll reference in the Nginx config.
Step 4: Configure the Nginx Server Block
sudo nano /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ /\.ht {
deny all;
}
}
If snippets/fastcgi-php.conf doesn’t exist on your system (it’s an Ubuntu/Debian convenience file), here’s what it contains, which you can inline directly instead:
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
A few directives worth understanding here:
fastcgi_split_path_info— separates the actual script path from any extra “path info” after it (used in URLs like/index.php/some/extra/path).SCRIPT_FILENAME— tells PHP-FPM the absolute filesystem path of the script to execute. Getting this wrong is the single most common cause of a blank page or “File not found” error.fastcgi_index index.php;— the default file to serve when a request maps to a directory.
Step 5: Enable the Site and Test
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Step 6: Verify With a Test File
echo "<?php phpinfo(); ?>" | sudo tee /var/www/example.com/public/info.php
Visit http://example.com/info.php in a browser — you should see the full PHP configuration page. Delete this file immediately after testing — a public phpinfo() page leaks detailed server configuration information that’s genuinely useful to attackers.
sudo rm /var/www/example.com/public/info.php
A Complete Example Configuration (WordPress-Style App)
Here’s a fuller, production-oriented setup that I’d use for a typical PHP application like WordPress or Laravel-adjacent structures:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php index.html;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_read_timeout 300;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
location ~* \.(jpg|jpeg|png|gif|css|js|ico|woff2)$ {
expires 30d;
add_header Cache-Control "public";
}
location ~ /\.(?!well-known).* {
deny all;
}
location = /wp-config.php {
deny all;
}
}
Notes on the additions:
client_max_body_size 64M;— raises the default 1MB upload limit, necessary for anything handling file uploads (this must match or exceed PHP’s ownupload_max_filesizesetting inphp.ini).fastcgi_read_timeout 300;— extends the timeout for long-running PHP scripts (report generation, imports, etc.) beyond the default 60 seconds.fastcgi_buffers/fastcgi_buffer_size— increases buffer sizes for larger PHP responses, reducing the chance of Nginx needing to write temp files to disk for buffering.- The dotfile-blocking regex explicitly allows
.well-known(needed for Let’s Encrypt ACME challenges and similar), while blocking everything else starting with a dot.
Tuning the PHP-FPM Process Pool
This is the part that separates a fragile setup from one that holds up under real traffic. Open the pool config:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Key settings I always review:
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
pm = dynamic— spawns worker processes based on load, between the min/max bounds. Good default for most sites. (statickeeps a fixed number of workers always running — useful for very high, consistent traffic;ondemandspawns workers only when needed — good for low-traffic or memory-constrained servers.)pm.max_children— the hard ceiling on concurrent PHP processes. This needs to be sized based on available RAM: divide your available memory by the average memory footprint of one PHP process (check withps aux | grep php-fpmand look at RSS). If each process uses ~40MB and you have 2GB available for PHP, that’s roughly2000 / 40 = 50as an upper bound, minus headroom for the OS and other services.pm.max_requests— restarts a worker after this many requests, which helps guard against memory leaks in long-running PHP processes accumulating over time.
After changing pool settings:
sudo systemctl restart php8.3-fpm
Testing the Setup
Confirm PHP is actually executing (not just being served as plain text — a classic sign of a misconfigured fastcgi_pass):
curl -s http://example.com/info.php | grep "PHP Version"
Check PHP-FPM’s own status page for a live look at the worker pool (enable it first in the pool config with pm.status_path = /status, then in Nginx):
location = /status {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
allow 127.0.0.1;
deny all;
}
curl http://127.0.0.1/status
This shows active processes, queue length, and max children reached — genuinely useful for diagnosing whether pm.max_children needs raising.
Load test to confirm pool sizing holds up:
ab -n 500 -c 30 http://example.com/
Watch pm.max_children reached counts in the status page during the test — if that number climbs, your pool is undersized for the concurrency you’re testing.
Troubleshooting Common Issues
502 Bad Gateway. This means Nginx can’t reach PHP-FPM. Check:
- Is the service running?
sudo systemctl status php8.3-fpm - Does the socket path in Nginx’s config match the actual socket path in the pool config? A version mismatch (
php8.2vsphp8.3) after an upgrade is a very common cause. - Check permissions on the socket file — it needs to be readable/writable by the Nginx worker’s user.
“File not found” (404-like error) despite the file existing. Almost always a SCRIPT_FILENAME mismatch. Confirm root in the server block matches the actual path, and that fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; is present.
PHP files downloading instead of executing. This means the .php location block isn’t matching at all — Nginx is falling through to treat the file as static content. Double-check the regex location ~ \.php$ is present and not overridden by an earlier, more specific block.
White screen with no error message. PHP errors are likely being suppressed. Check PHP-FPM’s own error log:
sudo tail -f /var/log/php8.3-fpm.log
And temporarily enable display errors in php.ini for debugging (never leave this on in production):
display_errors = On
error_reporting = E_ALL
504 Gateway Timeout on long-running scripts. Increase both fastcgi_read_timeout in Nginx and max_execution_time in php.ini, and make sure they’re aligned — a mismatch just moves the timeout point rather than fixing it.
Security Considerations
- Never expose PHP-FPM’s TCP port publicly if using TCP instead of a socket — bind it to
127.0.0.1only, or use a Unix socket, which isn’t network-reachable at all. - Run PHP-FPM as a dedicated, non-privileged user, not root. Check
userandgroupin the pool config. - Disable dangerous PHP functions you don’t need, in
php.ini:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
- Block direct access to sensitive config files like
wp-config.php,.env, or any file containing credentials, as shown in the complete example above. - Keep PHP updated. Old PHP versions accumulate known vulnerabilities; check your distro’s supported versions and plan upgrades rather than running end-of-life PHP indefinitely.
- Set
cgi.fix_pathinfo = 0inphp.ini— this closes a historical PHP-FPM path traversal issue where a request likeimage.jpg/malicious.phpcould get incorrectly executed as PHP.
Performance Tips
- Use Unix sockets over TCP for the Nginx-to-PHP-FPM connection when both are on the same server — it’s measurably faster and avoids unnecessary network stack overhead.
- Enable OPcache in
php.ini— this caches compiled PHP bytecode in memory, avoiding recompilation on every request, and is one of the single biggest PHP performance wins available:
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.revalidate_freq=2
- Tune
pm.max_childrenbased on actual memory usage, not a guess — undersizing causes request queuing under load, oversizing risks the server running out of memory entirely. - Use
pm = staticfor high, consistent traffic to avoid the overhead of constantly spawning/killing worker processes. - Cache full pages where possible (via a plugin for WordPress, or Nginx’s own
fastcgi_cache) to skip PHP execution entirely for content that doesn’t change per-request.
Real-World Use Cases
- A WordPress migration from Apache/mod_php, where switching to Nginx + PHP-FPM with OPcache enabled cut average page generation time roughly in half under the same traffic.
- A Laravel API backend, where tuning
pm.max_childrenagainst actual container memory limits prevented out-of-memory kills during traffic spikes that the default pool settings couldn’t handle. - A multi-tenant hosting setup, running a separate PHP-FPM pool per client site (different
.conffiles inpool.d/, each with its own socket and user), isolating resource usage and permissions between tenants on the same server.
Best Practices Summary
- Match the socket path exactly between PHP-FPM’s pool config and Nginx’s
fastcgi_pass. - Always set
SCRIPT_FILENAMEcorrectly — it’s the root cause of most “file not found” issues. - Size
pm.max_childrenbased on actual measured memory per process, not guesswork. - Enable OPcache — there’s essentially no downside for a production server.
- Block access to sensitive files and disable functions you don’t need.
- Delete any
phpinfo()test files immediately after use. - Monitor the PHP-FPM status page under load to catch pool sizing issues before they become outages.
Nginx and PHP-FPM is a genuinely solid, well-understood combination — the configuration itself is not complicated once you understand that Nginx is just a proxy handing work off to a separate process manager. Most of the real tuning work happens in the PHP-FPM pool settings, not the Nginx config itself.
Running Multiple PHP Versions Side by Side
I run into this constantly on shared hosting servers or during a gradual migration between PHP versions — one client site needs PHP 7.4 for legacy compatibility while a newer project runs PHP 8.3, both on the same server. PHP-FPM handles this cleanly because each version runs as its own separate service with its own socket:
sudo apt install php7.4-fpm php8.3-fpm -y
Each gets its own socket path by default:
/run/php/php7.4-fpm.sock
/run/php/php8.3-fpm.sock
Then it’s simply a matter of pointing each site’s Nginx server block at the correct socket:
# Legacy site
server {
server_name legacy.example.com;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php7.4-fpm.sock;
...
}
}
# Modern site
server {
server_name modern.example.com;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
...
}
}
Both services run independently, each with their own pool configuration, memory limits, and extension sets — one can be restarted or reconfigured without affecting the other at all.
Using Multiple Pools for Isolation
Beyond running different PHP versions, I also create multiple pools within a single PHP-FPM version when I want to isolate resource usage between sites that share the same PHP version — common on a server hosting several client projects.
sudo cp /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/client-a.conf
sudo nano /etc/php/8.3/fpm/pool.d/client-a.conf
Inside, I change the pool name and socket path so it doesn’t collide with the default pool:
[client-a]
user = client-a
group = client-a
listen = /run/php/php8.3-fpm-client-a.sock
pm = dynamic
pm.max_children = 10
Running each client under its own system user (user = client-a) means a compromised or buggy site can’t read or write files belonging to a different client on the same server — a meaningful isolation boundary on shared infrastructure, achieved entirely through PHP-FPM pool configuration rather than anything at the Nginx layer.
Monitoring PHP-FPM in Production
Beyond the /status page mentioned earlier, I keep an eye on a few specific things once a site is live:
Slow log — catches individual requests that take longer than a threshold, invaluable for finding a specific slow database query or an inefficient loop without needing a full profiler running constantly:
slowlog = /var/log/php8.3-fpm-slow.log
request_slowlog_timeout = 5s
Process count over time — if pm.max_children is consistently maxed out during business hours, that’s a clear signal to either raise the ceiling (if memory allows) or investigate why requests are taking longer than expected to complete and free up a worker.
Memory usage per worker — checked periodically with ps aux | grep php-fpm — a gradual upward creep across the lifetime of a worker before pm.max_requests recycles it can indicate a memory leak in a specific piece of application code worth investigating.
Frequently Asked Questions
Do I need PHP-FPM if I’m just running a small personal site? Yes — PHP-FPM (or an equivalent FastCGI process manager) is required for Nginx to run PHP at all, regardless of site size. There’s no lightweight alternative built into Nginx itself; the separation between web server and PHP execution is fundamental to how Nginx handles PHP.
Why does restarting PHP-FPM briefly drop requests, while reloading Nginx doesn’t? systemctl reload on PHP-FPM (rather than restart) achieves the same graceful behavior Nginx has — existing workers finish their current requests while new configuration takes effect for new ones. I use reload over restart whenever possible for exactly this reason, reserving a full restart for changes that require it (like extension changes).
Is Unix socket or TCP better for connecting Nginx to PHP-FPM? Unix sockets, when both are on the same machine — they avoid the TCP/IP stack overhead entirely and are the standard recommendation. TCP (127.0.0.1:9000) is really only necessary when PHP-FPM runs on a separate server from Nginx, which is uncommon but does happen in some larger, more distributed architectures.
What’s a reasonable starting point for pm.max_children on a small VPS? For a 2GB RAM server running a typical WordPress-style site, I’d start around 10-15 and monitor actual memory usage per process before adjusting — better to start conservative and raise the ceiling based on real data than to guess high and risk the server running out of memory under load.