Securing Your VPS: Fail2Ban, UFW, and SSH Hardening for Self-Hosters

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

The moment you spin up a fresh VPS, the clock starts ticking. Within minutes — sometimes seconds — automated bots are already probing your SSH port, trying thousands of username and password combinations. I've watched auth logs on a brand-new DigitalOcean Droplet and seen hundreds of failed login attempts before I'd even finished the initial setup. The good news is that a focused 30-minute hardening session with UFW, Fail2Ban, and a few SSH config changes will shut down the vast majority of that noise for good.

This tutorial walks through every step I take when provisioning a new Linux VPS for self-hosting. I'll cover the exact commands, the config options that actually matter, and the gotchas that have burned me before. I'm assuming Ubuntu 22.04 or 24.04, but the steps translate to Debian with minimal changes.

Step 1: Create a Non-Root User

Running everything as root is asking for trouble. Before touching anything else, I create a dedicated user and add them to the sudo group. This way, even if an attacker finds a way in through a misconfigured service, they don't land as root.

# Run as root on your fresh VPS
adduser deploy
usermod -aG sudo deploy

# Switch to the new user and test sudo
su - deploy
sudo whoami  # should print: root

Pick a username that isn't admin, ubuntu, or user — those are the first ones bots try after root. I usually use something project-specific like deploy or svchost.

Step 2: SSH Key Authentication — Do This Before Anything Else

Password-based SSH is a liability. SSH keys are not optional in my setup — they're the foundation everything else builds on. If you haven't already generated a key pair on your local machine, do that first:

# On your LOCAL machine (not the VPS)
ssh-keygen -t ed25519 -C "[email protected]"
# Accept the default location (~/.ssh/id_ed25519) or specify one
# Set a strong passphrase when prompted

# Copy your public key to the VPS
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@YOUR_VPS_IP

# Test that key auth works BEFORE disabling passwords
ssh -i ~/.ssh/id_ed25519 deploy@YOUR_VPS_IP

Once you've confirmed you can log in with your key, it's time to lock down the SSH daemon. Edit /etc/ssh/sshd_config and change or add the following lines:

sudo nano /etc/ssh/sshd_config
# Settings to change in /etc/ssh/sshd_config

Port 2222                        # Non-standard port — reduces bot noise significantly
PermitRootLogin no               # Never allow direct root login
PasswordAuthentication no        # Keys only, no passwords
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
X11Forwarding no
AllowUsers deploy                # Whitelist only your user(s)
MaxAuthTries 3                   # Limit brute-force attempts per connection
ClientAliveInterval 300
ClientAliveCountMax 2
# Validate the config before restarting — this saves you from lockouts
sudo sshd -t

# If no errors, restart SSH
sudo systemctl restart sshd
Watch out: Do NOT close your current SSH session before opening a new terminal and confirming you can still log in on the new port. If you've misconfigured sshd_config and lose access, you'll need your VPS provider's rescue console to recover. On DigitalOcean, that's the Droplet console in the web UI. Keep that tab open.

Changing the port from 22 to something like 2222 or a high random port isn't security through obscurity on its own — it genuinely cuts bot traffic by 90%+ because most scanners only hit port 22. It's a low-effort win.

Step 3: UFW Firewall — Default Deny Everything

UFW (Uncomplicated Firewall) wraps iptables in a sane interface. My philosophy is default-deny inbound and then selectively open only what I need. Here's the exact sequence I run:

# Install UFW if not already present
sudo apt update && sudo apt install ufw -y

# Set defaults: deny all incoming, allow all outgoing
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow your NEW SSH port (use whatever port you set above)
sudo ufw allow 2222/tcp comment 'SSH'

# Allow HTTP and HTTPS if you're running a web server or reverse proxy
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'

# If running a Caddy or Traefik reverse proxy, that's all you need publicly.
# Other services (Nextcloud, Gitea, etc.) should sit behind the proxy, not exposed directly.

# Enable UFW — this takes effect immediately
sudo ufw enable

# Verify the rules
sudo ufw status verbose

The output of ufw status verbose should show your three rules with ALLOW IN and everything else blocked. If you're running Tailscale, you'll also want to allow the tailscale0 interface:

sudo ufw allow in on tailscale0
Tip: If you run Portainer or another Docker management tool, be careful — Docker bypasses UFW by default and adds its own iptables rules. Exposed container ports will be publicly accessible even if UFW says they're blocked. The fix is to bind container ports to 127.0.0.1 in your docker-compose.yml (e.g. - "127.0.0.1:8080:8080") and let your reverse proxy handle external traffic.

Step 4: Fail2Ban — Automatic IP Banning for Repeat Offenders

Fail2Ban watches log files and bans IPs that show signs of brute-force behaviour. It integrates with UFW to add temporary firewall blocks. I configure it with a custom jail so my settings survive package upgrades.

sudo apt install fail2ban -y

# Never edit /etc/fail2ban/jail.conf directly — it gets overwritten on updates
# Create a local override instead
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo nano /etc/fail2ban/jail.local

Find the [DEFAULT] section and set these values:

[DEFAULT]
bantime  = 1h           # How long to ban an IP (use 24h or -1 for permanent)
findtime = 10m          # Window in which failures are counted
maxretry = 5            # Failures before ban
backend  = systemd      # Use systemd journal (correct for Ubuntu 22.04+)
banaction = ufw         # Use UFW to apply bans

[sshd]
enabled  = true
port     = 2222         # Must match your sshd Port setting
logpath  = %(sshd_log)s
maxretry = 3            # Be stricter for SSH specifically
bantime  = 24h
# Start and enable Fail2Ban
sudo systemctl enable --now fail2ban

# Check status
sudo fail2ban-client status
sudo fail2ban-client status sshd

# See currently banned IPs
sudo fail2ban-client get sshd banip

# Manually unban an IP if you lock yourself out
sudo fail2ban-client set sshd unbanip YOUR_IP

I've seen people set maxretry = 1 and then ban themselves when they mistype a passphrase. Three retries for SSH is the right balance. For web application jails — if you add one for Nextcloud or Gitea later — five is fine.

Step 5: Additional Hardening Worth Doing

Once the big three are in place, a few more quick wins round out a solid baseline:

# Keep packages updated automatically (security patches only)
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades

# Disable unused services — check what's listening
ss -tlnp

# If you see services you don't recognise, stop and disable them
sudo systemctl stop SERVICENAME
sudo systemctl disable SERVICENAME

# Check for world-writable files and SUID binaries periodically
sudo find / -perm -o+w -type f -not -path "/proc/*" 2>/dev/null
sudo find / -perm -4000 -type f 2>/dev/null

I also install logwatch for a daily email summary of auth attempts and system events. It's old-school but genuinely useful — getting a digest every morning means I catch anomalies before they become problems.

Verifying Everything Works Together

After all this is in place, I do a quick sanity check from a separate machine or terminal:

# Confirm SSH on new port works
ssh -p 2222 deploy@YOUR_VPS_IP

# Confirm port 22 is unreachable
nc -zv YOUR_VPS_IP 22    # Should time out or be refused

# Confirm 80/443 are open (if applicable)
nc -zv YOUR_VPS_IP 80    # Should connect

# Deliberately fail SSH auth a few times to trigger Fail2Ban, then check
sudo fail2ban-client status sshd

Wrapping Up

This stack — SSH keys, UFW default-deny, Fail2Ban with a custom jail, and a non-root user — covers the vast majority of what attackers throw at exposed VPS instances. It's not glamorous, but it works, and I've never had a server compromised while running this setup.

If you're looking for a reliable home for your self-hosted services, DigitalOcean Droplets are my go-to — predictable pricing, fast SSD storage, and a decent web console that saves you when you inevitably lock yourself out during a config change. Start spending more time on your projects and less time managing infrastructure. Create your DigitalOcean account today.

Your next steps: add a Fail2Ban jail for your reverse proxy (Caddy, Nginx, or Traefik all write logs that Fail2Ban can parse), and consider layering on Authelia for two-factor authentication in front of sensitive self-hosted apps. Both are covered in separate tutorials on this site.

Discussion