# 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.

Source: https://vpsnine.com/tutorials/install-php-fpm-nginx-ubuntu · Updated: 2026-10-21

Difficulty: intermediate · Time: about 25 minutes · Tested on: Ubuntu 24.04 LTS

Prerequisites:

- 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](https://vpsnine.com/help/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](https://vpsnine.com/help/php2shell-check-and-patch-php) 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](https://vpsnine.com/help/server-firewall) if the server has one. Then get a certificate: [Serve a site with nginx and HTTPS](https://vpsnine.com/help/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](https://vpsnine.com/tutorials/install-mariadb-ubuntu).
- Install WordPress on this stack: [Host WordPress on a VPS](https://vpsnine.com/help/wordpress-on-a-vps).
- Keep PHP patched: [automatic security updates](https://vpsnine.com/help/automatic-security-updates) install Ubuntu's PHP fixes for you, and [PHP2Shell](https://vpsnine.com/help/php2shell-check-and-patch-php) shows how to check a fix is live.
- References: [PHP-FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php), [nginx's PHP FastCGI example](https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html).
