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_stringserves the file if it exists, otherwise hands the request toindex.php. That’s what WordPress, Laravel and most PHP frameworks expect, and it’s how nginx replaces the.htaccessrewrite 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 allon dot files blocks.env,.gitand friends, while still allowing.well-known, which Let’s Encrypt needs.client_max_body_sizeis 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

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)

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.