A no-skip, practical security hardening guide covering SSH, firewall rules, filesystem permissions, web server hardening, database security, and intrusion prevention — for servers built on the LAMP or LEMP stack.
A freshly installed LAMP or LEMP server exposed to the internet will receive its first port-scan within minutes — not hours. Automated scanners crawl the entire IPv4 address space continuously, probing for weak SSH credentials, exposed admin panels, outdated PHP versions, and misconfigured web servers.
The Lesson 1 guides got your stack running. This lesson gets it secure. Skipping hardening is not a question of "if" something goes wrong, but "when."
| Attack Vector | What They Want | How We Stop It |
|---|---|---|
| Brute-forced SSH password | Root shell access | Key-only auth + Fail2Ban |
| Open ports (MySQL, Redis, etc.) | Direct DB access, data theft | UFW default-deny + bind to localhost |
| Directory listing / sensitive files | Config files, passwords, source code | Apache/Nginx config hardening |
| Outdated PHP / web software | Remote code execution (RCE) | Unattended-upgrades + PHP tuning |
| Weak database credentials | Data exfiltration, ransom | mysql_secure_installation |
| SQL injection / XSS via PHP apps | Data theft, account takeover | ModSecurity WAF + PHP hardening |
| World-writable web root files | Defacement, malware drop | Strict file permissions |
SSH is the primary remote access method for your server. It is also the most-targeted service on the internet. A server with a simple password-based SSH login is routinely broken into within hours of going online. The steps below replace password auth with key-based auth and lock down the daemon itself.
# On your desktop or laptop — Linux, macOS, or Windows (WSL/PowerShell) ssh-keygen -t ed25519 -C "your_email@example.com" # When prompted: # Save location: press Enter to accept default (~/.ssh/id_ed25519) # Passphrase: ALWAYS set a strong passphrase — adds a second layer # Copy your public key to the server ssh-copy-id -i ~/.ssh/id_ed25519.pub yourusername@YOUR_SERVER_IP # Now test key-based login in a NEW terminal window before continuing ssh -i ~/.ssh/id_ed25519 yourusername@YOUR_SERVER_IP
With key login confirmed, open the SSH daemon config on the server:
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak sudo nano /etc/ssh/sshd_config
Find and set (or add) the following directives. Lines beginning with # must be uncommented:
# Change the default port — not foolproof but reduces noise from automated scanners Port 2222 # Disable root login entirely — you should always sudo from a normal user PermitRootLogin no # Disable password authentication — key-only after this PasswordAuthentication no ChallengeResponseAuthentication no KbdInteractiveAuthentication no # Allow only your specific user(s) AllowUsers yourusername # Limit auth attempts per connection MaxAuthTries 3 # Disconnect idle sessions after 5 minutes of inactivity ClientAliveInterval 300 ClientAliveCountMax 2 # Disable legacy X11 forwarding if you don't use it X11Forwarding no # Disable TCP forwarding if you don't use SSH tunneling AllowTcpForwarding no
ssh -p 2222 yourusername@SERVER_IP# Verify the config has no syntax errors before restarting sudo sshd -t # If the above produces no output, restart SSH sudo systemctl restart ssh # Verify SSH is running on the new port sudo ss -tlnp | grep ssh
For an additional layer, you can require a time-based OTP (TOTP) alongside the SSH key:
sudo apt install libpam-google-authenticator -y
google-authenticator # Run as your normal user — follow the prompts
google-authenticator, scan the QR code with an authenticator app (Google Authenticator, Aegis, or Bitwarden Authenticator). Then edit /etc/pam.d/sshd to enable it and set AuthenticationMethods publickey,keyboard-interactive in sshd_config. Full PAM config is beyond this guide's scope but is well-documented in the Ubuntu SSH hardening docs.UFW (Uncomplicated Firewall) is a front-end for iptables built into Ubuntu. The strategy is simple: deny all inbound traffic by default, then explicitly allow only what the server legitimately needs to serve.
# Set default: deny all incoming, allow all outgoing sudo ufw default deny incoming sudo ufw default allow outgoing # Allow SSH — CHANGE 2222 to your actual SSH port sudo ufw allow 2222/tcp comment 'SSH' # Allow HTTP and HTTPS for your web server sudo ufw allow 80/tcp comment 'HTTP' sudo ufw allow 443/tcp comment 'HTTPS' # Enable the firewall sudo ufw enable # Verify rules are active sudo ufw status verbose
MySQL (port 3306) and MariaDB should never be reachable from the internet. Confirm these are blocked:
# These should NOT appear in ufw status — if they do, delete them sudo ufw status | grep 3306 # Confirm MySQL is bound to localhost only (see Section 9 for the my.cnf setting) sudo ss -tlnp | grep 3306 # Should show: 127.0.0.1:3306 — NOT 0.0.0.0:3306
UFW has a built-in rate limiter for SSH that blocks IPs that connect more than 6 times in 30 seconds:
# Replace 2222 with your SSH port
sudo ufw delete allow 2222/tcp
sudo ufw limit 2222/tcp comment 'SSH rate-limited'
The single most effective thing you can do to prevent exploitation is keep your software current. Ubuntu's unattended-upgrades package handles this automatically for security patches.
# Install the package (usually pre-installed on Ubuntu 24.04) sudo apt install unattended-upgrades apt-listchanges -y # Enable automatic security updates sudo dpkg-reconfigure -plow unattended-upgrades # Choose "Yes" at the prompt # Optional: Verify current configuration cat /etc/apt/apt.conf.d/20auto-upgrades
// These two lines should be present after running dpkg-reconfigure:
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
/etc/apt/apt.conf.d/50unattended-upgrades and uncomment Unattended-Upgrade::Automatic-Reboot "true";
to allow automatic reboots at a scheduled time. For production servers, use Automatic-Reboot-Time "03:00";
to keep reboots in your maintenance window.
Incorrect file permissions are a top cause of web server compromises. The goal is to make files readable by the web server process but not writable — and to prevent the web server from executing anything it shouldn't.
| What | Owner | Permissions | Rationale |
|---|---|---|---|
| Web root directory | root:www-data |
755 |
Traversable by web server, not writable |
| PHP / HTML files | root:www-data |
644 |
Readable by web server, not writable |
| Upload directories | www-data:www-data |
755 |
Writable by web server for uploads only |
| Config files with credentials | root:www-data |
640 |
Readable by app, not world-readable |
| SSL private key files | root:root |
600 |
Never readable by web process |
# Replace /var/www/yourdomain.com with your actual web root path # Set directory ownership: root owns, web server (www-data) is group sudo chown -R root:www-data /var/www/yourdomain.com # Directories: executable bit needed to traverse sudo find /var/www/yourdomain.com -type d -exec chmod 755 {} \; # Files: readable but not executable or writable sudo find /var/www/yourdomain.com -type f -exec chmod 644 {} \; # For CMS upload directories (e.g., WordPress wp-content/uploads) sudo chown -R www-data:www-data /var/www/yourdomain.com/wp-content/uploads sudo find /var/www/yourdomain.com/wp-content/uploads -type d -exec chmod 755 {} \; sudo find /var/www/yourdomain.com/wp-content/uploads -type f -exec chmod 644 {} \;
chmod 777 makes a file or directory writable by every user and process on the system, including the web server. If PHP is compromised, an attacker can write malware directly into your web root. If you see tutorials suggesting 777, ignore them.
Apache's default configuration is functional but exposes more information than necessary and enables several features that are better disabled on a production server.
# Hide Apache and OS version from HTTP response headers ServerTokens Prod ServerSignature Off # Disable TRACE method (used in cross-site tracing attacks) TraceEnable Off
sudo nano /etc/apache2/conf-available/security.conf
# Set the three directives above, then apply:
sudo a2enconf security
sudo systemctl reload apache2
Directory listing lets anyone browsing your server see a full file listing of directories that lack an index file. This must be disabled.
<Directory /var/www/>
# Remove Indexes to disable directory listing
Options -Indexes +FollowSymLinks
# Prevent access to .htaccess and .htpasswd files directly
AllowOverride All
# Deny access to hidden files (filenames starting with .)
<FilesMatch "^\.ht">
Require all denied
</FilesMatch>
</Directory>
# Enable mod_headers for security header control sudo a2enmod headers sudo a2enmod rewrite # Required for .htaccess rewrites (WordPress, etc.) sudo a2enmod ssl # Disable modules you don't need — reduces attack surface sudo a2dismod autoindex # Disables directory listing at the module level sudo a2dismod status # Disables /server-status endpoint sudo systemctl reload apache2
Add the following to your VirtualHost <VirtualHost *:443> block (or a separate conf file):
<IfModule mod_headers.c>
# Prevent clickjacking
Header always set X-Frame-Options "SAMEORIGIN"
# Block MIME-type sniffing
Header always set X-Content-Type-Options "nosniff"
# Enable XSS filter in older browsers
Header always set X-XSS-Protection "1; mode=block"
# Enforce HTTPS — only add after SSL is working
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Control referrer information
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Remove the X-Powered-By header (also done in php.ini)
Header unset X-Powered-By
Header always unset X-Powered-By
</IfModule>
sudo apache2ctl configtest # Should output: Syntax OK
sudo systemctl reload apache2
Nginx is leaner than Apache out of the box but still benefits from explicit hardening of its server block configuration and global settings.
# Hide version number from Server: response header
server_tokens off;
Add the following to your HTTPS server block:
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
root /var/www/yourdomain.com/public;
index index.php index.html;
# Disable directory listing
autoindex off;
# Security headers
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 Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
more_clear_headers X-Powered-By;
# Deny access to hidden files and sensitive paths
location ~ /\. {
deny all;
return 404;
}
# Deny direct access to configuration files
location ~* \.(env|git|htaccess|htpasswd|ini|log|sh|sql|conf)$ {
deny all;
return 404;
}
# Limit request methods to GET, HEAD, POST only
if ($request_method !~ ^(GET|HEAD|POST)$) {
return 444;
}
# Limit request body size (adjust for file upload needs)
client_max_body_size 10m;
# PHP-FPM block
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
}
sudo nginx -t # Should output: configuration file ... syntax is ok
sudo systemctl reload nginx
nginx-extras package
(sudo apt install nginx-extras). If you prefer not to install it,
use fastcgi_hide_header X-Powered-By; inside your PHP location block instead.
PHP's default configuration prioritizes convenience over security. These settings should be applied to php.ini on every production server regardless of whether you use Apache or Nginx.
# For Apache (LAMP) — PHP runs as mod_php or php-fpm sudo nano /etc/php/8.3/apache2/php.ini # For Nginx (LEMP) — PHP runs as PHP-FPM sudo nano /etc/php/8.3/fpm/php.ini
; Hide PHP version from HTTP headers and error messages expose_php = Off ; Disable functions that are almost never legitimately needed in web apps ; and commonly abused for remote code execution disable_functions = exec,passthru,shell_exec,system,proc_open,popen, curl_exec,curl_multi_exec,parse_ini_file,show_source,eval ; Disable allow_url_fopen and allow_url_include ; Prevents PHP from loading remote files (used in RFI attacks) allow_url_fopen = Off allow_url_include = Off ; Restrict PHP file access to within the web root ; Replace /var/www with your web root parent directory open_basedir = /var/www:/tmp:/usr/share/php ; Prevent PHP from running in upload directories ; (set to 0 to disable globally — override per-directory with .user.ini) ; upload_tmp_dir = /tmp ; Don't display errors to end users — log them instead display_errors = Off display_startup_errors = Off log_errors = On error_log = /var/log/php_errors.log ; Set a reasonable maximum upload and memory limit upload_max_filesize = 20M post_max_size = 25M memory_limit = 128M ; Set a session save path with restricted permissions session.save_path = "/var/lib/php/sessions" session.cookie_httponly = 1 session.cookie_secure = 1 session.use_strict_mode = 1
# LAMP — restart Apache sudo systemctl restart apache2 # LEMP — restart PHP-FPM then Nginx sudo systemctl restart php8.3-fpm sudo systemctl reload nginx # Verify expose_php is off — the Server header should no longer say "PHP/8.x" curl -I https://yourdomain.com | grep -i "x-powered\|php" # No output = success
The database is where your real data lives. A compromised database can mean complete data loss, GDPR violations, or ransom demands. These steps address the most critical database attack vectors.
# For MySQL: sudo mysql_secure_installation # For MariaDB (LEMP): sudo mariadb-secure-installation # Answer YES to all of the following when prompted: # - Set root password / validate password plugin # - Remove anonymous users # - Disallow root login remotely # - Remove test database # - Reload privilege tables
Your database should never be accessible from outside the server.
[mysqld]
# Bind to localhost only — refuse all external connections
bind-address = 127.0.0.1
sudo systemctl restart mysql # or: sudo systemctl restart mariadb sudo ss -tlnp | grep 3306 # Must show 127.0.0.1:3306 — not 0.0.0.0:3306
Your web application should use a dedicated user with only the privileges it needs. Never connect PHP to MySQL as root.
sudo mysql -u root -p -- Create a database for your app CREATE DATABASE myapp_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- Create a dedicated user with a strong password CREATE USER 'myapp_user'@'localhost' IDENTIFIED BY 'Use_A_Very_Strong_Passw0rd!'; -- Grant ONLY what this app needs on ONLY its own database GRANT SELECT, INSERT, UPDATE, DELETE ON myapp_db.* TO 'myapp_user'@'localhost'; -- Apply changes FLUSH PRIVILEGES; EXIT;
[mysqld]
# Enable binary log — records all data changes for audit and recovery
log_bin = /var/log/mysql/mysql-bin.log
expire_logs_days = 7
max_binlog_size = 100M
Fail2Ban monitors log files for repeated failed authentication attempts and automatically blocks the offending IP using firewall rules. It is one of the most effective tools against brute-force attacks.
sudo apt install fail2ban -y sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local sudo nano /etc/fail2ban/jail.local
[DEFAULT] # Ban time: 1 hour (3600 seconds). Increase for repeat offenders with bantime.increment bantime = 3600 findtime = 600 # Block after 5 failures within findtime maxretry = 5 # Whitelist your own IP so you can't lock yourself out ignoreip = 127.0.0.1/8 ::1 YOUR.HOME.IP.ADDRESS # Use UFW as the ban action backend banaction = ufw # Enable SSH jail — change port to match your SSH port [sshd] enabled = true port = 2222 filter = sshd logpath = /var/log/auth.log maxretry = 3 # Enable Apache/Nginx authentication failure protection [apache-auth] enabled = true port = http,https filter = apache-auth logpath = /var/log/apache2/error.log maxretry = 5 # LEMP: enable nginx-http-auth instead [nginx-http-auth] enabled = true port = http,https filter = nginx-http-auth logpath = /var/log/nginx/error.log # Block scanner bots probing for admin panels [apache-botsearch] enabled = true port = http,https filter = apache-botsearch logpath = /var/log/apache2/error.log maxretry = 2
sudo systemctl enable fail2ban sudo systemctl start fail2ban # Check status of all jails sudo fail2ban-client status # Check status of a specific jail sudo fail2ban-client status sshd # Manually unban an IP (if you lock yourself out) sudo fail2ban-client set sshd unbanip YOUR.IP.ADDRESS
ModSecurity is a WAF that sits in front of your web server and inspects every HTTP request, blocking common attacks like SQL injection, XSS, and remote file inclusion before they ever reach your PHP code.
# Install ModSecurity for Apache
sudo apt install libapache2-mod-security2 -y
sudo a2enmod security2
sudo cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf
sudo nano /etc/modsecurity/modsecurity.conf
# Nginx requires ModSecurity compiled as a dynamic module sudo apt install nginx-extras libmodsecurity3 -y # Clone the OWASP Core Rule Set sudo git clone https://github.com/coreruleset/coreruleset /etc/nginx/modsec/coreruleset sudo cp /etc/nginx/modsec/coreruleset/crs-setup.conf.example \ /etc/nginx/modsec/coreruleset/crs-setup.conf
# Start in DetectionOnly — change to On after testing SecRuleEngine DetectionOnly # Log all matched rules SecAuditEngine RelevantOnly SecAuditLog /var/log/modsec_audit.log
# Install the OWASP Core Rule Set (CRS) — the industry-standard WAF ruleset sudo apt install modsecurity-crs -y # For Apache: enable CRS sudo a2enconf modsecurity-crs sudo systemctl restart apache2 # Watch the audit log to review what would have been blocked sudo tail -f /var/log/modsec_audit.log
SecRuleEngine DetectionOnly to SecRuleEngine On and restart your web server.
ModSecurity will now actively block malicious requests.
Your Lesson 1 guide covered installing a Let's Encrypt certificate via Certbot. This section goes further, ensuring your TLS configuration is hardened against known protocol weaknesses.
TLS 1.0 and 1.1 are deprecated and have known vulnerabilities. Only TLS 1.2 and 1.3 should be allowed.
# Allow only TLS 1.2 and TLS 1.3 SSLProtocol -all +TLSv1.2 +TLSv1.3 # Use only strong cipher suites SSLCipherSuite 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 # Prefer server cipher order SSLHonorCipherOrder off # TLS 1.3 handles this; let the client choose for 1.2 # Disable compression (CRIME attack) SSLCompression off
# Allow only TLS 1.2 and TLS 1.3 ssl_protocols TLSv1.2 TLSv1.3; # Strong cipher suite (OWASP recommended) 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; ssl_prefer_server_ciphers off; # Enable OCSP stapling for faster certificate validation ssl_stapling on; ssl_stapling_verify on; resolver 1.1.1.1 8.8.8.8 valid=300s; resolver_timeout 5s;
Use the free SSL Labs server test to grade your configuration from an external perspective:
max-age=31536000 (one year).
Security hardening doesn't end after configuration. You need to watch what your server is doing. These are the most important logs and how to monitor them.
| Service | Log Path | What to Watch For |
|---|---|---|
| SSH / Auth | /var/log/auth.log |
Failed logins, sudo attempts |
| Apache access | /var/log/apache2/access.log |
404 floods, scanner patterns |
| Apache errors | /var/log/apache2/error.log |
PHP errors, mod_security blocks |
| Nginx access | /var/log/nginx/access.log |
404 floods, scanner patterns |
| Nginx errors | /var/log/nginx/error.log |
PHP-FPM errors, upstream failures |
| MySQL / MariaDB | /var/log/mysql/error.log |
Failed auth, unusual queries |
| Fail2Ban | /var/log/fail2ban.log |
Bans, unbans, jail activity |
| ModSecurity | /var/log/modsec_audit.log |
Blocked requests, rule matches |
| System (kernel/daemons) | /var/log/syslog |
Kernel errors, daemon crashes |
sudo apt install logwatch -y
# Send daily email reports (requires a mail server or relay)
# Test immediately:
sudo logwatch --output stdout --format text --range today --detail med | less
sudo apt install lynis -y
sudo lynis audit system
# Lynis scores your system and produces a list of hardening suggestions.
# Run it after completing this guide to find anything you missed.
sudo apt install rkhunter -y
sudo rkhunter --update
sudo rkhunter --check
# Add to cron for weekly checks:
# 0 3 * * 0 root rkhunter --check --skip-keypress --report-warnings-only
Use this checklist to verify your server has been fully hardened. Every item should be confirmed before exposing the server to production traffic.
PermitRootLogin no)expose_php = Off in php.inidisplay_errors = Off — errors logged, not displayedallow_url_include = Off and allow_url_fopen = Offdisable_functionsopen_basedir restricts PHP file access to web roothttponly, secure, strict_mode)mysql_secure_installation (or mariadb-secure-installation) has been run127.0.0.1 only — not externally accessible