How To

Install LEMP Stack on Ubuntu 26.04 LTS (Nginx, MariaDB, PHP 8.5)

Most production web stacks on Ubuntu run Nginx over Apache these days. Nginx handles static content and reverse proxying more efficiently, consumes less memory per connection, and scales better under high concurrency. Paired with MariaDB and PHP-FPM, you get a LEMP stack that powers everything from WordPress sites to custom PHP applications.

Original content from computingforgeeks.com - post 165988

This guide walks through a complete LEMP installation on Ubuntu 26.04 LTS (Resolute Raccoon) with Nginx, MariaDB and PHP-FPM, shows you how to prove PHP is patched against PHP2Shell, and ends with production tuning for worker processes, PHP-FPM pools, and MariaDB buffer sizing. Every step below was run again in October 2026 on a fresh Ubuntu 26.04 LTS system with the patched PHP 8.5.4 security build, so the stack you end up with doesn’t serve requests on a PHP2Shell-vulnerable runtime. The systemctl status blocks, the socket listing and the sample SQL table are kept from the original spring run, because their timestamps and PIDs are the only things that differ.

Prerequisites

Before starting, make sure you have:

  • Ubuntu 26.04 LTS server with root or sudo access (initial server setup guide)
  • At least 1 GB RAM and 10 GB disk space
  • SSH access to the server
  • Tested on: Ubuntu 26.04 LTS, Nginx 1.28.3, MariaDB 11.8.6, PHP 8.5.4 (security build 8.5.4-0ubuntu1.5)

Update System Packages

Start with a package index refresh to make sure you pull the latest versions from the Ubuntu repositories.

sudo apt update && sudo apt upgrade -y

On a fresh 26.04 install, expect anywhere from a handful to 100+ packages to upgrade, depending on how old your install media is. This takes a minute or two depending on your connection speed.

Install Nginx

Nginx is available directly from the Ubuntu 26.04 default repositories. The version shipped with Resolute Raccoon is 1.28.3, which includes HTTP/3 support and improved QUIC handling.

sudo apt install -y nginx

Verify the installed version:

nginx -v

You should see the version confirmed:

nginx version: nginx/1.28.3 (Ubuntu)

Check that the service is running:

systemctl status nginx

The output should show active (running):

● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Tue 2026-04-14 00:24:58 UTC; 9s ago
       Docs: man:nginx(8)
    Process: 2537 ExecStartPre=/usr/sbin/nginx -t -q -g daemon on; master_process on; (code=exited, status=0/SUCCESS)
    Process: 2538 ExecStart=/usr/sbin/nginx -g daemon on; master_process on; (code=exited, status=0/SUCCESS)
   Main PID: 2566 (nginx)
      Tasks: 3 (limit: 3522)

Nginx is enabled by default on Ubuntu 26.04, so it will start automatically on reboot.

Install MariaDB

Ubuntu 26.04 ships MariaDB 11.8.6, a significant jump from the 10.x series in older Ubuntu releases. The 11.x branch brings improved optimizer performance, better JSON handling, and native vector search support.

sudo apt install -y mariadb-server mariadb-client

Confirm the version:

mariadb --version

Expected output:

mariadb from 11.8.6-MariaDB, client 15.2 for debian-linux-gnu (x86_64) using  EditLine wrapper

The service starts automatically after installation. Verify it:

systemctl status mariadb

Look for active (running) and the status message “Taking your SQL requests now…”:

● mariadb.service - MariaDB 11.8.6 database server
     Loaded: loaded (/usr/lib/systemd/system/mariadb.service; enabled; preset: enabled)
     Active: active (running) since Tue 2026-04-14 00:25:25 UTC; 11s ago
       Docs: man:mariadbd(8)
             https://mariadb.com/kb/en/library/systemd/
   Main PID: 3396 (mariadbd)
     Status: "Taking your SQL requests now..."
      Tasks: 14 (limit: 23249)

MariaDB listens only on the local socket and 127.0.0.1 by default, and nothing in this guide changes that, so port 3306 stays closed to the network.

Secure the MariaDB Installation

Set a root password for MariaDB. On Ubuntu 26.04, MariaDB uses unix_socket authentication by default, which means root can connect without a password when logged in as the system root user. Keep that method when you add a password: a plain IDENTIFIED BY replaces unix_socket, and every later sudo mariadb in this guide then fails with ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: NO). Open the MariaDB shell:

sudo mariadb

Inside the MariaDB shell, allow root in through the socket OR a password:

ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD('YourStrongPassword');
FLUSH PRIVILEGES;
EXIT;

Test the new password by logging in with it:

mariadb -u root -p

Enter your password at the prompt. If you get the MariaDB shell, the password works, and sudo mariadb still logs you in without one. We checked both on the test server, and a non-root shell running mariadb -u root without -p was refused with ERROR 1698, which is what you want.

Install PHP 8.5 with PHP-FPM

Ubuntu 26.04 includes PHP 8.5, which brings the pipe operator, clone() with properties, #[\NoDiscard] and a built-in URI extension. Install PHP-FPM along with the most commonly needed extensions:

sudo apt install -y php-fpm php-mysql php-cli php-curl php-gd php-mbstring php-xml php-zip

This pulls in php8.5-fpm and all the listed extensions. Verify the PHP version:

php --version

Every Ubuntu build of this package reports 8.5.4, patched or not, so this line confirms the install and nothing more. The security check comes after the module list:

PHP 8.5.4 (cli) (built: Oct 19 2026 14:35:18) (NTS)
Copyright (c) The PHP Group
Built by Ubuntu
Zend Engine v4.5.4, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.4, Copyright (c), by Zend Technologies

Confirm PHP-FPM is running:

systemctl status php8.5-fpm

The status should show “Ready to handle connections”:

● php8.5-fpm.service - The PHP 8.5 FastCGI Process Manager
     Loaded: loaded (/usr/lib/systemd/system/php8.5-fpm.service; enabled; preset: enabled)
     Active: active (running) since Tue 2026-04-14 00:26:22 UTC; 6s ago
       Docs: man:php-fpm8.5(8)
   Main PID: 13719 (php-fpm8.5)
     Status: "Ready to handle connections"
      Tasks: 3 (limit: 3522)

Check that the PHP-FPM socket exists. Nginx communicates with PHP-FPM through this Unix socket rather than TCP, which reduces overhead:

ls -la /run/php/php8.5-fpm.sock

You should see the socket file owned by www-data:

srw-rw---- 1 www-data www-data 0 Apr 14 00:26 /run/php/php8.5-fpm.sock

List the enabled PHP modules to confirm everything installed correctly:

php -m | grep -iE 'mysql|curl|gd|mbstring|xml|zip'

All requested extensions should appear:

curl
gd
libxml
mbstring
mysqli
mysqlnd
pdo_mysql
SimpleXML
xml
xmlreader
xmlwriter
zip

If any extension is missing from that list, reinstall it by its php- name from the install command above before you go further.

PHP2Shell and the Patched PHP-FPM Build

PHP2Shell is a pair of critical remote code execution bugs in PHP itself, CVE-2026-109618 and CVE-2026-109619, both rated CVSS v4.0 9.2 in PHP’s pre-announcement of 20 October 2026 and fixed upstream on 21 October in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 (see the PHP security advisory). Ubuntu 26.04 already ships the fix for the PHP 8.5 branch this stack uses, as package 8.5.4-0ubuntu1.5 in resolute-security, and the install step above pulls it. Don’t take that on trust, because PHP-FPM is about to answer requests from the internet through Nginx. Ubuntu backported the patch and kept the 8.5.4 number, so php --version reads the same on a vulnerable server. The package changelog tells them apart:

zgrep -E 'CVE-2026-10961[89]' /usr/share/doc/php8.5-common/changelog.Debian.gz

A patched build lists the backported patches and both CVE IDs. This is what our fresh install printed, and if yours matches, a new server is done with this section and can move on to the Nginx configuration:

    - debian/patches/CVE-2026-109618_109619-pre1.patch: ext/filter: fix use-
    - debian/patches/CVE-2026-109618_109619-pre2.patch: Fix GH-23204: use-after-
    - debian/patches/CVE-2026-109618_109619-pre3.patch: Fix GH-17399: iconv
    - debian/patches/CVE-2026-109618_109619-pre4.patch: Fix JIT vm_interrupt
    - debian/patches/CVE-2026-109618_109619-pre5.patch: Fix GH-21664:
    - debian/patches/CVE-2026-109618_109619.patch: Avoid state corruption via
    - CVE-2026-109618
    - CVE-2026-109619

No output means the build is older than 8.5.4-0ubuntu1.5. A minimized install has no /usr/share/doc changelogs, so there zgrep fails with a missing-file error instead. This version comparison works with or without the docs:

dpkg --compare-versions "$(dpkg-query -W -f='${Version}' php8.5-common)" ge 8.5.4-0ubuntu1.5 && echo patched || echo VULNERABLE

It printed patched on the fresh install. To stand in for an older LEMP server, we rolled the php8.5 packages back to the April build 8.5.4-0ubuntu1, and there the zgrep printed nothing and the comparison printed this:

VULNERABLE

On an existing server that reports VULNERABLE, upgrade the php8.5 packages in place. The glob only upgrades packages you already have, so it pulls in nothing new (Apache included). It doesn’t match libphp8.5-embed, so add that name if it’s installed:

sudo apt update
sudo apt install --only-upgrade -y "php8.5*"

The package restarted PHP-FPM by itself in our test, but restart it anyway, because that automatic restart isn’t guaranteed (a held or deferred restart, or a local policy, can skip it) and FPM workers keep running the old code until a new master process loads the patched library:

sudo systemctl restart php8.5-fpm

Since the version string never moves, prove the running service is newer than the upgrade. Compare the FPM start time with the last php8.5 entry in the dpkg logs, rotated copies included:

systemctl show -p ActiveEnterTimestamp php8.5-fpm
zgrep -hE ' (install|upgrade) php8.5-(common|fpm):' /var/log/dpkg.log* | sort | tail -1

On our test server FPM came up 28 seconds after dpkg upgraded php8.5-common, which is the order you want:

ActiveEnterTimestamp=Thu 2026-10-22 20:42:47 UTC
2026-10-22 20:42:19 upgrade php8.5-common:amd64 8.5.4-0ubuntu1 8.5.4-0ubuntu1.5

If the FPM start time is earlier than that dpkg line, restart again. Queue workers and other long-running PHP CLI processes under Supervisor or their own systemd unit keep the old code loaded too, so restart those as well. If you can’t upgrade right away, the PHP advisory lists partial mitigations, among them tighter limits on decoded request size, field, part and header counts and nesting depth, which belong at the web server or proxy in front of PHP. On the PHP side it names session.upload_progress.enabled=0 and, for PHP 8.5, fatal_error_backtraces=0, both set in php.ini or the FPM pool (never through ini_set or .user.ini), followed by a restart of every PHP worker. They only reduce exposure; the package upgrade is the fix. No third-party repository is needed for it either, and the Ondrej PPA has no 26.04 build to offer anyway.

Configure Nginx to Process PHP Files

By default, Nginx serves static files but does not know how to handle PHP. You need to edit the default server block to pass .php requests to PHP-FPM via the Unix socket.

Open the default site configuration:

sudo vi /etc/nginx/sites-available/default

Replace the entire contents with:

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    root /var/www/html;
    index index.php index.html index.htm;

    server_name _;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
    }

    location ~ /\.ht {
        deny all;
    }
}

The key line is fastcgi_pass unix:/run/php/php8.5-fpm.sock, which tells Nginx to forward PHP requests to the FPM process manager over a Unix socket. The snippets/fastcgi-php.conf include handles the FastCGI parameter mapping.

Test the configuration for syntax errors:

sudo nginx -t

A clean test returns:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Reload Nginx to apply the changes:

sudo systemctl reload nginx

A reload keeps existing connections open. If nginx -t had failed, the reload would not have applied anything, and the old configuration would keep serving.

Configure the Firewall

If UFW is installed (it is by default on Ubuntu 26.04), allow HTTP and HTTPS traffic. Nginx registers application profiles with UFW during installation.

sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable

Verify the firewall rules:

sudo ufw status

The output confirms both Nginx and SSH are allowed:

Status: active

To                         Action      From
--                         ------      ----
Nginx Full                 ALLOW       Anywhere
OpenSSH                    ALLOW       Anywhere
Nginx Full (v6)            ALLOW       Anywhere (v6)
OpenSSH (v6)               ALLOW       Anywhere (v6)

Allow OpenSSH before you run ufw enable, never after, because once UFW is active without that rule, every new SSH connection is refused and your next login fails. MariaDB’s port 3306 is not in the list, and it shouldn’t be.

Test PHP Processing

Create a PHP info page to confirm Nginx passes requests to PHP-FPM correctly:

echo '<?php phpinfo(); ?>' | sudo tee /var/www/html/info.php

Open http://10.0.1.50/info.php in your browser. You should see the full PHP information page showing PHP 8.5.4 with all the extensions you installed. The Server API line should read FPM/FastCGI, confirming that Nginx is routing through PHP-FPM rather than CGI.

Remove the info page after testing, since it exposes sensitive server details:

sudo rm /var/www/html/info.php

Do not leave phpinfo() pages on a public server. They hand an attacker the exact PHP build, loaded modules and file paths.

Create a Test Database

With all three components running, test the full stack by creating a database, populating it with sample data, and querying it from PHP. Log into MariaDB:

sudo mariadb

Create a database, a dedicated user, and a table with sample records:

CREATE DATABASE lemp_test;
CREATE USER 'lemp_user'@'localhost' IDENTIFIED BY 'YourStrongPassword';
GRANT ALL PRIVILEGES ON lemp_test.* TO 'lemp_user'@'localhost';
FLUSH PRIVILEGES;

USE lemp_test;
CREATE TABLE servers (
    id INT AUTO_INCREMENT PRIMARY KEY,
    hostname VARCHAR(100) NOT NULL,
    ip_address VARCHAR(45) NOT NULL,
    os VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO servers (hostname, ip_address, os) VALUES
    ('web01', '10.0.1.50', 'Ubuntu 26.04 LTS'),
    ('db01', '10.0.1.51', 'Ubuntu 26.04 LTS'),
    ('app01', '10.0.1.52', 'Rocky Linux 10');

SELECT * FROM servers;
EXIT;

The query returns the three sample records:

+----+----------+------------+-------------------+---------------------+
| id | hostname | ip_address | os                | created_at          |
+----+----------+------------+-------------------+---------------------+
|  1 | web01    | 10.0.1.50  | Ubuntu 26.04 LTS  | 2026-04-14 00:26:54 |
|  2 | db01     | 10.0.1.51  | Ubuntu 26.04 LTS  | 2026-04-14 00:26:54 |
|  3 | app01    | 10.0.1.52  | Rocky Linux 10    | 2026-04-14 00:26:54 |
+----+----------+------------+-------------------+---------------------+

The lemp_user account can only reach lemp_test, which is the least-privilege pattern to copy for each real application database.

Test PHP and MariaDB Together

Create a PHP script that connects to MariaDB and queries the test table. This validates the full LEMP chain: Nginx receives the request, passes it to PHP-FPM, and PHP queries MariaDB.

sudo vi /var/www/html/db_test.php

Add the following PHP code:

<?php
$host = "localhost";
$user = "lemp_user";
$pass = "YourStrongPassword";
$db   = "lemp_test";

$conn = new mysqli($host, $user, $pass, $db);
if ($conn->connect_error) {
    die("Connection failed: " . $conn->connect_error);
}
echo "<h2>LEMP Stack Database Test</h2>";
echo "<p>Connected to MariaDB successfully.</p>";

$result = $conn->query("SELECT * FROM servers");
echo "<table border='1'><tr><th>ID</th><th>Hostname</th><th>IP</th><th>OS</th><th>Created</th></tr>";
while ($row = $result->fetch_assoc()) {
    echo "<tr><td>{$row['id']}</td><td>{$row['hostname']}</td><td>{$row['ip_address']}</td><td>{$row['os']}</td><td>{$row['created_at']}</td></tr>";
}
echo "</table>";
$conn->close();
?>

Open http://10.0.1.50/db_test.php in a browser. The page should display “Connected to MariaDB successfully” along with the servers table data. Once verified, remove the test file:

sudo rm /var/www/html/db_test.php

That file holds the database password in plain text, so delete it as soon as the page loads.

All Services at a Glance

Confirm all three LEMP components are active and enabled to start on boot:

systemctl is-active nginx mariadb php8.5-fpm

All three should return active:

active
active
active

Check that they are enabled for boot:

systemctl is-enabled nginx mariadb php8.5-fpm

Each should return enabled.

Nginx 1.28.3 MariaDB 11.8.6 PHP 8.5.4 active on Ubuntu 26.04 LTS
LEMP stack versions and service status on Ubuntu 26.04 LTS (captured at first publication, before the PHP2Shell security build)

The screenshot predates the PHP2Shell update, so use the changelog check from the PHP section, not this version line, to judge your own server.

Performance Tuning for Production

The default settings work for testing, but production workloads need tuning. Here are the key parameters for each component.

Nginx Worker Tuning

The default worker_processes auto in /etc/nginx/nginx.conf matches the number of CPU cores, which is correct for most cases. On a 2-core server, Nginx spawns 2 workers. The worker_connections directive defaults to 768, which limits each worker to 768 simultaneous connections.

For a server handling more traffic, increase worker_connections in the events block:

sudo vi /etc/nginx/nginx.conf

Find the events block and adjust:

events {
    worker_connections 2048;
    multi_accept on;
}

With multi_accept on, each worker accepts all new connections at once instead of one at a time. On a 2-core server with 2048 connections per worker, Nginx can handle roughly 4096 concurrent connections.

PHP-FPM Pool Sizing

The default PHP-FPM pool at /etc/php/8.5/fpm/pool.d/www.conf uses pm = dynamic with pm.max_children = 5. For a server with 4 GB RAM, a rough formula for max_children is:

max_children = (Total RAM - System overhead) / Average PHP process size

Each PHP-FPM worker typically uses 30 to 50 MB. On a 4 GB server with ~1 GB reserved for the OS, MariaDB, and Nginx, that leaves about 3 GB for PHP. At 40 MB per process, you can set pm.max_children = 75. Adjust the pool config:

sudo vi /etc/php/8.5/fpm/pool.d/www.conf

Update the process manager settings:

pm = dynamic
pm.max_children = 75
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500

The pm.max_requests = 500 setting recycles workers after 500 requests, which prevents memory leaks from accumulating. Restart PHP-FPM after changes:

sudo systemctl restart php8.5-fpm

If the restart fails, sudo php-fpm8.5 -t names the line it rejects. Raise pm.max_children only as far as RAM allows, because the pool can grow to that many workers at 30 to 50 MB each, and the OOM killer picks its own victims when it runs out.

MariaDB Buffer Pool

The InnoDB buffer pool is the single most impactful MariaDB setting. It caches table data and indexes in memory, reducing disk I/O. The default is 128 MB, which is too small for anything beyond trivial databases.

Set it to about 50 to 70 percent of available RAM dedicated to MariaDB. On a 4 GB server where MariaDB is the primary consumer, 1 to 2 GB is reasonable:

sudo vi /etc/mysql/mariadb.conf.d/50-server.cnf

Under the [mariadbd] section, add or modify:

innodb_buffer_pool_size = 1G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2

Setting innodb_flush_log_at_trx_commit = 2 gives a significant write performance boost at the cost of losing up to one second of transactions in a crash. For most web applications, this tradeoff is acceptable. Restart MariaDB to apply:

sudo systemctl restart mariadb

You can verify the buffer pool size took effect with:

mariadb -u root -p -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"

The value is in bytes, so 1G reads as 1073741824, which is what our test server returned. If you still see 134217728 (the 128 MB default), the lines went in under the wrong section header and MariaDB ignored them.

Where to Go from Here

With LEMP running, you can deploy PHP applications like WordPress, Laravel, or custom frameworks. For container-based workflows, see our Docker CE installation guide for Ubuntu 26.04. If you need SSL with Let’s Encrypt on Nginx, that is covered separately. For PostgreSQL as an alternative to MariaDB, see our PostgreSQL 18 guide. If you prefer the Apache-based LAMP stack instead, we have a LAMP stack guide for Ubuntu 26.04 as well.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App FileBrowser Quantum: OIDC, LDAP and Multi-Source File Manager Containers FileBrowser Quantum: OIDC, LDAP and Multi-Source File Manager

Leave a Comment

Press ESC to close