Set up a LEMP stack on Ubuntu 24.04 LTS: Nginx, MariaDB, PHP 8.3 and Let's Encrypt

Production-ready installation of the LEMP stack on Ubuntu 24.04 LTS: Nginx web server, MariaDB 10.11, PHP 8.3-FPM pool tuning, UFW hardening and automatic Let's Encrypt TLS.

Reading time: 25 min

πŸ’‘ Newer LTS generation available: Planning a fresh install on the current long-term version? The guide Set up a LEMP stack on Ubuntu 26.04 LTS documents Nginx with native HTTP/3 and QUIC, MariaDB 11.4 and PHP 8.5.

The LEMP stack is one of the most proven and resource-efficient architectures for running dynamic web applications on Linux. Linux forms the stable operating-system foundation, Nginx acts as a high-performance asynchronous web server and reverse proxy, MariaDB provides relational database management and PHP runs the server-side application logic.

Compared with classic LAMP architectures (Apache with mod_php), the LEMP stack is marked by strict process decoupling. Nginx handles static requests through an event-driven non-blocking architecture and delegates dynamic script calls over Unix domain sockets to the FastCGI Process Manager (php-fpm). That cuts memory use under parallel connections drastically and protects the overall system from load spikes.

The following setup builds a hardened, production-ready LEMP stack on Ubuntu 24.04 LTS (Noble Numbat). It covers system hardening with UFW and Fail2ban, performance tuning of MariaDB and PHP 8.3-FPM, and encrypted delivery through Let's Encrypt with modern TLS 1.3.

πŸ’‘ Long-term support (LTS): Ubuntu 24.04 LTS guarantees official security updates and maintained package states until April 2029. In production this predictable five-year cycle provides high operational stability with minimal maintenance effort.

⚠️ Prerequisites: Administrative access (sudo) to a clean Ubuntu 24.04 LTS installation, basic shell-administration knowledge and a publicly resolvable DNS record (A/AAAA) for your domain if a Let's Encrypt TLS certificate is to be issued.

System requirements and component matrix

Before packages are installed, server hardware should be matched to the planned workload. Nginx is extremely frugal, while relational database queries and parallel PHP workers have a direct effect on RAM and I/O performance.

Component Package version in Ubuntu 24.04 Default port / socket Minimum Recommended for production
Operating system Ubuntu 24.04 LTS - 1 vCPU, 1 GB RAM 2–4 vCPUs, 4–8 GB RAM
Nginx 1.24+ TCP 80, 443 128 MB RAM 512 MB RAM
MariaDB 10.11 LTS TCP 3306 (127.0.0.1) 512 MB RAM 2 to 4 GB RAM (InnoDB buffer)
PHP-FPM 8.3 /run/php/php8.3-fpm.sock 256 MB RAM 1 to 2 GB RAM (for workers)
Storage NVMe / SSD - 10 GB free 25+ GB with dedicated backup

1. System preparation and baseline hardening

Every clean server deployment starts with an updated package index and a restrictive firewall configuration. That prevents unprotected default services from being reachable directly from the internet during setup.

Refresh package sources and clean up

First we bring the local package cache up to date and install pending kernel and security updates:


# Update package lists and upgrade installed packages
sudo apt update && sudo apt upgrade -y

# Remove unused leftover dependencies without residue
sudo apt autoremove --purge -y && sudo apt autoclean

Diagnosis and resource check:

Before installing larger server services we verify available disk space, memory and system time:


# Check disk space on the root partition
df -h /

# Check RAM availability and swap status
free -h

# Ensure time synchronization via NTP
timedatectl status

Firewall (UFW) and brute-force protection (Fail2ban)

Uncomplicated Firewall (UFW) manages the kernel packet filter (nftables) with clear default policies: all incoming traffic is blocked, outgoing connections are allowed.


# Install UFW and Fail2ban
sudo apt install ufw fail2ban -y

# Define default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Unlock SSH before enabling (port 22)
sudo ufw allow OpenSSH

# Enable the firewall and check status
sudo ufw --force enable
sudo ufw status verbose

⚠️ Critical pitfall: Never enable UFW without first explicitly allowing the SSH service (sudo ufw allow OpenSSH or sudo ufw allow 22/tcp). Otherwise you lose remote access immediately with the enable command and need your hoster's emergency console.


# Check existing web-server services (e.g. Apache leftovers)
sudo ss -tuln | grep -E ':(80|443)' || echo "Ports 80 and 443 are free"

2. Install Nginx as the web server

Nginx acts as the frontend of our stack. It accepts incoming TCP connections, terminates TLS, serves static assets (CSS, JavaScript, images) directly and forwards dynamic PHP requests to the FPM socket.

Install Nginx and extend the firewall rule


# Install Nginx from the official Ubuntu repositories
sudo apt install nginx -y

# Register the service for automatic start and start it
sudo systemctl enable nginx
sudo systemctl start nginx

# Allow HTTP (port 80) and HTTPS (port 443) in the firewall
sudo ufw allow 'Nginx Full'
sudo ufw status

Verify the operating state:


# Check the running status of the systemd unit
systemctl status nginx --no-pager

# Test the local HTTP response
curl -I http://127.0.0.1

The response must return status code HTTP/1.1 200 OK together with the header Server: nginx/....

⚠️ Avoid port conflicts: If Nginx does not start after installation, a preinstalled Apache service (apache2) often blocks port 80. Check with sudo ss -tuln | grep :80. If Apache is active, disable it with sudo systemctl stop apache2 && sudo systemctl disable apache2.

Nginx directory structure

On Debian and Ubuntu, Nginx follows a modular layout that separates configuration templates (sites-available) from active instances (sites-enabled) via symbolic links:


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                 Nginx directory structure                   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   /etc/nginx/                                               β”‚
β”‚   β”œβ”€β”€ nginx.conf          Global main configuration         β”‚
β”‚   β”œβ”€β”€ conf.d/             Modular server configurations     β”‚
β”‚   β”œβ”€β”€ sites-available/    VHost templates (inactive)        β”‚
β”‚   └── sites-enabled/      Active VHost symlinks             β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • nginx.conf: Global parameters such as worker processes, event models, gzip compression and logging formats.
  • sites-available/: Configuration files for individual virtual hosts (domains).
  • sites-enabled/: Contains symlinks to files in sites-available/. Only VHosts in this folder are actively loaded by Nginx.
  • conf.d/: Global modular configuration snippets for upstream pools or security policies.

3. Provide the MariaDB database server

As the relational database system we use MariaDB 10.11 LTS. MariaDB offers full MySQL compatibility, but stands out through the modern InnoDB storage engine, improved thread pools and high performance on web workloads.

Installation and automatic start


# Install MariaDB server and client tools
sudo apt install mariadb-server mariadb-client -y

# Enable the service and check status
sudo systemctl enable mariadb
sudo systemctl status mariadb --no-pager

Hardening with mariadb-secure-installation

Immediately after installation the database contains insecure defaults (anonymous user accounts, a remotely accessible root account and a public test database). We clean these weaknesses:


# Run the interactive security wizard
sudo mariadb-secure-installation

Recommended answers for production:

  1. Enter current password for root: press Enter (by default no password is set).
  2. Switch to unix_socket authentication [Y/n]: Y (allows root login via system root without a plaintext password).
  3. Change the root password? [Y/n]: Y (choose a complex, long password and store it externally).
  4. Remove anonymous users? [Y/n]: Y (remove anonymous accounts completely).
  5. Disallow root login remotely? [Y/n]: Y (restrict root access strictly to localhost).
  6. Remove test database and access to it? [Y/n]: Y (delete test tables).
  7. Reload privilege tables now? [Y/n]: Y (reload privilege tables immediately).

πŸ’‘ Socket authentication on Linux: Ubuntu uses the unix_socket plugin for the MariaDB root account by default. That means: the system user root can log in with sudo mariadb directly without a password. An external root password is needed mainly when third-party tools without sudo rights request local administrative access.

MariaDB directory structure and tuning

Configuration is organized modularly through /etc/mysql/:


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                MariaDB directory structure                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   /etc/mysql/                                               β”‚
β”‚   β”œβ”€β”€ my.cnf              Central inclusion symlink         β”‚
β”‚   └── mariadb.conf.d/     Server configurations             β”‚
β”‚       β”œβ”€β”€ 50-server.cnf   Main daemon configuration         β”‚
β”‚       └── 60-galera.cnf   Cluster and replication profiles  β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

To optimize for production web servers we adjust InnoDB resource allocation in /etc/mysql/mariadb.conf.d/50-server.cnf:


# Open the configuration for editing
sudo nano /etc/mysql/mariadb.conf.d/50-server.cnf

Core parameters in the [mysqld] section:


[mysqld]
# Restrict network binding strictly to localhost
bind-address            = 127.0.0.1

# Performance and InnoDB tuning
innodb_buffer_pool_size = 512M          # At 2 GB RAM; at 4 GB+ set to 50-70% of RAM
innodb_log_file_size    = 128M          # Writes transactions efficiently to the log
innodb_flush_log_at_trx_commit = 2     # Reduces disk I/O under write load
innodb_file_per_table   = 1             # Creates its own .ibd files per table
max_connections         = 100           # Prevents overload from too many clients

Explanation of the tuning parameters:

Parameter Recommended value Effect in server operation
bind-address 127.0.0.1 Protects MariaDB from direct network access from outside
innodb_buffer_pool_size 50–70% of free RAM Holds table data and indexes in fast memory
innodb_flush_log_at_trx_commit 2 Writes logs to disk once per second; drastic I/O gain
max_connections 50–150 Limits concurrent connections and prevents OOM crashes

After the change we restart the service:


# Reload the MariaDB configuration
sudo systemctl restart mariadb

4. Install and tune PHP 8.3-FPM

PHP is provided on Ubuntu 24.04 in version 8.3. The FastCGI Process Manager (php-fpm) runs as a standalone system service and manages worker processes that compile and execute PHP files.

Install PHP-FPM and core modules

Modern web applications (such as WordPress, Nextcloud or Laravel) need a set of specialized PHP extensions besides the FPM service:


# Install PHP 8.3-FPM plus database, image and optimization modules
sudo apt install -y php8.3-fpm php8.3-common php8.3-mysql php8.3-xml                     php8.3-curl php8.3-gd php8.3-mbstring php8.3-zip                     php8.3-opcache php8.3-intl

Check service status and modules:


# Check the PHP-FPM status
sudo systemctl status php8.3-fpm --no-pager

# List installed PHP version and active extensions
php -v
php -m | grep -E '(mysqli|pdo_mysql|opcache|curl)'

⚠️ Component dependency: Make sure the php8.3-fpm package is installed. If you accidentally install only the php metapackage, Ubuntu pulls Apache as a dependency and starts a colliding web server.

PHP 8.3 directory structure


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                PHP 8.3 directory structure                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   /etc/php/8.3/                                             β”‚
β”‚   β”œβ”€β”€ fpm/                                                  β”‚
β”‚   β”‚   β”œβ”€β”€ php-fpm.conf    Global master settings            β”‚
β”‚   β”‚   β”œβ”€β”€ php.ini         FPM runtime configuration         β”‚
β”‚   β”‚   └── pool.d/         Worker pools                      β”‚
β”‚   β”‚       └── www.conf    Default pool (www-data socket)    β”‚
β”‚   └── cli/                                                  β”‚
β”‚       └── php.ini         CLI runtime configuration         β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Worker-pool tuning in www.conf

The file /etc/php/8.3/fpm/pool.d/www.conf controls how PHP-FPM spawns processes and accepts requests. By default Ubuntu uses the dynamic mode:


; /etc/php/8.3/fpm/pool.d/www.conf
[www]
user = www-data
group = www-data

; Communication via local Unix-domain socket
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

; Process management
pm = dynamic
pm.max_children = 25
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500

Rules of thumb for calculating pm.max_children:


# Determine average RAM use of active PHP-FPM workers (in MB)
ps --no-headers -o rss -C php-fpm8.3 | awk '{total+=$1; count++} END {if (count>0) print int(total/count/1024) " MB"; else print "60 MB (default guideline)"}'

# Rule of thumb for pm.max_children:
# max_children = (available RAM for PHP in MB) / (average RAM per worker in MB)

# Calculation example: 2048 MB assigned RAM at about 60 MB per worker
echo $(( 2048 / 60 ))
# Result: 34
Directive Typical value Function
pm.max_children 20–50 Maximum number of concurrent worker processes
pm.start_servers 4–8 Number of workers created at service start
pm.min_spare_servers 2–4 Minimum reserve of idle workers at idle
pm.max_spare_servers 6–12 Maximum reserve of idle workers
pm.max_requests 500 Recycles workers after 500 requests to prevent memory leaks

# After a configuration change, reload the FPM service
sudo systemctl reload php8.3-fpm

5. Integrating Nginx and PHP-FPM

After Nginx and PHP-FPM are operational, we configure a virtual host (server block) that forwards PHP requests to the Unix socket /run/php/php8.3-fpm.sock.


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                 Nginx and PHP-FPM request flow              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   Client request (HTTP port 80 / HTTPS port 443)            β”‚
β”‚   β–Ό                                                         β”‚
β”‚   Nginx web server                                          β”‚
β”‚   β”œβ”€β”€ Static files       ──▢ Served directly                β”‚
β”‚   └── location ~ \.php$  ──▢ FastCGI proxy pass             β”‚
β”‚                              β”‚                              β”‚
β”‚                              β–Ό                              β”‚
β”‚                              Socket: php8.3-fpm.sock        β”‚
β”‚                              β”‚                              β”‚
β”‚                              β–Ό                              β”‚
β”‚                              PHP-FPM worker process         β”‚
β”‚                              β”‚                              β”‚
β”‚                              β–Ό                              β”‚
β”‚   Client response        ◀── HTML / JSON response           β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Creating the server block

We disable the default configuration and create a clean virtual host under /etc/nginx/sites-available/lemp.conf:


# Create the new server-block configuration
sudo nano /etc/nginx/sites-available/lemp.conf

Configuration content:


server {
    listen 80;
    listen [::]:80;
    server_name your-domain.example www.your-domain.example;

    root /var/www/html;
    index index.php index.html index.htm;

    # Security headers against clickjacking and MIME sniffing
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Static files and directory lookups
    location / {
        try_files $uri $uri/ =404;
    }

    # Forward dynamic scripts to the PHP 8.3 FastCGI 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;
        include fastcgi_params;

        # Buffer tuning for dynamic responses
        fastcgi_buffer_size 128k;
        fastcgi_buffers 4 256k;
        fastcgi_busy_buffers_size 256k;
    }

    # Strictly block access to hidden files (.git, .env, .htaccess)
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Enable the VHost and validate syntax


# Enable the VHost via symlink
sudo ln -s /etc/nginx/sites-available/lemp.conf /etc/nginx/sites-enabled/

# Disable the outdated default VHost
sudo rm -f /etc/nginx/sites-enabled/default

# Check Nginx syntax before reload
sudo nginx -t

# Reload Nginx configuration without downtime
sudo systemctl reload nginx

⚠️ FastCGI pitfall: If the browser shows 502 Bad Gateway, in 95 % of cases the cause is a wrong socket path or permission problems. Check with ls -la /run/php/php8.3-fpm.sock whether the socket exists and belongs to www-data:www-data.

6. End-to-end validation: database and web stack

We verify the interaction of all components with a real test database and a secure PHP script using prepared statements via PDO.


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚             End-to-end test: web, app and database          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   HTTP GET /db-test.php                                     β”‚
β”‚   β–Ό                                                         β”‚
β”‚   Nginx server block                                        β”‚
β”‚   β–Ό                                                         β”‚
β”‚   PHP 8.3-FPM (Unix domain socket)                          β”‚
β”‚   β–Ό                                                         β”‚
β”‚   PDO MySQL connection ────▢ MariaDB (127.0.0.1:3306)       β”‚
β”‚                              β”œβ”€β”€ UTF8mb4 connection         β”‚
β”‚                              β”œβ”€β”€ Prepared statement         β”‚
β”‚                              └── Process test data          β”‚
β”‚   β–Ό                                                         β”‚
β”‚   Status response ─────────▢ 200 OK (text/HTML)             β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Creating a dedicated test user in MariaDB


# Log into the MariaDB console
sudo mariadb -u root

Run the SQL commands:


CREATE DATABASE lemp_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'lemp_user'@'localhost' IDENTIFIED BY 'AVerySecretPassword123!';
GRANT ALL PRIVILEGES ON lemp_test.* TO 'lemp_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Creating a secure PDO test script

We place the test script at /var/www/html/db-test.php:


sudo nano /var/www/html/db-test.php

<?php
// /var/www/html/db-test.php - For verification purposes only!
declare(strict_types=1);

$dbConfig = [
    'host'    => '127.0.0.1',
    'dbname'  => 'lemp_test',
    'user'    => 'lemp_user',
    'pass'    => 'AVerySecretPassword123!',
    'charset' => 'utf8mb4'
];

$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
    PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4'
];

try {
    $dsn = "mysql:host={$dbConfig['host']};dbname={$dbConfig['dbname']};charset={$dbConfig['charset']}";
    $pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], $options);

    // Create the test table
    $pdo->exec("CREATE TABLE IF NOT EXISTS health_check (
        id INT AUTO_INCREMENT PRIMARY KEY,
        checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )");

    // Insert a test row via prepared statement
    $stmt = $pdo->prepare("INSERT INTO health_check (checked_at) VALUES (NOW())");
    $stmt->execute();

    // Fetch the number of rows
    $count = (int) $pdo->query("SELECT COUNT(*) FROM health_check")->fetchColumn();

    header('Content-Type: text/plain; charset=utf-8');
    echo "LEMP-Stack Health Check: OK
";
    echo "PHP Version: " . PHP_VERSION . "
";
    echo "MariaDB connection: Successful
";
    echo "Rows in health_check: {$count}
";

} catch (PDOException $e) {
    http_response_code(500);
    header('Content-Type: text/plain; charset=utf-8');
    echo "LEMP-Stack Health Check: ERROR
";
    echo "Reason: " . $e->getMessage() . "
";
}

# Set file permissions for the web-server user
sudo chown www-data:www-data /var/www/html/db-test.php
sudo chmod 640 /var/www/html/db-test.php

# Run the test via the local CLI
curl -s http://localhost/db-test.php

The output confirms correct execution:


LEMP-Stack Health Check: OK
PHP Version: 8.3.x
MariaDB connection: Successful
Rows in health_check: 1

⚠️ Test-script security risk: Delete db-test.php immediately after the function test (sudo rm -f /var/www/html/db-test.php). Public scripts that reveal database structure or connection details are an avoidable entry point for attackers.

7. TLS encryption with Let's Encrypt and Nginx hardening

A production web server today may only be operated over encrypted HTTPS connections. We automate issuance and renewal of TLS certificates with the EFF tool Certbot.


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚            Production HTTPS request flow                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   Client browser (HTTPS port 443)                           β”‚
β”‚   β–Ό                                                         β”‚
β”‚   TLS 1.3 handshake and Let's Encrypt certificate check     β”‚
β”‚   β–Ό                                                         β”‚
β”‚   Nginx web server with HSTS and security headers           β”‚
β”‚   β–Ό                                                         β”‚
β”‚   FastCGI Unix socket (/run/php/php8.3-fpm.sock)            β”‚
β”‚   β–Ό                                                         β”‚
β”‚   PHP-FPM worker process ──▢ MariaDB (127.0.0.1:3306)       β”‚
β”‚   β–Ό                                                         β”‚
β”‚   Gzip compression ────────▢ Response to client browser     β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Install Certbot and issue a certificate

The official and best-maintained version of Certbot is obtained through the Snap package-management system:


# Ensure snapd and install Certbot classically
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot

# Create a symlink in the standard path
sudo ln -sf /snap/bin/certbot /usr/bin/certbot

# Request a certificate and configure the VHost for HTTPS automatically
sudo certbot --nginx -d your-domain.example -d www.your-domain.example

Certbot asks for an email address for emergency notifications, verifies the DNS record through a temporary HTTP-01 challenge, automatically adjusts the server block /etc/nginx/sites-available/lemp.conf to port 443 (SSL) and sets up a 301 redirect from HTTP to HTTPS.

Extended TLS hardening and HSTS

For an A+ rating at SSL Labs we add modern TLS protocols and HTTP Strict Transport Security (HSTS) in the SSL server block:


# Addition in the server block (port 443)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;

# HSTS (enforces 2 years of HTTPS in the browser including subdomains)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# Validate the configuration and reload Nginx
sudo nginx -t && sudo systemctl reload nginx

# Test automatic certificate renewal in a dry run
sudo certbot renew --dry-run

πŸ’‘ Automatic renewal timer: On Snap installation Certbot automatically sets up a systemd timer (snap.certbot.renew.timer). It checks twice daily whether certificates expire within the next 30 days and renews them fully automatically.

Command Reference (Cheatsheet)

The most important operational commands for management, monitoring and fault diagnosis of the LEMP stack at a glance:

Service / task Command Explanation
Packages and system sudo apt update && sudo apt upgrade -y Updates package sources and installed packages
Firewall (UFW) sudo ufw status verbose Shows active rules, default policies and ports
Firewall (UFW) sudo ufw allow 'Nginx Full' Opens HTTP (port 80) and HTTPS (port 443)
Nginx syntax sudo nginx -t Checks configuration files for syntax and path errors
Nginx reload sudo systemctl reload nginx Applies configuration changes without interruption
MariaDB shell sudo mariadb -u root -p Opens the administrative database console
MariaDB status sudo systemctl status mariadb Checks process status and uptime of the database server
PHP-FPM status sudo systemctl status php8.3-fpm Checks the status of the PHP process manager
PHP-FPM socket ls -la /run/php/php8.3-fpm.sock Verifies existence and permission (www-data) of the socket
TLS certificate sudo certbot --nginx -d your-domain.example Obtains and installs Let's Encrypt TLS certificates
TLS renewal sudo certbot renew --dry-run Simulates automatic renewal of all certificates
Network ports sudo ss -tuln &#124; grep -E ':(80&#124;443&#124;3306)' Checks bound ports of Nginx and MariaDB

Further Resources

The following internal documentation and references go deeper into safe operation, hardening and system management:

Resource / documentation Description
Ubuntu upgrade: 22.04 to 24.04 LTS Fundamentals of the LTS release cycle, in-place upgrades and kernel features
LEMP stack on Ubuntu 26.04 LTS Follow-up guide for Ubuntu 26.04 with Nginx HTTP/3, MariaDB 11.4 and PHP 8.5
Linux server hardening: SSH and firewall Further hardening of the server with SSH-key enforcement, CrowdSec and Fail2ban
Virtual machines for tests Isolated test environments for staging web servers under KVM and Proxmox VE
Docker and container workloads on Linux Container-based deployment of web applications and microservices
Ubuntu server environments Overview of all guides for administration, maintenance and operation of Linux servers
Official Nginx documentation Reference handbook for all modules, upstream directives and tuning parameters
Official MariaDB documentation Handbook for InnoDB engine, replication and user privileges
Official PHP-FPM documentation Guide to process managers, pool directives and OPcache configuration

Conclusion

With the LEMP stack built here, Ubuntu 24.04 LTS has a performant, resource-efficient and hardened platform for modern web applications. Through the interplay of Nginx as an asynchronous web server, PHP 8.3-FPM over local Unix domain sockets and a hardened MariaDB 10.11 LTS server, all components are cleanly decoupled. The combination of strict UFW firewall rules, Fail2ban and automated Let's Encrypt encryption provides a stable security foundation in production.

πŸ’‘ Practical tip for production: Watch the load of your PHP-FPM workers in /etc/php/8.3/fpm/pool.d/www.conf. As visitor numbers rise, pm.max_children, pm.start_servers and pm.max_spare_servers should be adjusted to available memory to prevent worker bottlenecks (server reached max_children setting). Also set OPcache in php.ini to at least 128 MB so that bytecode of compiled PHP scripts stays in RAM and latency drops sharply.

For long-term operation it is advisable to extend the platform step by step with central monitoring and automated log rotation. Next you can further expand your web-server infrastructure with advanced security measures such as CrowdSec or a multi-VHost architecture.

Share & export

Export as Markdown

Related posts