How to Install LEMP Stack on Ubuntu 24.04 — Nginx + MySQL + PHP
The LEMP stack is the gold standard for running high-performance PHP applications in production. If you are hosting WordPress, Laravel, Magento, Drupal, or any custom PHP app, a properly tuned LEMP stack on Ubuntu 24.04 LTS will outperform the classic LAMP stack on virtually every real-world workload — especially under concurrency.
In this guide you will install the full LEMP stack on Ubuntu 24.04 from a clean VPS, configure Nginx to pass PHP requests to PHP-FPM 8.3 via a Unix socket, harden MySQL, enable HTTPS with Let's Encrypt, and tune the stack for production traffic. Total hands-on time is about 25 minutes.
What Is the LEMP Stack?
LEMP is an open-source software bundle used to serve dynamic websites and web applications:
- L — Linux (Ubuntu 24.04 LTS in this guide)
- E — Nginx (pronounced "Engine-X", hence the "E")
- M — MySQL (or MariaDB as a drop-in replacement)
- P — PHP (specifically PHP-FPM, the FastCGI Process Manager)
LEMP vs LAMP — Why Nginx Instead of Apache?
The only real difference between LAMP and LEMP is the web server. Both can run the same PHP apps, but they handle traffic very differently.
Apache (LAMP) uses a process-per-request or thread-per-request model (prefork/worker/event MPM). Each incoming request consumes a worker slot for its full duration, including slow clients and keep-alive idle time. When concurrency spikes, Apache hits MaxRequestWorkers and starts queuing — or, worse, starts swapping when each worker holds a full PHP interpreter in memory via mod_php.
Nginx (LEMP) uses an event-driven, asynchronous architecture. A handful of worker processes (typically one per CPU core) handle thousands of concurrent connections each via epoll. Nginx never embeds PHP — it hands PHP work off to PHP-FPM over a socket and goes straight back to handling other connections. The result:
- Higher concurrency: Nginx routinely handles 10,000+ concurrent connections per worker with a flat memory footprint, while Apache's memory scales linearly with active workers.
- Better static file performance: Nginx serves static assets (images, CSS, JS) 2-3x faster than Apache and uses
sendfile()for zero-copy delivery. - Lower RAM usage: On a 2 GB VPS, Nginx + PHP-FPM typically uses 30-40% less RAM than Apache + mod_php at the same request rate.
- Cleaner separation: PHP-FPM pools run as a dedicated user, can be tuned independently per site, and can be restarted without touching the web server.
- Native reverse proxy: Nginx is also a first-class reverse proxy, load balancer, and HTTP cache, which makes it trivial to put in front of Node.js, Python, or Go services later.
Prerequisites
Before you start you need:
- A fresh Ubuntu 24.04 LTS VPS with at least 1 GB RAM (2 GB recommended for production).
- Root or
sudoaccess via SSH. - A domain name with an A record pointing to your server's IPv4 address (required for Let's Encrypt SSL).
- Ports 80 and 443 open in your firewall / provider console.
Start by updating the system:
sudo apt update && sudo apt upgrade -yConfigure the UFW firewall:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable
sudo ufw statusNginx Full is a profile that opens both 80 and 443. It is registered automatically when you install Nginx in the next step — if UFW complains that the profile is unknown, run the Nginx install first and come back.
Step 1 — Install Nginx
Install Nginx from Ubuntu's default repositories:
sudo apt install nginx -yEnable and start the service:
sudo systemctl enable --now nginx
sudo systemctl status nginxYou should see active (running). Verify the default page loads by visiting http://YOUR_SERVER_IP in a browser — you should see the "Welcome to nginx!" page.
Confirm Nginx is listening on port 80:
sudo ss -tlnp | grep nginxStep 2 — Install MySQL and Secure It
Install the MySQL server package:
sudo apt install mysql-server -yEnable and start MySQL:
sudo systemctl enable --now mysqlRun the interactive hardening script:
sudo mysql_secure_installationAnswer the prompts as follows:
- VALIDATE PASSWORD component:
Yand choose2(STRONG) for production. - New root password: Set a long, random password and store it in a password manager.
- Remove anonymous users:
Y - Disallow root login remotely:
Y - Remove test database:
Y - Reload privilege tables:
Y
root account uses the auth_socket plugin by default — which means you log in via sudo mysql without a password, not by typing the password you just set. To create an application user for your site:sudo mysqlThen in the MySQL prompt:
CREATE DATABASE app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'CHANGE_ME_STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON app_db.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;MariaDB Alternative
If you prefer MariaDB — a community-maintained fork of MySQL that is fully wire-compatible — install it instead of mysql-server:
sudo apt install mariadb-server -y
sudo mysql_secure_installationEverything else in this guide (connection strings, PHP extensions, user creation) works identically. MariaDB tends to have slightly better write performance and a more liberal licensing story. Pick one or the other — do not install both.
Step 3 — Install PHP-FPM 8.3 and Extensions
Ubuntu 24.04 ships with PHP 8.3 in its default repositories, so no PPA is needed:
sudo apt install php8.3-fpm php8.3-mysql php8.3-cli php8.3-curl \
php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip php8.3-bcmath \
php8.3-intl php8.3-opcache php8.3-imagick -yThese extensions cover the needs of 95% of real-world PHP apps:
- php8.3-fpm — the FastCGI Process Manager (the PHP runtime itself)
- php8.3-mysql — PDO and mysqli drivers for MySQL/MariaDB
- php8.3-curl — outbound HTTP requests (required by most frameworks)
- php8.3-gd / php8.3-imagick — image processing (resizing, thumbnails)
- php8.3-mbstring / php8.3-xml / php8.3-intl — Unicode, XML parsing, i18n
- php8.3-zip — used by Composer and by WordPress plugin uploads
- php8.3-bcmath — required by many commerce apps for precise math
- php8.3-opcache — bytecode caching (huge performance win, enabled by default)
Verify PHP-FPM Is Running on a Socket
PHP-FPM starts automatically after install. Verify it:
sudo systemctl status php8.3-fpmBy default the Ubuntu package configures PHP-FPM to listen on a Unix domain socket at /run/php/php8.3-fpm.sock (not a TCP port). Unix sockets are significantly faster than TCP for same-host communication because they skip the networking stack entirely. Confirm the socket file exists:
ls -la /run/php/php8.3-fpm.sockYou should see something like:
srw-rw---- 1 www-data www-data 0 Apr 16 14:22 /run/php/php8.3-fpm.sockNote the owner is www-data — this matches the default Nginx user, so no permission changes are needed.
Step 4 — Configure Nginx to Pass PHP Requests to PHP-FPM
The glue between Nginx and PHP-FPM is a location ~ \.php$ block that uses fastcgi_pass to hand PHP requests to the socket. Create a dedicated Nginx server block for your site.
Create the web root:
sudo mkdir -p /var/www/example.com
sudo chown -R www-data:www-data /var/www/example.comCreate the server block:
sudo nano /etc/nginx/sites-available/example.comPaste the following complete configuration, replacing example.com with your domain:
server { listen 80; listen [::]:80;server_name example.com www.example.com; root /var/www/example.com;
index index.php index.html index.htm;
client_max_body_size 64M;
access_log /var/log/nginx/example.com.access.log; error_log /var/log/nginx/example.com.error.log;
# Main location block — try static files, then fall back to index.php location / { try_files $uri $uri/ /index.php?$query_string; }
# Hand PHP requests to PHP-FPM over the Unix socket location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.3-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_read_timeout 300; fastcgi_buffers 16 16k; fastcgi_buffer_size 32k; }
# Deny access to hidden files (.htaccess, .git, .env, etc.) location ~ /\. { deny all; access_log off; log_not_found off; }
# Long cache for static assets location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2|svg|webp)$ { expires 30d; add_header Cache-Control "public, no-transform"; access_log off; } }
Enable the site and disable the default:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/defaultTest the configuration and reload:
sudo nginx -t
sudo systemctl reload nginxKey Directives Explained
fastcgi_pass unix:/run/php/php8.3-fpm.sock;— this is the line that makes LEMP work. Nginx opens the socket and forwards the request to PHP-FPM using the FastCGI protocol.include snippets/fastcgi-php.conf;— Ubuntu ships a helper file at/etc/nginx/snippets/fastcgi-php.confthat sets all the standard FastCGI params (SCRIPT_NAME,REQUEST_URI, security:try_files $fastcgi_script_name =404, etc.). Always include it.try_files $uri $uri/ /index.php?$query_string;— required by front-controller frameworks like Laravel, Symfony, and WordPress. Nginx first tries the literal file, then a directory, then falls through toindex.phpwith the query string preserved.client_max_body_size 64M;— default is 1 MB, which is too small for media uploads. See the tuning section for matching PHP settings.
Step 5 — Test PHP with phpinfo
Create a phpinfo test page:
sudo tee /var/www/example.com/info.php > /dev/null <<'EOF'
<?php
phpinfo();
EOFVisit http://example.com/info.php in your browser. You should see the standard PHP info page showing:
- Server API:
FPM/FastCGI - Loaded Configuration File:
/etc/php/8.3/fpm/php.ini - A
mysqliandPDOsection confirming the MySQL driver is loaded
sudo rm /var/www/example.com/info.phpStep 6 — Enable HTTPS with Let's Encrypt
Install Certbot and the Nginx plugin:
sudo apt install certbot python3-certbot-nginx -yRequest and install a certificate (Certbot will automatically edit your server block to add TLS and a redirect from HTTP to HTTPS):
sudo certbot --nginx -d example.com -d www.example.comAnswer the prompts:
- Enter your email for renewal notices.
- Agree to the Terms of Service.
- Choose
2to redirect all HTTP traffic to HTTPS.
sudo systemctl list-timers | grep certbot
sudo certbot renew --dry-runProduction Tuning
Default packages are tuned for developer convenience, not production traffic. Spend 10 minutes on these changes before you go live.
PHP-FPM Pool Tuning
The PHP-FPM pool config lives at /etc/php/8.3/fpm/pool.d/www.conf. Open it:
sudo nano /etc/php/8.3/fpm/pool.d/www.confAdjust these directives based on your VPS RAM. A typical PHP worker uses 40-80 MB RSS (WordPress with a few plugins is around 60 MB). A safe rule of thumb: pm.max_children = (total_RAM_for_PHP_MB) / (avg_worker_MB).
For a 4 GB VPS with ~2 GB available for PHP after Nginx, MySQL, and the OS:
; Use dynamic process management pm = dynamic; Max simultaneous PHP workers pm.max_children = 25
; Workers to launch at startup pm.start_servers = 6
; Keep at least this many idle workers ready pm.min_spare_servers = 4
; Do not keep more than this many idle pm.max_spare_servers = 10
; Recycle each worker after this many requests (prevents memory leaks) pm.max_requests = 500
; Write a slow log for any request over 5s slowlog = /var/log/php8.3-fpm.slow.log request_slowlog_timeout = 5s
; Status endpoint (pair with an Nginx location for monitoring) pm.status_path = /fpm-status ping.path = /fpm-ping
For a 1-2 GB VPS, start with pm.max_children = 8, pm.start_servers = 2, pm.min_spare_servers = 1, pm.max_spare_servers = 3. For 8+ GB, you can scale to 50-80 children.
Also bump PHP's own limits in /etc/php/8.3/fpm/php.ini:
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 120
max_input_time = 120
date.timezone = UTCKeep upload_max_filesize and post_max_size in sync with Nginx's client_max_body_size, or uploads will fail silently at the web server layer before PHP ever sees them.
Enable and Tune OPcache
OPcache is installed and enabled by default, but the defaults are conservative. Edit /etc/php/8.3/fpm/php.ini and set:
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 2
opcache.save_comments = 1
opcache.jit_buffer_size = 128M
opcache.jit = tracingOPcache typically gives PHP apps a 2-3x throughput improvement by caching compiled bytecode. PHP 8.3's JIT adds another 10-20% on CPU-bound workloads. On production servers where code does not change, set opcache.validate_timestamps = 0 and manually reload PHP-FPM on deploys for maximum performance.
Apply all changes:
sudo systemctl reload php8.3-fpmNginx Worker Tuning
Edit /etc/nginx/nginx.conf:
worker_processes auto; worker_rlimit_nofile 65535;events { worker_connections 4096; multi_accept on; use epoll; }
http { sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 2048; server_tokens off;
gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; # ... rest of config }
Test and reload:
sudo nginx -t && sudo systemctl reload nginxMySQL / InnoDB Tuning
For a VPS with 4 GB RAM, add to /etc/mysql/mysql.conf.d/mysqld.cnf under [mysqld]:
innodb_buffer_pool_size = 1G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
max_connections = 100The buffer pool should be roughly 50-70% of total RAM on a dedicated DB host, or 25-40% on a shared LEMP box. Restart MySQL after changes:
sudo systemctl restart mysqlSecurity Hardening
- Server tokens off: Already set above — hides Nginx version in headers and error pages.
- Block hidden files: The server block denies
/\.— make sure.env,.git, and.htaccesscannot be fetched. - Fail2ban:
sudo apt install fail2banand enable thenginx-http-auth,nginx-botsearch, andsshdjails. - Unattended security upgrades:
sudo dpkg-reconfigure unattended-upgrades. - PHP
expose_php = Offinphp.ini— hides theX-Powered-By: PHP/8.3.xheader. - Disable dangerous PHP functions you do not use:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source(skip this if your app legitimately needsexec/shell_exec, e.g. Laravel queues that shell out). - MySQL bind: Ensure
bind-address = 127.0.0.1inmysqld.cnfso the database is never exposed to the public internet.
Backups
At minimum, back up the database and the web root daily. A simple cron:
sudo mkdir -p /var/backups/lemp
sudo tee /usr/local/bin/lemp-backup.sh > /dev/null <<'EOF'
#!/bin/bash
set -e
DATE=$(date +%F)
mysqldump --single-transaction --all-databases | gzip > /var/backups/lemp/db-$DATE.sql.gz
tar -czf /var/backups/lemp/www-$DATE.tar.gz /var/www
find /var/backups/lemp -type f -mtime +14 -delete
EOF
sudo chmod +x /usr/local/bin/lemp-backup.sh
echo "0 3 * root /usr/local/bin/lemp-backup.sh" | sudo tee /etc/cron.d/lemp-backupFor production, push the backup directory to off-site storage (S3, Backblaze B2, or a second VPS via rsync).
Troubleshooting
502 Bad Gateway
Almost always means Nginx cannot reach PHP-FPM. Check in order:
sudo systemctl status php8.3-fpmls -la /run/php/php8.3-fpm.sockphp8.3-fpm.sock, not php8.2-fpm.sock).sudo tail -50 /var/log/php8.3-fpm.logsudo tail -50 /var/log/nginx/example.com.error.log — the exact failed upstream will be logged.connect() to unix:/run/php/php8.3-fpm.sock failed (13: Permission denied), see the socket permission fix below.PHP Files Download Instead of Rendering
This happens when Nginx has no location ~ \.php$ block and falls through to serving the raw file. Confirm your server block includes the fastcgi_pass block and sudo nginx -t && sudo systemctl reload nginx.
Socket Permission Denied
If Nginx runs as a different user than www-data (unusual on Ubuntu, but possible in custom builds), edit /etc/php/8.3/fpm/pool.d/www.conf:
listen.owner = www-data
listen.group = www-data
listen.mode = 0660Then sudo systemctl restart php8.3-fpm. Confirm the Nginx user with grep '^user' /etc/nginx/nginx.conf — it should be www-data.
White Screen / Blank Page
Turn on error display temporarily in /etc/php/8.3/fpm/php.ini:
display_errors = On
display_startup_errors = On
error_reporting = E_ALLReload PHP-FPM and reload the page. For production, flip these back off and rely on error_log instead.
Uploads Fail at ~1 MB
You forgot to match client_max_body_size (Nginx), upload_max_filesize (PHP), and post_max_size (PHP). All three must be at least as large as your biggest expected upload.
"Connection refused" from PHP to MySQL
Confirm MySQL is listening on localhost: sudo ss -tlnp | grep mysql. Confirm the app user exists and has the right password: mysql -u app_user -p -h 127.0.0.1.
FAQ
Do I need PHP 8.3 specifically? PHP 8.3 is the current stable branch on Ubuntu 24.04 and has the best JIT and performance characteristics. PHP 8.2 works identically — just swap 8.3 for 8.2 everywhere. Avoid PHP 7.x, which is EOL.
Can I run multiple PHP versions? Yes. Install additional packages (e.g. php8.2-fpm) — each version gets its own socket at /run/php/php8.2-fpm.sock. Point different Nginx server blocks at different sockets.
Should I use TCP or Unix socket for PHP-FPM? Unix socket on the same host — it is 20-30% faster and avoids ephemeral port exhaustion. Use TCP only when PHP-FPM runs on a different server than Nginx.
How many concurrent users can a LEMP VPS handle? Depends on the app. A 4 GB VPS running WordPress with OPcache and a page cache plugin can comfortably serve 200-500 concurrent users. Without caching, expect 20-40.
Nginx vs OpenLiteSpeed vs Caddy? Nginx has the largest ecosystem and most mature documentation. OpenLiteSpeed has built-in LSCache for WordPress (faster out-of-the-box for WP specifically). Caddy has automatic HTTPS. For a general-purpose PHP stack, Nginx is the safest choice.
Do I need to tune MySQL if I am only running one small site? Defaults are usable for a site with under ~50 concurrent users. Tune the InnoDB buffer pool before you hit that scale.
Next Steps
Your LEMP stack is production-ready. Common next moves:
- Install WordPress: Create a DB, download WordPress into
/var/www/example.com, and walk through the web installer. Add a page cache plugin (W3 Total Cache, LiteSpeed Cache, or WP Super Cache) for another 5-10x throughput boost. - Deploy Laravel: Install Composer, clone your repo, point the web root at
/var/www/example.com/public, runcomposer install --no-dev, set up the.env, and runphp artisan migrate. Add Redis (sudo apt install redis-server php8.3-redis) for sessions and queues. - Add Redis or Memcached: Both live well alongside LEMP and dramatically speed up session storage, object caching, and rate limiting.
- Monitoring: Install Netdata (
curl https://my-netdata.io/kickstart.sh | sh) for a real-time dashboard covering Nginx, PHP-FPM, and MySQL. - Horizontal scaling: When you outgrow one box, move MySQL to a dedicated host, put a load balancer in front of two Nginx nodes, and share sessions via Redis.