Every production LEMP stack we deploy starts as a clean Ubuntu server with nginx, PHP-FPM, and MySQL installed. That is only the beginning. We treat hardening as a continuous practice, not a one-time checkbox. Over the years we have refined a repeatable checklist that covers the layers attackers scan first: exposed server tokens, over-privileged database users, unfiltered SSH access, and certificates that expire silently. Below we share what we actually do, with the concrete snippets and commands we run on real servers.
Nginx hardening headers
We begin with nginx because it is the first surface exposed to the internet. We disable version disclosure immediately. In the http block of /etc/nginx/nginx.conf we set server_tokens off;. This stops nginx from advertising its version in error pages and the Server header. We then add security headers in the server block for every site:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'self';" always;
We never rely solely on headers for security — they are a defense-in-depth layer — but they sharply reduce the attack surface for clickjacking and MIME-sniffing exploits. We also restrict upload sizes with client_max_body_size 2M; in each site block and disable unnecessary methods with limit_except GET HEAD POST { deny all; } where the application does not require PUT or DELETE. After every change we validate with sudo nginx -t && sudo systemctl reload nginx.
PHP-FPM and application restrictions
PHP-FPM is where most LEMP compromises happen: a vulnerable plugin or a misconfigured upload directory becomes a remote shell. We restrict PHP before the application code runs. In /etc/php/8.3/fpm/php.ini we set:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
expose_php = Off
allow_url_fopen = Off
We also set open_basedir to the document root plus the temporary upload directory, never /. In the FPM pool (/etc/php/8.3/fpm/pool.d/www.conf) we enforce process limits that contain a runaway script:
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
request_terminate_timeout = 30s
We restart the pool with sudo systemctl restart php8.3-fpm and confirm with sudo ss -tlnp | grep 9000. If a site needs file uploads, we configure nginx to pass only specific paths to PHP and block direct execution in upload folders using location ~* /(?:uploads|files)/.*\.php$ { deny all; }.
MySQL user privileges and access
We separate application users from admin access. The database user for the site never has SUPER, FILE, or GRANT OPTION. We create users with the narrowest scope possible:
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'long_random_password';
GRANT SELECT, INSERT, UPDATE, DELETE ON app_database.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;
We disable remote root access entirely: DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');. We enforce bind-address = 127.0.0.1 in /etc/mysql/mysql.conf.d/mysqld.cnf and never expose MySQL to the public interface. We audit privileges monthly with SELECT user, host FROM mysql.user; and SHOW GRANTS FOR 'app_user'@'localhost';. If a site needs backups, we create a dedicated backup user with SELECT, LOCK TABLES, RELOAD, REPLICATION CLIENT rather than reusing the application account.
Firewall rules and fail2ban
We use ufw as the first line and fail2ban as the reactive layer. Our base rules are explicit:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
We restrict SSH to a trusted source where possible with sudo ufw allow from 203.0.113.0/24 to any port 22. For fail2ban, we configure /etc/fail2ban/jail.local with aggressive but reasonable limits for sshd and nginx:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
[nginx-http-auth]
enabled = true
port = http,https
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 5
bantime = 1800
We restart with sudo systemctl restart fail2ban and verify with sudo fail2ban-client status sshd. We always monitor sudo tail -f /var/log/fail2ban.log after enabling a new jail to confirm bans occur as expected, not against legitimate traffic.
SSL renewal monitoring and log rotation
We use Certbot with a dry-run schedule and a monitoring script. Our renewal command is sudo certbot renew --quiet --post-hook "systemctl reload nginx". We add a cron entry at /etc/cron.d/certbot-renew that runs daily and emails if the dry-run fails. For monitoring expiration, we use a simple daily check:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates | grep notAfter
We pair this with log rotation so disks do not fill from nginx or PHP-FPM access logs. We configure /etc/logrotate.d/nginx with rotate 52, weekly, compress, delaycompress, and create 0640 www-data adm. For PHP-FPM, we set access.log = /var/log/php-fpm/access.log in the pool and rotate it with the same pattern. We restart services only after confirming rotation with sudo logrotate -d /etc/logrotate.d/nginx.
We also keep an internal reference to our wildcard SSL setup with Certbot and Cloudflare when subdomain-heavy projects need a single certificate, and we direct clients to our contact page when they need a full audit rather than a checklist. Projects and case studies are available at /work/.
Priority order
We start with nginx hardening and firewall rules because they are external-facing and cheap to deploy. Next we lock down PHP-FPM and MySQL, since those are where application-level compromise propagates. We add fail2ban and SSL monitoring only after the base layers are stable. Finally we configure log rotation and renewals so the server remains observable over months and years. Hardening is never finished — it is maintained — and this checklist is the first draft we apply to every site we host.