Install PHP 8.3 with PHP-FPM and nginx on Ubuntu 24.04

Install PHP 8.3 and PHP-FPM from Ubuntu's archive, wire it to nginx, give each site its own FPM pool and user, and tune the settings that matter.

Tested on Ubuntu 24.04 LTS

Before you start

  • A server running Ubuntu 24.04
  • Root or a user with sudo
  • A domain name pointed at the server (for HTTPS)

To install PHP 8.3 with nginx on Ubuntu 24.04, run sudo apt install nginx php-fpm plus the extensions your app needs, then point a location ~ \.php$ block in your nginx site at PHP-FPM's socket. This tutorial does that, then goes further than most: each site gets its own PHP-FPM pool running as its own user, so one compromised site can't read another's files.

nginx doesn't run PHP itself. It serves static files and passes requests for .php files to PHP-FPM (the FastCGI Process Manager), a separate service that keeps a pool of PHP worker processes ready.

What In this tutorial
The site's domain example.com
Its files /var/www/example.com/public
Its system user example
Its PHP-FPM socket /run/php/example.com.sock

Install nginx, PHP-FPM and extensions

Ubuntu 24.04's archive has PHP 8.3. Install nginx, PHP-FPM, the command-line PHP and the extensions most apps ask for:

bash
sudo apt update
sudo apt install -y nginx php-fpm php-cli php-mysql php-curl php-mbstring php-xml php-zip php-gd php-intl

The php-* names are Ubuntu's version-neutral aliases; they install php8.3-fpm, php8.3-mysql and so on. Installing php-fpm (not the php metapackage) avoids pulling in Apache. OPcache comes with it and is on by default.

Check it worked:

bash
php -v
systemctl status php8.3-fpm

php -v prints PHP 8.3.6. Ubuntu keeps the version number at 8.3.6 and backports security fixes into it, so the package version is what tells you how current it is: dpkg -l php8.3-fpm printed 8.3.6-0ubuntu0.24.04.13 on 21 October 2026. To check that your PHP has the fixes for the October 2026 PHP2Shell bugs, see PHP2Shell: check and patch PHP.

If your app needs a newer PHP than 8.3, the usual route is a third-party repository; the PHP2Shell guide's section on third-party PHP repositories explains what that means for security updates. Everything below works the same with another version once you change 8.3 in the paths.

Create a user and a directory for the site

Give the site its own system user. PHP code for this site runs as example, and only example can write to its files:

bash
sudo useradd --system --home-dir /var/www/example.com --shell /usr/sbin/nologin example
sudo mkdir -p /var/www/example.com/public
sudo chown -R example:example /var/www/example.com

Put a test page in the web root:

bash
echo '<?php echo "PHP ", PHP_VERSION, " running as ", posix_getpwuid(posix_geteuid())["name"], "\n";' | sudo -u example tee /var/www/example.com/public/index.php

Give the site its own PHP-FPM pool

Ubuntu's default pool, www, runs every site as www-data. A pool per site keeps sites apart. Create /etc/php/8.3/fpm/pool.d/example.com.conf:

ini
[example.com]
user = example
group = example

listen = /run/php/example.com.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 30s
pm.max_requests = 500

php_admin_value[open_basedir] = /var/www/example.com:/tmp
php_admin_value[upload_max_filesize] = 32M
php_admin_value[post_max_size] = 32M
php_admin_value[memory_limit] = 256M
  • user and group are who the PHP code runs as. listen.owner and listen.group let nginx (www-data) talk to the socket, and nobody else.
  • pm = ondemand starts workers when requests arrive and stops them after 30 idle seconds, which saves memory on a small server with several quiet sites. For one busy site, pm = dynamic keeps some warm.
  • pm.max_children caps the number of workers. Each takes roughly as much memory as your app uses per request (often 30 to 80 MB), so 10 workers can need several hundred MB. Size it to your plan.
  • open_basedir stops this site's PHP from opening files outside its own directory and /tmp.
  • php_admin_value settings apply to this pool only and can't be changed by the app with ini_set().

Test the configuration and restart PHP-FPM:

bash
sudo php-fpm8.3 -t
sudo systemctl restart php8.3-fpm

Check it worked: php-fpm8.3 -t ends with configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful, and the new socket exists:

bash
ls -l /run/php/
text
srw-rw---- 1 www-data www-data 0 Oct 21 23:00 example.com.sock
srw-rw---- 1 www-data www-data 0 Oct 21 23:00 php8.3-fpm.sock

If no site uses the default www pool, you can leave it (it costs a few MB) or rename www.conf to www.conf.disabled.

Configure the nginx site

Create /etc/nginx/sites-available/example.com:

nginx
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 32m;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/example.com.sock;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}
  • try_files ... /index.php?$query_string sends any path that isn't a real file to index.php, which is what front-controller apps (Laravel, Symfony, Slim, most CMSs) expect. For a site of plain .php files, try_files $uri $uri/ =404; is enough.
  • snippets/fastcgi-php.conf is Ubuntu's ready-made block. It refuses to pass a request to PHP when the .php file doesn't exist (try_files $fastcgi_script_name =404), which closes an old hole where an uploaded image could be run as PHP.
  • client_max_body_size must be at least the pool's post_max_size, or nginx rejects big uploads before PHP sees them.
  • The last block hides dotfiles such as .env and .git, but still allows /.well-known/ for certificates.

Enable it, remove the default site and reload:

bash
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Check it worked:

bash
curl -H "Host: example.com" http://127.0.0.1/
curl -o /dev/null -s -w "%{http_code}\n" -H "Host: example.com" http://127.0.0.1/.env

The first prints PHP 8.3.6 running as example, which shows nginx reached the site's own pool. The second prints 403.

Tune php.ini

Settings that apply to every site go in a file of your own in PHP-FPM's conf.d, so package upgrades never overwrite them. Create /etc/php/8.3/fpm/conf.d/90-custom.ini:

ini
expose_php = Off
max_execution_time = 60
opcache.memory_consumption = 128
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1

expose_php = Off stops PHP announcing its version in an X-Powered-By header. The OPcache lines give the bytecode cache room for bigger apps (Laravel or WordPress with many plugins). Restart PHP-FPM to apply: sudo systemctl restart php8.3-fpm.

Pool settings (php_admin_value in the pool file) win over php.ini, so per-site limits stay where you set them.

To see what a site really gets, put a phpinfo() page in its root, open it once, and delete it straight away: it shows the server's paths and settings to anyone.

bash
echo '<?php phpinfo();' | sudo -u example tee /var/www/example.com/public/info.php
curl -s -H "Host: example.com" http://127.0.0.1/info.php | grep -o 'open_basedir</td><td class="v">[^<]*'
sudo rm /var/www/example.com/public/info.php

Add HTTPS

Open TCP 80 and 443 in ufw (sudo ufw allow "Nginx Full") and, on VPSNine, in the panel firewall if the server has one. Then get a certificate: Serve a site with nginx and HTTPS covers Certbot, which adds HTTPS to the server block above and renews it by itself.

Troubleshooting

502 Bad Gateway, and nginx's error log says connect() to unix:/run/php/example.com.sock failed (2: No such file or directory). PHP-FPM isn't running, or the pool's listen path doesn't match fastcgi_pass. Run sudo php-fpm8.3 -t, then sudo systemctl restart php8.3-fpm and journalctl -u php8.3-fpm -n 30.

502 Bad Gateway with connect() to unix:/run/php/example.com.sock failed (13: Permission denied). nginx can't open the socket. Check listen.owner and listen.group are www-data in the pool file.

404 Not Found for a PHP file that exists, and the error log says stat() "/var/www/example.com/public/index.php" failed (13: Permission denied). nginx can't read the site's directory. Directories need to be readable and searchable by others (755) and files readable (644): sudo chmod 755 /var/www/example.com /var/www/example.com/public.

The browser downloads the .php file instead of running it. The request never reached PHP-FPM, usually because the site's location ~ \.php$ block is missing or another server block answered. Check which site handled it with sudo nginx -T | grep server_name.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee