On this page
- What You Need Before Starting
- How the Traffic Flows After Hardening
- Phase 1: Creating a Non-Root Admin User
- Phase 2: Installing Tailscale
- Phase 3: Binding SSH to the Tailscale Interface Only
- Phase 4: The Belt-and-Braces Firewall
- Why Socket Binding and Firewall Rules Are Both Necessary
- The Lockout Trap and How to Avoid It
The moment you provision a VPS and bind it to a public IP, it’s under attack. I’m not being dramatic — spin up a fresh Ubuntu server, check /var/log/auth.log an hour later, and you’ll find hundreds of failed SSH attempts from botnet IPs scattered across the globe. They’re trying root/password, admin/admin, ubuntu/ubuntu, and every other credential combination they’ve learned works on misconfigured servers.
Most hardening guides stop at “disable password authentication.” That’s necessary but not sufficient. A firewall misconfiguration, a ufW rule that gets flushed during an upgrade, a package update that resets sshd_config — any of these can re-expose Port 22 to the public internet. The “belt and braces” approach layers two independent protections: kernel-level socket binding (the SSH daemon literally can’t listen on the public interface) and firewall rules (packets targeting Port 22 from outside the Tailscale subnet are dropped). Either one alone is enough. Together, they make administrative access cryptographically invisible to the public internet.
What You Need Before Starting
- A firm grasp of Linux file permissions (the octal system)
- Asymmetric cryptography basics (public/private keypairs)
- OSI Model Layer 3/4 networking (IP addressing and TCP/UDP ports)
- A freshly provisioned Ubuntu/Debian VPS with a public IPv4 address
- Root access via an established SSH keypair (no passwords)
- A free Tailscale account with the client installed on your local workstation
How the Traffic Flows After Hardening
flowchart TD
subgraph Public Internet
A[Attacker / Botnet]
B[Legitimate User]
end
subgraph The Invisible VPS
FW{UFW Firewall}
TS[tailscale0 Interface \n CGNAT: 100.x.x.x]
SSH[SSH Daemon \n Bound strictly to 100.x.x.x]
FW -- Drop Port 22 --> A
FW -- Drop Port 22 --> B
TS -- Cryptographic Tunnel --> SSH
end
subgraph Local Workstation
C[Local Tailscale Client]
end
A -.->|Public IP:22| FW
B -.->|Public IP:22| FW
C ==>|WireGuard UDP Tunnel| TS
Attackers and legitimate users hitting the public IP both hit the UFW firewall and get dropped. The only way in is through the Tailscale WireGuard tunnel, which requires cryptographic authentication before a packet even arrives at the SSH daemon. And the SSH daemon isn’t even listening on the public interface anyway — that’s the belt-and-braces redundancy.
Phase 1: Creating a Non-Root Admin User
Working as root is a liability. Create a dedicated administrative user first, then migrate SSH access:
ssh root@<PUBLIC_VPS_IP>
adduser username
usermod -aG sudo username
mkdir -p /home/username/.ssh
cp /root/.ssh/authorized_keys /home/username/.ssh/authorized_keys
Copying the authorized_keys file directly from root ensures the same keypair you just used to log in will work for the new user. Then fix permissions — SSH is strict about this. If the .ssh directory or key file permissions are too loose, the daemon silently rejects the keys to protect you from local actors who might have read access:
chown -R username:username /home/username/.ssh
chmod 700 /home/username/.ssh
chmod 600 /home/username/.ssh/authorized_keys
# Verify the new user works before closing the root session
su - username
sudo ls
Phase 2: Installing Tailscale
sudo apt update && sudo apt upgrade -y
sudo usermod -aG docker username # If Docker is or will be installed
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
sudo tailscale up gives you an authentication link. Open it in your browser to join the VPS to your Tailscale network. Once authenticated, run tailscale ip to get the Tailscale IPv4 and IPv6 addresses — you’ll need both for the next phase.
Phase 3: Binding SSH to the Tailscale Interface Only
This is where we diverge from standard tutorials. Most guides leave SSH listening on 0.0.0.0 (all interfaces) and rely entirely on the firewall to block Port 22. The problem: if that firewall fails, Port 22 is immediately exposed.
We’ll reconfigure the SSH daemon to only bind to the Tailscale IP addresses. If the daemon isn’t listening on the public interface, a firewall failure can’t expose it.
sudo vim /etc/ssh/sshd_config
Set these directives:
ListenAddress <TAILSCALE_IPV4>
ListenAddress <TAILSCALE_IPV6>
PasswordAuthentication no
UsePAM no
PermitRootLogin no
Then restart and reboot:
sudo systemctl restart ssh
sudo reboot now
After this reboot, you cannot connect via the public IP. You must SSH using the Tailscale IP: ssh username@<TAILSCALE_IPV4>. If you don’t have this IP written down somewhere, this is your lockout moment.
Phase 4: The Belt-and-Braces Firewall
Even though SSH is only listening on the Tailscale interface, we configure UFW to aggressively drop any stray packets targeting Port 22 from the outside:
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Initially allow OpenSSH (you need this before switching to Tailscale-only rules)
sudo ufw allow OpenSSH
sudo ufw enable
Now evolve to the hardened configuration. Tailscale uses RFC 6598 Shared Address Space (Carrier-Grade NAT) — the 100.64.0.0/10 block — which is guaranteed not to route over the public internet. We whitelist that subnet:
sudo ufw allow from '100.64.0.0/10' to any port 22
sudo ufw allow from 'fd7a:115c:a1e0::/48' to any port 22
# Deny everything else hitting Port 22
sudo ufw deny 22
sudo ufw deny OpenSSH
sudo ufw status
sudo ufw enable
Why Socket Binding and Firewall Rules Are Both Necessary
When a Linux service starts, it makes a bind() system call to the kernel. If configured to 0.0.0.0, the kernel attaches the service to every network interface — including your public eth0. If UFW crashes, is accidentally flushed, or misconfigures during a late-night debugging session, your SSH daemon is instantly exposed to the public internet.
By defining <TAILSCALE_IPV4> in ListenAddress, we instruct the kernel to attach the SSH socket exclusively to the tailscale0 virtual network interface. Packets arriving on eth0 targeting Port 22 hit the kernel, and the kernel drops them immediately because no socket exists on that interface. That’s hardware-level invisibility, independent of any firewall state.
The firewall is then a second, independent layer. An attacker who somehow bypassed Tailscale authentication (which is cryptographically impractical) would still hit the firewall rules. An attacker who somehow corrupted the firewall rules would still find no socket listening on the public interface.
The Lockout Trap and How to Avoid It
When building zero-trust architectures, your greatest adversary is often yourself.
The biggest single point of failure is Tailscale dependency. If the tailscaled service crashes, or an aggressive firewall rule blocks Tailscale’s outbound UDP communication to its coordination servers, you’ll be locked out. Before implementing this on critical infrastructure, verify your VPS hosting provider offers an out-of-band web console (typically a VNC or Serial console in the browser). This console interacts directly with the virtualized hardware, bypassing network-level SSH and UFW rules entirely. It’s your emergency access path.
There’s also a boot ordering issue to watch for. We disabled UsePAM and PasswordAuthentication. If sshd tries to start during boot before tailscaled has fully initialized the tailscale0 interface, the SSH daemon might fail to bind to ListenAddress and crash. Systemd usually handles dependency ordering well, but if you hit this, modify the sshd.service file to add After=tailscaled.service.
With the perimeter locked down this way, you can deploy databases, CI runners, and production applications with confidence. The attack surface isn’t just reduced — it’s removed from the public internet entirely.