The cleanest way to stop SSH brute-force noise is not a better password policy or fail2ban. It is to make port 22 unreachable from the internet in the first place.
Every internet-facing Linux host I have ever run has the same line in its auth log: thousands of failed SSH login attempts a day, from botnets working through root, admin, ubuntu and a dictionary of the rest. Key-only authentication defeats them, but the noise is a signal that the door is standing in the street. So on the last few builds I stopped treating SSH as a public service. The web servers listen for SSH only on a WireGuard interface, the public firewall never opens 22, and an administrator connects to the VPN before they can reach the box at all. This is a field note on how that is wired and the one failure mode you have to design out before you rely on it.
The failure mode I was designing out
An exposed SSH port is a permanent, unauthenticated attack surface. It does not matter that you use keys and disable password auth today; you are still betting on OpenSSH having no pre-authentication remote vulnerability for the life of the server. That is usually a safe bet and occasionally a very bad one. The same instinct that says an API key should be scoped and never a bare liability applies to the admin plane: the management interface should not be sitting on the public internet accepting connections from anyone who can route a packet to it.
Free · 4 minutes
Would you survive contact with a determined attacker — or an auditor?
Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.
The goal is simple to state. The only thing the public interface should answer is the web application on 80 and 443, plus a single UDP port for the VPN handshake. SSH, the database admin port, an internal metrics endpoint, a staging panel — none of that should be reachable without first being on the admin network. WireGuard makes this cheap because it is a single kernel module, a single UDP port, and a config file you can read in one sitting.
The WireGuard admin network
On Ubuntu 24.04 the tooling is already there — wireguard-tools gives you wg and wg-quick, and the module is in the kernel. The server config is deliberately small. This is an admin network, not a full tunnel, so there is no forwarding, no NAT and no default route being pushed anywhere.
# /etc/wireguard/wg0.conf (on the web server)
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <server-private-key>
# Admin laptop. One /32 per person, so revoking access
# is deleting a peer block, not rotating a shared secret.
[Peer]
PublicKey = <admin-laptop-public-key>
AllowedIPs = 10.8.0.2/32
Bring it up and enable it across reboots with systemctl enable --now wg-quick@wg0. Each administrator is a peer with a single /32 inside 10.8.0.0/24. There is no shared credential to leak; offboarding someone is removing their [Peer] stanza and reloading. The server has a stable address on the tunnel, 10.8.0.1, and that address is the one SSH will bind to.
Binding SSH to the tunnel — and the socket-activation trap
Here is where a lot of guides are quietly wrong on current Ubuntu. The old advice is to set ListenAddress 10.8.0.1 in sshd_config and restart. On Ubuntu 22.10 and later, including 24.04 LTS with OpenSSH 9.6, SSH is started by socket activation through ssh.socket. When the socket unit owns the listening socket, the ListenAddress directive in sshd_config is ignored — systemd has already opened 0.0.0.0:22 and hands the connection to sshd. You change the bind by overriding the socket, not the daemon.
# systemctl edit ssh.socket
# writes to /etc/systemd/system/ssh.socket.d/override.conf
[Socket]
# An empty assignment first CLEARS the packaged default (0.0.0.0:22),
# otherwise the new value is simply appended to it.
ListenStream=
ListenStream=10.8.0.1:22
# IP_FREEBIND lets the socket bind to the tunnel address even before
# wg0 is up, which removes the boot-ordering race entirely.
FreeBind=yes
Then systemctl daemon-reload && systemctl restart ssh.socket. The empty ListenStream= line matters: systemd socket options are additive, so without it you would be listening on the public port and the tunnel. The FreeBind=yes line is the quiet hero. Without it, if sshd‘s socket tries to bind 10.8.0.1:22 at boot before wg0 exists, the bind fails and you have no SSH at all until something brings the interface up. IP_FREEBIND allows binding to a non-local address, so the ordering stops being fragile. If you prefer belt-and-braces, order the interface first as well:
# systemctl edit wg-quick@wg0
[Unit]
Before=ssh.socket
If you would rather not use socket activation at all, systemctl disable --now ssh.socket and systemctl enable --now ssh.service restores the classic behaviour where ListenAddress in sshd_config is honoured. Either route works; the mistake is assuming sshd_config is in charge when the socket is.
The firewall is what makes it true
Binding SSH to the tunnel is necessary but not sufficient — the firewall is what guarantees the public interface never answers on 22. With nftables the policy reads as a short, auditable list. The public NIC accepts web and the WireGuard handshake; SSH is only accepted when it arrives on wg0; everything else is dropped.
#!/usr/sbin/nft -f
# /etc/nftables.conf
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
# WireGuard handshake + data, public interface only
iifname "eth0" udp dport 51820 accept
# Public web application
iifname "eth0" tcp dport { 80, 443 } accept
# SSH is ONLY reachable from inside the tunnel
iifname "wg0" tcp dport 22 accept
}
}
There is no line that accepts tcp dport 22 on eth0, so from the internet the port is simply not there — not filtered with a rejection, just silent. A port scan of the public address returns 80, 443 and nothing else. The admin panel, the metrics endpoint and anything else you add later inherit the same treatment: bind them to 10.8.0.1, accept them only on wg0, and they never exist publicly. It is the same discipline as tightening what an authentication layer will actually accept — reduce the surface to exactly what is needed and refuse the rest by default.
The operational risk you must plan for
This design has a real cost and it is the honest reason to think twice: WireGuard is now on the critical path to administering the box. If the VPN breaks — a botched config push, a key rotation gone wrong, a kernel update that leaves wg0 down — and SSH is bound only to the tunnel, you have locked yourself out of a machine whose recovery clock is now ticking. That is a self-inflicted incident, and it goes straight into your mean-time-to-restore if it happens in anger.
So you plan for it before you rely on it. Keep an out-of-band console — the serial or web console every serious provider offers — and test that you can actually log in through it, because the time to discover your console password is wrong is not during a lockout. Never push a WireGuard or firewall change to every server at once; stage it, and keep a second peer path or a break-glass account that a colleague can bring up. The FreeBind and ordering settings above matter precisely because they stop a routine reboot from turning into a recovery exercise.
The trade is worth making. You exchange a permanent, unauthenticated, internet-facing attack surface for one operational dependency that you control, monitor and can rehearse. A brute-force log that used to scroll for pages goes empty, and the only way to reach the admin plane is to already be inside it. Just make sure the out-of-band door is real before you shut the public one.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Can you trust the architecture you have?
Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.