Skip to main content

UFW Without SSH Lockout

UFW rule targets​

Public: allow 80/tcp and 443/tcp; no general allowance for 22/tcp
tailscale0: allow 22/tcp
Default incoming: deny
Default outgoing: allow

On Linux, Tailscale defaults to on mode. Its firewall rules run first and accept traffic arriving through tailscale0.

UFW is an easy-to-use frontend for netfilter. Its rules allow or deny traffic; they do not start services. Opening port 80 does not create a website.

Why the change must happen in stages​

Setting deny incoming and enabling UFW immediately could cut off both current and future SSH access. Keep two independent paths during the change:

  • A known-good public SSH session that stays open for emergency recovery.
  • A verified OpenSSH-over-Tailscale session that will become the regular management path.

Connection tracking may keep an established SSH session alive after an incoming rule is deleted. That session is a lifeline, but it does not verify that a new connection will work.

Inspect first, without resetting​

[REMOTE: VPS]
sudo ufw status verbose
sudo ufw status numbered

Do not run ufw reset to get a “clean” state. Existing rules may belong to other infrastructure, and rule numbers change as rules are added or removed.

Enable UFW in a safe order​

Run these commands in the verified Tailscale session while keeping the public session open:

[REMOTE: VPS]
sudo ufw default deny incoming
sudo ufw default allow outgoing

# Keep public SSH temporarily
sudo ufw allow 22/tcp

# Future web entry points
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# Final SSH entry point
sudo ufw allow in on tailscale0 to any port 22 proto tcp

sudo ufw --force enable
sudo ufw status verbose
sudo ufw status numbered

At this point, open new public and Tailscale SSH sessions. If both succeed, UFW is enabled and the fallback path still works.

Remove the generic port 22 rule​

Read the rules again first. When the rule is unambiguous, prefer deleting it by its original definition, which handles the matching generic IPv4 and IPv6 rules together:

[REMOTE: VPS]
sudo ufw delete allow 22/tcp
sudo ufw status verbose
sudo ufw status numbered

A numbered deletion normally removes only that specific IPv4 or IPv6 entry. Do not reuse an old number without checking the current list. The final list should still contain:

80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
22/tcp on tailscale0 ALLOW IN Anywhere

Run the final tests from the client​

[LOCAL: WSL2]
# Must succeed
ssh -o ConnectTimeout=10 "$VPS_USER@$TAILSCALE_IPV4"

# Must time out or be rejected; must not succeed
ssh -o ConnectionAttempts=1 -o ConnectTimeout=5 \
"$VPS_USER@$VPS_PUBLIC_IPV4"

Only a successful Tailscale connection and a failed public-IP connection verify both SSH paths. Close the old public session kept for recovery only after these tests pass.

The Docker exception​

Docker-published container ports can bypass ordinary UFW rules. If a future Compose file contains:

ports:
- "8080:8080"

The output of ufw status alone does not establish that port 8080 is unexposed. When deploying an application, inspect Compose ports, listening addresses, and docker ps, then check the rules for Docker’s actual firewall backend. DOCKER-USER applies to the iptables backend; Docker’s native nftables backend has no DOCKER-USER chain. Do not switch backends or disable Docker’s firewall management as a shortcut: either can break networking or change which ports are exposed.

Recovery principles​

If a new Tailscale SSH connection fails after the generic port 22 rule is removed:

  1. Keep the surviving SSH session open.
  2. From that session, restore sudo ufw allow 22/tcp.
  3. Verify a new public-key SSH connection over the public address.
  4. Collect the state of UFW, tailscale0, and sshd.
  5. Stop tightening access until the network path is understood.

Recovery means restoring a verifiable path, rather than resetting the entire firewall under pressure.

Explore connectionsOpen network