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*-fpm

Those 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 versionphp -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-installation

Answer: 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 3306

Verify: 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 mariadb
CREATE 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/myapp
server {
    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 nginx

Always 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 -5

Verify: 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.php

6. HTTPS

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.com

# The one command people skip
sudo certbot renew --dry-run

Certbot 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 = On

upload_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 = 6
sudo php-fpm8.3 -t          # test the config
sudo systemctl reload php8.3-fpm

pm.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

SymptomCause
Browser downloads the .php fileThe location ~ \.php$ block is missing or not matching
502 Bad GatewayWrong socket path, or php-fpm not running
403 ForbiddenOwnership, or no index file
404 on every page but the home pageMissing try_files line
White page, nothing in the browserPHP error — check the log, not the screen
Uploads fail above a sizeThree limits must all agree
Emoji break the databaseutf8 instead of utf8mb4
Works, then 502 under loadpm.max_children too low
Permission denied, permissions look rightSELinux, 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 php

A 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-run passes
  • Database backups running, and a restore testedautomated 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 wantCommand
Find the PHP socketls /run/php/
Test Nginx configsudo nginx -t
Test PHP-FPM configsudo php-fpm8.3 -t
Which PHP modules are loadedphp -m
Open a database shellsudo mariadb
Test the app’s database loginmariadb -u myapp -p myapp
Is the database public?sudo ss -tulpn | grep 3306
Fix web root permissionschown -R www-data:www-data, then 755/644
Diagnose a 502Socket path, then journalctl -u php*-fpm

Related reading