This guide walks the whole path: a clean Ubuntu 24.04 LTS install goes in, and a site on nginx 1.24 and PHP-FPM 8.3 served over HTTPS with automatic renewal comes out. Along the way we deal with key-based access, the firewall, the server block, worker pool sizing and OPcache. It is aimed at anyone who has just rented their first VPS and would rather not assemble a config out of random snippets found in search results.
What you need
A VPS with a clean Ubuntu 24.04 LTS (Noble) install and either root access or a sudo user.
A domain whose A record already points at the server IP — without it you cannot issue a certificate.
An SSH client on your local machine and half an hour.
Every version below is the one shipped in the stock Noble repository: nginx 1.24.0 and PHP 8.3. No third-party PPAs required. The domain in the examples is example.com, the user is deploy, the server IP is 203.0.113.10.
First login and the user account
Update the system and create a regular user: running as root all the time is a bad habit.
sudo apt update && sudo apt upgrade -y
sudo adduser deploy
sudo usermod -aG sudo deployKeys instead of a password
Generate the key on your local machine, not on the server.
ssh-keygen -t ed25519 -C 'deploy@example.com'
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@203.0.113.10Confirm that key-based login works, and only then turn off password authentication. A separate file in /etc/ssh/sshd_config.d/ beats editing the main config: a package upgrade will not touch it.
sudo tee /etc/ssh/sshd_config.d/01-hardening.conf > /dev/null <<'EOF'
PasswordAuthentication no
PermitRootLogin no
KbdInteractiveAuthentication no
EOF
sudo sshd -t
sudo sshd -T | grep -Ei '^(passwordauthentication|permitrootlogin|kbdinteractiveauthentication)'
sudo systemctl reload sshsshd -t only checks syntax. The effective values the daemon will actually start with come from sshd -T — all three lines in its output must read no.
The number in the filename is not decoration. The line
Include /etc/ssh/sshd_config.d/*.confsits at the very top ofsshd_config, files are pulled in alphabetically, and sshd uses the first value it obtains for each keyword. Most cloud images already ship50-cloud-init.confwithPasswordAuthentication yes. Name your file99-hardening.confand it loses to cloud-init silently: the syntax check passes, the reload succeeds, and password login stays enabled.
The firewall
ufw is a front end to iptables (on Ubuntu, through the nft backend), so its rules show up in sudo iptables -S. Declare the rules first, enable afterwards: the other order cuts you off from the server.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw show added
sudo ufw enable
sudo ufw status verboseThe OpenSSH profile from /etc/ufw/applications.d/openssh-server hardcodes 22/tcp. If you move SSH to a different port you will have to write the rule by hand — the profile knows nothing about it.
SSH on a non-standard port
Changing the port will not stop a targeted attack, but it does clear most of the brute-force noise out of your logs. On Ubuntu 24.04 sshd starts through socket activation, so the port lives in the ssh.socket unit rather than in sshd_config. The empty ListenStream= line clears the inherited list — without it, 22 stays open alongside the new port.
sudo mkdir -p /etc/systemd/system/ssh.socket.d
sudo tee /etc/systemd/system/ssh.socket.d/override.conf > /dev/null <<'EOF'
[Socket]
ListenStream=
ListenStream=2222
EOFThe firewall rule for the new port must exist before the restart. And both units have to be restarted: with Accept=no systemd hands the listening descriptor to the running sshd, the process keeps its own copy, and restarting ssh.socket alone will not take the old socket away from the live daemon — 22 stays open and 2222 never comes up.
sudo ufw allow 2222/tcp comment 'ssh'
sudo systemctl daemon-reload
sudo systemctl stop ssh.service ssh.socket
sudo systemctl start ssh.socket
sudo ss -lntp | grep sshd # the output must show 2222, not 22Do not close your current session. Open a second terminal, log in with
ssh -p 2222 deploy@203.0.113.10, and only after that succeeds remove the old rule:sudo ufw delete allow OpenSSH. The reverse order is the fastest way to end up locked out of a perfectly healthy server.
Automatic updates and fail2ban
The bare minimum: unattended security updates and a ban for password guessing.
sudo apt install -y unattended-upgrades fail2ban
sudo dpkg-reconfigure --priority=low unattended-upgradesThe default sshd jail reads /var/log/auth.log, and on minimal or cloud 24.04 images without rsyslog that file does not exist — the service fails at startup with Have not found any log file for sshd jail. So set the systemd backend explicitly, and the new port along with it.
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
port = 2222
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshdInstalling nginx and PHP-FPM
nginx
sudo apt install -y nginx
nginx -v
sudo ufw allow 'Nginx Full'
systemctl status nginx --no-pagerThe Nginx Full profile opens 80 and 443. There is also Nginx HTTP for port 80 only, but you will need 443 ten minutes from now, and certbot needs 80 to validate the domain.
PHP 8.3 and extensions
sudo apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-xml php8.3-mbstring \
php8.3-curl php8.3-zip php8.3-intl php8.3-gd php8.3-opcache
php -v
systemctl status php8.3-fpm --no-pager
ls -l /run/php/php8.3-fpm.sockThe php8.3-fpm package immediately creates the www pool and the socket /run/php/php8.3-fpm.sock, owned by user and group www-data. Pick the extension list to match your application: php8.3-mysql is pointless if your database is PostgreSQL.
Site configuration
Directory and permissions
sudo mkdir -p /var/www/example.com/public
sudo chown -R deploy:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 2750 {} \;
sudo find /var/www/example.com -type f -exec chmod 640 {} \;The owner is the user who deploys the code; the group is www-data, which both nginx and FPM workers run as. The setgid bit in 2750 keeps the group on new files created inside the directory. The web server needs write access nowhere except upload and cache directories.
Drop in a temporary page so there is something to check:
echo '<?php phpinfo();' | sudo -u deploy tee /var/www/example.com/public/index.php > /dev/nullThe server block
The site config goes into /etc/nginx/sites-available/example.com.conf.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php index.html;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
client_max_body_size 34m;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ /\.(?!well-known).* {
deny all;
access_log off;
log_not_found off;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 60s;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
}
location ~* \.(css|js|jpg|jpeg|png|gif|svg|ico|woff2)$ {
expires 30d;
access_log off;
}
}The snippets/fastcgi-php.conf snippet ships with the package and already contains fastcgi_split_path_info, the protective try_files $fastcgi_script_name =404 and include fastcgi.conf — there is no need to repeat those lines.
Why location order matters
nginx evaluates regex locations in declaration order and stops at the first match. That is exactly why the block that hides dotfiles sits above the others. Move it to the bottom and a request for /.git/hooks/deploy.php lands in the ~ \.php$ location and goes straight to FPM, while /.git/config.js falls into the static block and is served with expires 30d and logging turned off — completely silently. A deny block at the end of the file gives you nothing but a feeling of safety.
The (?!well-known) lookahead keeps /.well-known/ reachable — that is how certbot proves domain ownership. Exact-match locations such as location = /fpm-status are unaffected by order: nginx checks them before any regular expression.
Enabling and checking
sudo ln -s /etc/nginx/sites-available/example.com.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxreload rather than restart: nginx spawns workers with the new config and lets the old ones finish the requests they are serving. Open http://example.com/ and you should get the phpinfo page. If the browser downloads a file instead, the request never reached FPM — check the ~ \.php$ location. Once it works, delete index.php: phpinfo exposes paths, versions and settings to the entire internet.
The PHP-FPM pool
Pool settings live in /etc/php/8.3/fpm/pool.d/www.conf. There is really one question here: how many concurrent PHP requests the server is prepared to serve.
The three pm modes
| Mode | How it behaves | When it fits |
|---|---|---|
static | Keeps exactly pm.max_children processes alive at all times; the memory is always occupied. | Steady, predictable load and memory to spare; no forking overhead. |
dynamic | Starts with pm.start_servers, forks new ones while there are fewer idle workers than pm.min_spare_servers, and kills off the extras once idle workers exceed pm.max_spare_servers. The total number of processes is capped by pm.max_children. | The default, and a sensible choice for most sites. |
ondemand | No workers exist until a request arrives; an idle process exits after pm.process_idle_timeout. | Many rarely visited sites on one machine, where memory costs more than the latency of a fork. |
How many workers will fit
Size the pool from free memory, not from core count. A worked example for a 4 GB server: leave roughly 1.9 GB to the OS, nginx and the database, which leaves about 2.1 GB for FPM. If an average worker takes around 70 MB, that gives 2100 / 70 = 30 processes. This is an illustration of the arithmetic, not a measurement: take your own number from real traffic.
ps --no-headers -o rss -C php-fpm8.3 | awk 'NR>1{s+=$1;n++} END{if(n) printf "%.0f MB\n", s/n/1024}'
# NR>1 drops the master process: its RSS is much lower than a worker'sThe edits in www.conf:
pm = dynamic
pm.max_children = 30
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
pm.status_path = /fpm-status
request_terminate_timeout = 65spm.max_requests recycles a worker after 500 handled requests — cheap insurance against memory leaks in third-party libraries. Keep request_terminate_timeout slightly above nginx's fastcgi_read_timeout (60 s): that way nginx times out first and you get a predictable 504 instead of a 502 with a dropped connection.
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpmUpload limits
There are three tiers here, and the top one is nginx: it cuts the request body off before PHP ever hears about it. In /etc/php/8.3/fpm/php.ini:
upload_max_filesize = 32M
post_max_size = 33M
memory_limit = 256MAnd in the server block, client_max_body_size 34m;. The order has to be exactly that: the nginx limit no lower than post_max_size, which in turn is slightly above upload_max_filesize, because the body carries form fields and multipart boundaries on top of the file. Set nginx to exactly 32m and a 32 MB file will never get through, while the headroom you configured in PHP is never used.
The two PHP limits have different symptoms and are worth telling apart. Exceed post_max_size and both $_POST and $_FILES arrive empty. Exceed only upload_max_filesize and $_FILES is populated, but the error field holds UPLOAD_ERR_INI_SIZE (1). Handle both cases.
Slow requests
slowlog = /var/log/php8.3-fpm.log.slow
request_slowlog_timeout = 5sFPM writes a stack trace for every request running longer than five seconds, which points at the exact function where everything stalled. Just check /etc/logrotate.d/php8.3-fpm first: the packaged rule does not cover an arbitrary path, and nobody rotates a custom-named slowlog. Under production traffic that file grows without limit.
OPcache
Where the settings belong
Do not edit /etc/php/8.3/fpm/conf.d/10-opcache.ini, and this is not obvious: it is a symlink to /etc/php/8.3/mods-available/opcache.ini, shared with the CLI SAPI. Your edits leak into the CLI, and phpenmod or an upgrade of the php8.3-opcache package overwrites them, throwing a config conflict prompt in on the way. Verify it yourself: ls -l /etc/php/8.3/fpm/conf.d/10-opcache.ini. Put your settings in a separate, real file with a higher number — it loads later and its values win.
sudo tee /etc/php/8.3/fpm/conf.d/99-opcache-tuning.ini > /dev/null <<'EOF'
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
EOF
sudo systemctl reload php8.3-fpm
php-fpm8.3 -i | grep -i 'opcache.memory_consumption\|Loaded Configuration'opcache.enable=1 is already set in mods-available/opcache.ini, so there is no need to repeat it. validate_timestamps=0 means PHP stops checking file mtimes: after every code deployment run sudo systemctl reload php8.3-fpm — the reload sends SIGUSR2 to the master and the shared memory, cache included, is recreated.
One small detail that reliably causes confusion: PHP rounds max_accelerated_files up to the nearest value in a fixed set of primes, so 20000 in the config shows up as 32531 in the status output. It changes nothing about behaviour, but the two numbers will not match.
How to inspect the cache
OPcache is disabled in the CLI by default (opcache.enable_cli=0), so php status.php tells you nothing useful. And if you do enable it, every CLI process gets its own cache instance that dies with the process. The state of the FPM pool is only visible from a request that FPM itself served.
<?php
$status = opcache_get_status(false);
if ($status === false) {
exit('OPcache is not available in this SAPI' . PHP_EOL);
}
$memory = $status['memory_usage'];
$stats = $status['opcache_statistics'];
printf(
"Used: %.1f MB, free: %.1f MB\n",
$memory['used_memory'] / 1048576,
$memory['free_memory'] / 1048576
);
printf(
"Keys cached: %d of %d, misses: %d\n",
$stats['num_cached_keys'],
$stats['max_cached_keys'],
$stats['misses']
);The false check is mandatory: without it, in a SAPI with no OPcache the script dies on $status['memory_usage']. And do not leave a file like this in public/ — it exposes your paths and the size of your codebase to anyone who asks. Serving the numbers through pm.status_path, restricted by IP, is safer:
location = /fpm-status {
allow 127.0.0.1;
allow ::1;
deny all;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}curl -s -H 'Host: example.com' http://127.0.0.1/fpm-statusHTTPS from Let's Encrypt
Issuing the certificate
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot
sudo certbot --nginx -d example.com -d www.example.comDomain ownership is validated over HTTP, so port 80 must be open and the domain must already resolve to this server. The --nginx plugin edits the server block itself and offers to add an HTTP to HTTPS redirect.
What appeared in the config
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;Plus a separate server on port 80 with the redirect. The http2 parameter inside listen is the correct form for nginx 1.24 from the Noble repository. It has been deprecated since 1.25.1: if you install nginx 1.26+ from a third-party repository, HTTP/2 is enabled with a separate http2 on; directive inside the server block.
Renewal and the deploy hook
The certificate is valid for 90 days and certbot renews it once fewer than 30 remain. The snap package installs its own timer; check it like this:
systemctl list-timers 'snap.certbot.*' --no-pagerAfter a renewal, though, nginx keeps the old certificate in memory until something makes it re-read the files. That is what the deploy hook is for:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
/usr/bin/systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run --run-deploy-hooks--dry-run talks to the staging server and does not consume issuance rate limits. But deploy hooks are skipped during a dry run by default: a typo in the script, a forgotten chmod +x or a wrong path to systemctl would go unnoticed until the real renewal two months later, when nginx starts serving an expired certificate. The --run-deploy-hooks flag exercises them too.
Triage: the status code as a hint
First, the right log
The server block above declares a per-site error_log, and every error from talking to FPM goes there. The global /var/log/nginx/error.log keeps only main- and http-level messages — look there and it is easy to conclude that "there is nothing in the logs". FPM likewise writes to the file configured in php-fpm.conf; the systemd journal holds only unit start, stop and failure, not 502s or worker problems.
sudo tail -n 50 /var/log/nginx/example.com.error.log
sudo tail -f /var/log/php8.3-fpm.logWhat each code means
An immediate 502 — nginx could not reach the socket. The site log will show
connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied)or(2: No such file or directory). Check that the service is running and inspect permissions along the whole path:namei -l /run/php/php8.3-fpm.sock.A 502 after exactly N seconds — the worker was killed by
request_terminate_timeout. nginx logsupstream prematurely closed connection while reading response header from upstream, and/var/log/php8.3-fpm.loggetsWARNING: child NNNN exited on signal 15 ... after N seconds from start.504 Gateway Time-out — a different mechanism entirely: nginx itself stopped waiting after
fastcgi_read_timeout. Confuse the two and you will be tuning the wrong parameter.413 Request Entity Too Large — the body is larger than
client_max_body_size. The request never reaches PHP at all.500 and a blank page — a PHP fatal error. In production
display_errorsis off, so read the site log and the FPM log.server reached pm.max_children settingin the FPM log — no idle workers left and requests are queueing. Raise the limit if you have the memory, or work out why requests became slow.
Both services can validate their config up front and re-read it without dropping connections:
sudo nginx -t && sudo systemctl reload nginx
sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpmChecklist before going live
sudo sshd -T | grep passwordauthenticationreturnsno.sudo ufw status verboselists only the ports you need, and SSH login has been verified from a second terminal.The test
index.phpwithphpinfo()is gone and status pages are restricted by IP.sudo certbot renew --dry-run --run-deploy-hooksfinishes without errors.client_max_body_size,post_max_sizeandupload_max_filesizeare aligned in that descending order.You know where both logs live and have opened them at least once.
Backups, monitoring and automated deployment come next, and each deserves its own article. But a server assembled from these steps is ready for real work: it refuses password logins, does not hand out .git, survives a certificate renewal, and tells you clearly in the logs what broke.







