LEMP is Linux, Engine-X, MariaDB and PHP — the stack behind WordPress, Nextcloud, and a large share of the web. LAMP is the same thing with Apache instead of Nginx.
Installing the four packages takes two minutes. The parts that go wrong are the socket path between Nginx and PHP, file ownership, and database privileges — so this spends its time there rather than on apt install.
It assumes you have worked through securing a new server. Commands are Debian and Ubuntu, with Red Hat notes where they differ.
1. Install
sudo apt update
sudo apt install nginx mariadb-server php-fpm php-mysql \
php-xml php-mbstring php-curl php-zip php-gd
# Red Hat family
sudo dnf install nginx mariadb-server php-fpm php-mysqlnd \
php-xml php-mbstring php-json php-gd
php -v
systemctl status nginx mariadb php*-fpmThose extra PHP modules are the ones nearly every application needs. Installing them now avoids the loop where an application’s installer refuses to start, you install one module, and it names another.
Note your PHP version — php -v — because it appears in the service name and the socket path, and getting it wrong is the most common failure in step 4.
Verify: all three services are active (running).
2. Secure the database
sudo mariadb-secure-installationAnswer: no to unix_socket changes if asked, no to changing the root password (see below), yes to removing anonymous users, yes to disallowing remote root login, yes to removing the test database, yes to reloading privileges.
Modern MariaDB authenticates the root user by unix socket, which means sudo mariadb works and there is no root password to leak. Leave it that way.
# Confirm it is not listening to the network
sudo ss -tulpn | grep 3306Verify: that shows 127.0.0.1:3306 and not 0.0.0.0:3306. If it shows the latter, the database is listening on every interface and only your firewall is protecting it — fix it with bind-address = 127.0.0.1 in /etc/mysql/mariadb.conf.d/50-server.cnf. Networking from first principles covers why the bind address beats a firewall rule.
3. A database and a user for the application
sudo mariadbCREATE DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'myapp'@'localhost' IDENTIFIED BY 'a-long-random-password';
-- Only this database, and no GRANT OPTION
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER
ON myapp.* TO 'myapp'@'localhost';
FLUSH PRIVILEGES;
EXIT;Two things here that most walkthroughs get wrong.
utf8mb4, not utf8. MySQL’s utf8 is a three-byte subset that cannot store emoji or some CJK characters, and it fails at insert time months later. utf8mb4 is real UTF-8. Getting this wrong is also the cause of the mojibake problem in migrating a server.
And grant specific privileges on one database, not ALL PRIVILEGES ON *.*. A compromised application should not be able to read every other database on the machine or create new users.
# Verify: this must succeed
mariadb -u myapp -p myapp -e "SELECT DATABASE();"4. Connect Nginx to PHP
This is the step that fails, and it fails because the socket path contains a version number.
# Find YOUR socket - do not copy one from a tutorial
ls /run/php/
# php8.3-fpm.sock
# Red Hat family uses a different path
ls /run/php-fpm/sudo nano /etc/nginx/sites-available/myappserver {
listen 80;
server_name myapp.example.com;
root /var/www/myapp;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock; # YOUR version
}
# Do not serve dotfiles - .env, .git and similar
location ~ /\. {
deny all;
}
client_max_body_size 64M;
}sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxAlways run nginx -t before reloading. It catches the syntax error that would otherwise stop Nginx from starting at all.
The location ~ /\. block deserves mention. Without it, Nginx will happily serve /.env and the contents of /.git to anyone who asks — and scanners ask constantly. It is two lines and it closes a real hole.
5. Files and ownership
sudo mkdir -p /var/www/myapp
echo "<?php phpinfo();" | sudo tee /var/www/myapp/index.php
sudo chown -R www-data:www-data /var/www/myapp
sudo find /var/www/myapp -type d -exec chmod 755 {} \;
sudo find /var/www/myapp -type f -exec chmod 644 {} \;The web server user is www-data on Debian and Ubuntu, and nginx or apache on Red Hat systems.
755 and 644, never 777. When an upload directory will not write, the answer is ownership — chown that one directory to the web server user — not making it world-writable. chmod 777 on a web root means anyone who finds any upload vulnerability can write a PHP file into a directory the server will execute.
# Verify
curl -I http://myapp.example.com
curl -s http://myapp.example.com | head -5Verify: the PHP info page renders. Then delete it immediately — it lists your PHP version, every module, and paths that are useful to an attacker.
sudo rm /var/www/myapp/index.php6. HTTPS
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.com
# The one command people skip
sudo certbot renew --dry-runCertbot edits your server block to add TLS and sets up automatic renewal. The dry run is what tells you renewal will actually work — a certificate that fails to renew eighteen months from now is the classic way a working site dies.
Running several applications? A reverse proxy with automatic TLS is the better arrangement.
7. Tuning worth doing
Defaults assume a shared host. Two files are worth adjusting on a machine that is yours.
# /etc/php/8.3/fpm/php.ini
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 60
# Do not tell the world which PHP you run
expose_php = Off
# Never display errors to visitors on a live site
display_errors = Off
log_errors = Onupload_max_filesize must be matched by client_max_body_size in Nginx, and both by post_max_size. An upload that fails at a specific size is nearly always one of the three being lower than the others.
# /etc/php/8.3/fpm/pool.d/www.conf - worker processes
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6sudo php-fpm8.3 -t # test the config
sudo systemctl reload php8.3-fpmpm.max_children is the one that matters under load. Each worker can use up to memory_limit, so 20 workers at 256 MB is potentially 5 GB — set it against your actual RAM, or the OOM killer will make the decision for you. Processes and memory covers what happens when it does.
Things that catch people out
| Symptom | Cause |
|---|---|
| Browser downloads the .php file | The location ~ \.php$ block is missing or not matching |
| 502 Bad Gateway | Wrong socket path, or php-fpm not running |
| 403 Forbidden | Ownership, or no index file |
| 404 on every page but the home page | Missing try_files line |
| White page, nothing in the browser | PHP error — check the log, not the screen |
| Uploads fail above a size | Three limits must all agree |
| Emoji break the database | utf8 instead of utf8mb4 |
| Works, then 502 under load | pm.max_children too low |
| Permission denied, permissions look right | SELinux, on Red Hat systems |
# The three logs that answer nearly everything
sudo tail -f /var/log/nginx/error.log
sudo journalctl -u php8.3-fpm -f
sudo journalctl -u mariadb -e
# Is php-fpm actually listening on that socket?
sudo ss -xlnp | grep phpA 502 means Nginx could not reach PHP. Check the socket path in your config against ls /run/php/ first — after a PHP version upgrade the old path stops existing and every site 502s at once, which looks far more alarming than it is.
On Fedora, Rocky or Alma, add SELinux to your list of suspects — a correct-looking permission denial is usually a labelling problem, covered in Fedora.
Before you call it done
- PHP info page deleted
display_errors = Off,expose_php = Off- Database bound to
127.0.0.1, application user limited to its own database - Only 80, 443 and SSH open — Linux firewalls
certbot renew --dry-runpasses- Database backups running, and a restore tested — automated backups
- An alert if any of the three services dies — simple monitoring
The backup line is the one to take seriously. A LEMP server holds its state in a database, and a database that has never been restored from is a database you are hoping about.
Quick reference
| You want | Command |
|---|---|
| Find the PHP socket | ls /run/php/ |
| Test Nginx config | sudo nginx -t |
| Test PHP-FPM config | sudo php-fpm8.3 -t |
| Which PHP modules are loaded | php -m |
| Open a database shell | sudo mariadb |
| Test the app’s database login | mariadb -u myapp -p myapp |
| Is the database public? | sudo ss -tulpn | grep 3306 |
| Fix web root permissions | chown -R www-data:www-data, then 755/644 |
| Diagnose a 502 | Socket path, then journalctl -u php*-fpm |
Related reading
- Set up a web server with Nginx — Nginx and certbot in more depth
- Securing a new server — do this first
- A reverse proxy with automatic TLS — for more than one application
- File permissions — 755, 644 and why not 777
- Automated backups — including database dumps
- Simple monitoring — knowing when one of these dies
