All systems operational Your IP: 216.73.216.190 info@cloudhosting.lv +371 66 66 29 69 Client area

← All questions

LEMP on Ubuntu 24.04: NGINX, PHP 8.3, MariaDB, SSL

Ubuntu 24.04 LTS ships PHP 8.3 in its own repositories. That single fact deletes the step most older guides open with, adding a third party PHP repository, and it deletes the maintenance debt that came with it. What follows is a full LEMP build on a clean 24.04 machine: NGINX, MariaDB, PHP 8.3 over a unix socket, a free Let's Encrypt certificate, and the parts of certificate renewal that people usually discover about seventy days later, on a Sunday.

Before the first apt command

You need a clean 24.04 server, root or a sudo account, and DNS already pointing at it. Publish the A record, and the AAAA record if you intend to use one, then wait until it resolves from outside your own network. Certbot does not care about your intentions, only about what the certificate authority sees.

apt update
apt full-upgrade
reboot

IPv6 deserves a warning right here, because it causes the most confusing failure in the whole process. If your domain has an AAAA record, Let's Encrypt connects over IPv6 first, and what happens next depends on how the v6 path fails. If nothing is listening on [::]:80 the connection is refused, and a refusal is not a timeout, so there is no retry and issuance fails there. If a firewall silently drops IPv6 packets you get a timeout instead, and that one case Let's Encrypt retries over IPv4, so a first issuance can succeed on a server whose IPv6 is broken. The retry covers only that first request. Once port 80 redirects the challenge to HTTPS, which is where the setup below ends up, the redirect is followed over IPv6 again with no fallback, and the renewal dies months later on a server nobody touched. Either way the error names the address it tried and never the word IPv6, which is why people stare at it for an hour. Make v6 work end to end, or do not publish the AAAA record at all. A modest VPS in our Riga data centre handles this stack comfortably for a normal site.

NGINX

apt install -y nginx
systemctl enable --now nginx
nginx -v

Noble ships the 1.24 branch with the familiar Debian layout: /etc/nginx/sites-available, /etc/nginx/sites-enabled, the snippets directory and a ufw application profile. The default welcome site is a catch all on port 80 and will happily answer for hostnames you never configured, so remove it once your own site is in place.

unlink /etc/nginx/sites-enabled/default

MariaDB

apt install -y mariadb-server
mariadb-secure-installation

The hardening script is called mariadb-secure-installation in the 10.11 series that noble ships. The old mysql_secure_installation name still works as a compatibility link, but it is the one that will disappear first. More important: the MariaDB root account authenticates through the unix_socket plugin, so sudo mariadb gets you a prompt with no password at all. Setting a root password on top of that mostly makes backup scripts more annoying. Say no to changing the root authentication, and yes to removing anonymous users, the test database and remote root login.

sudo mariadb
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;

The Ubuntu package binds to 127.0.0.1 in /etc/mysql/mariadb.conf.d/50-server.cnf. Leave it there. Confirm with ss -lntp | grep 3306 and you never have to think about port 3306 in the firewall.

PHP 8.3 and the extensions a real site needs

apt install -y php8.3-fpm php8.3-cli php8.3-mysql php8.3-xml php8.3-curl php8.3-mbstring php8.3-zip php8.3-gd php8.3-intl php8.3-bcmath

Three things about that list. First, php8.3-cli is on that line for clarity rather than out of necessity: on Debian and Ubuntu php8.3-fpm depends on it, so apt installs the CLI, /usr/bin/php8.3 and the php alternative whether you name the package or not, and Composer, WP-CLI and cron jobs have an interpreter from the start. Write it out anyway, because it keeps in view that you are running two SAPIs and configuring them separately, which is the subject of the next paragraph. Second, there is no php8.3-json package: JSON has been compiled into core since PHP 8.0, and asking apt for it simply errors out. That line, copied from PHP 7 era guides, is the most common reason a paste of this whole command fails. Third, php8.3-mysql is what provides both mysqli and PDO MySQL.

OPcache lives in its own package, php8.3-opcache, and comes in with the above. Verify it where it matters: the CLI and the FPM pool read different configuration directories, /etc/php/8.3/cli/conf.d and /etc/php/8.3/fpm/conf.d, so php -m in your shell proves nothing about what FPM loaded. Drop a temporary phpinfo() file, look at it through NGINX, then delete it.

systemctl status php8.3-fpm
ls -l /run/php/php8.3-fpm.sock

When the ondrej PPA is justified

PHP 8.3 in noble receives security updates for the LTS lifetime, so the PPA is only worth adding when the application genuinely needs a newer branch, or is stuck on an older one.

add-apt-repository ppa:ondrej/php
apt update
apt install -y php8.4-fpm php8.4-cli

Understand what you just changed. The service name becomes php8.4-fpm, the configuration moves to /etc/php/8.4, and the socket becomes /run/php/php8.4-fpm.sock. If you do not update fastcgi_pass in the same maintenance window, every PHP request returns 502 Bad Gateway. Do not leave two FPM versions running and pointed at each other's sockets while you work it out. And from that day on your PHP version follows a third party's release schedule, not Ubuntu's.

A server block that works

mkdir -p /var/www/example.com/public
chown -R www-data:www-data /var/www/example.com

Write /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;

    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;
    }
}
ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx

The snippet snippets/fastcgi-php.conf comes with the Ubuntu package and already sets SCRIPT_FILENAME, splits PATH_INFO and adds the try_files ... =404 guard that stops arbitrary file execution. Do not hand roll those lines. Note the shape of the dotfile rule: the version without the negative lookahead, location ~ /\. { deny all; }, also blocks /.well-known/acme-challenge/ and will break both issuance and every renewal after it.

Firewall

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
ufw status verbose

Allow SSH before you enable ufw, and if sshd listens on a non standard port, allow that port by number instead. The Nginx Full profile covers 80 and 443 and is provided by the nginx-common package; if you installed NGINX from the vendor repository the profile does not exist, so open 80,443/tcp manually.

The certificate

Both routes are fine. From the archive:

apt install -y certbot python3-certbot-nginx

Or from snap, which is what upstream recommends and which tracks certbot releases more closely:

snap install core
snap refresh core
snap install --classic certbot
ln -s /snap/bin/certbot /usr/bin/certbot

Pick one. Installing both leaves two binaries fighting over /usr/bin/certbot and two timers renewing the same lineage. Then issue:

certbot --nginx -d example.com -d www.example.com

The nginx plugin edits your server block in place: it adds the listen 443 ssl lines, the certificate paths, the shared TLS options file, and, if you accept the redirect offer, a separate port 80 server that redirects everything to HTTPS.

One redirect, in one place

Either let certbot write the redirect or write it yourself, never both. Two redirects in the same request path produce either a loop or a browser complaining about too many redirects, and it often only shows up behind a proxy. Written by hand it should be dull:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

That block replaces the port 80 block for those names, it does not sit next to it. Two server blocks listening on 80 with the same server_name make NGINX log a conflicting server name warning and quietly use the first one. Use $host rather than a hard coded name, and keep $request_uri so the path survives. A redirect to https://example.com/ that throws the path away is a classic cause of failed renewals later, because the ACME challenge URL becomes the site root. If TLS is terminated in front of this server, redirect on X-Forwarded-Proto instead, otherwise the loop is guaranteed.

What the renewal timer actually does

systemctl list-timers | grep -i certbot
systemctl cat certbot.timer

The apt package installs certbot.timer, the snap installs snap.certbot.renew.timer. Both fire twice a day with a large random delay, so the world does not hit the ACME API on the same minute. The unit runs certbot -q renew, and renew does nothing at all unless a certificate is within roughly thirty days of expiry. A log full of no action taken is the healthy state, not a broken cron. The apt package also drops /etc/cron.d/certbot, which checks for systemd and exits, so you are not renewing twice.

Renewal replays /etc/letsencrypt/renewal/example.com.conf, not your shell history: the authenticator, the installer, the webroot path, the exact list of names. If you later restructure the site, edit that file. And check the reload path. With the nginx installer, certbot reloads NGINX itself. With a bare webroot authenticator and no installer, nothing reloads NGINX, and it keeps the old certificate in memory until something else restarts it, which is how a perfectly renewed certificate still shows as expired in a browser. Add this line under [renewalparams]:

renew_hook = systemctl reload nginx

Testing renewal without burning rate limits

certbot renew --dry-run

This runs against the staging CA, so your production limits are untouched. It exercises DNS, the challenge and the plugin, but writes nothing into /etc/letsencrypt/live, so it will not prove that your reload step works. To test the scheduled path itself, trigger the unit once: systemctl start certbot.service, or snap.certbot.renew.service on the snap. It is a real renew attempt and a no-op when nothing is due.

For a brand new hostname you are unsure about, issue once with --test-cert, confirm the flow, then reissue for real. What you must not do is loop certbot --nginx for the same names while debugging. Repeated identical certificates hit the duplicate certificate limit for the week, and failed validations have their own hourly limit, so you end up locked out of issuance for a name on a server that is otherwise perfectly configured.

The two mistakes that break renewal later

The challenge location. Anything that stops http://example.com/.well-known/acme-challenge/<token> from being served breaks renewal weeks after you stopped thinking about the server. The usual suspects: the dotfile deny rule shown earlier, a redirect that drops the path, basic auth or an IP allow list applied to the whole site, or a WAF rule in front. Validation follows redirects, including to HTTPS, so a plain 301 is not the problem by itself. Test by hand:

mkdir -p /var/www/example.com/public/.well-known/acme-challenge
echo ok > /var/www/example.com/public/.well-known/acme-challenge/test
curl -IL http://example.com/.well-known/acme-challenge/test

You want a 200 at the end of that chain, then delete the file. If the site is behind basic auth, carve out an exception with location ^~ /.well-known/acme-challenge/ { auth_basic off; allow all; }.

A stale symlink. /etc/letsencrypt/live/example.com/fullchain.pem is a symlink into /etc/letsencrypt/archive/, and renewal repoints it. Two habits break that. Copying the pem files into /etc/nginx/ssl and pointing NGINX there means renewal keeps updating files nobody serves. Deleting and re-running certbot creates a second lineage named example.com-0001, which renews happily while NGINX still reads the abandoned example.com directory. Compare the two sides:

certbot certificates
grep -R ssl_certificate /etc/nginx/sites-enabled/
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -dates -subject

The dates from s_client are the truth. If they disagree with certbot certificates, you are either serving a stale path or you never reloaded.

Checks before you call it done

nginx -t
systemctl status nginx php8.3-fpm mariadb --no-pager
systemctl is-enabled nginx php8.3-fpm mariadb certbot.timer
ss -lntp

Then reboot once and repeat the checks, because a stack that only works until the next kernel update is not finished. Keep unattended-upgrades enabled for security patches, and remember that PHP-FPM needs a restart, not just a reload, after an extension update. If you would rather not own any of this, managed WordPress hosting covers the same stack for you, and our engineers are available around the clock through IT support when a server you built yourself starts misbehaving.

Rather have us run it for you?
VPS. NVMe virtual servers, live in under a minute.
Learn more

Ready to start?

Deploy in minutes or talk to an engineer about what fits your project.