Setting up the UFW firewall without locking yourself out
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/tcpyou can use an application profile, for exampleufw 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, andufw app listprints only the headingAvailable applications:there and not a single entry. On Debian the ufw package alone brings around 38 profiles, and the SSH profile is calledSSHthere. TheOpenSSHprofile comes from theopenssh-serverpackage 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 byufw app list; the port based notationufw allow 22/tcp, on the other hand, behaves identically on all four systems and is therefore the more reliable choice. ufw enableasks interactively:Command may disrupt existing ssh connections. Proceed with operation (y|n)?. In scripts and in Ansible roles useufw --force enable, otherwise the run hangs.- The text after
commentshows up inufw 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
IPV6in/etc/default/ufwdoes not take effect throughufw reload. It needsufw disablefollowed byufw 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
rsynccalls, 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 exampleufw allow from 203.0.113.10 to any port 22 proto tcpwith 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 noin/etc/ssh/sshd_configor 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 unlikeufw limitit 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=yesout of the box. rsyslog is missing from minimal installations, so read logs withjournalctl -k. SSH viassh.service. - Debian 12 (bookworm): UFW has to be installed. Backend nf_tables.
IPV6=yesout of the box. rsyslog present depending on the installation variant, so/var/log/ufw.logis not guaranteed. SSH viassh.service. - Ubuntu 24.04 LTS: UFW is present on server images, but inactive. Backend nf_tables.
IPV6=yesout of the box./var/log/ufw.logpresent. SSH through socket activation viassh.socket, so always check the listening port withsudo ss -lntp | grep sshdand not withsshd -T. - Ubuntu 22.04 LTS: UFW is present on server images, but inactive. Backend nf_tables.
IPV6=yesout of the box./var/log/ufw.logpresent. SSH viassh.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:
- 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.
- Open the VNC console in the customer panel and log in there as root.
- Switch the firewall off:
ufw disable. That puts you back in the game, but also back to being unprotected. - Find the cause, do not guess. Look at
ufw status numberedand at the actual SSH port fromss -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). - 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?
Do I have to enable IPv6 in UFW separately?
Does UFW use iptables or nftables?
What exactly does ufw limit do?
I can no longer reach the server over SSH. Does a reboot help?
Why are my Docker containers reachable from outside despite an active UFW firewall?
Why does /var/log/ufw.log not exist on my Debian server?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

