How to Set Up Nginx with PHP-FPM

How to Set Up Nginx with PHP-FPM

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:

  1. A browser requests example.com/index.php.
  2. Nginx matches the request to a location block for .php files.
  3. Nginx passes the request to PHP-FPM using the FastCGI protocol, either over a Unix socket or a TCP port.
  4. PHP-FPM’s worker pool executes the PHP script and returns the output.
  5. 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 own upload_max_filesize setting in php.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. (static keeps a fixed number of workers always running — useful for very high, consistent traffic; ondemand spawns 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 with ps aux | grep php-fpm and look at RSS). If each process uses ~40MB and you have 2GB available for PHP, that’s roughly 2000 / 40 = 50 as 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:

  1. Is the service running? sudo systemctl status php8.3-fpm
  2. Does the socket path in Nginx’s config match the actual socket path in the pool config? A version mismatch (php8.2 vs php8.3) after an upgrade is a very common cause.
  3. 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.1 only, or use a Unix socket, which isn’t network-reachable at all.
  • Run PHP-FPM as a dedicated, non-privileged user, not root. Check user and group in 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 = 0 in php.ini — this closes a historical PHP-FPM path traversal issue where a request like image.jpg/malicious.php could 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_children based on actual memory usage, not a guess — undersizing causes request queuing under load, oversizing risks the server running out of memory entirely.
  • Use pm = static for 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_children against 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 .conf files in pool.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_FILENAME correctly — it’s the root cause of most “file not found” issues.
  • Size pm.max_children based 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.

Total
1
Shares

Leave a Reply

Previous Post
How to Configure Nginx for WordPress

How to Configure Nginx for WordPress

Next Post
How to Enable Gzip Compression in Nginx

How to Enable Gzip Compression in Nginx

Related Posts