Fall 2026brianmars.comBuilt at EGBT Technologies
ReferenceNetwork

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.

From the manual

Canonical setup

Deny incoming, allow outgoing, add the rules you need, then enable.

Defaults
sudo ufw default deny incoming
sudo ufw default allow outgoing

Upstream: UncomplicatedFirewall on the Ubuntu wiki.

Edited for this build

In this build

  1. Allow in on tailscale0One rule covers SSH and mosh's UDP range for anything arriving over the tailnet.
  2. WireGuard port 41641/udp openIt helps Tailscale make direct connections through NAT.
  3. 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.
  4. Containers may reach Caddy on 443From the Docker address range to the box's tailnet address only. Added once the runner needs it.
  5. 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.
Rules and enable
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 verbose
The container exception (part 4)
sudo ufw allow from 172.16.0.0/12 to 100.x.y.z port 443 proto tcp
Where it breaks

Seams

Where this meets the rest of the build. Each one leads with what you will actually see.

Containers meet the tailnet-only firewall

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'.

Docker's published ports meet UFW

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.

The firewall meets your session

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.

mDNS meets the firewall

Symptomdevbox.local stops resolving

UDP 5353 is blocked with everything else. Allow it, or use the MagicDNS name. See the base hardening card.

Verify

Checks
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.0

From 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.

Corrections and updates
  1. 17 Oct 2026First written. Verified on Debian 13.6.
  2. 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.