featured image

Severing the Public Umbilical: Forging an Invisible VPS with Zero-Trust SSH

The moment you provision a Virtual Private Server (VPS) and bind it to a public IP address, it is under attack. Within minutes, automated botnets will begin relentlessly brute-forcing Port 22, scanning for weak credentials and misconfigurations. Relying on password authentication or simple non-standard port obfuscation is a naive gamble. To build a truly robust, production-ready deployment platform, we must shift our paradigm from perimeter defense to zero-trust architecture.

Published

Tue Dec 16 2025

Technologies Used

Tailscale SSH Hardening Zero-Trust Security VPS
Advanced 39 minutes

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.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!