The LEMP stack — Linux, Nginx (pronounced "engine-x"), MySQL, and PHP — is the foundation of modern PHP web hosting. It powers WordPress, Laravel, Drupal, Magento, and thousands of custom PHP applications. Compared to the older LAMP stack (which uses Apache), LEMP handles concurrent connections more efficiently thanks to Nginx's event-driven architecture, making it the better choice for VPS deployments where resources are finite.
This guide walks through installing and configuring a complete production-ready LEMP stack on Ubuntu 24.04 LTS. You'll end up with Nginx serving PHP pages, MySQL handling your databases, SSL certificates from Let's Encrypt, and performance tuning appropriate for a VPS with 2-8 GB of RAM.
MassiveGRID Ubuntu VPS includes: Ubuntu 24.04 LTS pre-installed · Proxmox HA cluster with automatic failover · Ceph 3x replicated NVMe storage · Independent CPU/RAM/storage scaling · 12 Tbps DDoS protection · 4 global datacenter locations · 100% uptime SLA · 24/7 human support rated 9.5/10
Deploy a self-managed VPS — from $1.99/mo
Need dedicated resources? — from $8.30/mo
Want fully managed hosting? — we handle everything
Prerequisites
Before starting, you'll need:
- An Ubuntu 24.04 LTS VPS with at least 2 GB of RAM (4 GB recommended for production workloads)
- A non-root user with sudo privileges — follow our Ubuntu VPS initial setup guide if you haven't done this
- A domain name pointed to your server's IP address (for SSL certificate setup)
- UFW firewall configured with SSH, HTTP (port 80), and HTTPS (port 443) allowed
Open the required firewall ports if you haven't already:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status
Step 1: Installing Nginx
Nginx is the web server — it receives HTTP/HTTPS requests from visitors and either serves static files directly or passes dynamic requests to PHP-FPM for processing.
Update the package index and install Nginx:
sudo apt update
sudo apt install -y nginx
Nginx starts automatically after installation. Verify it's running:
sudo systemctl status nginx
You should see active (running) in the output. Enable Nginx to start on boot (it usually is by default, but confirm):
sudo systemctl enable nginx
Test that Nginx is responding to HTTP requests:
curl -I http://localhost
Expected output (first few lines):
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/html
...
If you open your server's IP address in a browser (http://YOUR_SERVER_IP), you should see the default "Welcome to nginx!" page. This confirms Nginx is installed, running, and accessible through the firewall.
Understanding Nginx File Locations
| Path | Purpose |
|---|---|
/etc/nginx/nginx.conf |
Main configuration file (global settings) |
/etc/nginx/sites-available/ |
Server block configs (all sites) |
/etc/nginx/sites-enabled/ |
Symlinks to active server blocks |
/etc/nginx/conf.d/ |
Additional configuration snippets |
/var/www/html/ |
Default web root directory |
/var/log/nginx/access.log |
Request log |
/var/log/nginx/error.log |
Error log |
Step 2: Installing MySQL 8.0
MySQL is the database server. It stores your application data — user accounts, blog posts, product catalogs, session data, and everything else your application needs to persist.
sudo apt install -y mysql-server
Check that MySQL is running:
sudo systemctl status mysql
Secure the MySQL Installation
MySQL ships with insecure defaults. The mysql_secure_installation script fixes the most critical ones:
sudo mysql_secure_installation
You'll be prompted for several settings:
- VALIDATE PASSWORD component: Press
yto enable. Choose password strength level 1 (MEDIUM) or 2 (STRONG) for production. - Root password: Set a strong password for the MySQL root user.
- Remove anonymous users:
y— anonymous users allow anyone to connect without a password. - Disallow root login remotely:
y— root should only connect from localhost. - Remove test database:
y— the test database is accessible to anyone. - Reload privilege tables:
y— applies all changes immediately.
Create a Database and Application User
Never use the MySQL root account for application connections. Create a dedicated database and user for each application:
sudo mysql
At the MySQL prompt:
-- Create a database
CREATE DATABASE myapp_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Create a user with a strong password
CREATE USER 'myapp_user'@'localhost' IDENTIFIED BY 'YourStrongPassword123!';
-- Grant privileges on the application database only
GRANT ALL PRIVILEGES ON myapp_db.* TO 'myapp_user'@'localhost';
-- Apply privilege changes
FLUSH PRIVILEGES;
-- Verify the user was created
SELECT user, host FROM mysql.user;
-- Exit
EXIT;
The utf8mb4 character set supports the full Unicode range including emojis — always use it instead of the older utf8 (which only supports 3-byte characters).
Test the new user can connect:
mysql -u myapp_user -p myapp_db
Enter the password when prompted. If you get a MySQL prompt, the user and database are working correctly. Type EXIT; to quit.
Step 3: Installing PHP 8.3 with PHP-FPM
PHP-FPM (FastCGI Process Manager) is the process manager that runs PHP code. Unlike mod_php (used with Apache), PHP-FPM runs as a separate service that Nginx communicates with via a Unix socket. This separation gives you independent control over PHP processes — you can restart PHP without restarting Nginx, and vice versa.
Install PHP 8.3 with commonly needed extensions:
sudo apt install -y php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip php8.3-gd php8.3-intl php8.3-bcmath php8.3-opcache php8.3-readline
Here's what each extension provides:
| Extension | Purpose |
|---|---|
php8.3-fpm |
FastCGI Process Manager — required for Nginx + PHP |
php8.3-mysql |
MySQL/MariaDB database driver (PDO and mysqli) |
php8.3-mbstring |
Multibyte string handling (required by most frameworks) |
php8.3-xml |
XML parsing (used by many packages and frameworks) |
php8.3-curl |
HTTP client library (API calls, webhooks) |
php8.3-zip |
ZIP archive handling |
php8.3-gd |
Image processing (thumbnails, resizing) |
php8.3-intl |
Internationalization (locale-aware formatting) |
php8.3-bcmath |
Arbitrary precision math (financial calculations) |
php8.3-opcache |
Bytecode caching (significant performance improvement) |
Verify PHP-FPM is running:
sudo systemctl status php8.3-fpm
Check the installed PHP version and loaded modules:
php -v
php -m
Step 4: Configuring Nginx to Serve PHP
Now we connect the pieces: configure Nginx to pass PHP requests to PHP-FPM. Create a new server block for your domain:
sudo nano /etc/nginx/sites-available/myapp
Add this server block configuration:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/myapp/public;
index index.php index.html index.htm;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Logging
access_log /var/log/nginx/myapp.access.log;
error_log /var/log/nginx/myapp.error.log;
# Main location block
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM processing
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
# FastCGI performance settings
fastcgi_buffering on;
fastcgi_buffer_size 16k;
fastcgi_buffers 16 16k;
fastcgi_connect_timeout 60s;
fastcgi_send_timeout 60s;
fastcgi_read_timeout 60s;
}
# Deny access to hidden files (except .well-known for Let's Encrypt)
location ~ /\.(?!well-known) {
deny all;
}
# Cache static assets
location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Deny access to sensitive files
location ~* \.(env|log|bak|sql|conf)$ {
deny all;
}
# Limit request body size (adjust for file uploads)
client_max_body_size 64M;
}
Let's break down the critical directives:
root /var/www/myapp/public— the document root. For frameworks like Laravel, this is thepublic/subdirectory. For WordPress, use/var/www/myappwithout the/publicsuffix.try_files $uri $uri/ /index.php?$query_string— try to serve the requested file directly; if it doesn't exist, pass the request toindex.php(front controller pattern used by most frameworks).fastcgi_pass unix:/run/php/php8.3-fpm.sock— sends PHP requests to PHP-FPM via a Unix socket (faster than TCP).
Create the web root directory and set permissions:
sudo mkdir -p /var/www/myapp/public
sudo chown -R www-data:www-data /var/www/myapp
sudo chmod -R 755 /var/www/myapp
Enable the site and disable the default site:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
Test the Nginx configuration for syntax errors:
sudo nginx -t
Expected output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload Nginx to apply the changes:
sudo systemctl reload nginx
Step 5: Testing the LEMP Stack
Create a PHP test file to verify everything works together:
sudo nano /var/www/myapp/public/info.php
Add this content:
<?php
phpinfo();
?>
Open http://YOUR_SERVER_IP/info.php in your browser (or http://example.com/info.php if your DNS is configured). You should see the PHP information page showing version 8.3, loaded extensions, and configuration details.
Security warning: The phpinfo() page exposes detailed server configuration. Remove it immediately after testing:
sudo rm /var/www/myapp/public/info.php
To test the database connection, create a temporary test script:
sudo nano /var/www/myapp/public/dbtest.php
<?php
$host = 'localhost';
$db = 'myapp_db';
$user = 'myapp_user';
$pass = 'YourStrongPassword123!';
try {
$pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "Database connection successful! MySQL version: " . $pdo->query('SELECT VERSION()')->fetchColumn();
} catch (PDOException $e) {
echo "Connection failed: " . $e->getMessage();
}
?>
Open http://YOUR_SERVER_IP/dbtest.php — you should see a success message with the MySQL version. Remove this file immediately:
sudo rm /var/www/myapp/public/dbtest.php
Step 6: SSL/TLS with Let's Encrypt
Every production site needs HTTPS. Let's Encrypt provides free SSL certificates, and Certbot automates the entire process including Nginx configuration and certificate renewal.
Install Certbot and the Nginx plugin:
sudo apt install -y certbot python3-certbot-nginx
Obtain and install the certificate (replace with your actual domain):
sudo certbot --nginx -d example.com -d www.example.com
Certbot will:
- Verify you control the domain (via HTTP-01 challenge)
- Obtain the certificate from Let's Encrypt
- Automatically modify your Nginx server block to add SSL directives
- Set up a redirect from HTTP to HTTPS
When prompted, choose option 2 to redirect all HTTP traffic to HTTPS (recommended).
Verify automatic renewal is configured:
sudo certbot renew --dry-run
Certbot installs a systemd timer that checks for renewal twice daily. Certificates are valid for 90 days and are renewed when they have 30 days or less remaining. You can verify the timer is active:
sudo systemctl status certbot.timer
Step 7: Production Performance Tuning
The default configurations for Nginx, PHP-FPM, and MySQL are conservative. Tuning them for your available RAM makes a significant difference.
Nginx Tuning
Edit the main Nginx configuration:
sudo nano /etc/nginx/nginx.conf
Key directives to adjust:
# Set to the number of CPU cores (or 'auto' to detect)
worker_processes auto;
# Maximum connections per worker
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off; # Hide Nginx version number
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
}
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
Key optimizations explained:
worker_processes auto— automatically matches the number of CPU cores. On a 4 vCPU VPS, this creates 4 worker processes.worker_connections 1024— each worker can handle 1024 simultaneous connections. Total capacity = workers x connections (e.g., 4 x 1024 = 4096 concurrent connections).use epoll— uses the Linux epoll event mechanism, which is more efficient than the default poll/select for handling many connections.sendfile on— bypasses user-space when serving static files, letting the kernel send files directly to the network socket.server_tokens off— hides the Nginx version number from response headers and error pages, reducing information leakage.gzipsettings — compresses text-based responses before sending them to clients. Level 5 provides good compression without excessive CPU usage. Thegzip_min_length 256setting avoids compressing tiny responses where the overhead exceeds the savings.
PHP-FPM Pool Tuning
The PHP-FPM pool determines how many PHP processes run simultaneously. Too few and requests queue up; too many and you exhaust RAM. Edit the pool configuration:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
The critical setting is the process manager mode and its parameters. For a VPS, ondemand or dynamic modes are appropriate:
; Process manager mode
pm = dynamic
; Maximum number of child processes
; Formula: (Total RAM - RAM for OS - RAM for MySQL - RAM for Nginx) / Average PHP process size
; Average PHP process: ~30-50MB. For 4GB RAM: (4096 - 512 - 1024 - 128) / 40 ≈ 58
pm.max_children = 50
; Starting number of processes
pm.start_servers = 10
; Minimum idle processes
pm.min_spare_servers = 5
; Maximum idle processes
pm.max_spare_servers = 20
; Requests before a process is recycled (prevents memory leaks)
pm.max_requests = 500
For VPS plans with less memory, use these conservative values:
| VPS RAM | pm.max_children | pm.start_servers | pm.min_spare | pm.max_spare |
|---|---|---|---|---|
| 2 GB | 15 | 4 | 2 | 6 |
| 4 GB | 50 | 10 | 5 | 20 |
| 8 GB | 100 | 20 | 10 | 40 |
| 16 GB | 200 | 40 | 20 | 80 |
Also configure OPcache in the PHP configuration for significant performance gains:
sudo nano /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.revalidate_freq=60
opcache.validate_timestamps=1
Restart PHP-FPM to apply changes:
sudo systemctl restart php8.3-fpm
MySQL/InnoDB Tuning
MySQL's performance is heavily influenced by the InnoDB buffer pool — the in-memory cache for table data and indexes. Edit the MySQL configuration:
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
Add or modify these settings in the [mysqld] section:
[mysqld]
# InnoDB buffer pool — set to ~50-70% of available RAM for a dedicated DB server
# For a shared LEMP server, use 25-40% of total RAM
innodb_buffer_pool_size = 1G
# Log file size — larger values improve write performance but increase recovery time
innodb_log_file_size = 256M
# Flush method — O_DIRECT avoids double buffering with the OS page cache
innodb_flush_method = O_DIRECT
# Per-table tablespace (default in MySQL 8, but confirm it's on)
innodb_file_per_table = 1
# Temporary table size
tmp_table_size = 64M
max_heap_table_size = 64M
# Query cache is removed in MySQL 8.0 — don't try to configure it
# Connection limits
max_connections = 100
# Slow query log (essential for finding performance bottlenecks)
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 2
Restart MySQL:
sudo systemctl restart mysql
Verify the buffer pool size was applied:
sudo mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
Your Database Is Already Protected: Ceph 3x Replication
MySQL writes data to /var/lib/mysql/ on disk. On a standard VPS, that disk is a single drive — if it fails, your database is gone. On MassiveGRID, that disk is backed by Ceph distributed storage with 3x replication.
Every write to your MySQL data files is replicated across three different physical NVMe drives on three different servers. If a drive fails, Ceph seamlessly serves data from the remaining replicas and automatically rebuilds the third copy on another drive. Your database never sees the failure.
This doesn't replace application-level backups — you should still run mysqldump or use xtrabackup for point-in-time recovery and logical backups. But it does mean you don't need to worry about RAID configuration or drive monitoring at the infrastructure level. The storage layer is already redundant.
When to Upgrade to Dedicated Resources
A shared VPS is perfect for small to medium websites. But as your traffic grows, you'll notice that shared vCPUs can introduce inconsistent response times during peak hours — when other VMs on the same host are also busy, your PHP processes compete for CPU time.
Signs it's time to upgrade to a Dedicated VPS (VDS):
- MySQL slow query log shows queries taking longer than expected, even after optimizing indexes
htopshows CPU steal time (st) above 5-10% consistently- PHP-FPM is hitting
pm.max_childrenregularly (check/var/log/php8.3-fpm.log) - Response times vary significantly between peak and off-peak hours
Dedicated vCPUs eliminate noisy-neighbor effects entirely. Your MySQL queries and PHP processes get consistent CPU time regardless of what other customers are doing.
Next Steps
Your LEMP stack is installed, secured with SSL, and tuned for production. From here:
- Host multiple WordPress sites on this LEMP stack with separate Nginx server blocks and MySQL databases per site
- Harden your server security if you haven't already — SSH keys, Fail2Ban, and kernel hardening
- Consider Docker if you prefer containerized deployments over traditional stack management
Skip the Stack Management Entirely
Installing and tuning a LEMP stack is educational, but ongoing maintenance — PHP version upgrades, MySQL security patches, Nginx configuration changes, SSL certificate management, log rotation, performance monitoring — adds up. If you'd rather deploy your application and let someone else handle the infrastructure, MassiveGRID's Managed Dedicated Cloud Servers come with a fully configured, optimized, and maintained server stack. You deploy your code; they handle everything else.