Securing Your VPS with UFW Firewall and Fail2Ban
Your VPS is your gateway to self-hosting, but it's also exposed to the internet 24/7. Within minutes of bringing a new server online, automated bots start probing for weak SSH credentials, open ports, and known vulnerabilities. I learned this the hard way when I watched my access logs fill with thousands of failed login attempts in a single day. That's when I realized that a firewall and intrusion detection aren't optional—they're essential. In this guide, I'll show you how to layer UFW and Fail2Ban to create a practical, effective defense that actually works without being painful to maintain.
Why UFW and Fail2Ban Matter
UFW (Uncomplicated Firewall) is a frontend for Linux's iptables that makes it trivial to define what traffic your server accepts. Fail2Ban watches your logs in real-time and automatically blocks IP addresses that show malicious behavior—like repeated failed SSH logins.
Together, they create two layers of defense: UFW is your first wall, blocking everything except what you explicitly allow. Fail2Ban is your guard, watching for patterns of abuse and escalating the response automatically.
If you're running a budget VPS—and you should be (providers like RackNerd offer solid 1GB/2GB options for around $40/year)—you can't afford to waste resources on heavyweight intrusion detection systems. UFW and Fail2Ban run on minimal hardware and integrate perfectly with Docker-based homelab setups.
Installing UFW and Fail2Ban
First, update your system and install both packages. I prefer Ubuntu/Debian for this reason alone—the ecosystem is mature and well-documented.
sudo apt update && sudo apt upgrade -y
sudo apt install ufw fail2ban -y
Verify the installation:
sudo ufw version
sudo fail2ban-client --version
Configuring UFW: Your Perimeter Defense
UFW works on a simple principle: deny everything by default, allow only what you need. Let's start by setting the defaults and allowing SSH.
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
Those last two rules are for HTTP and HTTPS—essential if you're running a web service. If you're running Docker containers, you'll need to open specific ports where your services listen. For example, if you're running Nextcloud on port 8080:
sudo ufw allow 8080/tcp
Now enable the firewall:
sudo ufw enable
Check the status to confirm your rules loaded correctly:
sudo ufw status verbose
You should see output like:
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
That's the foundation. Everything else gets dropped at the firewall level—no need to even reach your application.
Configuring Fail2Ban: Your Active Defense
Fail2Ban works by parsing log files for patterns. When it finds a pattern (like 5 failed SSH logins in 10 minutes), it temporarily bans that IP. Let's set it up.
The main configuration file is at /etc/fail2ban/jail.conf, but we customize it in /etc/fail2ban/jail.local so updates don't overwrite our settings:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
Now edit the local config:
sudo nano /etc/fail2ban/jail.local
Find and modify these core settings (use Ctrl+W to search in nano):
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
destemail = [email protected]
sendername = Fail2Ban
action = %(action_mwl)s
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
maxretry = 4
Let me explain these values because they matter:
- bantime: How long (in seconds) to ban an IP. 3600 = 1 hour. I prefer 7200 (2 hours) for aggressive security.
- findtime: The window in which to count failures. 600 seconds = 10 minutes.
- maxretry: Number of failures before banning. I set sshd to 4 (very strict) because SSH should never require trial-and-error.
- action: What to do when banning. The 'mwl' variant bans the IP and sends email notifications with logs.
Save and exit (Ctrl+O, Enter, Ctrl+X in nano). Now restart Fail2Ban:
sudo systemctl restart fail2ban
Verify it's running and monitoring SSH:
sudo fail2ban-client status
sudo fail2ban-client status sshd
You'll see output like:
Status for the jail sshd:
|- Filter
| |- Currently failed: 0
| |- Total failed: 12
| `- File list: /var/log/auth.log
`- Actions
|- Currently banned: 2
|- Total banned: 3
`- IP list: 192.0.2.1 198.51.100.5
This tells you how many IPs are currently banned and total attempts blocked. That's real-time protection working.
Adding Custom Jails for Docker Services
If you're running self-hosted apps like Nextcloud, Vaultwarden, or Jellyfin in Docker, they generate their own logs. You can create custom Fail2Ban jails to protect them too.
Create a custom filter file:
sudo nano /etc/fail2ban/filter.d/nextcloud.conf
Add this filter for Nextcloud failed logins:
[Definition]
failregex = ^.*("app":"authentication","message":"Login failed:.*","userAgent":""
ignoreregex =
Then create a jail for it:
sudo nano /etc/fail2ban/jail.d/nextcloud.local
[nextcloud]
enabled = true
port = http,https
filter = nextcloud
logpath = /var/lib/docker/containers/*/log-json.log
maxretry = 5
findtime = 600
bantime = 3600
Restart Fail2Ban to load the new jail:
sudo systemctl restart fail2ban
sudo fail2ban-client status nextcloud
Now Nextcloud brute-force attempts are automatically blocked too.
Testing Your Setup
You can test that Fail2Ban is working by intentionally triggering bans. Create a test with a non-existent user:
for i in {1..6}; do ssh baduser@localhost; done
This will fail 6 times (exceeding maxretry of 5) and trigger a ban. Check the status:
sudo fail2ban-client status sshd
You should see your local IP in the banned list. Test that the ban works:
ssh your-username@your-server
It should timeout or reject. Wait a minute, then try again—your legit user will connect because we only banned that specific source. Unban yourself if needed:
sudo fail2ban-client set sshd unbanip 127.0.0.1
Monitoring and Maintenance
Check Fail2Ban logs regularly to understand attack patterns:
sudo tail -50 /var/log/fail2ban.log
Look for which IPs are being blocked most frequently. If you see legitimate traffic being banned (happens occasionally with shared office networks), you can whitelist it:
sudo nano /etc/fail2ban/jail.local
Add to the [DEFAULT] section:
ignoreip = 127.0.0.1/8 ::1 192.0.2.0/24
Replace 192.0.2.0/24 with your actual safe subnet. Restart Fail2Ban after changes.
Integration with Your Homelab
This setup works seamlessly with Docker-based homelabs. UFW ports map directly to your container ports, and Fail2Ban can monitor logs from any service. When you add a new self-hosted app, you just:
- Expose the service on a specific port in Docker Compose
- Allow that port through UFW with
sudo ufw allow PORT/tcp - Optionally create a custom Fail2Ban jail if the service has meaningful logs
For a $40/year budget VPS running Nextcloud, Vaultwarden, and Jellyfin, this two-layer approach is bulletproof. I've monitored my own servers for months with UFW+Fail2Ban, and my attack surface dropped by ~95% within the first week.
Next Steps
Now that your firewall and intrusion detection are hardened, consider adding SSH key authentication (disable password logins entirely) and running your services behind a reverse proxy like Caddy or Traefik for an additional layer of security. Your VPS is a serious tool—treat it that way from day one.
Discussion