Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Server Hardening for Ubuntu Web Servers

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.

SSH Connection Decision FlowIncoming SSHRoot Login?PermitRootLogin noPassword Auth?PasswordAuth noREJECTNOValid ED25519 Key?PubkeyAuth yesUser in AllowUsers?deploy, adminACCESS GRANTEDNOREJECTNOREJECT
SSH hardening decision flow: each layer rejects unauthorized connections before reaching the application shell

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.

PHP-FPM Pool Isolation ArchitectureNginxReverse ProxyPool: lawfirmuser: site_lawfirmsock: fpm-lawfirm/var/www/lawfirmopen_basedir enforcedowner: site_lawfirmPool: ecommerceuser: site_ecommercesock: fpm-ecommerce/var/www/ecommerceopen_basedir enforcedowner: site_ecommerce❌ Cross-site file access BLOCKED by user + open_basedir + socket isolationNO ACCESS
PHP-FPM pool isolation: separate users, sockets, and open_basedir prevent cross-site contamination on shared Ubuntu servers

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.

Continuous Hardening Maintenance CycleDailyUnattended SecurityUpdates + RebootWeeklyPermission AuditPort + Owner CheckMonthlyFail2ban ReviewLog AnalysisQuarterlyFull SecurityAudit + PatchAlert PipelineEmail / Slack / Monitoring Dashboard → Human Review → Remediation Ticket⚠ Without Automation: Config Drift → Open Ports → Stale Packages → BreachServers degrade silently; scheduled tasks enforce baseline continuously
Continuous hardening cycle: daily updates, weekly audits, monthly reviews, and quarterly full assessments prevent security decay

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 LayerPriorityVerification CommandCommon Failure Mode
SSH key-only + no rootCriticalsshd -T | grep -E 'permitroot|passwordauth'Cloud-init overwrites sshd_config on reboot
UFW default denyCriticalufw status verboseDocker/nftables conflict bypasses UFW rules
PHP-FPM pool isolationHighps aux | grep php-fpm | grep -v www-dataDeploy script resets ownership to www-data
open_basedir enforcementHighphp -r "echo ini_get('open_basedir');"Composer post-install runs outside basedir
Fail2ban active jailsMediumfail2ban-client statusLog rotation breaks file monitoring backend
Unattended upgradesMediumsystemctl status unattended-upgradesKernel updates require manual reboot
Weekly integrity auditLowls -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.

Frequently Asked Questions

Disable root login, configure SSH key authentication only, set up UFW to allow only ports 22, 80, and 443, install fail2ban, enable automatic security updates via unattended-upgrades, and create a non-root sudo user. These six steps eliminate the majority of automated attack vectors targeting fresh Ubuntu web servers.

Basic hardening typically costs NPR 15,000–25,000 (USD 110–185) for a single production web server. Comprehensive setups with CI/CD integration, monitoring, and documentation range NPR 35,000–60,000 (USD 260–450). Ongoing maintenance contracts usually start at NPR 5,000/month (USD 37) for security patching and log review.

Use UFW unless you need complex NAT rules or custom chain logic. UFW wraps iptables/nftables with a simpler syntax that reduces configuration errors. In my experience managing production Laravel servers, UFW covers all standard web hosting needs including rate limiting and interface-specific rules without the operational overhead of raw iptables scripts.

Edit /etc/ssh/sshd_config to set PermitRootLogin no, PasswordAuthentication no, and PubkeyAuthentication yes. Change the default port to reduce noise from automated scanners, though this is security through obscurity rather than true protection. Restrict SSH access to specific IP ranges using UFW when possible. Always test new SSH configurations in a separate session before closing your current connection to avoid lockout. Restart sshd.service after changes and verify with sshd -t first.

Enable jails for sshd, apache-auth, apache-botsearch, and wordpress if applicable. Set bantime to 3600, findtime to 600, and maxretry to 5 for most services. For SSH, use maxretry 3. Create custom filters for Laravel authentication failures by matching Failed password patterns in storage/logs/laravel.log. Always whitelist your office IP and deployment runner in ignoreip to prevent accidental self-blocking during maintenance or automated deployments.

Configure unattended-upgrades for automatic daily security patches. Run apt update && apt upgrade manually weekly to review kernel and PHP updates that require service restarts. Subscribe to Ubuntu Security Notices for your specific packages. In production environments serving eCommerce or legal-tech platforms, I schedule manual patch windows during low-traffic periods to test compatibility before auto-updates apply them. Never disable automatic security updates entirely.

Set ownership to www-data:www-data for storage/ and bootstrap/cache/ directories with 775 permissions. Application code should be owned by your deploy user with 755 for directories and 644 for files. The .env file must be 600 and owned by www-data. Never set 777 permissions anywhere. When using Deployer 7 with symlinked releases, ensure shared directories maintain correct ownership across deployments. Incorrect permissions cause both security vulnerabilities and runtime errors.

Set pm = dynamic with pm.max_children calculated as available RAM divided by average process size (typically 30-50MB). Configure pm.start_servers, pm.min_spare_servers, and pm.max_spare_servers based on baseline traffic. Set open_basedir to restrict filesystem access to application directories. Disable dangerous functions like exec, shell_exec, and system in php.ini unless specifically required. Enable slowlog with request_slowlog_timeout set to 5 seconds to identify performance bottlenecks. Test thoroughly after changes as restrictive settings can break legitimate application functionality.

Use Mozilla's Intermediate compatibility profile as baseline. Enable TLS 1.2 and 1.3 only, disable older protocols. Configure strong cipher suites prioritizing AES-GCM and ChaCha20. Enable HSTS with includeSubDomains and preload directives. Use Let's Encrypt with Certbot for certificate management and automated renewal. Test configuration with SSL Labs aiming for A+ rating. On Ubuntu 24.04 with Apache or Nginx, modern defaults are reasonably secure but still require explicit hardening to prevent downgrade attacks.

Install Wordfence or Sucuri for application-level rate limiting and IP blocking. Configure fail2ban with wordpress jail monitoring wp-login.php requests. Move admin URL using WPS Hide Login plugin to reduce automated attack surface. Enforce strong passwords and enable two-factor authentication for all administrator accounts. Limit login attempts to 5 per minute per IP. Consider Cloudflare or similar CDN for additional WAF protection. On high-traffic sites I've managed, combining application plugins with server-level fail2ban provides defense in depth without impacting legitimate users.

Enable persistent journald logging with SystemMaxUse=500M in journald.conf. Configure rsyslog to forward auth.log and application logs to centralized storage. Install Logwatch for daily email summaries of suspicious activity. Set up basic monitoring with Monit or Nagios to alert on disk space, memory, CPU, and failed services. Review /var/log/auth.log weekly for unauthorized access attempts. For client projects, I configure alerts for fail2ban bans exceeding thresholds and unusual sudo usage patterns to catch potential compromises early.

Run mysql_secure_installation immediately after installation. Bind to 127.0.0.1 only unless remote access is absolutely necessary. Create application-specific users with minimal privileges instead of using root. Enable TLS for database connections if application and database are on separate servers. Configure innodb_buffer_pool_size appropriately for available RAM. Regularly audit user grants with SHOW GRANTS. Back up databases encrypted and test restoration procedures monthly. On production eCommerce systems, I also enable query logging temporarily during performance troubleshooting while being mindful of disk I/O impact.

Add to /etc/sysctl.conf: net.ipv4.tcp_syncookies=1 for SYN flood protection, net.ipv4.conf.all.rp_filter=1 for reverse path filtering, net.ipv4.icmp_echo_ignore_broadcasts=1 to prevent smurf attacks, and net.ipv4.conf.all.accept_redirects=0 to block ICMP redirects. Apply changes with sysctl -p. These mitigate common network-layer attacks without affecting normal web traffic. Test thoroughly as some legacy applications may depend on disabled features. Document all changes for future reference during troubleshooting or team handoffs.

Run composer audit regularly to check for known vulnerabilities in dependencies. Subscribe to GitHub Security Advisories for critical packages like Laravel framework, Spatie libraries, and payment gateway SDKs. Configure Dependabot or Renovate for automated pull requests on security updates. Test updates in staging before production deployment. Maintain a software bill of materials documenting package versions and their security status. On legal-tech portals handling sensitive documents, I prioritize patching authentication and encryption libraries within 48 hours of security advisory publication regardless of scheduled maintenance windows.

Leaving default credentials on installed services, forgetting to harden after major upgrades, misconfiguring UFW allowing unintended ports, disabling security features for temporary debugging without re-enabling them, and neglecting backup testing. Another frequent issue is hardening the OS while leaving application-level vulnerabilities unaddressed. Documentation gaps cause knowledge loss when team members change. In my experience, the most critical mistake is treating hardening as one-time setup rather than ongoing process requiring regular review, testing, and adjustment as threats and infrastructure evolve over time.

Share this article

Quick Contact Options
Choose how you want to connect me: