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

Production-ready installation of the LEMP stack on Ubuntu 26.04 LTS: Nginx 1.28.x with HTTP/2 and HTTP/3, MariaDB 11.8, PHP 8.5-FPM pool tuning and Let's Encrypt TLS.

Reading time: 25 min

πŸ’‘ Note for existing installations (Ubuntu 24.04 LTS): If the system is still on the previous version, the matching documentation is in Set up a LEMP stack on Ubuntu 24.04 LTS with Nginx 1.24, MariaDB 10.11 and PHP 8.3.

On Ubuntu 26.04 LTS (Resolute Raccoon) the LEMP stack is Nginx 1.28.x, MariaDB 11.8 LTS and PHP 8.5-FPM. Besides HTTP/1.1 and HTTP/2 over TCP, this layout supports native HTTP/3 over the QUIC transport protocol on UDP port 443.

The following setup covers installation and hardening of the stack for server operation: from firewall configuration through InnoDB and worker-pool tuning to automatic TLS certificate management with Certbot and HTTP/3 signalling via Alt-Svc.

⚠️ Network requirement for HTTP/3: QUIC is a transport protocol that uses UDP as its foundation. Make sure the upstream firewall (for example Hetzner, AWS Security Groups or Proxmox) opens UDP port 443 for inbound traffic in addition to TCP port 80 and TCP port 443.

System requirements and component matrix

Nginx handles thousands of parallel connections with low memory use. Database queries and parallel PHP workers, by contrast, drive RAM and I/O demand directly:

Component Package version in Ubuntu 26.04 Default port / socket Minimum requirement Recommended for production
Operating system Ubuntu 26.04 LTS - 1 vCPU, 1 GB RAM 2–4 vCPUs, 4–8 GB RAM
Nginx 1.28.x (HTTP/3 and HTTP/2) TCP 80, TCP 443, UDP 443 128 MB RAM 512 MB RAM
MariaDB 11.8.x LTS TCP 3306 (127.0.0.1) 512 MB RAM 2 to 4 GB RAM (InnoDB buffer)
PHP-FPM 8.5 /run/php/php8.5-fpm.sock 256 MB RAM 1 to 2 GB RAM (worker pool)
Storage NVMe / SSD - 15 GB free 30+ GB with daily backup

1. System preparation and baseline hardening

Before installing packages, update the base system and install the tools for administration and hardening:


# Refresh the package index and bring the base system up to date
sudo apt update && sudo apt upgrade -y

# Install relevant administration tools
sudo apt install -y curl wget gnupg2 ca-certificates lsb-release ufw fail2ban

Configure the firewall for the web server and HTTP/3 QUIC

Besides SSH and the standard web ports over TCP, open UDP port 443 for HTTP/3:


# Open the SSH port before enabling the firewall
sudo ufw allow 22/tcp comment "SSH Remote Access"

# Open standard web ports (Nginx Full profile)
sudo ufw allow "Nginx Full"

# Open UDP port 443 for native HTTP/3 and QUIC
sudo ufw allow 443/udp comment "HTTP3 QUIC UDP"

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

❗ SSH lock-out protection: Before enabling the firewall, confirm with sudo ufw status that port 22 (or your custom SSH port) is listed. Enabling too early disconnects the active session permanently.

2. Install the Nginx web server with native HTTP/3 (QUIC)

Install Nginx from the official Ubuntu 26.04 package sources:


# Install the Nginx web server
sudo apt install -y nginx

# Enable the service and start it immediately
sudo systemctl enable --now nginx

# Verify runtime status
sudo systemctl status nginx --no-pager

Check the build configuration and HTTP/3 module

Nginx has supported HTTP/3 since version 1.25.0 through the ngx_http_v3_module module. Upstream still documents this module as experimental, which matters on systems where availability is critical.

Do not infer build options from the version number alone; inspect the installed binary:


# Check whether the Nginx binary was compiled with HTTP/3 support
nginx -V 2>&1 | tr ' ' '\n' | grep -E '(http_v3|ssl)'

How to read the output:

  • --with-http_v3_module: The binary code for QUIC and HTTP/3 is compiled in.
  • built with OpenSSL ...: Shows the linked SSL library. For QUIC, Nginx recommends a modern OpenSSL or compatible TLS library. Depending on the library version, certain features (such as 0-RTT / Early Data) may be limited.

On Ubuntu, Nginx follows a modular layout that separates global settings from individual domain hostings:


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                 Nginx directory structure                   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   /etc/nginx/                                               β”‚
β”‚   β”œβ”€β”€ nginx.conf          Global master configuration       β”‚
β”‚   β”œβ”€β”€ conf.d/             Modular HTTP/3 snippets           β”‚
β”‚   β”œβ”€β”€ sites-available/    VHost templates (inactive)        β”‚
β”‚   └── sites-enabled/      Active VHost symlinks             β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • nginx.conf: Global parameters such as worker processes, event models, gzip compression and buffer limits.
  • conf.d/: Modular configuration fragments for TLS parameters, security headers or upstream definitions.
  • sites-available/: Configuration templates for individual websites and domains.
  • sites-enabled/: Contains symlinks to files in sites-available/. Nginx only loads VHosts linked here.

3. Harden and tune MariaDB 11.8 LTS

Install the database server and the client:


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

# Enable and start the service
sudo systemctl enable --now mariadb

# Verify status
sudo systemctl status mariadb --no-pager

Run the initial security wizard

The mariadb-secure-installation script closes default weaknesses of a fresh install:


sudo mariadb-secure-installation

Recommended answers in the interactive wizard:

  • Enter current password for root (enter for none): press [Enter].
  • Switch to unix_socket authentication: n (already the default on Ubuntu).
  • Change the root password? n (root login runs securely over the local Unix domain socket via sudo).
  • Remove anonymous users? Y (removes unauthorised test users).
  • Disallow root login remotely? Y (blocks external root connections).
  • Remove test database and access to it? Y (deletes the unprotected test database).
  • Reload privilege tables now? Y (reloads privileges immediately).

Directory structure and configuration tuning


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

For operational adjustments, create your own configuration file under /etc/mysql/mariadb.conf.d/99-performance.cnf.

⚠️ Deprecated directives: Parameters such as innodb_buffer_pool_instances have been removed or ignored in modern MariaDB branches. Use only valid variables for MariaDB 11.8.


[mysqld]
# Bind networking strictly to localhost
bind-address = 127.0.0.1

# Set character set to full 4-byte UTF-8
character-set-server = utf8mb4
collation-server     = utf8mb4_unicode_520_ci

# InnoDB buffer tuning (about 50-70 % of assigned database RAM)
innodb_buffer_pool_size = 1G
innodb_flush_method     = O_DIRECT

# Transaction safety vs. disk I/O:
# 1 = Full ACID compliance (default, no data loss on power failure)
# 2 = Higher I/O performance (risk: 1-2 sec. loss on system crash)
innodb_flush_log_at_trx_commit = 1

# Connection limits and timeouts
max_connections     = 100
connect_timeout     = 5
wait_timeout        = 600
interactive_timeout = 600
Parameter Default Recommended value Effect and trade-off
innodb_buffer_pool_size 128 MB 50–70 % of DB RAM Keeps indexes and table data in RAM, reduces NVMe access
innodb_flush_log_at_trx_commit 1 1 (or 2 for bulk I/O) 1 gives full ACID compliance; 2 flushes every second and eases I/O
innodb_flush_method fsync O_DIRECT Bypasses the operating-system cache and prevents double buffering
character-set-server utf8mb4 utf8mb4 Full support for emoji and international character sets

Validate the configuration before restart

Check the syntax of every configuration file before restarting the service:


# Check configuration files for unknown variables and syntax errors
sudo mariadbd --help --verbose > /dev/null

# If the check finishes without an error code, reload MariaDB
sudo systemctl restart mariadb

If the check fails, mariadbd writes an error to stderr (for example [ERROR] unknown variable) without interrupting the running service.

4. Set up PHP 8.5-FPM and extensions

PHP-FPM runs server-side scripts in separate worker processes and talks to the web server over Unix domain sockets:


# Install PHP 8.5 FPM and extensions
sudo apt install -y php8.5-fpm php8.5-mysql php8.5-common php8.5-curl \
                    php8.5-mbstring php8.5-xml php8.5-zip php8.5-intl \
                    php8.5-opcache php8.5-gd php8.5-bcmath

# Enable the service and check status
sudo systemctl enable --now php8.5-fpm
sudo systemctl status php8.5-fpm --no-pager

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                PHP 8.5 directory structure                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   /etc/php/8.5/                                             β”‚
β”‚   β”œβ”€β”€ 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         β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

PHP-FPM pool tuning by memory formula

PHP-FPM starts workers dynamically by default. To stop out-of-memory failures from uncontrolled processes, tune pm.max_children to available RAM:


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

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

# Worked example: 2048 MB reserved PHP RAM at about 50 MB per worker
echo $(( 2048 / 50 ))
# Result: 40

πŸ’‘ RAM budgeting across the whole system: This formula yields an upper capacity bound. From total physical memory you first subtract reserves for the operating system (~500–1,000 MB), the Nginx web server, MariaDB (innodb_buffer_pool_size), OPcache and buffers for load spikes, and only then assign the remaining budget to PHP workers.

Adjust /etc/php/8.5/fpm/pool.d/www.conf:


[www]
user = www-data
group = www-data

listen = /run/php/php8.5-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 40
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
pm.max_requests = 1000

pm.status_path = /fpm-status
Directive Value Operational benefit
pm = dynamic dynamic Adjusts the number of worker processes automatically to current system load
pm.max_children 40 Cap on concurrent PHP requests; prevents a server crash from RAM exhaustion
pm.max_requests 1000 Recycles workers after 1,000 requests and thereby limits the impact of creeping memory growth or possible memory leaks
listen.mode 0660 Grants only Nginx access to the Unix domain socket

Reload PHP-FPM to apply the configuration:


sudo systemctl reload php8.5-fpm

5. Configure the virtual host with FastCGI and HTTP/3

The virtual host serves static assets directly and forwards requests for .php files to PHP-FPM over the Unix domain socket:


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚            Nginx and PHP 8.5-FPM request flow               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   Client request (HTTP/2 TCP 443 or HTTP/3 UDP 443)         β”‚
β”‚   β–Ό                                                         β”‚
β”‚   Nginx web server                                          β”‚
β”‚   β”œβ”€β”€ Static assets      ──▢ zero-copy delivery             β”‚
β”‚   └── location ~ \.php$  ──▢ FastCGI proxy pass             β”‚
β”‚                                        β”‚                    β”‚
β”‚                                        β–Ό                    β”‚
β”‚                              Socket: php8.5-fpm.sock        β”‚
β”‚                                        β”‚                    β”‚
β”‚                                        β–Ό                    β”‚
β”‚                              PHP-FPM worker process         β”‚
β”‚                                        β”‚                    β”‚
β”‚                                        β–Ό                    β”‚
β”‚   Client response        ◀── HTML / JSON response           β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Create the server block

Remove the default symlink and create the configuration file /etc/nginx/sites-available/lemp.conf:


# Remove the default symlink
sudo rm -f /etc/nginx/sites-enabled/default

Create the file /etc/nginx/sites-available/lemp.conf:


server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    root /var/www/example.com/public;
    index index.php index.html;

    # Detailed logging paths
    access_log /var/log/nginx/example.com.access.log;
    error_log /var/log/nginx/example.com.error.log warn;

    # Security headers
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Serve static files directly with cache headers
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~* \.(css|js|jpg|jpeg|png|gif|ico|webp|svg|woff|woff2)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
        access_log off;
    }

    # PHP-FPM forwarding via Unix domain socket
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }

    # Strictly block access to hidden files (.git, .env)
    location ~ /\.(?!well-known).* {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Set up the web root and permission model

www-data should not own the entire application code. Compromised scripts could otherwise modify arbitrary PHP files. The code belongs to the administrative deployment user with read rights for www-data. The web server gets write rights only in directories such as storage:


# Create the web directory for public code and writable storage
sudo mkdir -p /var/www/example.com/public
sudo mkdir -p /var/www/example.com/storage

# Owner: deployment user (or root), group www-data
sudo chown -R $USER:www-data /var/www/example.com

# Default permissions: directories 755, files 644 (read-only for www-data)
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;

# Write rights for www-data ONLY on dedicated upload or cache directories
sudo chown -R www-data:www-data /var/www/example.com/storage
sudo find /var/www/example.com/storage -type d -exec chmod 775 {} \;
sudo find /var/www/example.com/storage -type f -exec chmod 664 {} \;

# Enable the virtual host
sudo ln -sf /etc/nginx/sites-available/lemp.conf /etc/nginx/sites-enabled/

# Run a syntax check and reload Nginx
sudo nginx -t && sudo systemctl reload nginx

6. End-to-end test: Nginx, PHP 8.5 and MariaDB

Check the interplay of web server, PHP-FPM and database with a test database and a PDO script:


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

Set up the test database and test user


# Open the database console
sudo mariadb -u root

SQL statements in the MariaDB console:


-- Create a test database and a locked-down user
CREATE DATABASE IF NOT EXISTS lemp_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;
CREATE USER IF NOT EXISTS 'lemp_user'@'localhost' IDENTIFIED BY 'ExamplePassword2026!#Secure';
GRANT ALL PRIVILEGES ON lemp_test.* TO 'lemp_user'@'localhost';
FLUSH PRIVILEGES;

-- Create a table and insert a sample row
USE lemp_test;
CREATE TABLE IF NOT EXISTS system_status (
    id INT AUTO_INCREMENT PRIMARY KEY,
    component VARCHAR(50) NOT NULL,
    status_msg VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

INSERT INTO system_status (component, status_msg) 
VALUES ('LEMP Stack 2026', 'Nginx 1.28, PHP 8.5 and MariaDB 11.8 work together correctly.');
EXIT;

❗ Password security note: The password shown above is an example placeholder. For production database users always use an individual, randomly generated password.

Create the PHP verification test script

Create /var/www/example.com/public/db-test.php:


<?php
declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');

$dsn = 'mysql:host=127.0.0.1;dbname=lemp_test;charset=utf8mb4';
$user = 'lemp_user';
$password = 'ExamplePassword2026!#Secure';

try {
    $pdo = new PDO($dsn, $user, $password, [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);

    $stmt = $pdo->query('SELECT component, status_msg, created_at FROM system_status LIMIT 1');
    $data = $stmt->fetch();

    echo json_encode([
        'status'    => 'success',
        'php'       => PHP_VERSION,
        'webserver' => $_SERVER['SERVER_SOFTWARE'] ?? 'Nginx',
        'database'  => $data,
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

} catch (PDOException $e) {
    http_response_code(500);
    echo json_encode([
        'status'  => 'error',
        'message' => $e->getMessage(),
    ]);
}

Fetch the script locally:


curl -i http://localhost/db-test.php

⚠️ Security note: After a successful verification, delete db-test.php from the web root immediately (sudo rm /var/www/example.com/public/db-test.php) so that database passwords or table structures are not exposed publicly.

7. TLS encryption with Let's Encrypt and HTTP/3 activation

For production, Certbot obtains a free TLS certificate from Let's Encrypt and sets up automatic renewal:

How it works: dual stack of TCP and UDP

HTTP/1.1 and HTTP/2 sit on TCP. HTTP/3 instead uses the QUIC transport protocol over UDP:


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚             Nginx dual-stack: TCP/TLS and UDP/QUIC          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   HTTP/1.1 ──┐                                              β”‚
β”‚              β”œβ”€β”€β–Ά TCP:443 ──▢ TLS 1.2/1.3 ──┐               β”‚
β”‚   HTTP/2   β”€β”€β”˜                              β”‚               β”‚
β”‚                                             β”œβ”€β”€β–Ά Nginx      β”‚
β”‚   HTTP/3   ─────▢ UDP:443 ──▢ QUIC/TLS 1.3 β”€β”˜               β”‚
β”‚                                                             β”‚
β”‚   Signal: Alt-Svc announces HTTP/3 over UDP                 β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • QUIC over UDP port 443: QUIC sits on UDP and integrates multiplexing plus packet recovery directly into the transport protocol. That removes TCP head-of-line blocking: if a packet is lost, HTTP/2 pauses every parallel stream of the connection, while HTTP/3 only waits on the affected stream.
  • Integrated TLS 1.3 handshake: QUIC uses TLS 1.3 as its encryption layer. Cryptographic key exchange is built directly into connection setup (1-RTT, or 0-RTT on reconnections).
  • The listen 443 quic reuseport; directive: Activates the QUIC listener for inbound UDP datagrams on port 443. The reuseport parameter (SO_REUSEPORT) is especially useful and recommended with multiple Nginx workers, because the Linux kernel can then distribute inbound UDP datagrams efficiently across the workers.
  • The role of the Alt-Svc header: The header Alt-Svc: h3=":443"; ma=86400 does not enable HTTP/3 on the server. It is an announcement mechanism (Alternative Services): when a client first connects over classic TCP (HTTP/1.1 or HTTP/2), the server signals: "This service is also reachable on port 443 via HTTP/3 (h3) over UDP; this information is valid for 86,400 seconds (24 hours)." Later requests can then be issued by the browser over UDP on its own.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚             Production-ready request flow                   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   Client request                                            β”‚
β”‚   β”œβ”€β”€ TCP:443 ──▢ TLS 1.2/1.3 (HTTP/1.1 and HTTP/2)         β”‚
β”‚   └── UDP:443 ──▢ QUIC and TLS 1.3 (HTTP/3)                 β”‚
β”‚                   β”‚                                         β”‚
β”‚                   β–Ό                                         β”‚
β”‚   Nginx web server (with HSTS and Alt-Svc header)           β”‚
β”‚   β”œβ”€β”€ Static files   ──▢ served directly                    β”‚
β”‚   └── Dynamic paths  ──▢ FastCGI Unix domain socket         β”‚
β”‚                             β”‚                               β”‚
β”‚                             β–Ό                               β”‚
β”‚   PHP 8.5 worker ─────────▢ MariaDB 11.8 (port 3306)        β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Install Certbot via the official Snap package and obtain the certificate:


# Install Certbot via the official Snap package
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot

# Fetch the TLS certificate for the domain and configure Nginx automatically
sudo certbot --nginx -d example.com -d www.example.com

Enable HTTP/2, HTTP/3 and QUIC in the Nginx server block

After the certificate is issued, add http2 on;, the QUIC listener and the Alt-Svc header to the SSL server block in /etc/nginx/sites-available/lemp.conf:


server {
    # Standard HTTPS over TCP (HTTP/1.1 and HTTP/2)
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    # Native HTTP/3 over UDP
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;

    server_name example.com www.example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Balanced protocol support for maximum client compatibility
    ssl_protocols TLSv1.2 TLSv1.3;

    # Signal HTTP/3 advertisement to browsers
    add_header Alt-Svc 'h3=":443"; ma=86400';

    # Conservative HSTS header (1 year)
    add_header Strict-Transport-Security "max-age=31536000" always;

    root /var/www/example.com/public;
    index index.php index.html;

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

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

⚠️ Important note on HSTS: The Strict-Transport-Security header forces encrypted connections. Extensions such as includeSubDomains bind every existing and future subdomain to HTTPS without exception. The extra preload requests inclusion in the preload list baked into browsers and is hard to reverse. Only add these extras when your entire domain infrastructure is demonstrably on HTTPS throughout.

Check the configuration and reload Nginx:


# Verify syntax and reload Nginx
sudo nginx -t && sudo systemctl reload nginx

# Check the automatic Certbot renewal timer
sudo systemctl list-timers | grep certbot

Run a real HTTP/3 and QUIC function test

A successful sudo nginx -t is not a function test for HTTP/3. It only confirms that Nginx accepts and can load the configuration syntax. Whether clients can actually talk over QUIC depends on UDP-port reachability and client support.

Before the function test, first check whether the installed curl version was built with HTTP/3 support at all:


# Check whether the local curl binary supports HTTP/3
curl -V | grep -i http3

The Features: line must contain the keyword HTTP3. If that entry is missing, the package was built without a QUIC library (such as ngtcp2 or quiche); in that case --http3-only already fails on the client side.

If HTTP3 is present in curl -V, run verification in three steps:

1. Check UDP socket binding on the server

First confirm that Nginx is actually listening on UDP port 443:


# Check whether Nginx has bound the UDP socket
sudo ss -ulnp | grep :443

The output must list a datagram socket for the nginx process (UNCONN ... 0.0.0.0:443).

2. Verify the Alt-Svc header over TCP

Check that Nginx sends the announcement header to the client on a regular TCP handshake:


# Query Alt-Svc signalling
curl -I https://example.com/ | grep -i alt-svc

The response must contain alt-svc: h3=":443"; ma=86400.

3. Force a real HTTP/3 connection

Now request the resource strictly and exclusively over the QUIC protocol:


# Force the connection strictly over HTTP/3 (UDP)
curl -Iv --http3-only https://example.com/

Success indicators in the output:

  • * Connected to example.com (...) port 443 (UDP): Confirms communication over UDP rather than TCP.
  • * Using HTTP/3: Shows negotiation of QUIC transport.
  • HTTP/3 200: The status line confirms successful receipt over HTTP/3.

If an intermediate firewall (for example at the cloud hoster) blocks UDP port 443, this command fails with a timeout or connection error, while ordinary calls without --http3-only would fall back transparently to HTTP/2 over TCP.

πŸ’‘ Browser check: In Google Chrome, Firefox or Microsoft Edge open the developer tools (F12) and switch to the Network tab. Right-click the table header and enable the Protocol column. On the first load the browser still shows h2; once the Alt-Svc header is registered, the protocol switches to h3 on reload.

Command Reference (Cheatsheet)

The following table summarises the most important administration commands for day-to-day operation of the LEMP stack:

Task Command to run Description
Nginx syntax check sudo nginx -t Checks every loaded configuration file for syntax errors
Nginx reload sudo systemctl reload nginx Applies configuration changes without interrupting active connections
HTTP/3 module check nginx -V 2>&1 &#124; grep http_v3 Checks whether Nginx was built with the HTTP/3 QUIC module
UDP socket check sudo ss -ulnp &#124; grep :443 Verifies that Nginx is listening on UDP port 443 (QUIC)
HTTP/3 function test curl -Iv --http3-only https://example.com/ Forces a pure HTTP/3 connection over the QUIC protocol
MariaDB config test sudo mariadbd --help --verbose > /dev/null Checks MySQL/MariaDB configuration files before restart
PHP-FPM status sudo systemctl status php8.5-fpm Shows active workers, uptime and PID of the PHP-FPM master process
PHP-FPM restart sudo systemctl restart php8.5-fpm Restarts the PHP master process and every configured worker pool
MariaDB console sudo mariadb -u root Opens the interactive SQL shell with local root authentication
MariaDB restart sudo systemctl restart mariadb Restarts the database service (for example after buffer changes)
UFW firewall status sudo ufw status verbose Lists every active firewall rule for TCP and UDP
Certbot renewal test sudo certbot renew --dry-run Simulates automatic certificate retrieval without write access
FastCGI socket check ls -la /run/php/php8.5-fpm.sock Verifies existence and permissions (www-data:www-data 0660)

Further Resources

Resource Link Purpose and content
Ubuntu 26.04 Server Ubuntu Server Guide Official system documentation for Ubuntu 26.04 LTS (Resolute Raccoon)
Nginx HTTP/3 documentation Nginx QUIC and HTTP/3 Official configuration reference for native QUIC listeners and Alt-Svc
MariaDB Knowledge Base MariaDB 11.8 documentation Reference handbook for InnoDB tuning, system variables and replication
PHP 8.5 documentation PHP Manual Complete documentation of language features, FPM options and OPcache
Certbot documentation Certbot EFF Guide Documentation for automated Let's Encrypt certificates under Nginx
LEMP stack (Ubuntu 24.04) Ubuntu 24.04 LEMP guide Reference article for existing installations based on Ubuntu 24.04 LTS

Conclusion

The LEMP stack on Ubuntu 26.04 LTS separates static asset delivery through Nginx from PHP execution in the FastCGI Process Manager. The MariaDB 11.8 database is bound locally and hardened over Unix domain sockets.

By configuring HTTP/2 over TCP and HTTP/3 over UDP port 443 in parallel, clients use whichever transport fits, without older browsers losing access.

In day-to-day operation, watch two areas in particular: the log /var/log/nginx/example.com.error.log for FastCGI worker bottlenecks (server reached max_children), and MariaDB memory use as the data set grows.

Share & export

Export as Markdown

Related posts