UFW Firewall Rules That Don't Lock You Out

UFW on Ubuntu and Debian without the lockout: SSH first, a dead-man switch, rule order, subnet and game ports, and the Docker bypass.

Share
UFW Firewall Rules That Don't Lock You Out

UFW is the firewall I put on every Ubuntu and Debian box. Not because it's the most powerful thing around — it's a frontend for iptables/nftables, nothing more — but because the rules read like English and I can audit a box in five seconds with ufw status. The only real way to hurt yourself with it is the order you do things in. So this post is mostly about order.

TL;DR: Allow SSH before you enable, set default deny incoming, then enable. Everything else is details.

sudo apt install ufw            # Debian doesn't ship it, Ubuntu does
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH          # or: sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw status verbose

That's a working baseline. Below is how I add rules without ever cutting the branch I'm sitting on.

The order that matters

ufw enable applies the rules immediately to the running kernel, including to your current SSH session. If SSH isn't allowed at that moment, your session dies and you're on the provider's web console. UFW even warns you (Command may disrupt existing ssh connections) and people still hit y.

Before enabling, check what's queued:

sudo ufw show added

This works while UFW is still inactive and lists every rule you've added. If there's no SSH rule in that list, don't enable.

OpenSSH is an application profile that ships with the openssh-server package. sudo ufw app list shows what's available — after installing nginx you also get Nginx HTTP, Nginx HTTPS and Nginx Full. If you moved SSH to another port, the OpenSSH profile is useless (it means 22), so allow the real port:

sudo ufw allow 2222/tcp comment 'ssh'

And if you're about to change the SSH port: open the new port first, restart SSH, log in on the new port from a second terminal, then remove the old rule. Never the other way round. The SSH hardening checklist walks through that.

A dead-man switch for risky changes

When I'm changing rules on a remote box and I'm not 100% sure, I schedule UFW to turn itself off in five minutes:

sudo systemd-run --on-active=5m /usr/sbin/ufw disable

That prints the name of a transient timer, something like Running timer as unit: run-r3f2a9c1d.timer. If everything still works after the change, cancel it:

sudo systemctl stop run-r3f2a9c1d.timer   # use the exact name it printed

If you locked yourself out, wait five minutes and you're back in with the firewall off. Not pretty, but way better than a rescue boot. echo "ufw disable" | sudo at now + 5 minutes does the same if you have at installed.

Everyday rules

# web
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# or both at once once nginx is installed:
sudo ufw allow 'Nginx Full'

# a game server — ranges need an explicit protocol
sudo ufw allow 25565/tcp comment 'minecraft'
sudo ufw allow 2456:2458/udp comment 'valheim'

# database only from the internal network
sudo ufw allow from 10.0.0.0/24 to any port 3306 proto tcp

# one admin IP gets everything
sudo ufw allow from 203.0.113.10

# SSH with built-in rate limiting — use INSTEAD of the plain allow
sudo ufw limit 22/tcp
sudo ufw delete allow OpenSSH

limit denies an IP that opens 6 or more connections within 30 seconds. It's crude but it takes the edge off brute-force noise. For real ban logic use fail2ban or CrowdSec on top.

The comment bit is optional but I always add it on anything that isn't obvious. Six months later 25565/tcp is obvious, 27015/udp on a box running three game servers is not.

Rule order: first match wins

UFW checks rules top to bottom and the first match decides. That matters as soon as you mix allow and deny. Say you have allow 22/tcp and later want to block one abusive IP:

sudo ufw deny from 198.51.100.7     # appended at the END — too late, 22 is already allowed above

That deny never fires for SSH because the allow above it matches first. Insert it at the top instead:

sudo ufw insert 1 deny from 198.51.100.7

Deleting rules, the readable way:

sudo ufw status numbered
sudo ufw delete 4
# or by spec, no number guessing:
sudo ufw delete allow 8080/tcp

Careful with delete <number> in a row — the numbers shift after every delete. Re-run status numbered each time, or delete from the bottom up.

Gotcha: Docker walks straight past UFW

This is the big one. When you publish a container port with -p 8080:80 or ports: in compose, Docker writes its own iptables rules in the nat table and the DOCKER chain. Traffic to that port gets forwarded to the container before UFW's input rules ever see it. ufw status says 8080 is closed, the internet says 8080 is open.

What I do: never publish container ports on all interfaces. Bind them to localhost and put a reverse proxy in front:

services:
  app:
    image: myapp:latest
    ports:
      - "127.0.0.1:8080:80"

Then nginx on the host proxies to 127.0.0.1:8080 — that's the setup in my nginx reverse proxy post. If a port really must be public, publish it on purpose and know that UFW is not the thing protecting it. There's also the DOCKER-USER chain and the ufw-docker helper project if you want UFW-style control over published ports, but localhost binding is simpler and harder to get wrong. More on the Docker side in the Docker install post.

Gotcha: IPv6 is a second firewall

UFW manages IPv4 and IPv6 rules together as long as /etc/default/ufw has:

IPV6=yes

That's the default on current Ubuntu and Debian. If someone set it to no, your v6 address is wide open while the v4 rules look perfect. Check ufw status — every rule should show up twice, once with (v6). If you change this setting, sudo ufw reload afterwards.

Gotcha: routed traffic needs its own rules

If the box forwards traffic — a WireGuard server, a NAT gateway, a host for VMs — the default forward policy in /etc/default/ufw is DEFAULT_FORWARD_POLICY="DROP". Your clients connect fine and then reach nothing. Either set it to ACCEPT or, better, allow only what you need:

sudo ufw route allow in on wg0 out on eth0

Swap eth0 for your real uplink (ip -br link tells you). The WireGuard setup has the rest of that picture.

Gotcha: the provider firewall is a separate layer

Many cloud providers have their own firewall in front of the VM. If a port is allowed in UFW and still unreachable, test from the box itself first:

sudo ss -tulpn | grep 8080          # is anything even listening?
curl -I http://127.0.0.1:8080       # does it answer locally?

Listening locally, allowed in UFW, unreachable from outside → it's upstream, not UFW. Also check the service listens on 0.0.0.0 or the public IP, not 127.0.0.1.

Logging and debugging

sudo ufw logging low

On Ubuntu with rsyslog the blocks land in /var/log/ufw.log. Debian 12 and newer don't install rsyslog by default, so read them from the kernel log instead:

sudo journalctl -k | grep 'UFW BLOCK'

Each line has SRC=, DST=, PROTO= and DPT= — enough to see whether the thing you think is blocked is actually the thing being blocked. Don't leave logging on high or full on a busy box; it fills the disk faster than you'd think.

Starting over

If the ruleset became a mess:

sudo ufw reset

That disables UFW and deletes all rules (it keeps backups of the old rule files in /etc/ufw/). Then re-add SSH first and work through the TL;DR again. Same rule as always: SSH in, check show added, then enable.

This firewall baseline is step one of my initial server setup checklist — everything else on a fresh box comes after it.


Related posts