I had Claude run a routine security check on the little VPS that hosts this site. Nothing specific — just "have a look." So it read the system journal, and the journal had a lot to say: in the previous seven days, the server had logged 242,296 failed SSH logins. Claude checked every username they tried against the server's config. The surprising part isn't that someone's knocking — it's that not one of those attempts could ever have worked, and the fix to keep it that way is three small changes.
A quarter-million brute-force attempts is just internet weather — it happens to every public server, all the time, like rain. It is harmless the moment the server only accepts SSH keys, with no password door for anyone to push on.
Every public IP with port 22 open gets this. Botnets sweep the whole internet around the clock, trying admin / admin on every address they find. It looks scary in the logs and means almost nothing — if you've shut the one door they're actually pushing on.
The whole thing started as one short message from me. Here's roughly how our conversation went (server names and addresses scrubbed):
journalctl -u ssh --since "7 days ago" \
| grep -ciE "failed|invalid user"
242296
It's not targeted at you — it's botnets sweeping the whole IPv4
range. They're mostly guessing admin,
ubuntu,
postgres…
A short, cleaned-up recreation of the actual session. That's me on the left.
Claude read through a quarter-million failed password guesses so I didn't have to. It is less exciting than it sounds. Once you see the breakdown, the fear drains out of the big number — this is a few dozen botnets running the same tired word list. Here's the week, by the day:
| Username guessed | Attempts | Why |
|---|---|---|
| admin | 16,814 | The eternal first guess |
| ubuntu | 3,337 | Default cloud-image user |
| user | 1,812 | Hope springs eternal |
| solana | 1,717 | Hunting crypto validators |
| postgres | 501 | Looking for an open database |
Notice what's not there: my actual username. They're not attacking me — they're trying default names on every IP that exists and seeing what sticks. Some of them spent 501 tries on postgres, a database user this server doesn't even have. It's a burglar walking down the street, trying every door handle — including the doors that don't exist.
A brute-force attack guesses passwords. If the server doesn't accept passwords — only cryptographic keys — there's nothing to guess. The attacker would need to steal a private key that never leaves my laptop, not type the right word. That single setting turns the whole flood into harmless noise. Claude checked the config: every account on this box is key-only. The attackers were guessing answers to a question the server stopped asking.
This is what Claude and I set up, in order of how much each one matters. The first one does almost all the work; the other two are good habits on top.
Generate a key pair on your laptop (ssh-keygen -t ed25519), copy the public half to the server (ssh-copy-id), confirm you can log in with it — then close the password door. Drop a file in /etc/ssh/sshd_config.d/:
PasswordAuthentication no KbdInteractiveAuthentication no
Then sudo sshd -t to check the syntax and sudo systemctl reload ssh. Keep your current session open and test a fresh login in a second terminal before you trust it. That open session is your way back in if you got something wrong. Claude tested the fresh login before reloading anything — locking me out of my own server would have been a short career for it.
Brute force can't break in, but a quarter-million log lines a week is still noise that hides the events you'd actually want to see. A firewall rule that slows down repeat connections from the same address — and bans the worst offenders for an hour — keeps the journal readable. The shape of it, in nftables:
# new SSH connections from one IP, faster than
# ~1/min sustained (burst 10) → drop & remember it
tcp dport 22 ct state new \
meter scan { ip saddr limit rate over 1/minute burst 10 packets } \
add @offenders { ip saddr } drop
# anyone in the offenders set is dropped on sight
tcp dport 22 ip saddr @offenders drop
Tools like fail2ban do the same job by watching the logs instead of the packets — either is fine. The point is to stop spending CPU and disk on people who can't get in anyway.
The strongest move is to not expose port 22 to the public internet at all. Put the server on a private mesh network — I use Tailscale — and reach it over that. The attacks don't slow down; they simply can't find the door. (One caveat: if something like a CI pipeline needs to SSH in from the public internet, you either keep a rate-limited public port for it or move that pipeline onto the mesh too.)
My box had five kernel security patches installed but never activated — it had been up 56 days. Unattended upgrades download and install fixes, but a kernel patch only takes effect after a restart. If your uptime is measured in months, you're probably running a kernel with known holes. Check for /var/run/reboot-required, pick a quiet moment, and reboot. (Check that your services come back up afterwards — one of ours lost a race for a network port on boot and needed a small push. That's a story for another note.)