Linux & Systems

ufw Without Locking Yourself Out

Set up ufw on a Debian or Ubuntu VPS with SSH allowed first, a timed rollback in case a rule is wrong, and a clear view of the Docker ports ufw cannot see.

6 min read
firewallsecuritysshdebianubuntudocker

Sheep grazing in green fields divided by dry stone walls, with a wooden gate and bare winter trees

A firewall that locks you out is working perfectly. Just not for you. With ufw, the risky moment is a single command, ufw enable, and everything that keeps you safe has to happen before it.

The goal: deny incoming traffic by default, allow SSH and web traffic, keep a rollback that fires on its own if you get something wrong, and understand the one big exception on Docker hosts. This applies to Debian 13 and Ubuntu 24.04 or 26.04. Ubuntu normally ships ufw installed but inactive. On Debian, run sudo apt install ufw, which installs it without turning it on.

Look first

sudo ufw status verbose
sudo sshd -T | grep -E '^port '
sudo ss -tlnp

Status: inactive is what you want before starting. sshd -T prints the port sshd is really configured to use, which is more reliable than remembering whether someone moved SSH to 2222 “temporarily” last year. ss -tlnp lists every listening TCP port and the process that owns it. Anything bound to 0.0.0.0 or [::] is reachable from outside right now, so that list tells you what to allow and what to shut down.

If your provider also has a cloud firewall in front of the VM, traffic must get through both.

Set the safety net first

Before you change anything, schedule your own rescue:

sudo systemd-run --on-active=10min --unit=ufw-rollback /usr/sbin/ufw disable
Running timer as unit: ufw-rollback.timer
Will run service as unit: ufw-rollback.service

Ten minutes from now, systemd runs ufw disable whether you’re still connected or not. That’s the point. The familiar sleep 600 && ufw disable & belongs to your shell, so if the shell’s SSH session is what just died, the background job can go down with it. A transient timer belongs to PID 1, which doesn’t care about your connection. at works too, but it usually isn’t installed.

Confirm the timer is armed with systemctl list-timers ufw-rollback.timer. Keep your current SSH session open for the rest of the setup.

Write the rules

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

The first two lines are already ufw’s defaults, and writing them out records what you meant. Replace 22 with whatever port sshd -T printed.

limit is allow plus a speed bump. An address that opens 6 or more connections within 30 seconds gets rejected until it slows down. People typing don’t notice. Scripts that open a new SSH connection for every command do, so a deploy loop or a configuration management run can trip it. If yours does, use allow for SSH and let something like fail2ban handle repeat offenders.

sudo ufw app list shows named port bundles. sudo ufw limit OpenSSH is equivalent to 22/tcp, using a profile installed by openssh-server. The profile hard-codes port 22, though, so it’s wrong for an sshd on 2222. If you see Could not find a profile matching 'OpenSSH', the profile file isn’t installed. Use the port number instead.

sudo ufw show added lists the rules you’ve added so far, which is worth a quick read before the next step.

Enable, then test from a new session

sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup

Leave the current session open, open a second terminal, and connect again. Only a new connection proves the rule works. Your existing session runs over an already-established connection, and ufw lets established traffic through.

  • The new session works: disarm the rollback with sudo systemctl stop ufw-rollback.timer.
  • The new session hangs: don’t try anything clever. Wait for the timer, let ufw disable itself, then fix the rules with the firewall off. If even that fails, your provider’s web console doesn’t go through your firewall.

If the ten minutes run out while you’re still adding rules, nothing breaks. ufw is disabled, and your rules are still saved. Arm the timer again before the next enable. If systemd complains that the unit already exists, stop the old ufw-rollback.timer first.

Read the status

sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     LIMIT IN    Anywhere
80/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere
22/tcp (v6)                LIMIT IN    Anywhere (v6)
80/tcp (v6)                ALLOW IN    Anywhere (v6)
443/tcp (v6)               ALLOW IN    Anywhere (v6)

Every rule has a (v6) twin because IPV6=yes is the default in /etc/default/ufw. Leave it that way. Setting it to no doesn’t leave IPv6 wide open. ufw then drops all IPv6 traffic except loopback, which comes as a surprise if you happen to SSH in over IPv6.

If routed says deny instead of disabled, IP forwarding is turned on. On a VPS, Docker is the usual reason, which leads to the biggest caveat.

Docker publishes around ufw

Docker’s documentation says plainly that Docker and ufw “use firewall rules in ways that make them incompatible with each other.” Traffic to a published container port is redirected in the nat table before ufw’s input rules ever see it. ufw deny 8080 does nothing for a container started with -p 8080:80.

See what’s actually published:

docker ps --format '{{.Names}}  {{.Ports}}'
app  0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
db  127.0.0.1:5432->5432/tcp

0.0.0.0 and [::] mean anyone can reach that port, ufw or not. 127.0.0.1 means only the host itself can. If a reverse proxy on the host sits in front of the container, publish to loopback with -p 127.0.0.1:8080:80 (in Compose, "127.0.0.1:8080:80") and let ufw guard the proxy on 80 and 443. For a port that has to stay public but filtered, Docker’s supported place for your rules is the DOCKER-USER chain, which is outside ufw entirely.

Changing and undoing rules

sudo ufw status numbered
     To                         Action      From
     --                         ------      ----
[ 1] 22/tcp                     LIMIT IN    Anywhere
[ 2] 80/tcp                     ALLOW IN    Anywhere
[ 3] 443/tcp                    ALLOW IN    Anywhere
[ 4] 22/tcp (v6)                LIMIT IN    Anywhere (v6)
[ 5] 80/tcp (v6)                ALLOW IN    Anywhere (v6)
[ 6] 443/tcp (v6)               ALLOW IN    Anywhere (v6)

sudo ufw delete 2 asks for confirmation and then removes rule 2, which is only the IPv4 rule. Number 5 is still there. The remaining rules are also renumbered, so run status numbered again before deleting anything else. Deleting by rule avoids both problems: sudo ufw delete allow 80/tcp removes the IPv4 and IPv6 versions together.

Order matters, because the first matching rule wins. sudo ufw insert 1 deny from 203.0.113.7 puts a rule at the top, ahead of the broad allows.

To back out entirely:

  • sudo ufw disable stops filtering and keeps your rules for the next enable.
  • sudo ufw reset disables ufw and restores the installed defaults. It backs up the old rule files in /etc/ufw with a timestamp suffix. Over SSH it asks first, and for good reason.

References

Cover photo: Unsplash.