Most small VPS breaches start the same way: something is listening on a public port that you forgot about, or that you remember but never patched. SSH on 22, an admin panel on 8080, a database somebody bound to 0.0.0.0 for a quick test. Scanners find these within minutes of a server coming up, and they are getting better at turning a found port into a working exploit.
The fix that removes the whole class is to stop accepting inbound connections at all. Two tools make that practical on a single VPS: Tailscale SSH for your own shell access, and Cloudflare Tunnel (cloudflared) for the public website. Both dial out from your server, so the firewall can deny everything inbound. This guide does it in an order that never locks you out, on Ubuntu, in about 15 minutes.
What you end up with
- SSH reachable only from devices in your tailnet, authenticated by Tailscale identity instead of a public sshd.
- The website served through Cloudflare.
cloudflaredopens outbound connections to Cloudflare's network, and traffic flows both ways over them, so the origin needs no open port. - A host firewall with
default deny incomingand a single allow rule for thetailscale0interface.
Keep your current SSH session open for the whole guide, and open a second terminal for every test. That second terminal is your proof that the next step is safe.

Step 1: join the server to your tailnet
Install Tailscale with the official script and bring it up. The up command prints a URL; open it and sign in with the same account as your laptop.

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
Note the server's Tailscale IP (a 100.x.y.z address) and name:
tailscale ip -4
tailscale status
Step 2: turn on Tailscale SSH
sudo tailscale set --ssh
This makes tailscaled answer port 22 on the Tailscale IP itself. It does not touch /etc/ssh/sshd_config or authorized_keys, so your normal sshd keeps working in parallel until you close it off. Tailscale warns that enabling it can hang an existing SSH session that already goes to the Tailscale IP; your public-IP session is unaffected.
Access is controlled in the tailnet policy file. You need two things: a grants rule that lets your devices reach the server, and an ssh rule that says who may log in as which user. If you never edited the policy, the default already lets you SSH to your own devices in check mode. If you did, make sure it still has rules like these:
{
"grants": [
{ "src": ["autogroup:member"], "dst": ["autogroup:self"], "ip": ["*"] }
],
"ssh": [
{
"action": "check",
"checkPeriod": "12h",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
}
]
}
This lets every member reach and SSH into the devices they own, which fits a server you added with your own account.
check asks you to re-authenticate in the browser periodically (12 hours is the default; it can be anything from one minute to 168 hours, or always). Use accept if you want no re-prompt. The users list must name accounts that exist on the host. One trap from the docs: if you change dst away from autogroup:self, do not leave autogroup:nonroot in users, or anyone allowed by src can log in as any non-root user.
Now test from your laptop, in a new terminal:
ssh ubuntu@my-vps
(my-vps is the machine name MagicDNS gave the server; the 100.x IP works too.) Do not continue until this works.
Step 3: bind the app to localhost
The tunnel will reach your app over loopback, so the app itself should listen only there. For a process listening on 0.0.0.0:8000, change its bind address to 127.0.0.1:8000. For Docker, publish to loopback only, because Docker writes its own iptables rules and a published port skips UFW entirely:
services:
web:
image: ghcr.io/example/web:latest
ports:
- "127.0.0.1:8000:8000"



