How to Set Up a LEMP Stack on Ubuntu (Linux, Nginx, MySQL, PHP)



How to Set Up a LEMP Stack on Ubuntu (Linux, Nginx, MySQL, PHP)

A complete, hands-on guide to building a modern web server from scratch.

If you’ve ever set up a LAMP stack (Linux, Apache, MySQL, PHP), you know the drill — it’s the backbone of half the internet. But there’s a modern alternative that’s faster, leaner, and surprisingly easier to configure once you get the hang of it.

It’s called a LEMP stack — same idea as LAMP, but we swap Apache for Nginx (pronounced “engine-x”). The “E” comes from the middle of “nginx.”

In this tutorial, we’ll build a complete LEMP stack from scratch on Ubuntu 24.04, secure it, and get it serving PHP. By the end, you’ll have a production-ready web server ready to host anything from a WordPress site to a custom PHP app.

LEMP Stack Architecture Diagram - showing Nginx, PHP-FPM, MySQL, and their connections
LEMP Stack Architecture — Request flows from the browser through Nginx, PHP-FPM, and MySQL

Why Nginx Instead of Apache?

Nginx vs Apache comparison - speed vs tradition
Nginx vs Apache — Different philosophies, different strengths

Let’s address the elephant in the room. Apache has been around forever and it’s great. So why switch?

  • Performance: Nginx handles concurrent connections with a fraction of the memory Apache uses. It’s built on an event-driven architecture instead of spawning a process per request.
  • Static files: Nginx serves static assets (images, CSS, JS) blazingly fast — often right from memory.
  • Config syntax: Once it clicks, Nginx config files are cleaner and more logical than Apache’s directives.
  • Reverse proxy: Nginx is the industry standard for reverse proxying, load balancing, and caching.

The main trade-off? Nginx doesn’t process .htaccess files. Everything lives in your server blocks. Honestly, that’s a good thing — it forces you to be intentional about your config, and it’s faster because Nginx isn’t checking for .htaccess files on every request.

What We’re Building

Stack:

  • Linux: Ubuntu 24.04 LTS
  • Nginx: Latest stable from official repo
  • MySQL: MySQL 8.x
  • PHP: PHP 8.3 with FPM
  • SSL: Let’s Encrypt via Certbot

Target audience: Comfortable with basic terminal commands, new to server administration.
Time to complete: ~30 minutes.

Prerequisites:

  • A fresh Ubuntu 24.04 server (VPS or local machine)
  • SSH access with sudo privileges
  • A domain name pointed at your server’s IP (for SSL)

Step 1: System Update and Essentials

First things first — let’s make sure the system has fresh packages and the tools we’ll need.

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl wget gnupg2 ca-certificates lsb-release \
    apt-transport-https software-properties-common ufw unzip

Why: We add the transport-https and properties-common packages now because we’ll need them later to add third-party repos for Nginx and PHP. Doing it all upfront keeps the flow smooth.


Step 2: Install Nginx

Ubuntu’s default repositories ship an older Nginx version. Let’s grab the stable release from Nginx’s official repository:

# Add the official Nginx signing key
curl -fsSL https://nginx.org/keys/nginx_signing.key | \
    sudo gpg --dearmor -o /usr/share/keyrings/nginx-archive-keyring.gpg

# Add the stable repository
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.pgp] \
    http://nginx.org/packages/ubuntu $(lsb_release -cs) nginx" | \
    sudo tee /etc/apt/sources.list.d/nginx.list

# Pin the official repo over Ubuntu's
cat <

Let's verify it's running:

sudo systemctl start nginx
sudo systemctl enable nginx
sudo nginx -v
# nginx version: nginx/1.26.x (or newer)

curl -I http://localhost
# HTTP/1.1 200 OK
# Server: nginx/1.26.x

What just happened: Nginx is installed and running. When you visit your server's IP in a browser, you'll see the default Nginx welcome page. That's the confirmation it's alive.

Understanding Nginx Config Structure

Before we go further, let's understand how Nginx organizes its configuration. This is a big mental shift if you're coming from Apache:

/etc/nginx/
├── nginx.conf          # Main config - global settings
├── conf.d/             # Drop-in config files (loaded alphabetically)
├── sites-available/    # All your site configs (like Apache's sites-available)
├── sites-enabled/      # Symlinks to sites-available (active sites)
└── snippets/           # Reusable config fragments

Key differences from Apache:

  • No .htaccess files — all config lives in server blocks
  • sites-available + sites-enabled mirrors Apache's pattern (familiar!)
  • Config changes require a reload: sudo nginx -t && sudo systemctl reload nginx
  • One server block per site (like Apache's VirtualHost)

Step 3: Install MySQL 8

MySQL is the "M" in LEMP. We'll install it and lock it down.

sudo apt install -y mysql-server
sudo systemctl start mysql
sudo systemctl enable mysql

Secure MySQL Installation

MySQL ships with a security script that walks you through locking things down:

sudo mysql_secure_installation

Here's what to choose:

VALIDATE PASSWORD COMPONENT?      Y (choose strength level 1 = medium)
Set root password?                Y (use a STRONG password)
Remove anonymous users?           Y
Disallow root remote login?       Y
Remove test database?             Y
Reload privilege tables?           Y

Important: For production, avoid using the root account for applications. We'll create a dedicated database and user in a moment.

Create a Database and Dedicated User

sudo mysql -u root -p
-- Create a database (change 'mysite' to your project name)
CREATE DATABASE mysite CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Create a dedicated user (use a real password!)
CREATE USER 'mysite_user'@'localhost' IDENTIFIED BY 'YourStr0ng_P@ssw0rd!';

-- Grant privileges
GRANT ALL PRIVILEGES ON mysite.* TO 'mysite_user'@'localhost';

-- Apply changes
FLUSH PRIVILEGES;

-- Verify
SHOW DATABASES;
SELECT user, host FROM mysql.user;

EXIT;

Why a dedicated user: If your app gets compromised, the attacker only has access to one database — not your entire MySQL instance. This is called the principle of least privilege, and it's a security fundamental.


Step 4: Install PHP 8.3 with FPM

Here's the big conceptual shift from Apache. With Apache, you'd typically use mod_php — PHP runs inside Apache itself. Nginx can't do that. Instead, we use PHP-FPM (FastCGI Process Manager), which is a separate service that handles PHP requests.

The flow is:

Browser → Nginx → (is it PHP?) → Yes → PHP-FPM → returns result
                              → No  → serve static file directly
# Add the Ondřej Surý PHP repo (kept up-to-date with latest PHP releases)
sudo add-apt-repository -y ppa:ondrej/php
sudo apt update

# Install PHP 8.3 and common extensions
sudo apt install -y php8.3-fpm php8.3-cli php8.3-common php8.3-mysql \
    php8.3-curl php8.3-gd php8.3-intl php8.3-mbstring php8.3-xml \
    php8.3-zip php8.3-bcmath php8.3-soap php8.3-imagick php8.3-opcache

Let's verify PHP-FPM is running:

sudo systemctl start php8.3-fpm
sudo systemctl enable php8.3-fpm
sudo systemctl status php8.3-fpm
# ✓ Active: active (running)

php -v
# PHP 8.3.x

Tune PHP-FPM

The default PHP-FPM config works, but let's tune it for a typical VPS (2GB RAM):

sudo nano /etc/php/8.3/fpm/pool.d/www.conf

Find and adjust these values:

; Process management — 'dynamic' is the sweet spot for most sites
pm = dynamic

; Max child processes (adjust based on your RAM)
; Rule of max_children = RAM_MB / 30 (so ~64 for 2GB)
pm.max_children = 50

; Start with this many processes
pm.start_servers = 5

; Minimum spare processes waiting for requests
pm.min_spare_servers = 5

; Maximum spare processes
pm.max_spare_servers = 35

; Max requests before a process recycles (prevent memory leaks)
pm.max_requests = 500
# Restart PHP-FPM to apply
sudo systemctl restart php8.3-fpm

Step 5: Wire Nginx + PHP-FPM Together

This is the heart of the LEMP stack — telling Nginx how to talk to PHP-FPM.

First, let's remove the default site and create our own:

# Remove default site
sudo rm /etc/nginx/sites-enabled/default

# Create our site config
sudo nano /etc/nginx/sites-available/mysite.conf

Paste this configuration (adjust server_name to your domain):

server {
    listen 80;
    server_name mysite.com www.mysite.com;

    # Document root — where your PHP files live
    root /var/www/mysite;
    index index.php index.html index.htm;

    # Logging
    access_log /var/log/nginx/mysite.access.log;
    error_log /var/log/nginx/mysite.error.log;

    # Main location block
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # PHP processing — THIS IS THE KEY PART
    location ~ \.php$ {
        # Pass to PHP-FPM via Unix socket (faster than TCP)
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;

        # Performance
        fastcgi_buffer_size 128k;
        fastcgi_buffers 4 256k;
        fastcgi_busy_buffers_size 256k;
        fastcgi_read_timeout 300;
    }

    # Security: Block access to hidden files
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # Security: Don't serve .env, config, git files
    location ~* \.(env|git|gitignore|gitattributes|lock)$ {
        deny all;
        access_log off;
        log_not_found off;
    }
}

About fastcgi_pass: PHP-FPM can be reached via Unix socket (/run/php/php8.3-fpm.sock) or TCP (127.0.0.1:9000). Unix sockets are slightly faster because they skip the network stack entirely. The socket method is the default on Ubuntu.

Enable the site and test:

# Create the document root
sudo mkdir -p /var/www/mysite
sudo chown -R www-data:www-data /var/www/mysite

# Enable the site
sudo ln -s /etc/nginx/sites-available/mysite.conf /etc/nginx/sites-enabled/

# Test config syntax (ALWAYS do this before reloading)
sudo nginx -t
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# Reload Nginx
sudo systemctl reload nginx

Create a Test PHP File

Let's confirm PHP is working:

cat <<'EOF' | sudo tee /var/www/mysite/index.php
<!DOCTYPE html>
<html>
<head><title>LEMP Stack Test</title></head>
<body>
    <h1>🎉 It Works!</h1>
    <p>Your LEMP stack is up and running.</p>
    <p>PHP Version: <?php echo phpversion(); ?></p>
    <p>Server Time: <?php echo date('Y-m-d H:i:s'); ?></p>

    <h2>MySQL Connection Test</h2>
    <?php
    try {
        $pdo = new PDO(
            'mysql:host=localhost;dbname=mysite',
            'mysite_user',
            'YourStr0ng_P@ssw0rd!'
        );
        echo '<p style="color: green;">✅ MySQL connection successful!</p>';
    } catch (PDOException $e) {
        echo '<p style="color: red;">❌ MySQL error: ' . $e->getMessage() . '</p>';
    }
    ?>

    <h2>PHP Modules</h2>
    <p><?php echo implode(', ', get_loaded_extensions()); ?></p>
</body>
</html>
EOF

Visit http://your-server-ip in your browser. You should see a green "It Works!" heading, your PHP version (8.3.x), a MySQL connection success message, and a list of loaded PHP modules.

If you see all green checkmarks, congratulations — your LEMP stack is fully operational! 🎉


Step 6: Set Up SSL with Let's Encrypt (Certbot)

A production server needs HTTPS. Let's Encrypt makes this free and automatic:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d mysite.com -d www.mysite.com

Follow the prompts:

  1. Enter your email address
  2. Agree to the terms
  3. Choose whether to redirect HTTP to HTTPS (choose YES — option 2)

Certbot will obtain a certificate from Let's Encrypt, automatically update your Nginx config to use it, and set up automatic renewal.

Verify auto-renewal:

sudo certbot renew --dry-run
# Congratulations, all renewals succeeded.

Check that your site loads at https://mysite.com with a valid lock icon 🔒


Step 7: Security Hardening

Let's lock things down properly.

Configure the Firewall

sudo ufw allow 'Nginx Full'    # HTTP + HTTPS
sudo ufw allow OpenSSH         # SSH access
sudo ufw enable
sudo ufw status
# Status: active
# To                  Action      From
# --                  ----        ----
# Nginx Full          ALLOW       Anywhere
# OpenSSH             ALLOW       Anywhere

Hide Nginx Version Number

By default, Nginx advertises its version in HTTP headers. Let's keep that private:

sudo nano /etc/nginx/nginx.conf

In the http block, add:

http {
    # Hide Nginx version
    server_tokens off;

    # ... rest of config
}
sudo nginx -t && sudo systemctl reload nginx

Harden MySQL

sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf

Add or update:

[mysqld]
# Only listen on localhost (no remote connections)
bind-address = 127.0.0.1

# Don't resolve hostnames (faster connections)
skip-name-resolve

# Disable symbolic links (security)
symbolic-links = 0
sudo systemctl restart mysql

Set Proper File Permissions

# Web root should be owned by www-data
sudo chown -R www-data:www-data /var/www/mysite

# Files: readable by all, writable by owner
sudo find /var/www/mysite -type f -exec chmod 644 {} \;

# Directories: executable by all, writable by owner
sudo find /var/www/mysite -type d -exec chmod 755 {} \;

Install Fail2Ban (Optional but Recommended)

sudo apt install -y fail2ban
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Fail2Ban monitors logs and bans IPs that show malicious behavior (like repeated failed SSH logins).


Step 8: Performance Tuning

A few quick tweaks to get more out of your stack.

Enable Gzip Compression

sudo nano /etc/nginx/conf.d/gzip.conf
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript
    text/xml application/xml application/xml+rss text/javascript
    image/svg+xml font/woff2;
gzip_min_length 256;

Configure OPcache (PHP Accelerator)

PHP normally recompiles scripts on every request. OPcache keeps compiled bytecode in memory:

sudo nano /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 2
opcache.validate_timestamps = 1

Note: Set opcache.validate_timestamps = 0 in production for maximum performance, but you'll need to manually clear the cache after deploying code changes (using php -r 'opcache_reset();' or restarting PHP-FPM).

sudo systemctl reload nginx
sudo systemctl restart php8.3-fpm

Quick Reference: Common Commands

# Nginx
sudo nginx -t                  # Test config syntax
sudo systemctl reload nginx    # Apply config changes (no downtime)
sudo systemctl restart nginx   # Full restart
sudo nginx -T                  # View full compiled config

# PHP-FPM
sudo systemctl restart php8.3-fpm
php -m                         # List loaded PHP modules
php -i | grep "php.ini"       # Find active php.ini location

# MySQL
sudo mysql -u root -p         # Access MySQL shell
sudo systemctl restart mysql
sudo mysqladmin -u root -p status  # Quick health check

# Certbot
sudo certbot certificates     # List certificates
sudo certbot renew --dry-run # Test renewal

# Logs
sudo tail -f /var/log/nginx/mysite.error.log
sudo tail -f /var/log/nginx/mysite.access.log
sudo tail -f /var/log/php8.3-fpm.log

Troubleshooting Common Issues

"File Not Found" errors
Check your root directive in the Nginx server block and verify the file exists at /var/www/mysite/yourfile.php.

PHP files download instead of executing
Nginx isn't passing PHP to PHP-FPM. Check that:

  1. PHP-FPM is running: sudo systemctl status php8.3-fpm
  2. The socket path matches: ls -la /run/php/php8.3-fpm.sock
  3. Your location ~ \.php$ block is correct

"502 Bad Gateway"
PHP-FPM isn't responding. Usually means:

  • PHP-FPM is crashed: sudo systemctl restart php8.3-fpm
  • Wrong socket path in Nginx config
  • PHP-FPM ran out of processes (check pm.max_children)

MySQL connection refused

  • MySQL isn't running: sudo systemctl start mysql
  • Wrong credentials in your PHP app
  • User doesn't have permissions: SHOW GRANTS FOR 'mysite_user'@'localhost';

Nginx won't start after config changes
Always check syntax first: sudo nginx -t — it'll tell you exactly which line has the problem.


What's Next?

You've got a fully functional LEMP stack! Here's where to go from here:

  1. Install WordPress: Download it to /var/www/mysite, point your browser at it, and complete the famous 5-minute install using the MySQL credentials we created.
  2. Set up a reverse proxy: Nginx can proxy requests to Node.js, Python, or other backend apps running on different ports.
  3. Configure monitoring: Tools like netdata or prometheus + grafana give you real-time insight into your server's health.
  4. Set up automated backups: Cron jobs to dump your MySQL database and rsync your web files to a remote location.

Last updated: June 2026. Tested on Ubuntu 24.04 LTS with Nginx 1.26, PHP 8.3, MySQL 8.4.


Leave a Reply

Your email address will not be published. Required fields are marked *