ufw stands for Uncomplicated Firewall, and the name is the whole product pitch. It is a front end that turns a sentence like "allow SSH from the office" into the twenty-odd chains and jumps that netfilter actually needs. You give up expressiveness and gain the ability to secure a host correctly in about four commands, which on a box that is not a firewall is usually the right trade.
This article is part of the Linux networking commands guide. Everything below is real output from a Debian 13 host bridged into a CML topology: the host is at 10.77.0.100 on ens224, SRV1 at 10.77.3.10 is three router hops away running iperf3 on 5201 and an HTTP server on 8080, and the SSH session driving all of it comes in over a separate management NIC. That last detail is the whole reason the first command in this article is an allow rule.
Do not enable it first
ufw ships disabled and its default incoming policy is deny. Turn it on before allowing your own access and the SSH session dies with the command that killed it, on a host you now cannot reach.
j@llmbits:~$ sudo ufw version
ufw 0.36.2
Copyright 2008-2023 Canonical Ltd.
j@llmbits:~$ sudo ufw status verbose
Status: inactiveWrite the rule that protects your access path while the firewall is still inert:
j@llmbits:~$ sudo ufw allow from 192.168.88.0/24 comment 'mgmt subnet - do not lock yourself out'
Rules updated
j@llmbits:~$ sudo ufw show added
Added user rules (see 'ufw status' for running firewall):
ufw allow from 192.168.88.0/24 comment 'mgmt subnet - do not lock yourself out'ufw show added is the command nobody knows about and everybody needs. Rules exist as configuration whether or not the firewall is running, so you can stage a whole policy, read it back, and only then switch it on. Use comment on every rule; six months later the comment is the only thing that tells you why a rule exists.
Now enable:
j@llmbits:~$ sudo ufw --force enable
Firewall is active and enabled on system startup
j@llmbits:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
Anywhere ALLOW IN 192.168.88.0/24 # mgmt subnet - do not lock yourself out--force skips the interactive "this may disrupt existing ssh connections" prompt, which matters when you are driving the box from a script. The Default: line is the part to read: deny inbound, allow outbound, forwarding disabled. That default is why the lab still works immediately after enabling, since outbound is permitted and the replies come back as established flows:
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!
j@llmbits:~$ ping -c 2 10.77.3.10 | tail -2
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 4.921/5.256/5.591/0.335 msAlso worth knowing: enable sets the firewall to start at boot. Unlike raw iptables, where persistence is a separate package you have to remember, ufw persists by default. That is a genuine advantage and occasionally a surprise.
The rule grammar
ufw rules read left to right, and the documented order is this:
ufw [delete] [insert NUM] allow|deny|reject|limit [in|out [on IFACE]] [log|log-all] [proto P] [from SRC [port N]] [to DST [port N]] [comment 'TEXT']The parser is relaxed about some of that (every example in this article puts proto tcp at the end rather than in its documented position, and ufw accepts it) and completely rigid about the rest. log must follow the on IFACE clause, not precede it, and getting that wrong produces a token error rather than a helpful message. This is a real attempt from the lab:
j@llmbits:~$ sudo ufw deny out log on ens224 to 10.77.3.10 port 5201 proto tcp
ERROR: Invalid token 'on'
j@llmbits:~$ sudo ufw deny out on ens224 log to 10.77.3.10 port 5201 proto tcp
Rule addedSame words, one moved. When ufw rejects a rule, the problem is nearly always word order, not the rule itself.
Here is the outbound block that the rest of this article uses:
j@llmbits:~$ sudo ufw deny out on ens224 to 10.77.3.10 port 5201 proto tcp comment 'lab: block iperf3'
Rule added
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
nc: connect to 10.77.3.10 port 5201 (tcp) timed out: Operation now in progress
j@llmbits:~$ nc -zv -w 3 10.77.3.10 8080
Connection to 10.77.3.10 8080 port [tcp/http-alt] succeeded!One port blocked, the neighboring port untouched, and the connection times out rather than refusing, because ufw's deny compiles to DROP. If you want the fast failure instead, use reject, which compiles to REJECT and returns an ICMP error. The iptables article shows the timing difference measured on this same lab.
Numbered status is the one you use
j@llmbits:~$ sudo ufw status numbered
Status: active
To Action From
-- ------ ----
[ 1] Anywhere ALLOW IN 192.168.88.0/24 # mgmt subnet - do not lock yourself out
[ 2] 10.77.3.10 5201/tcp DENY OUT Anywhere on ens224 (out) # lab: block iperf3Plain ufw status gives you the same list without numbers, which makes it useless for editing. Get into the habit of always typing numbered. Deleting works by number or by repeating the rule:
j@llmbits:~$ sudo ufw --force delete 3
Rule deleted
j@llmbits:~$ sudo ufw --force delete deny out on ens224 to 10.77.3.10 port 5201 proto tcp
Rule deleted
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!Deleting by number renumbers everything below it, so deleting rules 2 and 3 in that order actually deletes rule 2 and then whatever moved up into position 3. Delete from the bottom up, or delete by specification. There is also ufw insert 2 allow ... when a rule needs to land above an existing deny, because ufw evaluates first match wins exactly like the chains underneath it.
Three shortcuts worth knowing
Application profiles. Packages drop port definitions into /etc/ufw/applications.d, and ufw lets you allow them by name:
j@llmbits:~$ sudo ufw app list
Available applications:
CUPS
DNS
IMAP
...
OpenSSH
...
WWW Full
WWW Secure
XMPP
iperf3ufw allow OpenSSH is the canonical first command on a new Debian or Ubuntu box, and ufw app info 'WWW Full' tells you which ports a profile covers before you trust it.
limit. One keyword gives you brute force protection:
j@llmbits:~$ sudo ufw limit from 10.77.0.0/16 to any port 22 proto tcp
Rule added
j@llmbits:~$ sudo ufw status numbered
[ 3] 22/tcp LIMIT IN 10.77.0.0/16What that compiles to is the point:
j@llmbits:~$ sudo iptables -S ufw-user-input
-N ufw-user-input
-A ufw-user-input -s 192.168.88.0/24 -j ACCEPT
-A ufw-user-input -s 10.77.0.0/16 -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --set --name DEFAULT --mask 255.255.255.255 --rsource
-A ufw-user-input -s 10.77.0.0/16 -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 --name DEFAULT --mask 255.255.255.255 --rsource -j ufw-user-limit
-A ufw-user-input -s 10.77.0.0/16 -p tcp -m tcp --dport 22 -j ufw-user-limit-acceptThree rules using the recent module, blocking a source that opens six or more new connections within thirty seconds, so the sixth attempt is the one that gets denied. Writing that by hand is a five-minute job and one typo away from being wrong; here it is one word. The hard-coded threshold is not tunable through ufw, which is exactly the trade this tool asks you to make.
Dry run. --dry-run prints the iptables-restore input a command would produce and applies nothing:
j@llmbits:~$ sudo ufw --dry-run allow out on ens224 to 10.77.2.10 port 80 proto tcp | grep -A3 ufw-user-output
-A ufw-user-output -o ens224 -p tcp -d 10.77.3.10 --dport 5201 -j DROP
### tuple ### allow tcp 80 10.77.2.10 any 0.0.0.0/0 out_ens224
-A ufw-user-output -o ens224 -p tcp -d 10.77.2.10 --dport 80 -j ACCEPTUse it before running anything against production, and use it as a teaching tool: every ufw command becomes readable as the iptables rules it generates.
What is underneath
ufw is not a firewall, it is a rule generator. Enabling it builds this:
j@llmbits:~$ sudo iptables -S | head -30
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT ACCEPT
-N ufw-after-forward
-N ufw-after-input
-N ufw-after-logging-forward
-N ufw-after-logging-input
-N ufw-after-logging-output
-N ufw-after-output
-N ufw-before-forward
-N ufw-before-input
-N ufw-before-logging-forward
-N ufw-before-logging-input
-N ufw-before-logging-output
-N ufw-before-output
-N ufw-logging-allow
-N ufw-logging-deny
-N ufw-not-local
-N ufw-reject-forward
-N ufw-reject-input
-N ufw-reject-output
-N ufw-skip-to-policy-forward
-N ufw-skip-to-policy-input
-N ufw-skip-to-policy-output
-N ufw-track-forward
-N ufw-track-input
-N ufw-track-output
-N ufw-user-forward
-N ufw-user-input
-N ufw-user-limitTwenty-plus chains, of which exactly one holds anything you typed:
j@llmbits:~$ sudo nft list chain ip filter ufw-user-output
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
chain ufw-user-output {
ip daddr 10.77.3.10 oifname "ens224" tcp dport 5201 counter packets 3 bytes 180 drop
}
}Two useful facts fall out of that. Your rules always land in ufw-user-input, ufw-user-output or ufw-user-forward, which is where to look when ufw status and observed behavior disagree. And ufw on Debian 13 is generating iptables rules that land in the nftables engine like everything else, which is why nft can print them and why nftables warns you not to hand-edit that table.
The before and after chains are ufw's extension points. If you need something ufw's grammar cannot express, you write raw iptables rules into /etc/ufw/before.rules and ufw loads them ahead of your user rules. That is the escape hatch, and needing it regularly is the signal to stop using ufw on that host.
Logging
ufw logging on|off|low|medium|high|full controls verbosity. On a minimal Debian 13 install the file everyone expects is not there:
j@llmbits:~$ ls -l /var/log/ufw.log
ls: cannot access '/var/log/ufw.log': No such file or directory
j@llmbits:~$ systemctl is-active rsyslog
inactiveufw logs through the kernel, and /var/log/ufw.log is written by an rsyslog rule that ships with the package. No rsyslog, no file. The entries are still there, in the journal:
j@llmbits:~$ sudo journalctl -k --since '-1min' | grep 'UFW BLOCK' | tail -2
Aug 19 10:32:23 llmbits kernel: [UFW BLOCK] IN= OUT=ens224 SRC=10.77.0.100 DST=10.77.3.10 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=59519 DF PROTO=TCP SPT=44210 DPT=5201
Aug 19 10:32:24 llmbits kernel: [UFW BLOCK] IN= OUT=ens224 SRC=10.77.0.100 DST=10.77.3.10 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=59520 DF PROTO=TCP SPT=44210 DPT=5201There is a second catch worth more than the first. Those lines only appeared after the rule was recreated with the log keyword. A plain deny rule drops silently no matter what the global logging level is, because ufw only wires a logging jump into rules that ask for one:
j@llmbits:~$ sudo iptables -S ufw-user-output
-N ufw-user-output
-A ufw-user-output -d 10.77.3.10/32 -o ens224 -p tcp -m tcp --dport 5201 -j ufw-user-logging-output
-A ufw-user-output -d 10.77.3.10/32 -o ens224 -p tcp -m tcp --dport 5201 -j DROPTwo rules for one deny out log: the logging jump and then the drop. If you are hunting a rule that you believe is firing and the journal is silent, check whether the rule carries log before you conclude it is not matching. The global level governs default-policy drops and other automatic logging, not your individual deny rules.
Turning it off, and the mess it leaves
ufw disable stops the firewall and clears its rules. ufw reset goes further, disabling it and backing up every rules file first:
j@llmbits:~$ sudo ufw --force reset
Backing up 'user.rules' to '/etc/ufw/user.rules.20260819_102357'
Backing up 'before.rules' to '/etc/ufw/before.rules.20260819_102357'
Backing up 'after.rules' to '/etc/ufw/after.rules.20260819_102357'
Backing up 'user6.rules' to '/etc/ufw/user6.rules.20260819_102357'
Backing up 'before6.rules' to '/etc/ufw/before6.rules.20260819_102357'
Backing up 'after6.rules' to '/etc/ufw/after6.rules.20260819_102357'
Status: inactiveThose backups accumulate, one set per reset, and nothing ever cleans them up. More importantly, inactive is not the same as gone:
j@llmbits:~$ sudo iptables -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N ufw-after-forward
-N ufw-after-input
...
-A INPUT -j ufw-before-logging-input
-A INPUT -j ufw-before-input
-A INPUT -j ufw-after-input
-A INPUT -j ufw-after-logging-input
-A INPUT -j ufw-reject-input
-A INPUT -j ufw-track-inputThe policies are back to ACCEPT and the user rules are gone, but the chains and the jumps into them are still in place until something flushes them or the box reboots. They are empty, so traffic passes, but anyone auditing that host will see a firewall that looks configured. And the IPv6 side is a separate cleanup entirely:
j@llmbits:~$ sudo iptables -F
j@llmbits:~$ sudo iptables -X
j@llmbits:~$ sudo nft list ruleset
table ip filter {
}
table ip6 filter {
chain ufw6-before-logging-input {
}
chain ufw6-before-input {
}
...
chain INPUT {
type filter hook input priority filter; policy accept;
counter packets 149 bytes 40784 jump ufw6-before-logging-input
counter packets 149 bytes 40784 jump ufw6-before-input
}
}Flushing IPv4 left every ufw6- chain and its jumps behind. A full teardown is four commands, and skipping the last two is how hosts end up with a half-configured IPv6 firewall nobody remembers creating:
j@llmbits:~$ sudo iptables -F; sudo iptables -X
j@llmbits:~$ sudo ip6tables -F; sudo ip6tables -X
j@llmbits:~$ sudo ufw status
Status: inactive
j@llmbits:~$ nc -zv -w 3 10.77.3.10 5201
Connection to 10.77.3.10 5201 port [tcp/*] succeeded!When ufw is the right answer
That Docker interaction is the one failure mode worth memorizing, because it is silent and it is common. docker run -p 8080:80 inserts a DNAT rule in the nat table's PREROUTING chain and its own DOCKER-USER and DOCKER chains ahead of ufw's in FORWARD, so the translated packet is accepted before ufw-user-forward is ever reached. ufw's routed policy is disabled by default on top of that, which the status verbose output earlier in this article shows. ufw status will happily report a deny that is not being consulted. Bind containers to 127.0.0.1 and put a reverse proxy in front, or manage that host's firewall with nftables directly.
Key takeaways
- Allow your management path before running
ufw enable. The default incoming policy is deny and it takes effect immediately. ufw show addedlists staged rules while the firewall is still inactive, so you can review a policy before switching it on.loggoes after theon IFACEclause, and getting it wrong producesERROR: Invalid token. Word order, not the rule, is almost always the problem.- Always use
ufw status numbered. Deleting by number renumbers everything below, so delete bottom up or delete by specification. denycompiles to DROP and times the client out;rejectcompiles to REJECT and fails immediately.limitis threerecent-module rules in one word: six or more new connections in thirty seconds from one source, not tunable from ufw.--dry-runshows the exact iptables rules a command would write, without writing them.- Your rules always live in
ufw-user-input,ufw-user-outputandufw-user-forward. Everything else is scaffolding. - A deny rule logs nothing unless it carries the
logkeyword, and without rsyslog the entries are injournalctl -k, not/var/log/ufw.log. ufw disableandufw resetleave the ufw chains and their jumps in place, in both address families, until you flush them or reboot.- ufw and Docker do not coexist. Published container ports bypass ufw's user chains.
ufw is the top of a three-layer stack. Below it is the iptables syntax it generates, and below that the nftables engine that actually runs the rules. Knowing all three means you can secure a box in four commands and still explain what happened when the four commands are not enough. The full toolkit is indexed in the complete guide to Linux networking commands.