SSH, ufw and fail2ban basics for a single VPS
· 10 min read
On this page (7)
Three tools show up in every "secure your server" guide: sshd settings, a firewall, and fail2ban. They get presented as a stack, as if each one adds protection on top of the other. In practice each one covers a different failure, and each has a gap that the others do not fill. If you know which is which, you can configure all three in twenty minutes and stop worrying about the wrong things.
This post goes through the options that matter, shows working config for Debian 12 or 13, and says plainly what each layer will not do for you.
Three tools, three different jobs
Who needs this
- A Node or Go app on a $6 VPS. Ports 80 and 443 are public on purpose. Everything else, including SSH, Postgres, the app's own port and the metrics endpoint you added last month, should not be. The firewall is the thing that makes "should not be" true even when a config file says otherwise.
- A server that has been up for a year. Open
/var/log/auth.log(or runjournalctl -u ssh) and you will see thousands of failed logins from addresses you have never heard of. You want to know whether that is a problem. Mostly it is noise, and sshd settings tell you whether it can ever turn into a breach. - A team of three sharing one box. People come and go. You want only named users to be able to log in, only with keys, and you want one place to remove access when someone leaves.
- A box that runs Docker. You believe ufw protects your containers. This is the case where it does not, and it is worth knowing before an internal admin panel ends up on the open internet.
Layer 1: sshd, where the real protection is
Most of the value is in a handful of options. Put them in a drop-in so that package upgrades never overwrite them:
# /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
X11Forwarding no
AllowUsers deploy
LoginGraceTime 30
Then validate and reload:
sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|allowusers|maxauthtries) '
What each option does:
PasswordAuthentication no and KbdInteractiveAuthentication no. These two together remove password guessing as an attack. With PAM enabled, keyboard-interactive can still prompt for a password, so disabling only the first is not enough. After the change, the failure message from a client trying a password becomes Permission denied (publickey): the server no longer offers anything but keys.
PermitRootLogin no. Root is the one account name every scanner tries. The setting prohibit-password (the old default) allows root with a key, which is not terrible, but a named user plus sudo leaves a better trail and means that a leaked key alone does not give root.
AllowUsers deploy. An allowlist. Only the listed accounts can log in over SSH, whatever other users exist on the machine. Service accounts like www-data or postgres should never be able to open a shell remotely, and this makes that true by policy rather than by hope. For a team, AllowGroups sshusers scales better.
MaxAuthTries 3. How many attempts per connection. It is a small speed bump for guessers and mainly keeps logs tidy. It also bites you if your agent offers many keys: each key tried counts. If you have a pile of keys in ssh-agent, you can hit the limit before the right key is offered. Use IdentitiesOnly yes with an explicit IdentityFile in your client config.
LoginGraceTime 30. How long an unauthenticated connection may sit there. The default is 120 seconds. Shorter frees slots sooner.
X11Forwarding no. You are not running graphical programs over SSH on a web server. Turn off what you do not use.
Two more worth knowing about, not necessarily setting:
MaxStartupslimits concurrent unauthenticated connections (default10:30:100, meaning random drops start at 10 pending and all are refused at 100). Under a flood of connection attempts it decides whether legitimate logins squeeze through.AuthenticationMethods publickeyis stricter than the defaults when you want to be explicit, andpublickey,keyboard-interactiveis how you would add a second factor through PAM.
The drop-in ordering trap
sshd reads the first value it finds for most keywords. Debian's sshd_config includes /etc/ssh/sshd_config.d/*.conf near the top, and files are read in alphabetical order. A cloud image can ship a later file, for example 50-cloud-init.conf, containing PasswordAuthentication yes. If your file is called 99-hardening.conf, it loses. Naming it 10-hardening.conf makes it win. After any change, sshd -T shows the value sshd will actually use. Trust that output, not the file you edited.
What sshd hardening does not stop
It does not stop an attacker who already has your private key (use a passphrase, and keep the key off shared machines). It does not stop a vulnerability in sshd itself, which is why automatic security updates matter. It does not hide the port, so the log noise remains. And it says nothing about your other services: a database listening on all interfaces is a separate problem.
Layer 2: ufw, which decides what can be reached at all
A host firewall's job is to make the list of reachable services short and explicit. With ufw, that is a default-deny policy plus allow rules:
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
sudo ufw --force enable
sudo ufw status verbose
Always allow SSH before enabling. Enabling a deny-by-default firewall without the SSH rule drops your own session.
Some rules that earn their keep:
# Rate-limit SSH: deny a source that opens 6+ connections in 30 seconds
sudo ufw limit 22/tcp
# SSH only from your office or VPN address
sudo ufw delete allow 22/tcp
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
# Postgres reachable from one private address only
sudo ufw allow from 10.0.0.5 to any port 5432 proto tcp
# Review with rule numbers, remove by number
sudo ufw status numbered
sudo ufw delete 4
ufw limit uses iptables rate limiting on new connections and is a reasonable middle path when you cannot restrict by address, for example because your home IP changes. Be careful with address-restricted SSH rules: if your IP changes and you have no other way in, the provider's console is your exit. The address 203.0.113.10 above is from a reserved documentation range; use your own.
ufw adds IPv6 rules automatically when IPv6 is enabled in /etc/default/ufw (the default), so you will see every rule twice. If a service answers over IPv6 and not over IPv4 or the reverse, look at the (v6) lines first.
Why ufw does not see Docker ports
What ufw does not stop
Docker. Ports published with -p are handled by Docker's own iptables rules, which run before ufw's. A container started with -p 5432:5432 is reachable from the internet even with ufw default deny incoming. Publish to loopback (-p 127.0.0.1:5432:5432), or put the container on a private network and let only the reverse proxy publish ports.
Anything on an allowed port. ufw does not look inside port 443. If your app has an SQL injection, the firewall waves it through. A firewall is about reachability, not about the quality of what answers.
A provider firewall that disagrees. Many providers also have a network-level firewall. When both exist, a connection must pass both. If something times out and ufw says allowed, check the dashboard.
Outbound traffic. The default allow-outgoing policy means a compromised process can call out freely. Restricting egress is possible but takes real work and is rarely worth it on a one-person server.
You can test a firewall properly only from outside. From your laptop, nc -vz SERVER_IP 22, then nc -vz SERVER_IP 8080. An allowed port connects; a denied port hangs until timeout, because ufw drops rather than rejects.
Layer 3: fail2ban, the log-reading bouncer
fail2ban watches a log source, counts failures per source address, and adds a temporary firewall ban. Its value is operational: it cuts log noise, reduces wasted connections, and slows persistent scanners. It is not an authentication control. If password login is off, it cannot prevent a break-in, because there is nothing to guess.
Install and create a local override. Never edit jail.conf; it is replaced on upgrades. Write jail.local instead:
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
backend = systemd
EOF
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
What the numbers mean: a source that fails maxretry times within findtime gets banned for bantime. With the values above, five failures in ten minutes earns one hour. ignoreip is your safety net; add your own static address or VPN range there so a typo storm from your own laptop cannot ban you.
backend = systemd matters on modern Debian. A minimal Debian 12 image has no rsyslog, and so no /var/log/auth.log; sshd's messages live only in the journal. Reading from the journal is what makes the jail see anything. Debian's package already ships a default that does this, but stating it in your own file removes the guess.
Repeat offenders can be banned for longer each time:
[DEFAULT]
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
Check what it is doing:
sudo fail2ban-client status sshd # currently banned addresses, total failed
sudo fail2ban-client set sshd unbanip 198.51.100.7
sudo journalctl -u fail2ban --since today
Test the jail on purpose, from a machine you can afford to ban (a phone hotspot works): try to log in with a wrong user a few times and watch the address appear in the banned list. A jail you never saw trigger is a jail you do not know works.
How a ban happens with the example settings
What fail2ban does not stop
It does not stop a distributed attack where each address tries once or twice. It does not protect a service that does not log failed attempts in a way the filter understands. It adds a moving part: a ban rule lives in the firewall, and a misconfigured ignoreip or an unlucky retry count can lock out a real user. And it does nothing for exploits that succeed on the first request. Treat it as hygiene.
For web paths such as /wp-login.php hitting your reverse proxy, you can add jails for that too, but make sure the filter matches your actual log format first (fail2ban-regex tests a filter against a log). Before reaching for that, ask whether the app should be reachable at all from the open internet.
How the layers fit
| Layer | Stops | Does not stop | Cost |
|---|---|---|---|
| sshd options | Password guessing, root login, unlisted users | Stolen keys, sshd bugs | A drop-in file |
| ufw | Unintended services being reachable | Attacks on allowed ports, Docker-published ports | A few rules |
| fail2ban | Log noise, repeat scanners | Slow distributed guessing, first-try exploits | One more daemon to watch |
| Automatic updates | Known bugs in all of the above | Zero-days, your own app's bugs | A reboot policy |
If you only do one thing, do the sshd drop-in. If you do two, add the firewall. fail2ban is the third and the most optional.
Rule of thumb: sshd settings keep intruders out, ufw keeps services hidden, fail2ban keeps logs readable. Do them in that order.
Mistakes we see
Disabling password login from the same session without testing. Always open a second terminal and log in with the key first. Everything else on this list is recoverable from the provider's console; this is the one that costs you an afternoon.
Writing the drop-in with the wrong prefix. A file named 99-hardening.conf quietly loses to a provider file. Check sshd -T.
Assuming ufw covers Docker. See above. Run a port scan from outside after you start any container.
Banning yourself. A short bantime while testing and an ignoreip entry save you. If you are banned anyway, the provider console and fail2ban-client set sshd unbanip fix it.
Using fail2ban as the reason to keep passwords on. If you tolerate password login because "fail2ban will catch guessers", a patient attacker spreading attempts across many addresses will not be caught, and a weak password will fall.
Opening ports and forgetting. After a debugging session you open 3000 or 8080 to test from your phone, and it stays open. sudo ufw status numbered should be something you can read in five seconds. If it is longer, find out why.
Once SSH is key-only and the firewall is closed, the next thing worth setting up is knowing when something breaks; monitor one VPS without a stack covers that, and hardening a fresh Debian VPS puts these pieces into the order you should do them.
FAQ
Should I change the SSH port?
It cuts the amount of noise in your logs, since most scanners try port 22, and it protects against nothing that is aimed at you. Key-only login does the real work. If you move it, allow the new port in ufw before reloading sshd, and note that on Ubuntu 22.10 and later sshd may be started through ssh.socket, where the port is set in the socket unit instead.
Does fail2ban work with ufw?
Yes. fail2ban inserts its own ban rules through iptables or nftables rather than through ufw commands. The two coexist; you will see banned addresses in fail2ban-client status sshd rather than in ufw status.
How do I know if my SSH hardening actually took effect?
Run sudo sshd -T and read the effective values for permitrootlogin and passwordauthentication. Then try to log in with a password from another machine; the message should end in (publickey) and nothing else.
Is a VPN better than exposing SSH to the internet?
A private network or VPN that is the only way to reach port 22 removes the whole public attack surface for SSH, and it is a sound choice if you already run one. For a single small box it adds a second thing to keep working, and key-only SSH is a defensible baseline. Pick the one you will actually maintain.
The VPS Handbook includes the sshd drop-in, a firewall script that adds the SSH rule before enabling ufw, and a first-day checklist; they are in the handbook if you want them ready to run.