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:
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-intlThe 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:
php -v
systemctl status php8.3-fpmphp -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:
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.comPut a test page in the web root:
echo '<?php echo "PHP ", PHP_VERSION, " running as ", posix_getpwuid(posix_geteuid())["name"], "\n";' | sudo -u example tee /var/www/example.com/public/index.phpGive 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:
[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] = 256Muserandgroupare who the PHP code runs as.listen.ownerandlisten.grouplet nginx (www-data) talk to the socket, and nobody else.pm = ondemandstarts 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 = dynamickeeps some warm.pm.max_childrencaps 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_basedirstops this site's PHP from opening files outside its own directory and/tmp.php_admin_valuesettings apply to this pool only and can't be changed by the app withini_set().
Test the configuration and restart PHP-FPM:
sudo php-fpm8.3 -t
sudo systemctl restart php8.3-fpmCheck 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:
ls -l /run/php/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.sockIf 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:
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_stringsends any path that isn't a real file toindex.php, which is what front-controller apps (Laravel, Symfony, Slim, most CMSs) expect. For a site of plain.phpfiles,try_files $uri $uri/ =404;is enough.snippets/fastcgi-php.confis Ubuntu's ready-made block. It refuses to pass a request to PHP when the.phpfile 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_sizemust be at least the pool'spost_max_size, or nginx rejects big uploads before PHP sees them.- The last block hides dotfiles such as
.envand.git, but still allows/.well-known/for certificates.
Enable it, remove the default site and reload:
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 nginxCheck it worked:
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/.envThe 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:
expose_php = Off
max_execution_time = 60
opcache.memory_consumption = 128
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1expose_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.
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.phpAdd 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
- Add a database: Install MariaDB on Ubuntu 24.04.
- Install WordPress on this stack: Host WordPress on a VPS.
- Keep PHP patched: automatic security updates install Ubuntu's PHP fixes for you, and PHP2Shell shows how to check a fix is live.
- References: PHP-FPM configuration, nginx's PHP FastCGI example.