Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

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

By Kokil Thapa | Last reviewed: August 2026

You need to set up a LEMP stack on Ubuntu that actually handles production traffic without collapsing under load or exposing security holes. While basic tutorials get Nginx, MySQL, and PHP running, they often skip the critical tuning required for modern frameworks like Laravel 12 or high-traffic WooCommerce stores. This guide covers the complete installation and hardening process for Ubuntu 24.04 LTS, focusing on the specific configurations I use when deploying client projects. If you are evaluating infrastructure options before starting, understanding these requirements helps clarify whether managed cloud hosting services in Nepal or a self-managed VPS better fits your budget and technical capacity.

What Is Required to Set Up a LEMP Stack on Ubuntu in 2026?

The LEMP acronym replaces Apache with Nginx (pronounced "Engine-X") as the web server, keeping Linux, MySQL/MariaDB, and PHP as the core components. In 2026, the minimum viable versions for a secure, performant stack differ significantly from older documentation you might encounter. Using outdated versions exposes you to unpatched vulnerabilities and compatibility issues with modern application code.

For Ubuntu 24.04 LTS (Noble Numbat), which is the current standard for new deployments, you should target these specific component versions:

  • Nginx: 1.26+ (stable branch) or 1.27+ (mainline). Ubuntu 24.04 ships with 1.24 by default; adding the official Nginx PPA is recommended for latest stable releases.
  • MySQL: 8.4 LTS is the current long-term support release. MySQL 8.0 remains supported but 8.4 offers improved defaults and performance schema enhancements.
  • PHP: PHP 8.4 is the latest stable release as of late 2025 and fully supported through 2026. PHP 8.3 is also widely used and stable. Laravel 12 requires minimum PHP 8.2; Symfony 7.x requires PHP 8.2+. Never deploy PHP 8.1 or lower for new projects in 2026.
  • OS: Ubuntu 24.04 LTS provides five years of security updates. Avoid non-LTS releases like 25.04 for production servers.
Client BrowserHTTPS RequestNginxPort 80/443Static FilesReverse ProxyPHP 8.4-FPMUnix SocketMySQL 8.4Port 3306Ubuntu 24.04 LTS Host System
LEMP stack request flow: Nginx handles static content directly and proxies dynamic PHP requests to PHP-FPM via Unix socket

Before installation, ensure your server has at least 2GB RAM for comfortable operation of all three services. For Laravel applications with queue workers or WordPress with multiple plugins, 4GB is a safer baseline. Run sudo apt update && sudo apt upgrade -y first to patch existing packages. Also configure UFW firewall rules immediately after SSH access: allow ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) before installing any services.

How Do You Install and Configure Nginx for Production?

Nginx serves as the front-line reverse proxy and static file server in the LEMP stack. Unlike Apache's process-per-request model, Nginx uses an event-driven architecture that handles thousands of concurrent connections with minimal memory overhead. This makes it ideal for serving Laravel APIs or WordPress sites where many requests are static assets.

Installation and Basic Hardening

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

# Verify version and status
nginx -v
sudo systemctl status nginx

After installation, edit the main configuration file to harden security headers and disable unnecessary features:

sudo nano /etc/nginx/nginx.conf

# Add inside http {} block:
server_tokens off;
client_max_body_size 64M;
types_hash_max_size 2048;

# Security headers (add to server blocks or include globally)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;

The server_tokens off; directive prevents Nginx from disclosing its version number in response headers and error pages, reducing information available to attackers. The client_max_body_size limit prevents large upload attacks; adjust based on your application's needs (e.g., media uploads for WordPress or document uploads for legal-tech portals).

Creating Server Blocks for Applications

Never modify the default site configuration for production applications. Create dedicated server blocks in /etc/nginx/sites-available/ and symlink them to /etc/nginx/sites-enabled/. Here is a production-ready template for a Laravel 12 application:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    root /var/www/example.com/public;
    index index.php index.html;

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

    # Laravel routing
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # PHP processing
    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 4 16k;
    }

    # Deny access to hidden files
    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Critical details often missed: the root must point to the /public directory for Laravel, never the project root. The fastcgi_pass uses a Unix socket rather than TCP localhost for better performance on single-server setups. After creating the config, test syntax with sudo nginx -t before reloading with sudo systemctl reload nginx.

How Should MySQL 8.4 Be Secured During Installation?

Database security failures cause more breaches than any other LEMP component. MySQL 8.4 introduces changes to authentication plugins and default behaviors that require explicit attention during setup. Running mysql_secure_installation is mandatory, but insufficient alone.

Installation and Authentication Setup

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

During the secure installation wizard, make these specific choices:

  1. VALIDATE PASSWORD COMPONENT: Enable with strength level 2 (MEDIUM) for production. This enforces mixed case, numbers, and special characters.
  2. Remove anonymous users: YES. Anonymous accounts allow unauthenticated local access.
  3. Disallow root login remotely: YES. Root should only connect via Unix socket locally.
  4. Remove test database: YES. The test database is accessible by any user and poses a risk.
  5. Reload privilege tables: YES. Applies changes immediately.

MySQL 8.4 defaults to caching_sha2_password authentication plugin, which is secure but incompatible with some older PHP drivers. Verify your PHP MySQL extension supports it:

# Check PHP MySQL driver
php -i | grep mysqlnd

# If using legacy apps requiring mysql_native_password:
ALTER USER 'app_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'StrongP@ss!';

Creating Application-Specific Users

Never run applications as root. Create dedicated users with minimal privileges for each application:

CREATE DATABASE laravel_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'laravel_user'@'localhost' IDENTIFIED BY 'Str0ng!Pass#2026';
GRANT ALL PRIVILEGES ON laravel_app.* TO 'laravel_user'@'localhost';
FLUSH PRIVILEGES;

The utf8mb4 charset with utf8mb4_unicode_ci collation is essential for proper emoji and international character support, particularly important for Nepali-language legal documents or multilingual eCommerce catalogs. Restrict host to localhost unless remote database access is explicitly required (which it rarely should be).

MySQL 8.4 Security Hardening LayersAuthenticationcaching_sha2_passwordValidate Password PluginNo Remote Root AccessUser IsolationPer-App Database UsersMinimal GRANT PrivilegesLocalhost-Only BindingNetwork & Databind-address = 127.0.0.1utf8mb4 Charset DefaultEncrypted ConnectionsVerification CommandSELECT user, host, plugin FROM mysql.user WHERE user != 'mysql.sys';
Three-layer MySQL security model: authentication hardening, per-application user isolation, and network-level restrictions

How Do You Tune PHP 8.4-FPM for Real Workloads?

PHP-FPM (FastCGI Process Manager) is where most LEMP performance problems originate. Default configurations assume minimal load and fail catastrophically under real traffic. Tuning requires understanding your server's RAM and your application's memory footprint.

Calculating Worker Processes

The critical setting is pm.max_children, which determines how many PHP processes can handle requests simultaneously. Each Laravel worker consumes approximately 40-60MB RAM; WordPress workers typically use 30-50MB. Calculate using this formula:

Available RAM for PHP = Total RAM - (MySQL + Nginx + OS Reserve)
max_children = Available RAM / Average Process Size

# Example: 4GB server, MySQL uses 1GB, reserve 512MB for OS/Nginx
# Available: 4096 - 1024 - 512 = 2560MB
# Laravel avg 50MB: 2560 / 50 = 51 max_children

Edit the pool configuration file:

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

; Dynamic process manager for variable traffic
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000

; Slow log for debugging
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 5s

The pm.max_requests = 1000 setting recycles workers after handling 1000 requests, preventing memory leaks from accumulating over time. This is especially important for long-running Laravel queue workers or WordPress installations with poorly coded plugins. The slow log captures any request taking longer than 5 seconds, invaluable for identifying bottlenecks in production.

OPcache Configuration for Performance

OPcache stores precompiled PHP bytecode in shared memory, eliminating parsing overhead on every request. Enable and tune it in /etc/php/8.4/fpm/conf.d/10-opcache.ini:

opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1

Setting validate_timestamps=0 disables file change checking entirely, providing maximum performance. However, this means you must restart PHP-FPM after every deployment (sudo systemctl restart php8.4-fpm). On development servers, keep validation enabled. For production deployments using Deployer or similar tools, integrate the FPM restart into your deployment script automatically.

What Are Common Mistakes When Setting Up LEMP on Ubuntu?

After years of troubleshooting LEMP stacks for clients, certain mistakes appear repeatedly. Avoiding these saves hours of debugging and prevents security incidents.

MistakeConsequenceCorrect Approach
Using TCP instead of Unix sockets for PHP-FPM10-15% higher latency under load due to TCP overheadConfigure fastcgi_pass unix:/run/php/php8.4-fpm.sock;
Running applications as root or www-dataCompromised app gains full system accessCreate dedicated system users per application with minimal permissions
Leaving default MySQL bind-addressDatabase exposed to public internet if firewall misconfiguredSet bind-address = 127.0.0.1 in /etc/mysql/mysql.conf.d/mysqld.cnf
Not setting client_max_body_size in NginxFile uploads fail silently with 413 errorsMatch limit to application requirements (e.g., 64M for media uploads)
Skipping OPcache restart after deploysUsers see cached old code, causing errors and data corruptionAdd systemctl restart php8.4-fpm to deployment pipeline
Using outdated PHP versions for new projectsMissing security patches, framework incompatibilityMinimum PHP 8.2 for Laravel 12/Symfony 7; prefer 8.4 for new installs

One mistake deserves special emphasis: file ownership. After cloning a repository or extracting an archive, always set correct ownership:

# For Laravel applications
sudo chown -R www-data:www-data /var/www/example.com/storage
sudo chown -R www-data:www-data /var/www/example.com/bootstrap/cache
sudo chmod -R 775 /var/www/example.com/storage

# For WordPress
sudo chown -R www-data:www-data /var/www/example.com/wp-content

Incorrect permissions cause cryptic "permission denied" errors in logs, failed uploads, and cache write failures. The storage and cache directories must be writable by the web server user; application code files should remain read-only to prevent tampering.

Traffic Pattern?Steady / PredictableConsistent daily loadVariable / SpikyCampaigns, events, newsLow / DevelopmentTesting, staging sitespm = staticFixed worker countBest for sustained loadpm = dynamicAuto-scales workersRecommended defaultpm = ondemandSpawns on requestSaves RAM, adds latencyProduction Default RecommendationUse pm = dynamic with calculated max_children based on available RAM
PHP-FPM process manager selection guide: dynamic mode suits most production workloads with variable traffic patterns

How Do You Verify and Maintain a Production LEMP Stack?

Installation completes the setup; verification ensures it works correctly under real conditions. Run these checks after configuring all components:

  1. PHP-FPM Status: Create a temporary info.php file in your web root containing <?php phpinfo(); ?>, access it via browser, verify OPcache is enabled and loaded extensions match requirements, then delete the file immediately.
  2. MySQL Connectivity: Test application database connections using the CLI: mysql -u laravel_user -p laravel_app. Verify charset with SHOW VARIABLES LIKE 'character_set%';.
  3. Nginx Configuration: Run sudo nginx -T to dump the complete effective configuration. Review for syntax errors, incorrect root paths, or missing security headers.
  4. Socket Permissions: Verify PHP-FPM socket exists and has correct permissions: ls -la /run/php/php8.4-fpm.sock. Should show srw-rw----owned by www-data.
  5. Firewall Rules: Confirm UFW allows only necessary ports: sudo ufw status verbose. Ports 22, 80, 443 should be ALLOW; 3306 should be DENY or restricted to specific IPs.

Ongoing maintenance prevents degradation. Set up automated security updates with unattended-upgrades, configure log rotation for Nginx and PHP-FPM logs to prevent disk exhaustion, and schedule weekly MySQL backups using mysqldump or Percona XtraBackup. Monitor resource usage with tools like htop or Netdata to identify when scaling becomes necessary.

For developers building Laravel applications specifically, review Laravel developer best practices to ensure your application code complements your server configuration. Similarly, if you're deploying WordPress, understanding WordPress development patterns helps avoid plugin conflicts that undermine server-level optimizations.

Next Steps After Your LEMP Stack Is Running

A properly configured LEMP stack provides the foundation, but production systems require additional layers. Install Certbot for automated SSL/TLS certificates from Let's Encrypt — never run HTTP-only sites in 2026. Configure Redis for session storage and caching to reduce MySQL load. Set up automated backups with verification restores. Implement monitoring with alerting for disk space, memory pressure, and service failures.

If managing infrastructure feels overwhelming or your project requires specialized expertise, consider working with an experienced web developer in Nepal who understands both application code and server administration. Many businesses find that professional setup and ongoing maintenance costs less than debugging self-configured servers after outages.

Ready to deploy your application on a properly configured LEMP stack? Contact me to discuss your project requirements, get a server audit, or arrange professional setup and hardening for your Ubuntu infrastructure.

Frequently Asked Questions

PHP 8.2 is the current minimum for Laravel 12 and Symfony 7.x, while PHP 8.4 is the latest stable release recommended for new Ubuntu LEMP installations.

A basic DigitalOcean or Hetzner VPS runs Rs 800–1,500 per month (USD 6–12), sufficient for low-traffic sites; production eCommerce typically requires Rs 3,000+ monthly (USD 23+) for adequate RAM and CPU.

Nginx handles high concurrency with lower memory overhead than Apache prefork, making it better suited for serving static assets and proxying PHP-FPM on resource-constrained Ubuntu servers.

Add the Ondřej Surý PPA via add-apt-repository ppa:ondrej/php, update apt, then run apt install php8.4-fpm php8.4-mysql php8.4-xml php8.4-curl. Verify with systemctl status php8.4-fpm and check socket path at /run/php/php8.4-fpm.sock for Nginx upstream configuration. Always pin specific extensions needed by your application rather than installing meta-packages that pull unnecessary dependencies.

Set ownership to www-data:www-data with directories at 755 and files at 644 using chown -R www-data:www-data /var/www/html && find /var/www/html -type d -exec chmod 755 {} \; && find /var/www/html -type f -exec chmod 644 {} \;. Never use 777 permissions. For Laravel storage and cache directories, set group-writable 775 only where the application writes logs, sessions, or compiled views. Incorrect permissions cause blank pages or 500 errors that are difficult to diagnose without checking error logs.

In your server block, set fastcgi_pass unix:/run/php/php8.4-fpm.sock;, include fastcgi_params, and define SCRIPT_FILENAME as $document_root$fastcgi_script_name. Missing or incorrect SCRIPT_FILENAME causes primary script not found errors. Test configuration with nginx -t before reloading. On Ubuntu 24.04, verify the socket path matches your installed PHP version exactly. Mismatched socket paths between Nginx config and actual PHP-FPM service are among the most common deployment failures I encounter on client projects.

MySQL 8.4 LTS offers official Oracle support and compatibility with modern Laravel features, while MariaDB 11.x provides drop-in replacement with potentially better performance for read-heavy workloads. For Nepal-based legal-tech or eCommerce projects requiring eSewa or Khalti integration testing against standard MySQL drivers, I default to MySQL 8.4 LTS. MariaDB remains excellent for WordPress and WooCommerce where community packages assume MariaDB compatibility. Choose based on your application ORM requirements and long-term maintenance preferences rather than benchmark micro-differences.

Configure UFW to allow only ports 22, 80, and 443. Install fail2ban with nginx-botsearch and php-url-fopen jails. Disable SSH password authentication and enforce key-based login. Set expose_php = Off and disable_functions = exec,passthru,shell_exec,system in php.ini. Use Let's Encrypt SSL via certbot --nginx. Regularly run unattended-upgrades for security patches. On production legal portals I maintain, this baseline prevents automated exploitation attempts that target default Ubuntu configurations. Security is layered; no single measure suffices.

This indicates Nginx cannot communicate with PHP-FPM. Check if PHP-FPM is running via systemctl status php8.4-fpm. Verify the socket path in both /etc/nginx/sites-available/your-site and /etc/php/8.4/fpm/pool.d/www.conf match exactly. Confirm www-data user owns the socket file. Review /var/log/php8.4-fpm.log and /var/log/nginx/error.log simultaneously. After PHP upgrades, old socket references persist until you restart both services. I have debugged this repeatedly during Ubuntu minor releases where PHP-FPM socket naming changed unexpectedly between point versions.

Ensure SSL is active first, as HTTP/2 requires HTTPS. Add http2 on; to your listen 443 directive. Install libnginx-mod-brotli via apt, then add brotli on; brotli_types text/plain text/css application/json application/javascript text/xml; to your server block. Verify with curl -I --http2 https://yoursite.com showing HTTP/2 200 and content-encoding: br headers. Brotli outperforms gzip for text assets by 15-20%. On WooCommerce stores I have optimized, enabling Brotli reduced page weight significantly without any application code changes, directly improving Core Web Vitals scores.

Start with pm = dynamic, pm.max_children calculated as available RAM divided by average PHP process size (typically 30-50MB). Set pm.start_servers to 25% of max_children, pm.min_spare_servers to 10%, and pm.max_spare_servers to 50%. Configure request_terminate_timeout = 60s and max_execution_time = 30 in php.ini. Monitor slow log at /var/log/php8.4-fpm.slow.log with request_slowlog_timeout = 5s. Default Ubuntu settings suit development but exhaust resources on production eCommerce during traffic spikes. Tuning these values based on actual memory usage prevents cascading failures during peak hours.

Install certbot python3-certbot-nginx, run certbot --nginx -d yourdomain.com, and verify automatic timer via systemctl list-timers | grep certbot. Certbot creates /etc/cron.d/certbot running twice daily. Test renewal manually with certbot renew --dry-run. Ensure Nginx reloads after renewal by adding --deploy-hook "systemctl reload nginx" to your certbot command or hook directory. Expired certificates cause immediate downtime and SEO penalties. On sister sites sharing Deployer 7 pipelines, I include SSL verification in post-deploy hooks to catch certificate issues before they affect users.

Yes, install parallel FPM packages like php8.2-fpm and php8.4-fpm from Ondřej Surý PPA. Each version gets its own socket at /run/php/phpX.Y-fpm.sock. Configure separate Nginx server blocks pointing fastcgi_pass to the appropriate socket for each site. Manage independent pools in /etc/php/X.Y/fpm/pool.d/. This allows legacy Laravel 9 apps on PHP 8.2 alongside new Laravel 12 projects on PHP 8.4. I use this pattern frequently when maintaining older client systems while building new features, avoiding forced migrations that risk breaking working production applications.

Edit /etc/mysql/mysql.conf.d/mysqld.cnf to set innodb_buffer_pool_size to 70% of available RAM for dedicated database servers. Enable slow_query_log with long_query_time = 1. Configure innodb_log_file_size = 256M and innodb_flush_log_at_trx_commit = 2 for write-heavy WooCommerce order processing. Use mysqltuner-perl package to analyze current settings against actual workload. Index columns used in WHERE, JOIN, and ORDER BY clauses identified through EXPLAIN analysis. Default Ubuntu MySQL configuration suits development but bottlenecks production catalogs exceeding 10,000 products. Profile queries before optimizing blindly.

Using deprecated include snippets/fastcgi_params instead of include fastcgi_params causes missing environment variables. Forgetting to adjust apparmor profiles after custom PHP-FPM socket paths blocks access silently. Not setting server_tokens off exposes Nginx version information. Skipping php-fpm restart after editing pool configuration leaves old settings active. Assuming php-cli and php-fpm share identical ini files leads to CLI-working-but-web-broken scenarios. Always validate with nginx -t and systemctl restart php8.4-fpm after changes. Document every deviation from defaults; future debugging depends on knowing what was customized versus stock Ubuntu behavior.

Share this article

Quick Contact Options
Choose how you want to connect me: