UFW, tailnet only
A firewall that answers only on the Tailscale interface, with one narrow exception for containers reaching the front door.
Names and addresses are placeholders. The list says what each one stands for.
What it does here
Nothing on the box answers on the internet. The tailnet is the way in. The home network gets SSH and mosh as a fallback and nothing else, and no router ports are opened.
Canonical setup
Deny incoming, allow outgoing, add the rules you need, then enable.
sudo ufw default deny incoming
sudo ufw default allow outgoingUpstream: UncomplicatedFirewall on the Ubuntu wiki.
In this build
- Allow in on tailscale0One rule covers SSH and mosh's UDP range for anything arriving over the tailnet.
- WireGuard port 41641/udp openIt helps Tailscale make direct connections through NAT.
- A LAN fallback, limited to the home subnetFor when Tailscale is down: SSH and mosh only, and only from the home subnet, never from Anywhere. Add these before enabling.
- Containers may reach Caddy on 443From the Docker address range to the box's tailnet address only. Added once the runner needs it.
- Published container ports bound to the tailnet addressDocker's port rules bypass UFW, so a plain
"2222:22"answers on the home network whatever UFW says. See the Docker seam below.
sudo ufw allow in on tailscale0
sudo ufw allow 41641/udp
sudo ufw allow from 192.168.x.0/24 to any app OpenSSH
sudo ufw allow from 192.168.x.0/24 to any port 60000:61000 proto udp
sudo ufw enable && sudo ufw status verbosesudo ufw allow from 172.16.0.0/12 to 100.x.y.z port 443 proto tcpSeams
Where this meets the rest of the build. Each one leads with what you will actually see.
Symptomconnections from a container to https://git.dev.tailnet.example time out
A container reaching a service name goes out to the box's tailnet address and back in to Caddy, and the firewall drops that hairpin. The scoped rule above lets it through, only for the Docker range, only to 443. Check it with docker run --rm alpine sh -c 'nc -zv git.dev.tailnet.example 443'.
Symptomnothing breaks, and git over SSH answers on the home network anyway
Docker writes its own iptables rules for published ports, and they run before UFW's. A port published as "2222:22" listens on every interface, and UFW never sees the traffic. Bind it to the tailnet address instead, "100.x.y.z:2222:22", and set net.ipv4.ip_nonlocal_bind = 1 in /etc/sysctl.d/ so the bind still works at boot if Docker starts before Tailscale. Ports bound to 127.0.0.1 and host-network containers aren't affected. See the Forgejo card.
Symptomssh stops answering after ufw enable
Confirm ssh youruser@devbox works over Tailscale in a second terminal before you walk away. Keep console access to the box until you have.
Symptomdevbox.local stops resolving
UDP 5353 is blocked with everything else. Allow it, or use the MagicDNS name. See the base hardening card.
Verify
sudo ufw status verbose # deny incoming; allow on tailscale0; SSH and mosh from the home subnet
ss -tln | grep 2222 # 100.x.y.z:2222 only, not 0.0.0.0From another tailnet device, ssh youruser@devbox still works. From a device on the home network that isn't on the tailnet, SSH answers as the fallback and nothing else does, port 2222 included.
- 17 Oct 2026First written. Verified on Debian 13.6.
- 23 Oct 2026The LAN fallback is now in the rules, limited to the home subnet. Added the seam for Docker's published ports bypassing UFW. Verified on Debian 13.7.