A LEMP stack is the open-source web stack that powers most of the dynamic PHP sites on the internet: Linux as the operating system, **(E)**Nginx as the web server, MySQL as the database, and PHP as the application runtime. It is the modern replacement for the older LAMP stack — Nginx replaces Apache and is generally faster, lighter, and easier to tune for high-concurrency workloads.
This guide walks through a clean install on Ubuntu 20.04 LTS: Nginx, MySQL 8, and PHP 7.4 (the version shipped in focal). It covers the configuration glue you actually need (server blocks, PHP-FPM Unix socket, index.php), the security steps that are often skipped, and the verification commands to confirm everything is wired up correctly.
If you are starting fresh today, consider Ubuntu 22.04 or 24.04 — they ship newer PHP and MySQL by default. The steps are nearly identical; only the package names for PHP differ. This guide stays focused on 20.04 because it is still common in long-running production fleets.
Prerequisites
Before you begin, you need:
- An Ubuntu 20.04 server with at least 1 GB RAM (2 GB recommended once MySQL is involved).
- A non-root user with
sudoprivileges. Running everything asrootwill work but is a bad habit. - SSH access to the server.
- (Optional but recommended) A domain name pointing an
Arecord at the server's public IP if you plan to issue a TLS certificate.
Make sure the system is up to date before installing anything new:
sudo apt update
sudo apt upgrade -y
If apt upgrade pulls in a new kernel, reboot before continuing:
sudo reboot
1. Configure the firewall
Ubuntu ships ufw (Uncomplicated Firewall). Allow SSH first — locking yourself out of a remote box is the classic LEMP-install mistake — then open HTTP and HTTPS:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full' # opens 80 and 443
sudo ufw enable
sudo ufw status
Nginx Full is a profile registered when Nginx is installed; if you run this command before installing Nginx, use sudo ufw allow 80/tcp and sudo ufw allow 443/tcp instead.
2. Install Nginx
sudo apt install -y nginx
Start it and enable it on boot:
sudo systemctl enable --now nginx
sudo systemctl status nginx
Visit http://<your-server-ip>/ in a browser. You should see the default "Welcome to nginx!" page. If you do not, double-check ufw status and that your cloud provider's network firewall (AWS security group, Hetzner Cloud Firewall, etc.) also allows inbound 80/tcp.
Useful Nginx commands
sudo systemctl reload nginx # zero-downtime config reload
sudo systemctl restart nginx # full restart
sudo nginx -t # validate config without reloading
nginx -t is the single most important command on this list — always run it before reloading after a config change. The full directive reference lives in the official Nginx documentation — bookmark it; you will be back.
3. Install MySQL
sudo apt install -y mysql-server
sudo systemctl enable --now mysql
Run the security script
MySQL ships with sane defaults but several insecure leftovers — anonymous users, a test database, and remote root login. Clean those up immediately:
sudo mysql_secure_installation
You will be asked, in order, to:
- Enable the VALIDATE PASSWORD plugin. Choose
MEDIUMfor a sensible default (or skip it and enforce policy at the application layer). - Set a strong root password. Save it in your password manager — you will need it.
- Remove anonymous users — answer
Y. - Disallow remote root login — answer
Y. - Remove the test database — answer
Y. - Reload privilege tables — answer
Y.
Note about MySQL 8 root authentication on Ubuntu 20.04
On a fresh Ubuntu 20.04 install, the MySQL root account uses the auth_socket plugin — meaning local Unix users matching the MySQL user can log in without a password. So this works without prompting for the password you just set:
sudo mysql
That is intentional and fine for administrative work. Do not change root to use mysql_native_password unless you actually need to log in as root from a script with a password — and even then, prefer creating a dedicated application user.
Create an application user and database
Inside sudo mysql:
CREATE DATABASE example_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'example_user'@'localhost' IDENTIFIED BY 'change-me-to-a-real-password';
GRANT ALL PRIVILEGES ON example_app.* TO 'example_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Test the new user:
mysql -u example_user -p
Enter the password you set; you should land in the mysql> prompt. Type EXIT; to leave.
4. Install PHP-FPM and the MySQL extension
Nginx does not run PHP itself — it hands .php requests off to PHP-FPM over a Unix socket. Install PHP-FPM and the MySQL driver:
sudo apt install -y php-fpm php-mysql
On Ubuntu 20.04 this installs PHP 7.4 by default. Confirm:
php -v
You will see something like PHP 7.4.x (cli) .... The PHP-FPM service runs as php7.4-fpm:
sudo systemctl status php7.4-fpm
Want a newer PHP?
Ubuntu 20.04 only ships PHP 7.4 in its main repos. For PHP 8.x, add Ondřej Surý's PPA (the de-facto upstream for PHP packages on Debian/Ubuntu):
sudo apt install -y software-properties-common
sudo add-apt-repository -y ppa:ondrej/php
sudo apt update
sudo apt install -y php8.2-fpm php8.2-mysql php8.2-cli php8.2-curl php8.2-xml php8.2-mbstring
Then substitute php8.2-fpm everywhere this guide says php7.4-fpm. Pick one PHP version and stick with it — running two FPM versions side by side works but is rarely worth the operational complexity.
Common PHP extensions
Most PHP applications need a handful of extensions beyond php-mysql. A reasonable starter set:
sudo apt install -y \
php-cli php-curl php-xml php-mbstring \
php-zip php-gd php-intl php-bcmath
After installing extensions, restart FPM so it picks them up:
sudo systemctl restart php7.4-fpm
5. Configure an Nginx server block for PHP
Nginx ships with a default server block at /etc/nginx/sites-available/default. For anything beyond a quick test, create a dedicated server block per site so you can enable/disable/replace them without touching the default.
Create the site root
sudo mkdir -p /var/www/example.com
sudo chown -R $USER:$USER /var/www/example.com
Create the server block
sudo nano /etc/nginx/sites-available/example.com
Paste:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php7.4-fpm.sock;
}
# Deny access to .ht* files (legacy Apache artifacts)
location ~ /\.ht {
deny all;
}
}
Replace example.com with your real domain (or the server IP if you do not have one yet — drop www.example.com in that case). If you used PHP 8.2 above, change the socket path to /run/php/php8.2-fpm.sock.
Enable the site and reload
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
If nginx -t reports an error, fix it before reloading — the running config will keep serving traffic until a successful reload.
Disable the default site (optional)
If this is a single-site server, you can remove the default to keep things clean:
sudo rm /etc/nginx/sites-enabled/default
sudo systemctl reload nginx
6. Test PHP
Drop a quick PHP info page into the new site root:
echo '<?php phpinfo(); ?>' | sudo tee /var/www/example.com/info.php > /dev/null
Visit http://<server-ip-or-domain>/info.php. You should see a long PHP information table — the version, loaded extensions, FPM as the SAPI, and the MySQL/PDO modules listed under their own sections. Confirm in particular:
- Server API:
FPM/FastCGI - mysqli and pdo_mysql sections are present
- Loaded Configuration File:
/etc/php/7.4/fpm/php.ini
Then delete the test file immediately — phpinfo() exposes paths, versions, and extensions that you do not want indexed by the next vulnerability scanner that hits the server:
sudo rm /var/www/example.com/info.php
End-to-end test against MySQL
Create a small script that actually connects to the database:
sudo nano /var/www/example.com/db-test.php
<?php
$db = new mysqli('localhost', 'example_user', 'change-me-to-a-real-password', 'example_app');
if ($db->connect_errno) {
http_response_code(500);
exit('MySQL connection failed: ' . $db->connect_error);
}
echo 'Connected. MySQL server version: ' . $db->server_info;
Hit it once in a browser or with curl http://example.com/db-test.php. You should see Connected. MySQL server version: 8.0.x. Delete the file once it works.
7. Tune PHP-FPM (optional but recommended)
The default www pool in /etc/php/7.4/fpm/pool.d/www.conf is conservative. The two settings that matter most on a real workload:
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
Rule of thumb: pm.max_children ≈ available RAM for PHP / average PHP process size. On a 2 GB box where each PHP worker uses ~50 MB, max_children of 20–25 is reasonable. Going higher will not make the site faster — it will just OOM under load.
pm.max_requests = 500 recycles workers periodically, which is a cheap mitigation for slow memory leaks in third-party PHP code.
Apply with:
sudo systemctl reload php7.4-fpm
In /etc/php/7.4/fpm/php.ini, the typical edits are:
upload_max_filesize = 32M
post_max_size = 32M
memory_limit = 256M
max_execution_time = 60
date.timezone = UTC
post_max_size must be at least as large as upload_max_filesize, otherwise uploads will fail silently. Restart FPM after editing php.ini:
sudo systemctl restart php7.4-fpm
8. (Optional) Add HTTPS with Let's Encrypt
If your domain points to the server, getting a free TLS certificate takes about 30 seconds with Certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot will:
- Verify domain ownership over HTTP (port 80 must be reachable from the public internet).
- Edit your Nginx server block to add
listen 443 ssl;and the cert paths. - Optionally configure an HTTP → HTTPS redirect (recommended).
- Install a systemd timer that renews the cert automatically.
Confirm renewal works in dry-run mode:
sudo certbot renew --dry-run
Visit https://example.com/ — you should see a valid certificate served by Nginx. Certbot's renewal timer is reliable but not infallible — keep an eye on expiry with SSL certificate monitoring so a silently-failed renewal does not become a Sunday-morning outage. For deeper Certbot configuration (DNS challenges, wildcard certs, custom hooks) refer to the official Certbot documentation.
9. Final verification checklist
A LEMP stack has four moving pieces and three "joints" between them. Run through this list before you trust the box with a real application:
# All services running?
systemctl is-active nginx mysql php7.4-fpm
# Nginx config valid?
sudo nginx -t
# PHP-FPM listening on the expected socket?
sudo ls -l /run/php/php7.4-fpm.sock
# MySQL accepting local connections?
mysqladmin ping -u example_user -p
# Site responds and serves PHP?
curl -I http://example.com/
curl -s http://example.com/db-test.php # if you kept the test script
systemctl is-active returning active for all three services is necessary but not sufficient — services can be running while still misconfigured. The end-to-end curl against a .php URL is the test that actually exercises Nginx → PHP-FPM → MySQL together.
Operational tips
- One server block per site, one log file per site. Use
access_log /var/log/nginx/example.com.access.log;anderror_log /var/log/nginx/example.com.error.log;inside eachserver { … }so that a misbehaving site does not pollute the shared log. - Always
nginx -tbefore reload. A typo in a server block will stop Nginx from reloading and you will not notice until the next certificate renewal silently fails. - Never world-write the web root.
sudo chown -R $USER:www-data /var/www/example.com && sudo chmod -R 750 /var/www/example.comis a sane default — owner can edit, thewww-datagroup (which FPM runs as) can read, nobody else can see anything. - Back up MySQL before you need to.
mysqldump --single-transaction --routines --events example_app > example_app-$(date +%F).sqlis a one-line backup; pipe it throughgzipand ship to off-host storage. - Watch the slow query log. Enable
slow_query_log = 1andlong_query_time = 1in/etc/mysql/mysql.conf.d/mysqld.cnfonce the application is in production — most PHP performance complaints turn out to be unindexed queries. - Keep PHP and MySQL patched.
sudo apt upgrade -yonce a week or rely onunattended-upgrades. Out-of-date PHP and MySQL are the two most common entry points on a public LEMP server.
Troubleshooting
502 Bad Gatewayfrom Nginx on a.phpURL — Nginx cannot reach PHP-FPM. Check the socket path in your server block matches/etc/php/<version>/fpm/pool.d/www.conf(listen = …), and confirmphp<version>-fpmis running.File not found.from FPM — usually a wrongrootdirective or a missingindex.php. The error appears in/var/log/nginx/error.log.Access denied for user 'example_user'@'localhost'— wrong password, or the user was created with a different host than'localhost'. List users withSELECT user, host FROM mysql.user;asroot.- PHP changes do not take effect — you edited
php.inibut did not restart FPM.sudo systemctl restart php7.4-fpm. The CLI'sphp.ini(/etc/php/7.4/cli/php.ini) is a separate file from the FPM one. - Certbot fails the HTTP challenge — port 80 is not reachable, or your DNS
Arecord has not propagated yet. Verify withcurl -I http://example.com/from a different network before retrying. - MySQL refuses to start, complains about
mysqld.sock— usually a leftover from a hard reboot. Checkjournalctl -u mysqlfor the real error; the.sockfile is created at start time and is never the root cause on its own.
Summary
A working LEMP stack on Ubuntu 20.04 is six concrete steps:
- Update the system and open
80/443inufw. apt install nginxand confirm the welcome page.apt install mysql-server, runmysql_secure_installation, create a per-app user and database.apt install php-fpm php-mysql(plus the extensions your app needs).- Create a per-site server block that hands
.phpto the FPM Unix socket. - Verify end-to-end with a
phpinfo()page and a real DB connection — then delete the test files.
Once the stack is running, the next two things worth doing are TLS (certbot --nginx) and monitoring — at minimum, an HTTPS uptime check on https://example.com/ plus a server-agent integration that watches nginx, mysql, and php-fpm as services. Once Xitogent is on the box, enable the Nginx and MySQL integrations to track connections, query rates, and slow queries directly. A LEMP stack that nobody is watching tends to fail quietly: PHP-FPM hits its max_children ceiling, MySQL runs out of disk for binlogs, or the TLS cert expires on a Sunday morning. Catching those before users do is what keeps the stack actually useful.