DevOps & Workflow12 min read

    How to Install a LEMP Stack on Ubuntu 20.04

    By DanaServer Monitoring & Linux
    Share

    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 sudo privileges. Running everything as root will work but is a bad habit.
    • SSH access to the server.
    • (Optional but recommended) A domain name pointing an A record 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:

    1. Enable the VALIDATE PASSWORD plugin. Choose MEDIUM for a sensible default (or skip it and enforce policy at the application layer).
    2. Set a strong root password. Save it in your password manager — you will need it.
    3. Remove anonymous users — answer Y.
    4. Disallow remote root login — answer Y.
    5. Remove the test database — answer Y.
    6. 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 immediatelyphpinfo() 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.


    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:

    1. Verify domain ownership over HTTP (port 80 must be reachable from the public internet).
    2. Edit your Nginx server block to add listen 443 ssl; and the cert paths.
    3. Optionally configure an HTTP → HTTPS redirect (recommended).
    4. 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; and error_log /var/log/nginx/example.com.error.log; inside each server { … } so that a misbehaving site does not pollute the shared log.
    • Always nginx -t before 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.com is a sane default — owner can edit, the www-data group (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).sql is a one-line backup; pipe it through gzip and ship to off-host storage.
    • Watch the slow query log. Enable slow_query_log = 1 and long_query_time = 1 in /etc/mysql/mysql.conf.d/mysqld.cnf once the application is in production — most PHP performance complaints turn out to be unindexed queries.
    • Keep PHP and MySQL patched. sudo apt upgrade -y once a week or rely on unattended-upgrades. Out-of-date PHP and MySQL are the two most common entry points on a public LEMP server.

    Troubleshooting

    • 502 Bad Gateway from Nginx on a .php URL — Nginx cannot reach PHP-FPM. Check the socket path in your server block matches /etc/php/<version>/fpm/pool.d/www.conf (listen = …), and confirm php<version>-fpm is running.
    • File not found. from FPM — usually a wrong root directive or a missing index.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 with SELECT user, host FROM mysql.user; as root.
    • PHP changes do not take effect — you edited php.ini but did not restart FPM. sudo systemctl restart php7.4-fpm. The CLI's php.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 A record has not propagated yet. Verify with curl -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. Check journalctl -u mysql for the real error; the .sock file 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:

    1. Update the system and open 80/443 in ufw.
    2. apt install nginx and confirm the welcome page.
    3. apt install mysql-server, run mysql_secure_installation, create a per-app user and database.
    4. apt install php-fpm php-mysql (plus the extensions your app needs).
    5. Create a per-site server block that hands .php to the FPM Unix socket.
    6. 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.