
Anyone who has spun up a fresh Ubuntu server and typed apt install nginx mysql-server php knows the LEMP stack looks deceptively simple on paper. Nginx serves the pages, MySQL stores the data, PHP glues the logic together, and Linux runs the whole show. In practice, getting this stack production-ready on Ubuntu 26.04 LTS (codenamed Resolute Raccoon) involves a handful of decisions that don’t show up in the quick-start tutorials: which PHP branch to actually use, how PHP-FPM pools should be sized for your RAM budget, whether MySQL 8.4’s authentication defaults will break your existing application, and how to keep Nginx from silently swallowing 502 errors during traffic spikes.
Ubuntu 26.04 LTS shipped on April 23, 2026, and it’s a meaningful jump from 24.04 Noble Numbat. It runs on kernel 7.0, ships PostgreSQL 18 and MariaDB 12 in the archive, and defaults to MySQL 8.4.8, which is the current MySQL LTS branch. Nginx sits at 1.28.2 in the repos, and PHP has moved to 8.5 as the default archive package, a jump from 24.04’s PHP 8.3. That PHP jump alone trips up a lot of admins migrating older WordPress installs or legacy Laravel apps that were pinned to 8.1 or 8.2.
This guide walks through a complete LEMP installation on 26.04, but it goes further than the standard checklist. It covers the configuration choices that actually matter once real traffic hits the server: PHP-FPM pool tuning, MySQL memory allocation based on available RAM, firewall rules that don’t lock you out over SSH, and the troubleshooting steps you’ll actually need when Nginx returns a blank 502 page at 2 a.m. If you’re deploying WordPress, a Laravel API, or a custom PHP application on a VPS or bare-metal box, this is the version of the guide written from the perspective of someone who has broken production more than once figuring this stuff out.
What LEMP Actually Means and Why the Stack Choice Matters
LEMP stands for Linux, Nginx (pronounced “Engine-X,” hence the E), MySQL, and PHP. It’s the event-driven cousin of the older LAMP stack, where Apache handles connections with a process-per-request or thread-per-request model. Nginx instead uses an asynchronous, event-driven architecture that handles thousands of concurrent connections with a fraction of the memory footprint. That difference isn’t academic. On a 2GB VPS serving a moderately busy WordPress site, Apache with mod_php can chew through available RAM under concurrent load, while Nginx with PHP-FPM keeps memory usage predictable because PHP processing is offloaded to a separate pool of worker processes that Nginx talks to over a socket.
The tradeoff is configuration complexity. Nginx doesn’t read .htaccess files, doesn’t process PHP natively, and requires explicit location blocks for URL rewriting rules that Apache handles more implicitly. For anyone coming from a WordPress-on-Apache background, this is usually the first surprise: your pretty permalinks won’t work until you write the rewrite rules yourself.
Before You Start: Server Prep and Prerequisites
You’ll need a server running Ubuntu 26.04 LTS with sudo access, either a fresh cloud instance from a provider like DigitalOcean, Hetzner, or Vultr, or a bare-metal box. A minimum of 1 vCPU and 1GB RAM works for testing, but for anything serving real traffic, budget at least 2GB RAM. MySQL alone can comfortably use 512MB to 1GB depending on buffer pool settings, and PHP-FPM pools add up quickly under concurrency.
Start every fresh deployment the same way, regardless of what you’re installing on top:
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget gnupg2 software-properties-common ca-certificates lsb-release ufw
Confirm you’re actually on 26.04 before going further, because copy-pasting steps meant for 24.04 into a 26.04 box (or vice versa) is a common source of package version mismatches:
lsb_release -a
You should see Ubuntu 26.04 LTS with the codename Resolute Raccoon. If the output shows something else, stop here and double check your provider’s image selection.
Step 1: Installing Nginx
Ubuntu’s default repository carries Nginx 1.28.2 for 26.04, which is current enough for production use without needing a third-party PPA. Unless you specifically need the Nginx mainline branch with experimental modules, the archive package is the sane default.
sudo apt install -y nginx
Enable it to start on boot and verify it’s actually running:
sudo systemctl enable --now nginx
sudo systemctl status nginx
Test it by hitting your server’s IP address in a browser. You should see the default “Welcome to nginx” page. If nothing loads, it’s almost always a firewall problem, not an Nginx problem, so don’t waste time restarting the service repeatedly before checking ufw status.
A quick but important habit: check the Nginx configuration syntax before reloading, every single time you make a change.
sudo nginx -t
Skipping this step is how a mistyped semicolon in a config file takes down an entire production site at the worst possible moment. It costs two seconds and it’s non-negotiable.
Step 2: Installing MySQL 8.4 LTS
Ubuntu 26.04 ships MySQL 8.4.8, which is the current MySQL LTS release line, meaning it receives security patches through point releases without introducing breaking schema changes mid-cycle. This is a meaningful shift from 24.04, which shipped MySQL 8.0. If you’re migrating an existing application, be aware that MySQL 8.4 removed some deprecated features that were still functional (if warned against) in 8.0, including certain legacy authentication plugins.
Install it:
sudo apt install -y mysql-server
Once installed, run the security script. This is the step almost everyone skips on test servers and almost everyone regrets skipping on production servers:
sudo mysql_secure_installation
You’ll be prompted to set a validation policy for passwords, set (or confirm) the root password, remove anonymous users, disallow remote root login, and remove the test database. Say yes to all of it on a production box. The only debatable one is the password validation strictness level; MEDIUM is a reasonable default for most teams.
Verify the service and check the version:
sudo systemctl status mysql
mysql --version
Creating a Dedicated Database and User
Never run your application against the root MySQL account. It’s a bad habit that turns into a security incident eventually. Create a scoped user instead:
sudo mysql
CREATE DATABASE app_production;
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'use_a_strong_password_here';
GRANT ALL PRIVILEGES ON app_production.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Scoping the grant to a single database, rather than *.*, limits the blast radius if that application’s credentials ever leak through a code vulnerability or a misconfigured .env file committed to a public repo, which happens more often than anyone likes to admit.
Step 3: Installing PHP 8.5 and PHP-FPM
This is where 26.04 diverges most sharply from earlier LTS releases. The default archive PHP branch is now 8.5, up from 8.3 on 24.04 and 8.1 on 22.04. PHP 8.5 brings the pipe operator, the #[\NoDiscard] attribute, native array_first() and array_last() functions, and persistent cURL handles, but more importantly for a sysadmin, it also means some older applications built against PHP 7.x conventions or relying on now-removed functions will need testing before you point a production domain at this stack.
Install PHP-FPM along with the extensions most PHP applications actually need:
sudo apt install -y php-fpm php-mysql php-curl php-gd php-mbstring php-xml php-zip php-bcmath php-intl
If your application specifically requires an older PHP branch, like 8.2 or 8.3, for compatibility reasons, don’t fight it. Add Ondřej Surý’s repository, which publishes builds for 26.04:
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install -y php8.2-fpm php8.2-mysql php8.2-curl php8.2-gd php8.2-mbstring php8.2-xml
Just be aware the Sury PPA sometimes takes a few weeks after a new Ubuntu release to publish builds targeting it, so on launch day of a new LTS, don’t be surprised if it’s briefly unavailable.
Confirm PHP-FPM is running and check which socket it’s listening on, since this detail matters for the Nginx config in the next step:
sudo systemctl status php8.5-fpm
sudo ls /run/php/
You should see something like php8.5-fpm.sock in that directory.
Step 4: Configuring Nginx to Process PHP
This is the step where LEMP setups most commonly go wrong, because the default Nginx config doesn’t know PHP exists. Create a new server block instead of editing the default one directly, since that keeps your setup portable across multiple sites later.
sudo nano /etc/nginx/sites-available/yourdomain.com
A solid baseline configuration looks like this:
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/yourdomain.com/html;
index index.php index.html;
access_log /var/log/nginx/yourdomain.com.access.log;
error_log /var/log/nginx/yourdomain.com.error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
location ~ /\.ht {
deny all;
}
client_max_body_size 32M;
}
The try_files directive matters more than it looks. It’s what allows clean URLs and permalinks to work for frameworks like WordPress, Laravel, or any application using front-controller routing. Without it, every URL other than the homepage returns a 404.
Create the document root, set correct ownership, and drop in a test file:
sudo mkdir -p /var/www/yourdomain.com/html
sudo chown -R www-data:www-data /var/www/yourdomain.com
echo "<?php phpinfo(); ?>" | sudo tee /var/www/yourdomain.com/html/index.php
Enable the site and disable the default one to avoid conflicts:
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
sudo unlink /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Visit your domain or IP in a browser. A PHP info page confirms Nginx and PHP-FPM are talking to each other correctly. Delete that index.php file immediately afterward; leaving phpinfo() exposed is a minor but real information disclosure risk that automated scanners actively look for.
Real-World Tuning: What the Tutorials Skip
PHP-FPM Pool Sizing
The default www.conf pool settings are conservative and rarely match your actual server capacity. On a 2GB RAM VPS running Nginx, MySQL, and PHP together, a common mistake is leaving pm.max_children at its default, which can either starve PHP under load or, worse, spawn enough workers to trigger the OOM killer.
Edit /etc/php/8.5/fpm/pool.d/www.conf and calculate pm.max_children based on available memory divided by average process size (typically 40-80MB for a WordPress-style app):
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
Restart PHP-FPM after any pool changes:
sudo systemctl restart php8.5-fpm
MySQL Memory Allocation
By default, MySQL’s innodb_buffer_pool_size is set conservatively for compatibility, not performance. On a dedicated database server, this value should generally sit around 60-70% of available RAM. Edit /etc/mysql/mysql.conf.d/mysqld.cnf:
[mysqld]
innodb_buffer_pool_size = 1G
innodb_log_file_size = 256M
max_connections = 150
An undersized buffer pool means MySQL constantly re-reads data from disk instead of keeping it cached in memory, which shows up as slow query times that seem to appear randomly under load rather than consistently.
Enabling HTTP/2 and TLS
Traffic without HTTPS isn’t a serious option for anything public-facing in 2026. Use Certbot for a free, auto-renewing certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Certbot rewrites your server block to redirect HTTP to HTTPS and adds the certificate paths automatically. Confirm auto-renewal is scheduled:
sudo systemctl status certbot.timer
Firewall and Security Hardening
Enable UFW and only open what’s necessary. This is one of those steps where the order of operations genuinely matters, since allowing SSH before enabling the firewall prevents accidentally locking yourself out of a remote box:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status verbose
A few additional hardening steps worth doing on any production LEMP box:
- Disable root SSH login and password authentication in
/etc/ssh/sshd_config, relying on key-based auth exclusively. - Run
sudo mysql_secure_installation(already covered above) and never expose port 3306 externally unless a remote application genuinely needs it, in which case restrict it to specific IPs via UFW. - Keep automatic security updates enabled with
unattended-upgrades, since Nginx, PHP, and MySQL CVEs get patched regularly and a forgotten server is a vulnerable one. - Set
expose_php = Offinphp.inito stop PHP from advertising its version in response headers, which reduces the information available to automated attack scripts probing for known vulnerabilities.
Troubleshooting Common LEMP Issues
502 Bad Gateway errors. This almost always means Nginx can’t reach PHP-FPM. Check that the socket path in your server block matches the actual running socket in /run/php/, and confirm PHP-FPM is actually running with systemctl status php8.5-fpm. A mismatched PHP version number between the Nginx config and the installed FPM service is the single most common cause.
White screen with no error message. PHP is likely failing silently because display_errors is off, which is correct for production but unhelpful for debugging. Check /var/log/php8.5-fpm.log and your Nginx error log instead of guessing.
MySQL “Access denied for user” errors after migration. MySQL 8.4 uses caching_sha2_password as the default authentication plugin, and some older application drivers still expect mysql_native_password. If an older app can’t connect, alter the user explicitly: ALTER USER 'app_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'; then flush privileges.
Site loads but uploads or large forms fail. Check three settings at once: Nginx’s client_max_body_size, and PHP’s upload_max_filesize and post_max_size in /etc/php/8.5/fpm/php.ini. All three need to agree, and it’s easy to fix one and forget the other two.
High load average with low CPU usage. This pattern usually points to disk I/O wait, often from MySQL swapping due to an undersized buffer pool, or from slow query logging left enabled unnecessarily on a busy database. Check with iostat -x 1 and mysqladmin processlist before assuming it’s a CPU problem.
Best Practices for Production Deployments
Run a staging environment that mirrors production package versions, especially PHP branch versions, before pushing updates. A PHP 8.5 deprecation warning that’s harmless in isolation can break a plugin dependency chain you didn’t know existed.
Set up log rotation for Nginx and PHP-FPM logs explicitly, since unmanaged logs on a busy server can fill a disk faster than expected, and a full disk takes MySQL down hard.
Monitor with something lightweight rather than nothing at all. Even a basic combination of htop, netdata, or a Prometheus/Grafana stack catches memory leaks and traffic anomalies before they become outages.
Back up MySQL with mysqldump or, for larger databases, physical backup tooling, and actually test the restore process periodically. A backup nobody has ever restored is a backup you don’t actually have.