PHP and Nginx is one of the most common pairings in web hosting, but it works differently than people coming from Apache often expect. Apache traditionally embeds PHP directly via mod_php; Nginx doesn’t run PHP itself at all — it hands .php requests off to a separate process, PHP-FPM (FastCGI Process Manager), over a socket or TCP connection, and PHP-FPM does the actual execution. Understanding that split is the key to getting this configuration right, debugging it when something breaks, and tuning it properly under load.
This guide walks through the complete setup for a typical PHP application (works for a plain PHP app, and covers the specific tweaks needed for common frameworks like Laravel and WordPress), including PHP-FPM configuration, testing, security hardening, and performance tuning.
How Nginx + PHP-FPM Fit Together
The request flow looks like this:
- A browser requests
https://example.com/index.php. - Nginx receives the request. For static files (images, CSS, JS), Nginx serves them directly — no PHP involved.
- For
.phprequests, Nginx passes the request to PHP-FPM via thefastcgi_passdirective, using either a Unix socket or a TCP address. - PHP-FPM executes the PHP code, generates a response, and hands it back to Nginx.
- Nginx sends that response to the browser.
This separation is actually a strength — Nginx stays lightweight and handles static content and connection management extremely efficiently, while PHP-FPM can be tuned, scaled, and even run on a separate server entirely if needed, independent of the web server itself.
Requirements
- A Linux server (Ubuntu 22.04/24.04, Debian, or similar) with root access.
- Nginx installed.
- PHP-FPM installed, matching the PHP version your application requires.
- Your application’s code deployed to the server (or ready to deploy).
- A domain name if deploying publicly, with DNS pointed at your server.
Step 1: Install Nginx and PHP-FPM
sudo apt update
sudo apt install nginx php8.3-fpm php8.3-cli php8.3-mysql php8.3-xml php8.3-mbstring php8.3-curl php8.3-zip -y
Adjust the PHP version and extension list to match your application’s requirements (a Laravel app typically needs mbstring, xml, curl, zip, and a database driver; WordPress needs mysqli support and often gd or imagick for image handling).
Verify PHP-FPM is running:
sudo systemctl status php8.3-fpm
Find its socket path (this varies by distro/version):
ls /run/php/
You should see something like php8.3-fpm.sock.
Step 2: Set Up the Application Directory
sudo mkdir -p /var/www/example.com
sudo chown -R www-data:www-data /var/www/example.com
Deploy your application code here (via git clone, scp, CI/CD pipeline, etc.). For this guide we’ll assume a standard structure with index.php at the root, or in a public/ subdirectory for frameworks like Laravel.
Step 3: Configure Nginx for a Basic PHP App
Create /etc/nginx/sites-available/example.com:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com;
index index.php index.html;
access_log /var/log/nginx/example.com_access.log;
error_log /var/log/nginx/example.com_error.log;
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;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
access_log off;
}
}
Key parts explained:
try_files $uri $uri/ /index.php?$query_string;— this is what makes “pretty URLs” work. If the request doesn’t match a real file or directory, it falls through toindex.php, letting your application’s router handle it.fastcgi_pass unix:/run/php/php8.3-fpm.sock;— hands off PHP execution to PHP-FPM via its Unix socket (faster than TCP for same-server setups; usefastcgi_pass 127.0.0.1:9000;if PHP-FPM is configured for TCP instead, common when PHP-FPM runs on a separate server).location ~ /\.(?!well-known).* { deny all; }— blocks access to dotfiles (.env,.git,.htaccessleftovers) while still allowing.well-knownthrough for Let’s Encrypt/ACME challenges.- The static asset location block sets long cache lifetimes and disables access logging for these high-volume, low-value-to-log requests.
Enable the site:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Step 4: Framework-Specific Adjustments
Laravel (or any framework using a public/ document root):
root /var/www/example.com/public;
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;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
The only real difference from the generic config is pointing root at the public/ subdirectory rather than the project root — this keeps the rest of your application code (config files, .env, vendor directory) outside the web-servable path entirely, which is both correct framework structure and a meaningful security boundary.
WordPress:
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~* /wp-config.php {
deny all;
}
location ~* wp-content/uploads/.*\.php$ {
deny all;
}
Note the $args instead of $query_string (WordPress’s own rewrite conventions expect this), and the explicit block on PHP execution inside the uploads directory — a very common target for malware uploads exploiting vulnerable plugins, and worth blocking outright since legitimate uploads never need to execute as PHP.
Step 5: Tune PHP-FPM
Edit the pool config, typically at /etc/php/8.3/fpm/pool.d/www.conf:
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 and kills worker processes based on load, a good default for most sites.pm.max_children— the hard ceiling on concurrent PHP processes. Calculate this based on available RAM divided by average PHP process memory usage (check withps aux | grep php-fpmunder load, then divide free RAM by that number, leaving headroom for the OS and Nginx itself).pm.max_requests— restarts a worker after this many requests, guarding against memory leaks in long-running PHP processes.
Restart PHP-FPM after changes:
sudo systemctl restart php8.3-fpm
Step 6: Test the Setup
Create a quick test file:
echo "<?php phpinfo(); ?>" | sudo tee /var/www/example.com/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 (sudo rm /var/www/example.com/info.php) since it exposes detailed server configuration information publicly.
Test your actual application routes, particularly ones using pretty URLs/routing, to confirm try_files is working correctly.
Troubleshooting Common Issues
502 Bad Gateway. PHP-FPM isn’t running, or the socket path in your Nginx config doesn’t match the actual socket PHP-FPM is listening on. Check sudo systemctl status php8.3-fpm and confirm the socket path with ls /run/php/.
PHP files download instead of executing. The location ~ \.php$ block is missing, misconfigured, or being overridden by another location block matching first. Check block ordering — Nginx location matching has specific precedence rules (exact match > longest prefix > regex in order).
“File not found” errors despite the file existing. Usually a SCRIPT_FILENAME mismatch — confirm root in your server block is correct and fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; is present exactly as shown.
White screen with no error. PHP error display is likely off (correct for production) but nothing’s logging either. Check PHP-FPM’s own error log:
sudo tail -f /var/log/php8.3-fpm.log
And confirm display_errors = Off and log_errors = On in php.ini for production, with error_log pointed somewhere you’re actually monitoring.
Pretty URLs/routing 404. try_files directive is missing or incorrect — this is the single most common PHP+Nginx misconfiguration for anyone coming from an Apache/.htaccess background, since Nginx doesn’t read .htaccess files at all.
Security Considerations
- Never expose
.env,.git,wp-config.php, or similar sensitive files — explicitly deny access to dotfiles and known sensitive filenames as shown above. - Block PHP execution in upload/media directories — a huge share of real-world PHP compromises come from an attacker uploading a malicious
.phpfile disguised as an image and then executing it directly. - Keep PHP itself updated; unsupported PHP versions (anything past its EOL date) accumulate known, unpatched vulnerabilities.
- Set
cgi.fix_pathinfo = 0inphp.ini— this closes a historical PHP-FPM path-info vulnerability class where crafted URLs could trick PHP into executing an unintended file. - Run PHP-FPM as a dedicated, non-privileged user (
www-databy default on Debian-based systems) rather than root. - Set appropriate
open_basedirrestrictions inphp.iniif hosting multiple applications on one server, to prevent one compromised app from reading another’s files.
Performance Tips
- Enable OPcache — this alone is often the single biggest PHP performance win available, caching compiled PHP bytecode so scripts don’t need to be re-parsed on every request:
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
(validate_timestamps=0 means OPcache won’t check for file changes — great for production performance, but you’ll need to reload PHP-FPM after deploys since it won’t pick up code changes automatically.)
- Use
fastcgi_cachefor full-page caching on cacheable routes (works well for WordPress and similar content-heavy sites):
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=PHPCACHE:100m inactive=60m;
location ~ \.php$ {
fastcgi_cache PHPCACHE;
fastcgi_cache_valid 200 30m;
add_header X-FastCGI-Cache $upstream_cache_status;
...
}
- Tune
pm.max_childrenbased on actual measured memory usage per process, not guesswork — undersizing causes 502s under load, oversizing risks OOM kills. - Use
gzipfor text-based PHP output, same as any other content type.
Real-World Use Cases
- A WordPress site with heavy traffic uses
fastcgi_cachefor logged-out visitors, dramatically cutting PHP-FPM load since most visitors never need PHP to actually execute per-request. - A Laravel API backend runs behind Nginx with
pm.max_childrencarefully tuned based on load testing, keeping response times stable during traffic spikes. - A multi-tenant hosting setup runs separate PHP-FPM pools per client (different
pool.dconfig files, each with its own user and socket), isolating applications from each other even though they share the same physical server.
Best Practices
- Always separate static asset serving from PHP handling — let Nginx serve static files directly and only hand off to PHP-FPM when actually needed.
- Point
rootat the framework’s public/webroot directory specifically, not the project root, keeping sensitive files outside the web-servable path. - Enable OPcache in production and understand the
validate_timestampstradeoff for your deployment workflow. - Block PHP execution explicitly in any directory meant only for uploads.
- Monitor PHP-FPM’s slow log (
slowlogdirective in the pool config) to catch slow-running scripts before they cause cascading 502s under load.
Wrapping Up
Nginx and PHP-FPM is a proven, high-performance combination once you understand the fundamental split between “Nginx serves and routes” and “PHP-FPM executes.” Most of the friction people hit — 502s, 404s on pretty URLs, PHP files downloading instead of running — traces back to a handful of specific misconfigurations covered here: socket path mismatches, missing try_files, or an absent PHP location block. Get the base config right, tune pm.max_children to your actual server’s memory, turn on OPcache, and this setup will comfortably handle everything from a small personal site to a high-traffic production application.