“Default configs aren’t secure. They’re defaults — built for compatibility, not production. Running SSL Labs against a stock Nginx install and getting a B or F isn’t a surprise. It’s the expected result.”

This is a full hardening walkthrough for a LEMP stack on Ubuntu 22.04 — Nginx TLS configuration, PHP-FPM lockdown, MySQL least-privilege setup, firewall, Fail2Ban, and what the actual path to A+ on SSL Labs looks like. Not the happy path. The real one.


Architecture#

Internet (HTTPS/443)
    ↓
Nginx — TLS termination, security headers, rate limiting
    ↓
PHP-FPM — hardened, dangerous functions disabled
    ↓
MySQL — least-privilege user, localhost only, no remote root

Defense in depth. Each layer has its own controls — a breach at one layer still has the next to get through.

Why LEMP over LAMP:

  • Nginx handles concurrent connections with significantly lower memory than Apache (~2-5MB per worker vs Apache’s thread-per-connection model)
  • Native HTTP/2 and TLS 1.3 support without module juggling
  • Better suited for serving static assets alongside PHP

Initial Setup#

Fresh Ubuntu 22.04 LTS. LTS because I’m not upgrading a production server every six months.

sudo apt update && sudo apt upgrade -y

# Non-root user for day-to-day operations
sudo adduser webadmin
sudo usermod -aG sudo webadmin

Nginx#

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

Initial config before SSL:

server {
    listen 80;
    server_name sys.elijahu.me;

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

    # No directory listings
    autoindex off;

    # Don't leak Nginx version in headers
    server_tokens off;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
    }

    location ~ /\.ht {
        deny all;
    }
}

server_tokens off is a one-liner that stops Nginx from advertising its version in response headers and error pages. Default behavior is to announce it. There’s no reason to.

sudo nginx -t && sudo systemctl reload nginx

Always test before reload. A broken config on reload takes down the server.


MySQL#

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

Answers: validate password component on, strong level, remove anonymous users, disallow remote root, remove test database, reload privileges.

Least-Privilege User#

Most tutorials hand out GRANT ALL PRIVILEGES ON *.* TO 'user'@'%'. That’s full access across all databases from any host. It’s not a starting point — it’s a liability.

sudo mysql -u root -p
CREATE DATABASE demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'demo_user'@'localhost' IDENTIFIED BY 'StrongP@ssw0rd!2025';

-- Only what the application actually needs
GRANT SELECT, INSERT, UPDATE, DELETE ON demo.* TO 'demo_user'@'localhost';

FLUSH PRIVILEGES;
SHOW GRANTS FOR 'demo_user'@'localhost';

No CREATE, DROP, or ALTER. No access to other databases. No remote connections. If the application credentials are compromised, the blast radius is one database and four operations. That’s containment by design.

I learned this the hard way — a PHP vulnerability in a test script let an attacker reach MySQL through the application. With GRANT ALL, they had full access. With least-privilege, they had nothing useful.


PHP-FPM#

sudo apt install php8.1-fpm php8.1-mysql php8.1-mbstring php8.1-xml php8.1-curl -y
sudo systemctl start php8.1-fpm && sudo systemctl enable php8.1-fpm

php.ini Hardening#

sudo nano /etc/php/8.1/fpm/php.ini
; Don't advertise PHP version
expose_php = Off

; Errors go to log, not to the browser
display_errors = Off
log_errors = On
error_log = /var/log/php/error.log

; Disable functions that have no place in a web application
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source

; Upload limits
upload_max_filesize = 10M
post_max_size = 10M
max_file_uploads = 5

; Resource limits
memory_limit = 128M
max_execution_time = 30
max_input_time = 30

; No remote file access
allow_url_fopen = Off
allow_url_include = Off

; Session hardening
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1

disable_functions is the one that matters most. exec(), shell_exec(), system() — these are how PHP webshells work. A compromised PHP file that can call system() is remote code execution. Disabled functions can’t be called regardless of what the PHP code says.

sudo mkdir -p /var/log/php
sudo chown www-data:www-data /var/log/php
sudo systemctl restart php8.1-fpm

Firewall#

sudo ufw default deny incoming
sudo ufw default allow outgoing

# SSH first — always
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status verbose

Allow SSH before enabling. Enabling UFW without SSH allowed means you lock yourself out. If that happens on a cloud instance you need console access to recover. I’ve done it. It’s annoying. Do SSH first.


TLS — The Path to A+#

sudo apt install certbot python3-certbot-nginx -y

Always test with staging before hitting production rate limits:

# Staging first
sudo certbot --nginx -d sys.elijahu.me --staging

# Then production
sudo certbot --nginx -d sys.elijahu.me

Let’s Encrypt rate-limits to 5 certificates per week per domain set. Hit that limit while iterating on config and you’re waiting a week. Use --staging until you know certbot runs clean.

Certbot’s default Nginx config gets you a B rating on SSL Labs. Here’s what gets you to A+:

server {
    listen 443 ssl http2;
    server_name sys.elijahu.me;

    ssl_certificate /etc/letsencrypt/live/sys.elijahu.me/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sys.elijahu.me/privkey.pem;

    # TLS 1.2 minimum, 1.3 preferred
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
    ssl_prefer_server_ciphers off;

    # OCSP Stapling — client gets cert revocation status without a separate lookup
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/sys.elijahu.me/chain.pem;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    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 "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;

    server_tokens off;
    autoindex off;

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

    location / {
        try_files $uri $uri/ =404;
    }

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

server {
    listen 80;
    server_name sys.elijahu.me;
    return 301 https://$server_name$request_uri;
}

What moves the needle on SSL Labs:

  • ssl_protocols TLSv1.2 TLSv1.3 — drop 1.0 and 1.1, both deprecated
  • ssl_prefer_server_ciphers off — lets clients use their preferred cipher from the allowed list, which is the current recommendation
  • OCSP Stapling — required for A+, Certbot doesn’t enable it by default
  • HSTS with max-age=31536000 — a short max-age gets you A, not A+. One year minimum

My path: B (default Certbot) → A (fixed ciphers) → A (HSTS too short) → A+ (full config above). Four SSL Labs runs.

sudo nginx -t && sudo systemctl reload nginx

Certificate Auto-Renewal#

sudo certbot renew --dry-run
systemctl list-timers | grep certbot

Certbot installs a systemd timer. Verify it’s active. Certificates expire in 90 days — don’t find out it’s broken when the cert expires.


File Permissions#

sudo chown -R www-data:www-data /var/www/html

# Directories: 755
sudo find /var/www/html -type d -exec chmod 755 {} \;

# Files: 644
sudo find /var/www/html -type f -exec chmod 644 {} \;

# Config files: 600
sudo chmod 600 /var/www/html/config.php

If you’re getting 403s after setting these, check every directory in the path — not just the target. The /var/www directory itself needs to be traversable. A 700 on a parent directory blocks access regardless of what permissions the child has.

# Debug 403s by checking the full path
namei -l /var/www/html/index.php

namei -l shows permissions on every component of the path. Use it before spending an hour rechecking the same directory.


Troubleshooting Reference#

PHP files downloading instead of executing:

# Find the actual socket path
sudo find /var/run/php -name "*.sock"
# Update fastcgi_pass in Nginx config to match

MySQL connection refused from PHP:

// Use "localhost" not "127.0.0.1"
// localhost = Unix socket (faster, no network port)
// 127.0.0.1 = TCP (slower, opens a port)
$conn = mysqli_connect("localhost", "user", "pass", "db");

Nginx not starting after reboot:

sudo systemctl edit nginx
[Unit]
After=network-online.target
Wants=network-online.target

Fail2Ban#

sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5

[sshd]
enabled = true
port = ssh
logpath = /var/log/auth.log

[nginx-http-auth]
enabled = true
filter = nginx-http-auth
port = http,https
logpath = /var/log/nginx/error.log
sudo systemctl start fail2ban && sudo systemctl enable fail2ban
sudo fail2ban-client status

Security Audit with Lynis#

sudo apt install lynis -y
sudo lynis audit system

Baseline score on a stock Ubuntu 22.04 install is typically in the 55–65 range. After this full hardening pass: 89. There are diminishing returns past that point — the remaining suggestions are mostly kernel parameter tuning and audit daemon configuration.


Results#

TestResult
SSL LabsA+
Security HeadersA
Lynis Hardening Index89/100
PHP dangerous functionsDisabled
MySQL access scopelocalhost, 4 operations, 1 database

Performance before/after hardening:

MetricBeforeAfter
Page load450ms380ms
Memory180MB165MB

The performance improvement is mostly from removing unnecessary Nginx modules and PHP extensions that ship enabled by default. Hardening and performance aren’t opposed — trimming the attack surface means trimming unused code paths.


What I’d Do Differently#

Automate the whole setup with Ansible or a shell script. Right now reproducing this environment means running through this doc manually. That’s fine once. It’s not fine for a second server or after a failure.

Test SSL Labs in staging before production. The rate limit is 5 certs per week. I hit it. Use --staging until you’re confident.

Set up monitoring before going live. Lynis tells you your current state. It doesn’t tell you when something changes. Prometheus + alerting should be in place before the server takes real traffic.


Source#

Full config files — Nginx, php.ini, MySQL grants, Fail2Ban, systemd unit overrides — on GitHub .

Live server: sys.elijahu.me


Tags#

#Infrastructure #Security #Linux #Nginx #TLS #LEMP


About the Author#

Elijah Udom (elijahu) is an Infrastructure & Cloud Engineer based in Lagos, Nigeria. AWS, Kubernetes, eBPF security, AI/ML infrastructure. Building in the open.

Elijah Udom


← Previous: CI/CD Pipeline with Docker & AWS | Next: Kodekloud Days 1–4 →