Setting up the UFW firewall without locking yourself out

Published on 18 min read

The correct order when building a UFW rule set, IPv6 rules, nftables as the backend, rate limiting with ufw limit and the way back in through the console if it does go wrong.

A packet filter is not a nice-to-have on a root server, it is basic equipment. UFW (Uncomplicated Firewall) makes that pleasantly simple, but it has one property that locks thousands of administrators out of their own servers every year: the command that arms the firewall is the same one that can cut your running SSH session. This guide shows the order of operations in which that does not happen and, almost more importantly, the way back in if it happens anyway.

Everything here applies to Debian 13 (trixie), Debian 12 (bookworm), Ubuntu 24.04 LTS and Ubuntu 22.04 LTS. The commands are written for use as root. If you work as a regular user, prefix every command with sudo.

Why the order of operations decides everything

The classic mistake looks like this: someone first sets the default policy to "drop everything incoming", enables the firewall and then intends to add the SSH rule at leisure. Exactly in between lies the window in which the server stops being reachable.

The reason this mistake so often goes unnoticed is nastier than the mistake itself. UFW ships a rule in /etc/ufw/before.rules that lets already established connections through:

-A ufw-before-input -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

Your current SSH session is an established connection. It therefore survives the firewall being switched on even when no SSH rule exists at all. The prompt stays where it is, everything looks fine. Only the next connection attempt, typically your login the following morning, runs into a timeout. Hence the rule: as long as the firewall has not been verified with a second, freshly opened session, do not leave the first session.

Rule of thumb: allow first, then deny, then enable, then verify with a second session, and only after that close the first session.

Before the first command: escape route and inventory

Before you change anything about packet filtering, settle two questions.

1. How do you reach the server without SSH?

With KVM root servers and dedicated servers from KernelHost you reach the VNC console directly in the customer panel. This console does not hang off the guest system's network stack, it hangs off the virtualization layer or off the network port itself. A firewall rule inside the guest therefore cannot block it. Log in through this console once beforehand and make sure you know the root password. An escape route that you try for the first time during an emergency is not an escape route.

2. What is actually listening on this server?

Rules for services that do not exist are harmless. A service you overlooked costs you access or an outage. Get an overview:

ss -lntup

The Local Address:Port column cleanly distinguishes between 0.0.0.0:22 (IPv4 only), [::]:22 (IPv6 and, through the dual stack socket, usually IPv4 as well) and 127.0.0.1:3306 (local only, needs no firewall rule). Anything bound to 127.0.0.1 or ::1 does not need to be opened up.

The actual SSH port matters most of all. Do not guess, look it up. The command needs read access to the host keys, so it runs as root or with sudo:

sudo sshd -T | grep -i "^port "

One detail that many guides leave out: on Ubuntu 24.04 the SSH service is started through socket activation via ssh.socket, not via ssh.service. There, systemctl is-enabled ssh.socket reports enabled and ssh.service reports disabled. On Debian 12, Debian 13 and Ubuntu 22.04 it is exactly the other way round, those systems use the classic long-running service.

The consequence for this topic is uncomfortably concrete: on Ubuntu 24.04 the port that sshd -T reports is not necessarily the port that is actually being listened on. What counts there is ListenStream in /lib/systemd/system/ssh.socket or in a drop-in file under /etc/systemd/system/ssh.socket.d/. Anyone who has changed the SSH port and then relies on sshd -T opens the wrong port number in UFW and is locked out at the next login. On all four systems, only a look at the process that is really listening is reliable:

sudo ss -lntp | grep sshd

3. The dead man's switch

In case something goes wrong, set up a timer before the risky change that switches the firewall off again by itself after ten minutes:

nohup sh -c 'sleep 600; ufw disable' >/dev/null 2>&1 &

Once everything works, cancel it:

pkill -f 'sleep 600; ufw disable'

The pkill only hits the enclosing shell process. The sleep keeps running as an orphan and ends without consequence, because nobody is left who could call ufw disable afterwards.

The order that does not lock you out

On Debian, UFW is usually not preinstalled, on Ubuntu Server it usually is. Installing it does no harm in any case:

apt-get update
apt-get install -y ufw
ufw version

Now the order, and exactly like this:

ufw allow 22/tcp comment 'SSH'
ufw default deny incoming
ufw default allow outgoing
ufw --force enable

Four points on that:

  • The allow rule comes before everything else. UFW accepts rules even while it is inactive and stores them in /etc/ufw/user.rules. The moment you enable it, they are in place.
  • Instead of 22/tcp you can use an application profile, for example ufw allow OpenSSH. Do not rely on that blindly, though: the profiles do not come from UFW itself, they come from the installed packages. On Ubuntu, /etc/ufw/applications.d/ is empty without installed services, and ufw app list prints only the heading Available applications: there and not a single entry. On Debian the ufw package alone brings around 38 profiles, and the SSH profile is called SSH there. The OpenSSH profile comes from the openssh-server package on both distributions, so it is the normal case on servers. With a non-standard SSH port the profile misses the mark anyway. Which profiles your system knows is shown by ufw app list; the port based notation ufw allow 22/tcp, on the other hand, behaves identically on all four systems and is therefore the more reliable choice.
  • ufw enable asks interactively: Command may disrupt existing ssh connections. Proceed with operation (y|n)?. In scripts and in Ansible roles use ufw --force enable, otherwise the run hangs.
  • The text after comment shows up in ufw status. Six months from now you would otherwise no longer know what port 8443 is open for.

Further services are added afterwards, a web server for example:

ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'

And only now do you open a second terminal and log in again. Only once that login works is the job done.

IPv6: the second address family everyone forgets

Every modern server has IPv6, and usually without anyone having set it up on purpose. If you only think in IPv4, you end up with a firewall that governs exactly half of the traffic and waves the other half through. First check whether global IPv6 addresses are configured at all:

ip -6 addr show scope global

The good news: on all four distributions covered here, /etc/default/ufw ships with IPV6=yes out of the box. UFW then maintains an IPv6 rule alongside every IPv4 rule. Verify instead of trusting:

grep '^IPV6' /etc/default/ufw

A second, harder proof is the default policy of the IPv6 chain itself:

ip6tables -L INPUT -n

The first line has to read Chain INPUT (policy DROP). If it says policy ACCEPT and there are no ufw chains below it, then your server is wide open over IPv6, no matter how good the IPv4 rules look.

Two pitfalls:

  • A change to IPV6 in /etc/default/ufw does not take effect through ufw reload. It needs ufw disable followed by ufw enable. In exactly that gap you briefly have no firewall at all, so do not do this casually on an exposed system.
  • Blocking ICMPv6 across the board destroys your own connectivity. Neighbor Discovery and "Packet too big" are not optional with IPv6, they are part of the protocol. UFW already permits the necessary types in /etc/ufw/before6.rules. Only touch that file if you know exactly what you are doing.

When a port based rule has been created correctly in both address families, UFW reports two lines while adding it: Rule added and Rule added (v6). If the second line is missing, half the protection is missing. Source restricted rules are an exception: with ufw allow from 203.0.113.10 to any port 22 proto tcp only Rule added appears, and that is correct, because an IPv4 source address has no IPv6 counterpart.

What UFW really writes: nftables as the backend

There is a lot of half knowledge around this. The situation on Debian 12, Debian 13, Ubuntu 22.04 and Ubuntu 24.04 is uniform: UFW still speaks iptables syntax, but the iptables command is the compatibility tool iptables-nft on all four systems. The rules therefore end up in the kernel's nftables subsystem. The proof in a single line:

iptables -V

The output ends with (nf_tables). If it says (legacy), your system is working with the old backend. That does function, but it means rules from two worlds sit side by side in the kernel and mask each other. Which variant is selected is shown by update-alternatives --display iptables.

Seen from the nftables side, it looks like this. The important part is that you only look after the firewall has been switched on:

apt-get install -y nftables
nft list tables

As long as UFW is not active, the table does not exist at all: iptables-nft only creates it once rules are actually loaded, so at the earliest with ufw --force enable. Before that, nft list tables stays empty and an nft list table ip filter aborts with Error: No such file or directory. That is not a defect, it is the expected state. Only once table ip filter and table ip6 filter show up in the list is it worth looking inside:

nft list table ip filter

The output starts with the line Warning: table ip filter is managed by iptables-nft, do not touch!, and that is meant literally: looking is fine, editing by hand is not. Below it you will find chains such as ufw-before-input, ufw-user-input and ufw-after-input. This is the point where the most important conflict becomes visible: do not mix UFW with hand written nft rules. An nft flush ruleset deletes every UFW rule from the kernel without UFW noticing a thing. ufw status keeps reporting Status: active afterwards, while in reality not a single rule applies. That is one of the most unpleasant sources of error there is, because the tool you check with is lying to you. The way back:

ufw reload

So after every intervention with other firewall tools (Docker, Kubernetes, VPN software, iptables-persistent), do not check the UFW status, check the actual kernel rule set.

ufw limit against brute force, and where it stops

For SSH, UFW offers rate limiting:

ufw limit 22/tcp comment 'SSH rate limit'

The semantics are clearly defined in the manual: connections are allowed normally, but dropped as soon as a single source IP opens six or more new connections within 30 seconds. The values are hard wired and cannot be changed through the UFW interface. Under the hood this works with the recent module, visible in iptables -S. For IPv6, UFW creates an equivalent rule provided the kernel module is available, which it is on all four distributions.

If you already set ufw allow 22/tcp beforehand, you now have a second rule. The old one has to go, otherwise it matches first and the limit never takes effect:

ufw status numbered
ufw delete allow 22/tcp

If you delete by number instead (ufw delete 3), keep two quirks in mind. First, ufw status numbered numbers IPv4 and IPv6 rules consecutively in a single list, and after every deletion all following numbers shift. So always delete just one rule, print the list again afterwards, or work from the highest number downwards. Second, UFW asks interactively while doing so: Proceed with operation (y|n)?.

And now the honest assessment that most guides leave out:

  • It does not help against distributed attacks. The counting happens per source IP. A botnet with a thousand addresses makes five attempts per address and stays below the threshold.
  • It hits your own automation. A backup script with many individual rsync calls, an Ansible run with several forks or a CI job can hit the same limit. In that case you are not locking out the attacker, you are locking out your own deployment pipeline. For sources like that it is better to put an explicit exception in front, for example ufw allow from 203.0.113.10 to any port 22 proto tcp with the real address of your build server.
  • It is no substitute for a clean SSH configuration. The most effective step against password guessing is to switch passwords off altogether: PasswordAuthentication no in /etc/ssh/sshd_config or in a file under /etc/ssh/sshd_config.d/. What you do not accept cannot be guessed. On top of that, fail2ban is worth having, because unlike ufw limit it reacts to log entries and blocks for longer.

How to tell that it really worked

"The command ran without errors" is not proof. Four checks that actually mean something:

First, the overall state.

ufw status verbose

The expected output looks like this:

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

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

The decisive part is the second line with the (v6) suffix. Without it, the IPv6 coverage is missing.

Second, the reboot. A firewall that does not survive a reboot is worthless.

systemctl is-enabled ufw

The answer has to be enabled. And then actually reboot the server once and log in. That is the only test that really answers the question.

Third, the view from outside. From another host, check whether a port you deliberately did not open is really closed, for example with nc -zv YOUR-IP 3306. Important: a test from localhost proves nothing at all, because UFW always lets traffic over the loopback interface through.

Fourth, the logs. Here there is a real difference between the distributions. At the default level low, UFW records blocked packets through the kernel log. On Ubuntu 22.04 and 24.04 rsyslog is present, so the messages additionally end up in /var/log/ufw.log. On Debian 12, and especially on Debian 13, rsyslog is missing from minimal installations, and that file simply does not exist there. The approach that works everywhere:

journalctl -k -n 50

What you are looking for are lines that begin with [UFW BLOCK]. If you want to see more, turn the level up (ufw logging medium) and afterwards back down to ufw logging low. On a server with public traffic, the higher level fills the disk faster than you would expect.

Error messages verbatim

ERROR: problem running ufw-init: the most common trigger is a conflict with a second firewall tool, usually nftables.service or iptables-persistent, or a mix of the legacy and the nft backend. Check iptables -V and switch off competing services. UFW also ships a check script that walks through the kernel prerequisites one by one and reports which module requirement fails.

ERROR: Could not find a profile matching 'OpenSSH': the application profile is missing because openssh-server is not installed or the file under /etc/ufw/applications.d/ was removed. On Ubuntu that directory is empty anyway without installed services. Use the port number instead.

ERROR: Bad port: usually a typo or a service name that /etc/services does not know. Port numbers are always unambiguous.

Skipping adding existing rule or Skipping adding existing rule (v6): not an error message, just the hint that the rule is already there. If you believe you changed a rule but it stays as it was, this is the reason.

ERROR: Invalid position '0': for deleting and inserting, UFW counts from 1, not from 0. The numbers come from ufw status numbered and shift after every deletion. So always delete from the highest number downwards.

WARN: Rules updated but not applied: the rule is in the configuration, but the firewall is inactive. A ufw enable is missing.

Distribution differences at a glance

  • Debian 13 (trixie): UFW has to be installed. Backend nf_tables. IPV6=yes out of the box. rsyslog is missing from minimal installations, so read logs with journalctl -k. SSH via ssh.service.
  • Debian 12 (bookworm): UFW has to be installed. Backend nf_tables. IPV6=yes out of the box. rsyslog present depending on the installation variant, so /var/log/ufw.log is not guaranteed. SSH via ssh.service.
  • Ubuntu 24.04 LTS: UFW is present on server images, but inactive. Backend nf_tables. IPV6=yes out of the box. /var/log/ufw.log present. SSH through socket activation via ssh.socket, so always check the listening port with sudo ss -lntp | grep sshd and not with sshd -T.
  • Ubuntu 22.04 LTS: UFW is present on server images, but inactive. Backend nf_tables. IPV6=yes out of the box. /var/log/ufw.log present. SSH via ssh.service.

What is identical on all four systems: UFW is always inactive after installation. Nobody switches the firewall on behind your back, and nobody switches it off behind your back.

Docker bypasses UFW

If Docker runs on the server, one important restriction applies: published container ports ignore your UFW rules. Docker creates its own chains and works with destination address translation, so the packets pass the UFW chains in INPUT untouched. A docker run -p 8080:80 is therefore reachable from outside even though ufw status shows a clean "deny incoming".

The simplest and most robust countermeasure is not to publish on all addresses in the first place, but only locally, and to route access through a reverse proxy:

docker run -d -p 127.0.0.1:8080:80 nginx

In a Compose file that corresponds to the port entry "127.0.0.1:8080:80". Alternatively, you can hook UFW into the DOCKER-USER chain that Docker reserves for exactly this purpose using your own rules. That is effective, but it takes maintenance and has to be rechecked with every Docker update. Binding to 127.0.0.1 solves the problem at the root.

If you do get locked out

It happened, SSH no longer answers. One step at a time:

  1. Do not reboot. A reboot does not help, because UFW is enabled as a systemd service and restores its rules while booting. The server comes back just as closed as it went down.
  2. Open the VNC console in the customer panel and log in there as root.
  3. Switch the firewall off: ufw disable. That puts you back in the game, but also back to being unprotected.
  4. Find the cause, do not guess. Look at ufw status numbered and at the actual SSH port from ss -lntup. In nine out of ten cases the cause is one of these three: there never was an allow rule for SSH, the rule points at port 22 while sshd listens on a different port, or the rule was restricted to an IP address that your own connection no longer has (dynamic address assignment on the internet line).
  5. Fix it and switch on again, this time in the right order and with the dead man's switch armed.

If you want to reset the rule set completely, there is ufw reset. Worth knowing: this command deactivates the firewall and stores backup copies of the previous rule files under /etc/ufw/ with a timestamp in the file name. So if in doubt you can look up what applied before. Never run it over an SSH connection without having the console open next to it.

A sensible starting configuration

For a typical web server the complete sequence looks like this:

apt-get update
apt-get install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw limit 22/tcp comment 'SSH rate limit'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable
ufw status verbose

Note that default deny incoming does come before the SSH rule here, but switching on happens right at the end. As long as UFW is inactive, the default policy does no damage. The only critical thing is the state at the moment of enable, and by then the SSH rule has long been stored.

If you additionally want administrative access to be reachable only from your own network, work with source restricted rules following the pattern ufw allow from 203.0.113.0/24 to any port 22 proto tcp. Cleaner still is not to expose administrative services to the open internet at all, but to reach them through a VPN. A WireGuard instance is set up in a few minutes on any of the four systems and replaces a whole stack of firewall exceptions with a single open UDP port.

And finally the perspective worth keeping in mind: UFW filters on the server itself. Against volumetric attacks that saturate the connectivity it cannot help by design, because the packets have already travelled the line by the time your kernel drops them. That needs filtering in the network upstream. At KernelHost this is handled by the upstream infrastructure in the maincubes datacenter in Frankfurt am Main with Arbor real-time filtering. Your local firewall and network level protection solve two different problems, and you need both.

Frequently asked questions

Does "ufw enable" immediately lock me out of my running SSH session?
No, and that is exactly the trap. In /etc/ufw/before.rules UFW permits already established connections (state RELATED,ESTABLISHED). Your current session therefore survives the firewall being switched on even when no SSH rule exists at all. Only the next connection attempt fails. So always verify with a second, freshly opened session before you close the first one.
Do I have to enable IPv6 in UFW separately?
On Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04, IPV6=yes is already set out of the box in /etc/default/ufw. UFW then creates an IPv6 counterpart for every rule automatically, recognizable by the message "Rule added (v6)" and by the "(v6)" suffix in ufw status. You can prove it the hard way with ip6tables -L INPUT -n: it has to say "policy DROP" there. A change to IPV6 only takes effect after ufw disable followed by ufw enable, a ufw reload is not enough.
Does UFW use iptables or nftables?
Both, in a sense. UFW still speaks iptables syntax, but the iptables command is the compatibility layer iptables-nft on all four distributions. The rules therefore end up in the kernel's nftables subsystem and are visible with nft list table ip filter once the firewall is active. As long as UFW has not been switched on, that table does not exist at all and the command reports "Error: No such file or directory". You can check the backend with iptables -V, the output ends with (nf_tables). Do not mix UFW with hand written nft rules: an nft flush ruleset deletes every UFW rule while ufw status keeps reporting "active".
What exactly does ufw limit do?
ufw limit allows connections normally, but drops them as soon as a single source IP opens six or more new connections within 30 seconds. The values are fixed and cannot be changed through the UFW interface. It does not help against distributed attacks, because the counting happens per source IP. It can also hit your own automation, for example backup scripts or CI jobs with many parallel SSH connections. For sources like that, put an explicit exception in front.
I can no longer reach the server over SSH. Does a reboot help?
No. UFW is enabled as a systemd service and restores its rules while booting, so the server comes back just as closed as before. Use the VNC console in the customer panel instead, log in there as root and run ufw disable. Then look for the cause: a missing SSH rule, the wrong port, or a source restriction to an IP address that your own connection no longer has.
Why are my Docker containers reachable from outside despite an active UFW firewall?
Docker creates its own chains and works with destination address translation, so published container ports pass the UFW chains untouched. "deny incoming" does not apply to that traffic. The most robust solution is to publish ports locally only, so -p 127.0.0.1:8080:80 instead of -p 8080:80, and to route access through a reverse proxy.
Why does /var/log/ufw.log not exist on my Debian server?
Because rsyslog is not installed there. Debian 13 no longer ships a syslog daemon in minimal installations and relies on systemd-journald, and Debian 12 does not either, depending on the installation variant. The messages are still there, you read them with journalctl -k and look for lines containing [UFW BLOCK]. On Ubuntu 22.04 and 24.04 rsyslog is present, so the file exists there.

UFW Firewall Linux Debian Ubuntu nftables IPv6 SSH Server Security Root Server