← posts / infrastructure

Take your VPS off the public internet: SSH over Tailscale, the site behind a Cloudflare Tunnel, and a firewall with zero inbound ports

A lockout-safe order for a small VPS: Tailscale SSH first, then serve the site through cloudflared, then deny every inbound port. Copy-pasteable commands for Ubuntu, and the checks that prove nothing listens publicly.

if.codesOct 25, 2026 · 6 min read#security#vps#tailscale#cloudflareAI-assisted

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. cloudflared opens 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 incoming and a single allow rule for the tailscale0 interface.

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.

A conceptual look at a server with a hardened perimeter.
A conceptual look at a server with a hardened perimeter.

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.

Connecting to a private network from a remote device.
Connecting to a private network from a remote device.
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"
Need the backend underneath done properly?I build and harden Go and Python backends. The estimate is free.

Step 4: serve the site through Cloudflare Tunnel

This assumes the domain's DNS is already on Cloudflare. Install cloudflared from Cloudflare's downloads page, then create a locally managed tunnel. login writes cert.pem, and create writes the tunnel's credentials JSON, both into ~/.cloudflared/:

A conceptual representation of an outbound tunnel for web traffic.
A conceptual representation of an outbound tunnel for web traffic.
cloudflared tunnel login
cloudflared tunnel create my-vps

create prints the tunnel UUID. Write ~/.cloudflared/config.yml with your UUID and hostname:

tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /home/ubuntu/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
ingress:
  - hostname: www.example.com
    service: http://localhost:8000
  - service: http_status:404

The last rule has no hostname and returns 404 for anything else; Cloudflare requires every config with ingress rules to end with such a catch-all. Validate, point DNS at the tunnel, and try it in the foreground:

cloudflared tunnel ingress validate
cloudflared tunnel route dns my-vps www.example.com
cloudflared tunnel run my-vps

route dns creates a CNAME to <UUID>.cfargotunnel.com. If the hostname already has an A record pointing at your server, delete that record first. Once the site loads through the tunnel, stop the foreground run and install it as a service. Because sudo sets $HOME to /root, pass the config path explicitly:

sudo cloudflared --config /home/ubuntu/.cloudflared/config.yml service install
sudo systemctl start cloudflared
systemctl status cloudflared

Step 5: close every inbound port

Only now, with SSH working over the tailnet and the site working through the tunnel, change the firewall. Allow everything that arrives on the Tailscale interface and deny the rest:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0
sudo ufw enable
sudo ufw status verbose

If an earlier setup added OpenSSH, 80 or 443 rules, list them with sudo ufw status numbered and remove them with sudo ufw delete <number>. Then, from a second terminal, confirm SSH over Tailscale still works and the public IP no longer answers on 22. Only after that, close your original session.

If your provider has a cloud firewall (Hetzner, DigitalOcean, AWS security groups), set it to no inbound rules as well. It is a second layer that also covers Docker's iptables bypass.

Step 6: prove nothing listens publicly

On the server, list listening sockets. Everything should be on 127.0.0.1, ::1 or the 100.x Tailscale address:

sudo ss -tulpn

From a machine outside your tailnet (or with Tailscale switched off), scan the public IP:

nmap -Pn -p 1-65535 --open 203.0.113.10

The result you want is no open ports. If one shows up, ss tells you which process owns it.

What this does not cover

  • The app itself. Requests still reach your app through the tunnel. An unpatched upload form is just as exploitable; the tunnel only removes the other doors.
  • Revoked access. Removing someone in the Tailscale policy stops their Tailscale SSH, but any SSH keys they had in authorized_keys still work through any path that reaches sshd. Clean those up too.
  • Break-glass access. If Tailscale or your identity provider is down, your way in is the provider's web console. Check that you can log in to it before you need it.
  • Multi-user laptops. Tailscale SSH uses no local key pair, so any OS user on an authorised client can connect. That is fine for a personal laptop, less so for a shared machine.

Sources

if.codesI build RAG, AI integrations and agent pipelines on Go and Python backends — and write about it here.
// keep reading

More posts on AI and backends.

// free quote

Read something you need? I’ll quote it for free.

RAG, AI integrations, agents or the backend underneath — tell me what you have and what should change. I read every request myself.

Free · no commitment

Tell me what you have. I’ll tell you what it takes.

1Describe the projectA few sentences is enough — about two minutes.
2I review itI read it myself and may ask a follow-up question.
3You get a free quoteScope, approach and estimate — yours to keep, no strings.
What kind of project is it?
Free and without obligation. Your details are used only to reply — see the privacy policy.