The network — Tailscale first, firewall last
The order is the lesson. If you lock down the firewall before Tailscale is up and the rest of the setup is tested, you will lock yourself out and spend a Saturday in the Hetzner cloud console. You only make that mistake once. That’s the whole reason this article is sequenced the way it is: Tailscale first, sshd second, ufw last.
Step 1: Install Tailscale
Tailscale gives the box an address on your tailnet. That address is in the 100.x.x.x range. It is the only address you need from now on. The public IP stops mattering the moment the tailnet is up.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale up prints a URL. Open it in a browser tab on your laptop, authenticate, then come back to the SSH session. Tailscale will report the box’s 100.x.x.x tailnet IP. Write that IP down. You need it for the SSH bind step below.
Step 2: SSH to the tailnet address
On your laptop, which also has Tailscale installed and is logged in to the same tailnet, point ~/.ssh/config at the tailnet IP:
Host hermes-01
HostName 100.x.x.x
User ubuntu
IdentityFile ~/.ssh/id_ed25519
Now ssh hermes-01 works from anywhere — coffee shop, hotel, phone hotspot — without touching the public internet. SSH over Tailscale is end-to-end encrypted by managed WireGuard, so it is also better than a public SSH listener for actual confidentiality.
Step 3: Bind SSH to the Tailscale interface
Now that you can reach the box over the tailnet, you do not need the public SSH listener any more. The Hetzner public IP gets scanners within minutes of coming up. They would never get in, but I would rather they not even find the port.
On the box, drop a small file into sshd_config.d/:
sudo tee /etc/ssh/sshd_config.d/10-tailscale-only.conf <<EOF
Port 22
ListenAddress $(tailscale ip -4)
PasswordAuthentication no
PermitRootLogin no
EOF
sudo systemctl restart ssh
Three things going on in that drop-in:
ListenAddress $(tailscale ip -4) sets the SSH daemon to only listen on the Tailscale IP. The $(tailscale ip -4) shell substitution pulls it from the running Tailscale process at install time, so the drop-in is correct on first apply. After this, the only path in is over the tailnet.
PasswordAuthentication no is the right setting for any internet-facing SSH regardless of how you reach the box. If your authenticated key is ever compromised you do not want a password fallback to also get cracked. On a fresh Ubuntu 24.04 cloud image this happens to be the effective default, but only via a drop-in file at /etc/ssh/sshd_config.d/60-cloudimg-settings.conf. If you cat /etc/ssh/sshd_config directly you will see #PasswordAuthentication yes commented out, which is misleading. The drop-in is doing the work. I am making it explicit here so the behaviour does not depend on which drop-ins Ubuntu or your image maintainer chose to ship.
PermitRootLogin no is the right setting even though ubuntu is your login user. The Ubuntu default is PermitRootLogin prohibit-password — root key-only, no password fallback. no is stricter: no root login at all. Setting it explicitly also closes a subtle race: if a future sshd_config update changes the default value, the drop-in keeps the behaviour pinned. The combination, key-only auth, no root, interface-bound, is the minimum I would call acceptable for a box that has been up for more than a day.
One caveat the original draft did not warn about. If you ever turn Tailscale off (tailscale down), this drop-in stops accepting connections. Re-up before restarting sshd, otherwise you will lock yourself out with no SSH listener on any interface. This is the kind of mistake you only make once if you are warned about it, which is the point of this article.
After systemctl restart ssh, your existing SSH session keeps working. A fresh ssh hermes-01 from a host that is not on your tailnet gets Connection refused. That is the goal. Public SSH is gone.
Step 4: Understand ufw
Before any of this I locked down ufw myself and lost access. The mistake was not turning the firewall on. The mistake was turning it on before I understood what it was actually doing. Worth a minute of teaching before another command runs.
ufw is the Uncomplicated Firewall. It is a frontend over the host’s packet filter — the rules the Linux kernel applies to every packet that arrives at, leaves from, or traverses the box. Under the hood ufw is writing iptables or, on newer Ubuntu, nftables rules. You do not have to learn those. You do have to learn what ufw’s high-level knobs mean, because sudo ufw enable is not a vague good. It is a specific posture change, and if you do not know which one, you will be the one debugging from the Hetzner cloud console.
default deny incoming denies packets whose destination is the box itself. That is it. It does not deny outbound traffic, which is the next default and separate. It does not deny traffic the box is forwarding between interfaces, which is the thing a router does. This box is not a router. It denies: any new inbound connection attempt that does not match an allow rule. So if you turn it on and there are no allows, every SSH attempt from anywhere except an existing established connection gets dropped.
default allow outgoing allows everything the box initiates. Apt updates, curl, git fetch, the Telegram long-polling outbound that article 3 will run, all fine. The box still talks to the internet. The internet just cannot talk to it first.
allow in on tailscale0 to any port 22 re-opens SSH. Read it word by word: allow packets whose direction is in and whose incoming interface is tailscale0, destined to any address on port 22. A packet on port 22 arriving on eth0 still gets dropped. A packet on port 22 arriving on tailscale0 gets through. The interface binding is the whole point.
Why bind to the interface and not the IP? Because your tailnet IP can rotate. On re-auths, on tailnet changes, sometimes for no reason at all, 100.x.x.x becomes a different 100.x.x.x. If your ufw rule says allow from 100.x.x.x, it goes stale the next time the address changes and you will be locked out. If your rule says allow in on tailscale0, the interface is the same regardless of which address lives on it. The rule survives every rotation.
ufw status verbose shows you what the firewall actually believes after enable. A numbered list of rules, the default posture for incoming and outgoing, and per-rule columns: to (destination port), from (source), in (interface, if specified), and action. This is the command to run when something does not work, before you start guessing.
That paragraph is the teaching. If you read it and understand what each of those lines means, the next section is just four blocks of ufw invocations. If you skipped it, the next section is four ways to lock yourself out.
Step 5: Apply the ufw rules
One rule at a time. Deny by default, narrow allow-list on Tailscale, one optional fallback for SSH from home, then enable. That is the sequence and the rest of this section is just the sequence in ufw syntax.
Default posture: deny everything inbound, allow everything outbound.
sudo ufw default deny incoming
sudo ufw default allow outgoing
SSH on the Tailscale interface only. Public interface still closed.
sudo ufw allow in on tailscale0 to any port 22
Hermes dashboard on the Tailscale interface only — installed in article 4, but opening the port now means we do not touch ufw mid-cluster.
sudo ufw allow in on tailscale0 to any port 9119
Narrow fallback from your home public IP. Replace 203.0.113.45 with your real home IP. Look it up at ifconfig.me from home. If you do not have a static home IP, skip this rule.
sudo ufw allow from 203.0.113.45 to any port 22 proto tcp
Step 6: Enable and verify
sudo ufw enable
sudo ufw status verbose
After ufw enable, run a sanity check from your laptop over Tailscale. ssh hermes-01, then check the expected rules are in place. If something is wrong, sudo ufw disable rolls everything back instantly. That is the recovery path I want the reader to know exists before they ever need it.
Where this leaves you
The box is locked down. SSH only over the tailnet. The firewall is on, deny-by-default. The Tailscale interface has the right holes. The public interface is closed. The next piece in this cluster wires up the Telegram bot.
Next: Article 3: Wiring a Telegram bot to your box.
More soon — Michael.
Michael Short is the founder of The Agent Files.