
August 15, 2026
10 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Server hardening for Ubuntu web servers is the systematic process of reducing the attack surface of a Linux host running production web applications like Laravel, WordPress, or custom PHP systems. Most breaches on small-to-medium business servers happen not because of sophisticated zero-day exploits, but because of default configurations, open ports, shared permissions, and neglected updates. This guide provides the exact hardening steps I apply to production Ubuntu 22.04 and 24.04 LTS servers hosting client projects in Nepal and internationally, focusing on practical defense layers that survive real-world traffic and deployment workflows.
How do you secure SSH access for Ubuntu server hardening?
SSH is the primary entry point for administrators and the most targeted service on any public-facing VPS. Default SSH configurations on Ubuntu are functional but insecure for production. On every web server security audit I perform, fixing SSH is always step one because credential stuffing against port 22 begins within minutes of DNS propagation.
Disable root login and password authentication
Edit the SSH daemon configuration file directly. Never rely solely on cloud-init or provider dashboards for persistent SSH settings, as package upgrades can reset defaults.
sudo nano /etc/ssh/sshd_config
# Apply these exact directives
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers deploy admin
Protocol 2 The AllowUsers directive is critical. Even if an attacker obtains valid credentials for a system account like www-data or ubuntu, they cannot log in unless explicitly listed. After editing, validate the configuration before restarting to avoid locking yourself out:
sudo sshd -t && sudo systemctl reload sshd Enforce ED25519 keys and disable legacy algorithms
RSA keys below 3072 bits and DSA keys are deprecated. In 2026, ED25519 offers better security and performance. Generate keys locally:
ssh-keygen -t ed25519 -C "deploy@production-server" Restrict accepted algorithms server-side by adding to /etc/ssh/sshd_config:
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
PubkeyAcceptedAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com This prevents downgrade attacks where clients negotiate weak ciphers. Test thoroughly with all legitimate clients (Deployer, GitLab CI runners, developer laptops) before enforcing, as older tools may lack ED25519 support.
How do you configure UFW firewall rules for web servers?
Uncomplicated Firewall (UFW) wraps nftables/iptables with a syntax that survives reboots and package upgrades. A common mistake on Laravel hosting environments is leaving database or Redis ports open to 0.0.0.0/0 because the developer assumed "internal" meant safe. On a single-VPS setup, there is no internal network — every bound port is public.
Baseline UFW configuration
# Reset to clean state (caution: disconnects active sessions if not careful)
sudo ufw --force reset
# Default policies: deny incoming, allow outgoing
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Rate-limit SSH to slow brute force
sudo ufw limit 22/tcp comment 'SSH rate-limited'
# Allow HTTP/HTTPS
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
# Enable and verify
sudo ufw enable
sudo ufw status verbose Restricting service ports to localhost
MySQL, PostgreSQL, Redis, and Memcached should never bind to public interfaces on a single-server setup. Bind them to 127.0.0.1 in their respective configs (/etc/mysql/mysql.conf.d/mysqld.cnf, /etc/redis/redis.conf) rather than relying on UFW alone. Defense in depth means the service itself refuses external connections even if a firewall rule is accidentally added later.
If you run a multi-server setup where app servers connect to a separate database VPS over private networking, restrict UFW to the specific private IP:
sudo ufw allow from 10.0.0.5 to any port 3306 proto tcp comment 'MySQL from app-server' Never use ufw allow 3306 without a source restriction. Automated scanners find open MySQL ports within hours.
Why should you isolate PHP-FPM pools per website?
Running multiple sites under a single www-data PHP-FPM pool is the most dangerous misconfiguration I see on shared Ubuntu web servers. If one WordPress site gets compromised through a vulnerable plugin, the attacker can read, modify, or inject malware into every other site sharing that user context. For legal-tech portals handling sensitive documents on platforms like Nepal law firm websites, this isolation is non-negotiable.
Create dedicated system users per site
# Create isolated user with no shell or home login
sudo adduser --system --no-create-home --shell /usr/sbin/nologin site_lawfirm
sudo adduser --system --no-create-home --shell /usr/sbin/nologin site_ecommerce
# Set ownership on document roots
sudo chown -R site_lawfirm:site_lawfirm /var/www/lawfirm
sudo chown -R site_ecommerce:site_ecommerce /var/www/ecommerce
# Ensure storage/cache dirs are writable
sudo chmod -R 750 /var/www/lawfirm/storage
sudo setfacl -R -m u:site_lawfirm:rwx /var/www/lawfirm/storage Configure separate PHP-FPM pool files
Create /etc/php/8.4/fpm/pool.d/lawfirm.conf:
[lawfirm]
user = site_lawfirm
group = site_lawfirm
listen = /run/php/php8.4-fpm-lawfirm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 4
php_admin_value[open_basedir] = /var/www/lawfirm:/tmp:/var/tmp
php_admin_value[upload_tmp_dir] = /var/www/lawfirm/tmp
php_admin_value[session.save_path] = /var/www/lawfirm/sessions
php_admin_flag[display_errors] = off The open_basedir directive is the critical security boundary. It prevents PHP scripts from reading files outside the designated paths, even if an attacker achieves code execution. Combined with separate Unix sockets and system users, this creates three independent isolation layers.
How does fail2ban protect against brute-force attacks?
Even with key-only SSH, your server logs will fill with automated login attempts. Fail2ban monitors log files and dynamically updates nftables/iptables rules to ban offending IPs. It also protects application-level endpoints like WordPress login pages and Laravel authentication routes, which UFW cannot filter.
Install and configure base jail
sudo apt install fail2ban
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local Edit /etc/fail2ban/jail.local with production-appropriate values:
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
backend = systemd
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = %(sshd_log)s
maxretry = 3
bantime = 86400
[nginx-http-auth]
enabled = true
port = http,https
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
[wordpress-auth]
enabled = true
port = http,https
filter = wordpress-auth
logpath = /var/www/*/logs/access.log
maxretry = 10
findtime = 300
bantime = 7200 Custom filter for Laravel authentication
Create /etc/fail2ban/filter.d/laravel-auth.conf to catch failed login attempts on Laravel applications:
[Definition]
failregex = ^<HOST>.*POST.*/login.*422
^<HOST>.*POST.*/auth/login.*401
ignoreregex = This matches Laravel's default 422 validation response for failed authentication. Adjust the regex if your application uses custom auth endpoints or returns different status codes. Always test filters with fail2ban-regex against actual log samples before enabling in production.
What automated maintenance keeps Ubuntu servers hardened long-term?
Hardening is not a one-time task. Servers drift from secure baselines through package upgrades, new deployments, and configuration changes. On client projects managed under annual maintenance agreements, automated maintenance prevents security decay without requiring monthly manual audits.
Enable unattended security upgrades
sudo apt install unattended-upgrades apt-listchanges
sudo dpkg-reconfigure unattended-upgrades Configure /etc/apt/apt.conf.d/50unattended-upgrades to auto-reboot only during maintenance windows:
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "03:00";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true"; Scheduled integrity and permission checks
Create a weekly cron job at /etc/cron.weekly/server-hardening-audit:
#!/bin/bash
# Check for world-writable files in web roots
find /var/www -perm -o+w -type f > /var/log/hardening/world-writable.log
# Verify PHP-FPM pool users match directory owners
for pool in /etc/php/8.4/fpm/pool.d/*.conf; do
user=$(grep "^user = " "$pool" | awk '{print $3}')
dir=$(grep "open_basedir" "$pool" | grep -oP '/var/www/[^:]+' | head -1)
owner=$(stat -c '%U' "$dir" 2>/dev/null)
if [ "$user" != "$owner" ]; then
echo "MISMATCH: $pool expects $user but $dir owned by $owner" \
>> /var/log/hardening/ownership-mismatch.log
fi
done
# Report open ports (verify no unexpected listeners)
ss -tlnp > /var/log/hardening/open-ports.log Ship these logs to your monitoring stack or email them weekly. Silent failures are worse than noisy alerts — if nobody reads the output, the automation provides false confidence.
Server Hardening for Ubuntu Web Servers: Implementation Checklist
Use this table to track hardening completion across your fleet. Every item has been validated on Ubuntu 22.04 and 24.04 LTS running PHP 8.3/8.4 with Nginx or Apache in 2026.
| Hardening Layer | Priority | Verification Command | Common Failure Mode |
|---|---|---|---|
| SSH key-only + no root | Critical | sshd -T | grep -E 'permitroot|passwordauth' | Cloud-init overwrites sshd_config on reboot |
| UFW default deny | Critical | ufw status verbose | Docker/nftables conflict bypasses UFW rules |
| PHP-FPM pool isolation | High | ps aux | grep php-fpm | grep -v www-data | Deploy script resets ownership to www-data |
| open_basedir enforcement | High | php -r "echo ini_get('open_basedir');" | Composer post-install runs outside basedir |
| Fail2ban active jails | Medium | fail2ban-client status | Log rotation breaks file monitoring backend |
| Unattended upgrades | Medium | systemctl status unattended-upgrades | Kernel updates require manual reboot |
| Weekly integrity audit | Low | ls -la /var/log/hardening/ | Cron path stale after PHP version upgrade |
A frequent gotcha on Deployer-managed Laravel deployments: the release symlink swap runs as the deploy user, but new release directories inherit the deploy user's ownership instead of the site-specific FPM user. Add a post-deploy task to fix ownership atomically:
task('fix:ownership', function () {
run('sudo chown -R site_lawfirm:site_lawfirm {{release_path}}');
run('sudo chmod -R 750 {{release_path}}/storage');
})->desc('Fix release ownership for FPM pool isolation'); Without this, the first deployment after hardening silently breaks the isolation you just configured.
Next Steps for Production Ubuntu Security
Server hardening for Ubuntu web servers is foundational infrastructure work that pays dividends in reduced incident response, cleaner logs, and client trust. The steps above cover the layers that matter most for small-to-medium production environments running Laravel, WordPress, or custom PHP applications. Start with SSH and UFW today — they take under thirty minutes and eliminate entire categories of automated attacks. Then schedule PHP-FPM isolation and fail2ban for your next maintenance window. If you need a security audit or hardening implementation for your production Ubuntu server, get in touch to discuss your specific environment and compliance requirements.

