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.

  • Nginx
  • PHP
  • PHP-FPM