Nginx doesn’t execute PHP itself. It hands .php requests to PHP-FPM (FastCGI Process Manager), a separate service that runs a pool of PHP worker processes. Getting the connection between the two right — and secure — is the core of every Nginx + PHP stack (WordPress, Laravel, Drupal, Nextcloud).
1. Install
sudo apt update
sudo apt install -y nginx php-fpm php-mysql php-curl php-gd php-mbstring php-xml php-zip php-intl
php -v
systemctl status php8.3-fpm --no-pager # version in the name
2. The socket
PHP-FPM listens on a Unix socket by default:
ls -l /run/php/
# php8.3-fpm.sock
The pool config (/etc/php/8.3/fpm/pool.d/www.conf) defines it:
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
user = www-data
group = www-data
Nginx (running as www-data on Debian/Ubuntu) must be able to read/write the socket.
Socket vs TCP: a Unix socket is slightly faster and not reachable over the network — use it when Nginx and PHP-FPM are on the same machine. Use TCP (listen = 127.0.0.1:9000) when PHP-FPM runs in another container or host; never bind it to a public interface.
3. The Nginx server block
server {
listen 80;
server_name 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 ~ /\.(?!well-known) {
deny all;
}
}
Debian/Ubuntu’s snippets/fastcgi-php.conf includes fastcgi.conf (which sets SCRIPT_FILENAME) and a try_files $fastcgi_script_name =404; check. On other distributions, write it out:
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
SCRIPT_FILENAME tells PHP-FPM which file to execute. If it’s missing or wrong, you get blank pages or “Primary script unknown” errors.
4. Security: only execute real PHP files
The try_files $uri =404; (or the snippet’s equivalent) inside the PHP location is important. Without it, a request like /uploads/avatar.jpg/x.php could make PHP execute an uploaded image containing PHP code (when cgi.fix_pathinfo is enabled). Also block PHP execution in upload directories:
location ~* /uploads/.*\.php$ {
deny all;
}
Place it before the general \.php$ location — regex locations match in order.
For front-controller frameworks (Laravel, Symfony), only index.php should run:
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
internal;
}
location ~ \.php$ { return 404; }
5. Apply and test
sudo nginx -t && sudo systemctl reload nginx
echo "<?php echo 'PHP ' . PHP_VERSION;" | sudo tee /var/www/example.com/public/test.php
curl http://example.com/test.php
sudo rm /var/www/example.com/public/test.php
6. Separate pools per site
On a server with several sites, give each its own pool and Linux user — a compromised site can’t read the others’ files:
; /etc/php/8.3/fpm/pool.d/shop.conf
[shop]
user = shop
group = shop
listen = /run/php/php8.3-fpm-shop.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
php_admin_value[open_basedir] = /var/www/shop:/tmp
Point the site’s fastcgi_pass at its socket. Tune worker counts in PHP-FPM tuning.
7. Several PHP versions side by side
With the Ondřej Surý PPA (Ubuntu) or sury.org (Debian), install e.g. php8.2-fpm and php8.3-fpm; each has its own socket. Each site’s Nginx block chooses its version via fastcgi_pass. See PHP versions on Ubuntu.
Common errors
| Symptom | Cause |
|---|---|
502 Bad Gateway, connect() … failed (2: No such file) |
Wrong socket path or PHP-FPM stopped — fix 502 |
(13: Permission denied) on the socket |
listen.owner/group doesn’t match Nginx’s user |
Primary script unknown |
Wrong root or SCRIPT_FILENAME |
| PHP file downloads instead of running | No PHP location matched (check order, ^~ prefixes) |
| 504 on long requests | Timeouts — see Nginx 504 |
Related: installing PHP and MySQL and WordPress with Nginx.