LAMP stack on Ubuntu 24.04 with PHP-FPM
Build a full LAMP stack on Ubuntu 24.04: Apache, MariaDB with unix_socket auth, PHP 8.3 with PHP-FPM, a name-based vhost, and free HTTPS via Certbot.
What you are building
A LAMP stack is four moving parts on one Ubuntu 24.04 server: Linux underneath, Apache answering HTTP, MariaDB holding the data, and PHP 8.3 running the code. By the end you will have a name-based virtual host serving a real application directory, a database with a dedicated least-privilege user, PHP wired into Apache through PHP-FPM, and a free Let's Encrypt certificate over the top.
The install itself is four apt commands. Almost everything in this guide is the wiring between the pieces, and the small set of mistakes that make a brand-new stack serve a blank page, hand your source code to the browser as a download, or refuse to let you into the database you just installed. Every one of those has a signature you can recognise, and each is named below with the exact text you will see.
Prerequisites and the honest gotchas
Assume a fresh Ubuntu 24.04 KVM VPS with a sudo user or root, and a public IPv4 address. A minimal stack runs in 1 GB of RAM; give it 2 GB before you put a real database-backed application on it, because MariaDB's default buffers plus a handful of PHP-FPM workers eat the first gigabyte quickly.
Two things need to be true before Certbot at the end will work, so line them up now. You need a domain name with an A record pointing at the VPS's public IP — Let's Encrypt validates over HTTP to that name, and a bare IP address can never get a certificate. And ports 80 and 443 must be reachable from the internet, which on many providers means opening them in a network firewall in the control panel as well as in ufw on the box. DNS changes can take up to an hour to propagate, so set the A record first and it will be live by the time you need it.
Step 1 - Install Apache and confirm the default page
sudo apt update
sudo apt install -y apache2
apt starts and enables the service for you. Check it:
systemctl status apache2
You want a line reading active (running). Now open http://YOUR_SERVER_IP/ in a browser. The Apache2 Ubuntu Default Page with the large "It works!" banner is the correct result — it is proof Apache is serving, not a mistake. That page lives at /var/www/html/index.html and is served by the shipped default virtual host 000-default.conf. You will disable both later; for now their presence is exactly what you want to see.
If the page does not load at all but systemctl says the process is running, a firewall is in the way. That is the next step.
Step 2 - Open the firewall for HTTP and HTTPS
The apache2 package registers three ufw application profiles. List them:
sudo ufw app list
You will see Apache, Apache Full, and Apache Secure. Apache is port 80 only, Apache Secure is 443 only, and Apache Full is both — that is the one you want, because you are adding TLS at the end.
sudo ufw allow OpenSSH
sudo ufw allow "Apache Full"
sudo ufw enable
Allow OpenSSH before you run ufw enable. ufw defaults to denying all incoming traffic, and enabling it without an SSH rule cuts your own connection the moment it activates — you keep the current session but can never reconnect. Confirm with sudo ufw status; you want OpenSSH, Apache Full, and their v6 equivalents all reading ALLOW.
Step 3 - Install MariaDB and secure it
sudo apt install -y mariadb-server
systemctl status mariadb
Ubuntu 24.04 ships MariaDB 10.11, a long-term-support release, so you do not need an external repository. With the service running, harden it:
sudo mysql_secure_installation
The prompts are worth reading rather than mashing Enter. When it asks for the current root password, press Enter — there is none yet. When it asks "Switch to unix_socket authentication?", the answer changes nothing because it is already enabled on this package, so press n. Say n to "Change the root password?" for the reason in the next paragraph, then answer Y to the rest: remove anonymous users, disallow remote root login, drop the test database, and reload privilege tables.
Here is the part that confuses everyone. On Ubuntu's MariaDB the root database account uses unix_socket authentication, not a password. That means the database trusts the operating-system user you already authenticated as. So this works from a root shell:
sudo mysql
...and it drops you at a MariaDB [(none)]> prompt with no password asked. The same command run as an unprivileged user is refused, which is the entire point: access to the database root is tied to sudo on the box, and there is no password to steal, phish, or brute-force. This is more secure than a password, so leave it alone. The rule that follows from it: never point an application at the root account. Create a dedicated user per application (Step 7), because an app connecting over TCP with a username and password cannot use socket auth anyway, and you want each app limited to its own database.
Step 4 - Install PHP 8.3 with PHP-FPM
Ubuntu 24.04's default PHP is 8.3. Install the FPM process manager and the extensions a typical app needs:
sudo apt install -y php8.3-fpm php8.3-mysql php8.3-cli \
php8.3-curl php8.3-xml php8.3-mbstring php8.3-zip
Note what is not in that list: libapache2-mod-php. That older package embeds a PHP interpreter inside every Apache process. It is simple, but every worker carries a PHP copy whether it is serving a script or a static image, the two share a lifecycle, and it only works with Apache's prefork MPM — the least efficient one. PHP-FPM instead runs PHP as its own pool of processes that Apache talks to over a socket. Apache can then use the threaded event MPM for static files and hand only PHP requests across, the pool is tuned independently of the web server, and the exact same FPM setup works later if you put nginx in front instead. It is the current default for good reason.
Apache reaches FPM through the proxy_fcgi module. Enable it, enable the config that the FPM package dropped in, and restart:
sudo a2enmod proxy_fcgi setenvif
sudo a2enconf php8.3-fpm
sudo systemctl restart apache2
a2enconf php8.3-fpm activates /etc/apache2/conf-available/php8.3-fpm.conf, which contains the rule that routes PHP files to the FPM socket. The heart of it matches any .php file and forwards it to the socket at /run/php/php8.3-fpm.sock:
<FilesMatch ".+\.ph(ar|p|tml)$">
SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost"
</FilesMatch>
You do not edit that file; it ships correct. But knowing the socket path is what lets you diagnose the "PHP downloads instead of running" and "Primary script unknown" failures later — both come down to Apache and FPM disagreeing about this socket or the file behind it.
Step 5 - A name-based virtual host for your app
Name-based virtual hosting lets one IP serve many sites; Apache picks the site by the Host: header in the request. Make a directory for the app, well away from the default /var/www/html:
sudo mkdir -p /var/www/testapp
sudo chown -R www-data:www-data /var/www/testapp
sudo chmod -R 755 /var/www/testapp
Ownership matters. Apache and PHP-FPM both run as the www-data user on Ubuntu, so files the web server must read — and directories an app must write to, such as an uploads folder — should be owned by www-data. If you will also edit files as your login user, a common pattern is to own the files yourself and add your user to the www-data group; for a plain deploy, www-data:www-data is the least surprising.
Create the virtual host at /etc/apache2/sites-available/testapp.conf:
<VirtualHost *:80>
ServerName app.example.com
DocumentRoot /var/www/testapp
<Directory /var/www/testapp>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/testapp-error.log
CustomLog ${APACHE_LOG_DIR}/testapp-access.log combined
</VirtualHost>
Set ServerName to your real domain. Options -Indexes stops Apache listing the directory when there is no index file — otherwise visitors browse your source tree. AllowOverride All lets a .htaccess file work, which most PHP applications expect for pretty URLs; drop it to None for a small speed gain if your app does not need it. Enable this site, disable the default, check the config, and reload:
sudo a2ensite testapp
sudo a2dissite 000-default
sudo apache2ctl configtest
sudo systemctl reload apache2
apache2ctl configtest should print Syntax OK. The a2dissite 000-default line is the one people forget, and it is why the default page later appears to be stuck — covered in the failures section.
Step 6 - Prove PHP runs, then delete the proof
Drop a one-line PHP file into the app root:
echo "<?php phpinfo();" | sudo tee /var/www/testapp/info.php
Visit http://app.example.com/info.php. A correct result is the long purple-and-grey PHP Version 8.3.x table listing your loaded modules, with the Server API line reading FPM/FastCGI. That last line confirms requests are going through PHP-FPM, not mod_php.
Now delete it immediately:
sudo rm /var/www/testapp/info.php
phpinfo() exposes your exact PHP version, every loaded extension, file paths, and environment details — a gift to anyone probing the server for a version with a known hole. It is a test, not a feature. Delete it the moment you have seen the page. If instead of the table your browser offered to download info.php, PHP is not wired to Apache; jump to the failures section before doing anything else.
Step 7 - Create the app database and a least-privilege user
Open the database as the socket-authenticated root:
sudo mysql
Then create one database and one user scoped to exactly that database:
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;
EXIT;
Three deliberate choices here. utf8mb4 is real four-byte UTF-8 — the old utf8 alias silently truncates emoji and some CJK characters, so always use utf8mb4. The grant is on appdb.*, not *.*: this user can touch its own database and nothing else, so a SQL-injection hole in the app cannot read every other site's tables. And 'appuser'@'localhost' restricts the account to connections originating on the box itself.
Test it as that user:
mysql -u appuser -p appdb
It asks for the password and drops you at a MariaDB [appdb]> prompt. Notice there is no -h flag — leave it off and the client connects over the local Unix socket, which is exactly what MariaDB counts as localhost. One gotcha worth knowing: to MySQL and MariaDB, localhost means the Unix socket and 127.0.0.1 means a TCP connection. On a stock Ubuntu 24.04 MariaDB the server still resolves a TCP connection from 127.0.0.1 back to localhost, so both match the account — but on servers with skip-name-resolve enabled (a common performance tweak, and the norm in many container images), the two are matched as different hosts, and an app that dials 127.0.0.1 is refused with ERROR 1045 (28000): Access denied for user 'appuser'@'127.0.0.1' (using password: YES) even when the password is correct.
So point your application at host localhost, user appuser, database appdb — never at root. PHP's mysqli and PDO both switch to the Unix socket when the host is the literal string localhost, matching the account you just created. If a framework insists on a numeric TCP host, create the user to match how it actually connects — 'appuser'@'127.0.0.1', or @'%' (paired with a firewall rule) only if it must reach the database from another machine.
Step 8 - Add HTTPS with Certbot
Serving a login form over plain HTTP sends passwords in clear text, and every modern browser flags the page as "Not secure". Certbot fixes that in one command. Install it with the Apache plugin:
sudo apt install -y certbot python3-certbot-apache
sudo certbot --apache
Certbot uses two plugins here. The apache authenticator proves you control the domain by briefly serving a challenge file through your running Apache, and the apache installer then rewrites your virtual host to add the 443 block, points it at the new certificate, and offers to redirect all HTTP traffic to HTTPS — say yes. Because you set a real ServerName in Step 5, Certbot detects the domain automatically. Certificates last 90 days and the package installs a systemd timer that renews them; verify the timer with sudo certbot renew --dry-run, which should end in Congratulations, all simulated renewals succeeded.
For the full walk-through of the challenge, the renewal timer, and the DNS and firewall requirements, see the companion guide on issuing free Let's Encrypt TLS certificates with Certbot — it is written around nginx, but the authenticator, renewal, and rate-limit sections apply identically to the Apache plugin.
Backups, upgrades, and hardening
Back up the two things that hold your state: the databases and the web root. A nightly logical dump is the simplest reliable approach — sudo sh -c 'mysqldump --all-databases --single-transaction | gzip > /root/db-$(date +%F).sql.gz', then copied off the box. Wrapping the whole pipeline in sudo sh -c matters: without it the shell runs the > /root/... redirect as your own user and fails with Permission denied, because only mysqldump inherited the sudo. --single-transaction gives a consistent snapshot of the InnoDB tables without locking them. Pair it with a tar of /var/www and /etc/apache2/sites-available, and you can rebuild the whole stack on a fresh VPS from those files.
Upgrades are an ordinary sudo apt update && sudo apt upgrade. The one that bites is a PHP version bump — when a future Ubuntu moves the default to PHP 8.4, apt may install php8.4-fpm alongside 8.3, the socket becomes /run/php/php8.4-fpm.sock, and your Apache config still points at the 8.3 socket. Enable the new conf (sudo a2enconf php8.4-fpm) and disable the old, or your site starts returning Primary script unknown after an otherwise routine upgrade. Because PHP releases move faster than an LTS distro, check the current PHP release notes rather than pinning a patch version.
Two hardening steps are worth doing on day one. First, put Fail2Ban watching SSH on the box — a public VPS gets automated login attempts within minutes, and a small jail turns thousands of tries into a handful before a ban. Second, if you would rather manage Apache virtual hosts, MariaDB databases, and users through a browser than by hand-editing files, the Webmin web-based control panel sits on top of this exact stack and drives the same config files you just wrote. Neither replaces understanding the pieces, but both cut day-to-day friction.
Failure modes, with the strings you will see
The default page will not go away. You edited your virtual host, reloaded, and the browser still shows "Apache2 Ubuntu Default Page" and its "It works!" banner. Apache serves the first matching virtual host, and when no ServerName matches the request, the alphabetically first config wins — 000-default.conf sorts before testapp.conf. Either the request's host name does not match your ServerName, or you never ran sudo a2dissite 000-default. Disable the default, sudo systemctl reload apache2, and confirm with apache2ctl -S, which prints the vhost map and shows which config owns the default. Clear the browser cache too; a cached 200 from the old page happily persists.
A .php file downloads instead of running. You open info.php and the browser downloads a file containing the raw <?php source, or shows it as plain text, instead of running it. Apache is serving the file as a static asset because the PHP handler is not attached — you skipped sudo a2enmod proxy_fcgi, or sudo a2enconf php8.3-fpm, or did not restart Apache afterwards. Run all three (Step 4) and reload. Confirm the module is loaded with apache2ctl -M | grep fcgi, which should list proxy_fcgi_module. This is a source-code leak, not a cosmetic bug, so fix it before putting anything real on the server.
ERROR 1698 (28000): Access denied for user 'root'@'localhost'. You ran mysql -u root or mariadb -u root without sudo. The root account uses unix_socket auth, so it only accepts you when your OS user is actually root. The fix is sudo mysql — no -u root, no password. This message is the expected behaviour of socket auth working correctly, not a broken install.
ERROR 1045 (28000): Access denied for user 'appuser'@'127.0.0.1' from the application, with the right password. The account exists as 'appuser'@'localhost', but your app is connecting over TCP to 127.0.0.1 on a server where host-name resolution is disabled (skip-name-resolve), so MariaDB matches the two as different hosts — localhost is the Unix socket, 127.0.0.1 is TCP. Point the app at host localhost so it uses the socket and matches the account, or create a second account 'appuser'@'127.0.0.1' if the framework will only speak TCP.
AH01071: Got error 'Primary script unknown' in /var/log/apache2/testapp-error.log, with the browser showing File not found.. Apache handed the request to PHP-FPM, but FPM could not find the script at the path Apache gave it. Two usual causes: the FPM socket in your config points at a PHP version that is not installed (a php8.4 socket after an upgrade while only 8.3 runs), or the file genuinely is not there because DocumentRoot and the real directory disagree. Check the socket exists with ls -l /run/php/, confirm DocumentRoot matches where the file lives, and restart both php8.3-fpm and apache2.
AH00558: apache2: Could not reliably determine the server's fully qualified domain name on every restart. This is a harmless warning, not an error — Apache is telling you no global ServerName is set. Silence it by writing ServerName your.domain into /etc/apache2/conf-available/servername.conf and running sudo a2enconf servername.
(98)Address already in use: AH00072: make_sock: could not bind to address 0.0.0.0:80 when Apache starts. Another web server already holds port 80 — often a stray nginx from a previous experiment. Find it with sudo ss -ltnp | grep :80, then stop and disable the other service before starting Apache.
FAQ
mod_php or PHP-FPM - which should I use?
Use PHP-FPM. mod_php embeds an interpreter in every Apache process and forces the slow prefork MPM, so Apache carries PHP overhead even when serving a static image. PHP-FPM runs PHP as a separate, independently tuned pool that Apache reaches over a socket, works with the faster threaded event MPM, and moves unchanged to nginx later. It is the modern default; mod_php only makes sense for a legacy app that depends on some in-process behaviour.
Why does my browser download the PHP file instead of running it?
Apache is treating the .php file as a static download because no PHP handler is attached to it. On Ubuntu 24.04 with FPM that means you missed one of sudo a2enmod proxy_fcgi, sudo a2enconf php8.3-fpm, or the Apache restart afterwards. Run all three and reload, then verify with apache2ctl -M | grep fcgi that proxy_fcgi_module is listed. Until you fix it the server is leaking source code, so treat it as urgent.
Why is root access denied in MariaDB even with the right password?
Because there is no password — Ubuntu's MariaDB authenticates the root account by unix_socket, tying it to the operating-system root user. mysql -u root from a normal shell returns ERROR 1698 (28000): Access denied for user 'root'@'localhost' by design. Connect with sudo mysql instead, and create a separate password-authenticated user for any application rather than reusing root.
How do I add HTTPS to my LAMP site?
Install certbot and python3-certbot-apache, point a domain's A record at the server, then run sudo certbot --apache. The apache authenticator proves domain control through your running Apache and the installer rewrites the virtual host for port 443 and sets up automatic renewal. The full Certbot and Let's Encrypt walk-through covers the challenge types, the renewal timer, and the rate limits to avoid.