How to Install a LEMP Stack on Ubuntu 26.04 From Scratch

LEMP is the same idea as LAMP with one swap: nginx (pronounced “engine-x”, hence the E) in place of Apache. That one change affects more than people expect. Nginx doesn’t run PHP at all, it hands every PHP request to PHP-FPM over a socket. It doesn’t read .htaccess files. It’s picky about config syntax in a way that can quietly leave your old config running. And Ubuntu 26.04 adds a couple of surprises of its own.

This guide builds the whole stack from a clean Ubuntu 26.04.1 LTS machine. Every command below was run for this post, and the output is what came back, including the parts that went wrong. By the end you’ll have nginx serving a proper server block, PHP 8.5 through PHP-FPM, MySQL 8.4 with a dedicated app user, and a test page proving all three work together.

What Ubuntu 26.04 gives you

All from the standard Ubuntu repositories, no PPAs:

Layer Package Version on Ubuntu 26.04
Linux Ubuntu 26.04.1 LTS Resolute
Nginx nginx 1.28.3
MySQL mysql-server 8.4.11 (LTS)
PHP php-fpm 8.5.4

You need an Ubuntu 26.04 server with a sudo user. 1 GB of RAM works for a small site, 2 GB is more comfortable once MySQL settles in. If any of the basic commands here are unfamiliar, keep our essential Linux commands list open in another tab.

Step 1: Install nginx

sudo apt update
sudo apt install -y nginx

nginx -v
nginx version: nginx/1.28.3 (Ubuntu)

systemctl is-active nginx; systemctl is-enabled nginx
active
enabled

curl -sI http://localhost | head -3
HTTP/1.1 200 OK
Server: nginx/1.28.3 (Ubuntu)
Date: Wed, 30 Sep 2026 04:56:23 GMT

Running and enabled at boot straight after install. Notice the Server header is telling the world your exact nginx version. We’ll fix that in step 8, and Ubuntu 26.04 has a small twist there. If you want to understand what active and enabled actually mean, our systemd service management guide covers it.

Step 2: Open the firewall

Nginx registers its own UFW profiles when it installs:

sudo ufw app list
Available applications:
  Nginx Full
  Nginx HTTP
  Nginx HTTPS
  Nginx QUIC

Nginx Full opens 80 and 443. Nginx QUIC is new, it’s for HTTP/3 over UDP 443, and you can ignore it unless you’re actually enabling HTTP/3. If you’re logged in over SSH, run sudo ufw allow OpenSSH before enabling the firewall, or you’ll cut your own connection.

sudo ufw allow 'Nginx Full'
Rules updated
Rules updated (v6)

sudo ufw --force enable
Firewall is active and enabled on system startup

sudo ufw status
Status: active

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

Step 3: Install MySQL 8.4

sudo apt install -y mysql-server
mysql --version
mysql  Ver 8.4.11-0ubuntu0.26.04.1 for Linux on x86_64 ((Ubuntu))

Then secure it. I answered y to set up password validation, 1 for the MEDIUM policy, and y to everything else. These are the result lines from that run:

sudo mysql_secure_installation
...
Skipping password set for root as authentication with auth_socket is used by default.
Remove anonymous users? (Press y|Y for Yes, any other key for No) : Success.
Disallow root login remotely? (Press y|Y for Yes, any other key for No) : Success.
Success.
Success.
Reload privilege tables now? (Press y|Y for Yes, any other key for No) : Success.
All done!

That “Skipping password set for root” line is normal on Ubuntu. MySQL’s root account uses auth_socket, which means it trusts your Linux user instead of a password. sudo mysql gets you in as root. mysql -u root -p won’t, and that’s by design. Leave it that way.

Two MySQL 8.4 changes catch people following older LEMP guides. The mysql_native_password plugin is disabled by default, so CREATE USER ... IDENTIFIED WITH mysql_native_password fails with ERROR 1524: Plugin 'mysql_native_password' is not loaded. And the old default_authentication_plugin variable no longer exists. New users get caching_sha2_password, which PHP’s mysqlnd driver handles fine, so just don’t specify a plugin at all.

Step 4: Create a database and app user

sudo mysql -e "CREATE DATABASE lemp_demo CHARACTER SET utf8mb4;
  CREATE USER 'lemp_app'@'localhost' IDENTIFIED BY 'Lemp#Demo2026!';
  GRANT ALL PRIVILEGES ON lemp_demo.* TO 'lemp_app'@'localhost';"

Use your own strong password. The MEDIUM policy from step 3 rejects anything without mixed case, a number and a symbol. Now some test data, as the new user, so we know its permissions work:

mysql -u lemp_app -p lemp_demo
CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(120) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO posts (title) VALUES
  ('Nginx is serving pages'),
  ('MySQL 8.4 is storing data'),
  ('PHP-FPM 8.5 is running the code');
mysql -t -u lemp_app -p lemp_demo -e 'SELECT * FROM posts'
+----+---------------------------------+---------------------+
| id | title                           | created_at          |
+----+---------------------------------+---------------------+
|  1 | Nginx is serving pages          | 2026-09-30 09:56:35 |
|  2 | MySQL 8.4 is storing data       | 2026-09-30 09:56:35 |
|  3 | PHP-FPM 8.5 is running the code | 2026-09-30 09:56:35 |
+----+---------------------------------+---------------------+

Step 5: Install PHP-FPM 8.5

Nginx can’t run PHP itself, so install php-fpm directly. Don’t install the plain php package. On a fresh Ubuntu 26.04 box it resolves to libapache2-mod-php8.5 first, and that drags in Apache, which then grabs port 80 and fights nginx for it.

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

php -v | head -1
PHP 8.5.4 (cli) (built: Sep  2 2026 14:42:18) (NTS)

dpkg -l | grep -cE 'apache2|libapache2-mod-php'
0

systemctl is-active php8.5-fpm; ls -l /run/php/php8.5-fpm.sock
active
srw-rw---- 1 www-data www-data 0 Sep 30 09:56 /run/php/php8.5-fpm.sock

Zero Apache packages, good. That socket file is the important bit: it’s the door nginx knocks on for every PHP request, and it’s owned by www-data, the same user nginx runs as.

Step 6: Create a server block

Server blocks are nginx’s version of Apache virtual hosts: one file per site. I’m using lemp.test, replace it with your domain. The web root is a public folder inside the site directory, so there’s somewhere one level up for files that must never be served, like database credentials.

sudo mkdir -p /var/www/lemp.test/public
sudo chown -R $USER:www-data /var/www/lemp.test
sudo chmod 2750 /var/www/lemp.test
sudo chmod 2755 /var/www/lemp.test/public
ls -ld /var/www/lemp.test /var/www/lemp.test/public
drwxr-s--- 3 asif www-data 4096 Sep 30 09:55 /var/www/lemp.test
drwxr-sr-x 2 asif www-data 4096 Sep 30 09:55 /var/www/lemp.test/public

The 2 at the front of those modes is the setgid bit (the s in the listing). Every file you create in there inherits the www-data group, so PHP-FPM can read it. Skip this and you’ll eventually create a file PHP can’t read, and get a baffling “Permission denied” error. For anything finer-grained, Linux ACLs are the tool.

Now the server block, /etc/nginx/sites-available/lemp.test:

server {
    listen 80;
    listen [::]:80;
    server_name lemp.test www.lemp.test;

    root /var/www/lemp.test/public;
    index index.php index.html;

    access_log /var/log/nginx/lemp.test-access.log;
    error_log  /var/log/nginx/lemp.test-error.log;

    client_max_body_size 64M;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

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

    location ~* .(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
        expires 30d;
        access_log off;
    }

    location ~ /.(?!well-known) {
        deny all;
    }
}

What each part is doing:

  • try_files $uri $uri/ /index.php?$query_string serves the file if it exists, otherwise hands the request to index.php. That’s what WordPress, Laravel and most PHP frameworks expect, and it’s how nginx replaces the .htaccess rewrite rules those apps ship with.
  • location ~ .php$ sends PHP files to the FPM socket.
  • The static files block tells browsers to cache CSS, JS and images for 30 days and keeps them out of the access log.
  • deny all on dot files blocks .env, .git and friends, while still allowing .well-known, which Let’s Encrypt needs.
  • client_max_body_size is the upload limit. More on that in step 7, because the default will bite you.

The fastcgi-php.conf snippet does more than it looks like. Here’s what Ubuntu ships:

grep -vE '^#|^$' /etc/nginx/snippets/fastcgi-php.conf
fastcgi_split_path_info ^(.+?.php)(/.*)$;
try_files $fastcgi_script_name =404;
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;
fastcgi_index index.php;
include fastcgi.conf;

That try_files $fastcgi_script_name =404 line is a security fix that older guides handled by editing cgi.fix_pathinfo in php.ini. The classic attack is uploading an “image” with PHP inside it, then requesting /uploads/photo.jpg/x.php so PHP executes the image. With this snippet, nginx checks that the script really exists first:

curl -s -o /dev/null -w '%{http_code}n' -H 'Host: lemp.test' http://localhost/note.txt/x.php
404

A 404, not an executed text file. So you don’t need to touch cgi.fix_pathinfo (it’s still commented out at its default in /etc/php/8.5/fpm/php.ini) as long as every PHP location includes this snippet. If you write your own PHP location from scratch without it, you lose that protection.

Enable the site, remove the default one, and test:

sudo ln -s /etc/nginx/sites-available/lemp.test /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
ls -l /etc/nginx/sites-enabled/
total 0
lrwxrwxrwx 1 root root 36 Sep 30 09:56 lemp.test -> /etc/nginx/sites-available/lemp.test

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

sudo systemctl reload nginx

Why nginx -t isn’t optional

I learned this one the annoying way while building this server. If your config has an error, systemctl reload fails, but nginx keeps running with the old config. It stays active, the site stays up, and nothing you just changed takes effect:

sudo systemctl reload nginx; echo exit=$?
Job for nginx.service failed.
See "systemctl status nginx.service" and "journalctl -xeu nginx.service" for details.
exit=1

systemctl is-active nginx
active

sudo nginx -t
2026/09/30 09:58:12 [emerg] 34818#34818: "server_tokens" directive is duplicate in /etc/nginx/conf.d/broken.conf:1
nginx: configuration file /etc/nginx/nginx.conf test failed

In a script, or when you’re reloading and not reading the output, that failure is easy to miss. For a good ten minutes my test results were “wrong” because every reload was silently failing. So make it a habit: sudo nginx -t && sudo systemctl reload nginx, always together.

Step 7: Connect PHP to MySQL

Credentials go in /var/www/lemp.test/config.php, one level above the web root:

<?php
// Outside the web root: Nginx can never serve this file.
return [
    'dsn'  => 'mysql:host=localhost;dbname=lemp_demo;charset=utf8mb4',
    'user' => 'lemp_app',
    'pass' => 'Lemp#Demo2026!',
];
chmod 640 /var/www/lemp.test/config.php
ls -l /var/www/lemp.test/config.php
-rw-r----- 1 asif www-data 205 Sep 30 09:56 /var/www/lemp.test/config.php

Group www-data without any extra step, thanks to the setgid bit from step 6. Then a test page at public/dbtest.php that connects with PDO and prints versions plus the rows from step 4:

<?php
$config = require __DIR__ . '/../config.php';

try {
    $pdo = new PDO($config['dsn'], $config['user'], $config['pass'], [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);
    $mysql = $pdo->query('SELECT VERSION()')->fetchColumn();
    $rows  = $pdo->query('SELECT id, title, created_at FROM posts ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
    $ok = true;
} catch (PDOException $e) {
    $ok = false;
    $error = $e->getMessage();
}
// ...then print PHP_VERSION, PHP_SAPI, $mysql and the rows as HTML

Before that, confirm PHP is really running through FPM with a throwaway phpinfo file:

echo '<?php phpinfo();' > /var/www/lemp.test/public/info.php
curl -s -H 'Host: lemp.test' http://localhost/info.php | grep -o 'Server API </td><td class="v">[^<]*' | sed 's/<[^>]*>/ /g'
Server API   FPM/FastCGI

And the database page:

curl -s -H 'Host: lemp.test' http://localhost/dbtest.php | sed -n '/<h1>/,/</table>/p' | sed 's/<[^>]*>/ /g' | tr -s ' ' | grep -v '^ *$'
 LEMP stack check
 Nginx, PHP and MySQL are talking to each other.
 Operating system Ubuntu 26.04.1 LTS
 Web server nginx/1.28.3
 PHP version 8.5.4
 PHP handler fpm-fcgi
 MySQL version 8.4.11-0ubuntu0.26.04.1
 Database / user lemp_demo / lemp_app
 id title created_at
 1 Nginx is serving pages 2026-09-30 09:56:35
 2 MySQL 8.4 is storing data 2026-09-30 09:56:35
 3 PHP-FPM 8.5 is running the code 2026-09-30 09:56:35
LEMP stack test page on Ubuntu 26.04 showing nginx 1.28.3, PHP 8.5.4 via fpm-fcgi and MySQL 8.4.11 with three rows read from the database
The test page in a browser: nginx 1.28.3, PHP 8.5.4 through PHP-FPM, MySQL 8.4.11, three rows read back through PDO.

Step 8: Fix the upload limit before it fixes you

Nginx’s default upload limit is 1 MB. Not 1 GB. One megabyte. The first time someone uploads a phone photo or a theme zip, this happens. I commented out client_max_body_size to show it with the default, then posted a 5 MB file:

curl -s -o /dev/null -w '%{http_code}n' -H 'Host: lemp.test' -F 'file=@/tmp/5mb.bin' http://localhost/dbtest.php
413

sudo tail -n 1 /var/log/nginx/lemp.test-error.log
2026/09/30 09:58:10 [error] 34722#34722: *35 client intended to send too large body: 5000211 bytes, client: ::1, server: lemp.test, request: "POST /dbtest.php HTTP/1.1", host: "lemp.test"

413 Request Entity Too Large, and PHP never even saw the request. With client_max_body_size 64M back in the server block, the same upload returns 200.

But that’s only half of it. PHP has its own limits, and they’re even smaller:

php-fpm8.5 -i | grep -E '^(upload_max_filesize|post_max_size) '
post_max_size => 8M => 8M
upload_max_filesize => 2M => 2M

Raise nginx and forget PHP, and nginx happily accepts a 5 MB upload that PHP then silently drops, because it’s over 2M. No error page, the file just isn’t there. Set both. Put PHP’s settings in their own file so package upgrades never overwrite them:

sudo nano /etc/php/8.5/fpm/conf.d/99-lemp.ini
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
expose_php = Off
sudo systemctl restart php8.5-fpm
php-fpm8.5 -i | grep -E '^(upload_max_filesize|post_max_size) '
post_max_size => 64M => 64M
upload_max_filesize => 64M => 64M

Keep the nginx and PHP numbers matched. php-fpm8.5 -i is worth remembering, too: it shows the values FPM actually uses, which can differ from the command line php, because the two read separate config folders under /etc/php/8.5/.

Step 9: Hardening, and a Ubuntu 26.04 surprise

Hide the nginx version

Most guides say “add server_tokens off;“. Drop it into a file in conf.d on Ubuntu 26.04, though, and nginx refuses to start:

2026/09/30 09:56:55 [emerg] 34333#34333: "server_tokens" directive is duplicate in /etc/nginx/conf.d/hardening.conf:2
nginx: configuration file /etc/nginx/nginx.conf test failed

Because Ubuntu’s own nginx.conf now sets it already:

grep -n 'server_tokens' /etc/nginx/nginx.conf
22:	server_tokens build; # Recommended practice is to turn this off

build is even chattier than the old default. Change that line itself instead of adding a new one:

sudo sed -i 's/server_tokens build;/server_tokens off;/' /etc/nginx/nginx.conf
grep -n 'server_tokens' /etc/nginx/nginx.conf
22:	server_tokens off; # Recommended practice is to turn this off

Add basic security headers

These are fine to put in their own file, since nothing sets them by default. Create /etc/nginx/conf.d/security-headers.conf:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
sudo nginx -t && sudo systemctl reload nginx
curl -sI -H 'Host: lemp.test' http://localhost/dbtest.php | grep -iE '^(server|x-|referrer)'
Server: nginx
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin

One catch with add_header: if a location block sets any header of its own, it stops inheriting these. So if you add headers inside a location later, repeat these three there too.

Look closely at the screenshot in step 7, by the way. The test page still says nginx/1.28.3 even with server_tokens off. That’s because fastcgi.conf passes the version to PHP as SERVER_SOFTWARE, separately from the response header. Visitors never see it unless your app prints it, but if you’d rather PHP never knows, add fastcgi_param SERVER_SOFTWARE nginx; after the include line in the PHP location.

Turn on gzip for more than HTML

Ubuntu’s nginx.conf has gzip on, but the gzip_types line is commented out, so only HTML gets compressed. Uncomment it (line 54 on my install) to cover CSS, JS and JSON:

grep -n 'gzip_types' /etc/nginx/nginx.conf
54:	gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' -H 'Host: lemp.test' http://localhost/style.css | grep -iE '^content-(type|encoding)'
Content-Type: text/css
Content-Encoding: gzip

curl -s -o /dev/null -w '%{size_download} bytes over the wiren' -H 'Accept-Encoding: gzip' -H 'Host: lemp.test' http://localhost/style.css
584 bytes over the wire

That stylesheet is 12,092 bytes on disk. 584 over the wire. Tiny files under about 20 bytes aren’t compressed at all, which is deliberate, so don’t test with a one-line file and conclude gzip is broken (I did exactly that).

Check the protections actually work

curl -s -o /dev/null -w '%{http_code}n' -H 'Host: lemp.test' http://localhost/.env
403

curl -sI -H 'Host: lemp.test' http://localhost/style.css | grep -iE '^(expires|cache-control)'
Expires: Fri, 30 Oct 2026 04:56:55 GMT
Cache-Control: max-age=2592000

The .env file is blocked and static files get a 30-day cache header. Then delete the phpinfo file, it has no business on a live server:

rm /var/www/lemp.test/public/info.php

For HTTPS, once a real domain points at the server, sudo apt install certbot python3-certbot-nginx and sudo certbot --nginx -d example.com get you a Let’s Encrypt certificate. I didn’t run that on this test VM, since it needs a public domain, but our Let’s Encrypt, Certbot and nginx guide walks through it with real output, including renewal. Beyond this post, the server hardening checklist and Fail2ban are the next two things worth doing.

Step 10: Size the PHP-FPM pool

This is the setting that decides how many PHP requests you can handle at once, and the default is small:

grep -E '^pm(.max_children|.start_servers|.min_spare_servers|.max_spare_servers)? ' /etc/php/8.5/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

Five workers. A sixth simultaneous PHP request waits in a queue, and on a busy WordPress site that’s where “the site feels slow” comes from, even though the server’s CPU is idle. Don’t just set it to 100, though, because each worker uses real memory. Measure yours:

ps -C php-fpm8.5 -o rss= | awk '{s+=$1; n++} END {printf "%d processes, avg %.1f MB eachn", n, s/n/1024}'
3 processes, avg 26.0 MB each

free -m | head -2
               total        used        free      shared  buff/cache   available
Mem:           15702        1287       13463          14        1179       14415

26 MB is an idle worker running a tiny test page. A real WordPress or Laravel worker is usually 40 to 80 MB, so measure again once your app is running. The rough formula: take the RAM you can spare for PHP after MySQL and the OS, divide by the per-worker size, and set pm.max_children a little under that. On a 2 GB server with about 1 GB left for PHP and 60 MB workers, that’s around 15. Change it in /etc/php/8.5/fpm/pool.d/www.conf, then sudo systemctl restart php8.5-fpm.

Troubleshooting LEMP errors

These are the errors from building this server, with the exact log lines. The site’s error log is /var/log/nginx/lemp.test-error.log. When you’re digging through logs, our guide to searching Linux logs fast helps.

502 Bad Gateway

sudo systemctl stop php8.5-fpm
curl -s -o /dev/null -w '%{http_code}n' -H 'Host: lemp.test' http://localhost/dbtest.php
502

sudo tail -n 1 /var/log/nginx/lemp.test-error.log
2026/09/30 09:56:55 [crit] 34198#34198: *10 connect() to unix:/run/php/php8.5-fpm.sock failed (2: No such file or directory) while connecting to upstream, client: ::1, server: lemp.test, request: "GET /dbtest.php HTTP/1.1", upstream: "fastcgi://unix:/run/php/php8.5-fpm.sock:", host: "lemp.test"

The most common LEMP error by far. Nginx is fine, but nothing’s listening on the PHP socket. Either PHP-FPM is stopped or crashed (systemctl status php8.5-fpm), or the fastcgi_pass path doesn’t match the real socket. That second one happens a lot after a PHP version upgrade, when the socket becomes php8.6-fpm.sock and the server block still says 8.5. ls /run/php/ shows you the real name.

413 Request Entity Too Large

Covered in step 8. Raise client_max_body_size in nginx, and upload_max_filesize plus post_max_size in PHP.

Changes aren’t taking effect

Almost always a failed reload, as in step 6. Run sudo nginx -t. If it reports an error, nginx is still running your previous config.

404 on every PHP page

Check that root in the server block points to the folder your files are actually in, and that the site is linked in sites-enabled. sudo nginx -T prints the full config nginx has actually loaded, which settles “which file is really active” arguments quickly:

sudo nginx -T 2>/dev/null | grep -E '(server_name|fastcgi_pass|root) ' | grep -v '#'
    server_name lemp.test www.lemp.test;
    root /var/www/lemp.test/public;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;

A PHP file downloads instead of running

The request never reached the location ~ .php$ block, usually because it’s missing, has a typo, or another location matched first. Nginx then treats the file as a static download, which can hand your source code to anyone who asks.

Setup confirmation: every check, in one place

The final state of the server, checked after everything above:

Check Result
nginx, MySQL and PHP-FPM running ✅ active active active
All three start at boot ✅ enabled enabled enabled
No Apache installed ✅ 0 apache2 / mod_php packages
Server block is what nginx loaded ✅ server_name lemp.test, root and FPM socket confirmed with nginx -T
PHP handled by FPM ✅ Server API FPM/FastCGI
PHP reads MySQL as the app user ✅ dbtest.php returns 200 with 3 rows
Path-info exploit blocked ✅ /note.txt/x.php returns 404
Uploads up to 64 MB accepted ✅ 5 MB POST returns 200 (413 on the 1 MB default)
Version hidden, security headers on ✅ Server: nginx + 3 headers
Dot files blocked ✅ /.env returns 403
gzip on CSS ✅ 12,092 bytes → 584 bytes
MySQL local only, firewall on ✅ 127.0.0.1:3306, UFW active with Nginx Full
phpinfo removed ✅ info.php returns 404
systemctl is-active nginx mysql php8.5-fpm | xargs
active active active

systemctl is-enabled nginx mysql php8.5-fpm | xargs
enabled enabled enabled

sudo ss -tlnp | grep -E ':80 |:3306 ' | awk '{print $4}'
0.0.0.0:80
127.0.0.1:3306
[::]:80

curl -s -o /dev/null -w '%{http_code}n' -H 'Host: lemp.test' http://localhost/info.php
404

curl -s -o /dev/null -w '%{http_code}n' -H 'Host: lemp.test' http://localhost/dbtest.php
200

nginx -v; mysql --version; php -v | head -1
nginx version: nginx/1.28.3 (Ubuntu)
mysql  Ver 8.4.11-0ubuntu0.26.04.1 for Linux on x86_64 ((Ubuntu))
PHP 8.5.4 (cli) (built: Sep  2 2026 14:42:18) (NTS)
Working LEMP stack on Ubuntu 26.04 confirmed in a browser: nginx, PHP 8.5.4 over PHP-FPM and MySQL 8.4.11
Setup confirmed in a browser: nginx serving the server block, PHP 8.5.4 over PHP-FPM, MySQL 8.4.11 returning data.

Frequently asked questions

LEMP or LAMP, which should I pick?

For most new sites, LEMP. Nginx uses less memory under load, serves static files very efficiently, and handles lots of simultaneous connections well. Pick LAMP when an app relies on .htaccess files you can’t easily translate, or when you’re on shared hosting where per-folder overrides matter. Performance-wise, with PHP-FPM behind either one, the difference for a typical site is smaller than the internet suggests.

How do I convert .htaccess rules to nginx?

By hand, in the server block, since nginx never reads .htaccess. For WordPress and most frameworks, the single try_files $uri $uri/ /index.php?$query_string; line in this guide replaces their entire rewrite section. Custom redirects become return 301 or rewrite rules. Test each one, because the syntax isn’t a one-to-one match.

Can I use MariaDB instead of MySQL?

Yes. sudo apt install mariadb-server gives you MariaDB 11.8 on Ubuntu 26.04, and everything in this guide works the same except the MySQL 8.4 authentication notes. MariaDB root uses unix_socket (same idea as auth_socket), and it doesn’t use caching_sha2_password.

Should I use a Unix socket or TCP for PHP-FPM?

A socket, when nginx and PHP are on the same server, like here. It skips the network stack and can’t be reached from outside. TCP (fastcgi_pass 127.0.0.1:9000;) is for when PHP-FPM runs on a different machine or in a separate container.

How do I host several sites?

One folder and one server block file per site, each with its own server_name, then link each into sites-enabled. For real isolation, give each site its own PHP-FPM pool running as its own Linux user, so one hacked site can’t read the others’ files.

Can nginx also sit in front of other apps, like Node.js?

Yes, that’s a reverse proxy, and it’s one of the most common things nginx does besides serving PHP. Instead of fastcgi_pass you use proxy_pass. If you’re headed that way, our nginx load balancer guide covers proxy_pass, headers and WebSockets with real output.

Where this leaves you

You now have a LEMP stack that’s configured the way you’d actually run one: nginx with a clean server block and sensible caching, PHP-FPM 8.5 on a socket with realistic upload limits, MySQL 8.4 with socket-authenticated root and a least-privilege app user, the path-info hole closed, and the version headers hidden. You’ve also seen the failures up close, the 502, the 413, the silent reload failure, which is what makes fixing them later quick.

Next: put your application in /var/www/lemp.test/public, add HTTPS, and schedule a nightly mysqldump with a cron job. When something breaks, check nginx -t, the site’s error log, and journalctl for php8.5-fpm, in that order.

Asif Khan

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.