A UFW rule that reads correctly can still do the wrong thing. On our test box, ufw allow from 10.0.1.0/24 port 8096 accepted every port, SSH included, from any subnet client that picked 8096 as its source port. A Docker container published on a port UFW never allowed answered a public address with HTTP 200. The UFW commands below are the ones you type every week, each shown with the output it printed on a live firewall, plus the spots where the syntax and the result disagree.
This UFW cheat sheet covers allow and deny rules, comments on existing rules, listing and deleting rules, inserting a rule at the top, rate limiting, application profiles, logging levels, Docker and port forwarding. The commands run top to bottom on two lab VMs (UFW 0.36.2, on Ubuntu 26.04 and 24.04), so every numbered list below is what you get if you follow along in order; a November 2026 update rewrites the cheat sheet form below, not the commands. If this is the first firewall on the machine, the UFW setup walkthrough for Ubuntu 26.04 goes through it in order.
What the test box measured
Steps 1 to 12 ran on bench01, an Ubuntu 26.04 VM with Docker installed, with perf-a on Ubuntu 24.04 as the client. Step 13 ran on perf-a, Step 14 on both boxes, and perf-a also repeated the version check, enable, comment, source-port and insert tests from the earlier steps. Two extra addresses on perf-a, 203.0.113.10 and 192.0.2.10 from the documentation ranges, stood in for internet clients so the Docker tests could tell a public source from a private one. The results that change how you write rules:
| Test | Result |
|---|---|
deny vs reject on a closed TCP port, seen by the client | deny: client gave up at its own 5 s timeout. reject: refused in 0.003 s |
ufw limit OpenSSH, one client, one attempt per second | Attempts 1 to 5 accepted, attempt 6 refused, open again after 31 s of quiet |
| Log lines for one 1,000-port SYN scan | low 10, medium 20, high 2,010, full 4,049 |
| Docker container on port 8888, no UFW rule for 8888 | HTTP 200 from the LAN and from both public addresses |
| Same container after the after.rules block and a reload | LAN HTTP 200, public addresses timed out; the block held after a reboot |
ufw allow from 10.0.1.0/24 port 8096 (missing to any) | Matched sshd on port 22, per ufw show listening |
NAT block in before.rules, three ufw reload runs | Three copies of the DNAT rule loaded |
ufw reset on a gateway and a Docker host | DNAT and DOCKER-USER rules stayed loaded |
The UFW code is the same on both releases: the 26.04 package differs from the 24.04 one only in packaging and documentation changes, per the Launchpad changelog. The only visible difference in our runs was the routed policy line in ufw status verbose, and it came from IP forwarding, not from the release. UFW still writes iptables rules through the nf_tables backend, so sudo nft list ruleset shows the same ufw- chains, which matters if another tool on the host also edits nftables.
Step 1: Check UFW and set the default policies
Ubuntu server images ship UFW preinstalled, and both lab VMs had it, but it stays inactive until you enable it. Check the version and the state first:
ufw version
sudo ufw status verbose
Both lab servers reported the same release. An inactive firewall prints nothing beyond its status, even when rules are already saved, so ufw status alone cannot tell you what enable is about to load.
ufw 0.36.2
Copyright 2008-2023 Canonical Ltd.
Status: inactive
The defaults already deny incoming and allow outgoing traffic, which is what /etc/default/ufw holds on a fresh install. Setting them explicitly still pays off on a server someone else built, because a hand-edited DEFAULT_INPUT_POLICY is invisible until it lets traffic through. Each command answers with Default incoming policy changed to 'deny' (or outgoing to ‘allow’) and a reminder to update your rules. The base is then in a known state before any allow rule goes on top of it.
sudo ufw default deny incoming
sudo ufw default allow outgoing
Allow SSH before enabling anything on a remote server. The deny policy applies the moment UFW starts, so a missing SSH rule cuts your session and leaves you on the provider console. The OpenSSH application profile opens 22/tcp, and ufw show added lists the pending rules while the firewall is still off. On bench01 it printed one line, ufw allow OpenSSH, which is the last cheap check before the switch:
sudo ufw allow OpenSSH
sudo ufw show added
Now enable the firewall. UFW asks for confirmation because it cannot tell whether the rules you added cover the session you are typing in, so answer y only after the check above:
sudo ufw enable
The prompt warns about SSH, and the second line confirms that UFW will also start at boot.
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
Run sudo ufw status verbose again. The Ubuntu 24.04 box, with nothing but SSH allowed, printed the logging level, the three default policies and the one rule pair:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
The routed policy reads disabled while net.ipv4.ip_forward is 0. On the 26.04 box the same line read deny (routed) because Docker had switched forwarding on, and perf-a flipped from disabled to deny the moment forwarding was enabled for Step 13. The policy itself (DEFAULT_FORWARD_POLICY="DROP") was the same on both, so the word only tells you whether the kernel forwards at all.
Step 2: Allow ports, services and port ranges with ufw allow
A rule takes a service name from /etc/services, a port with a protocol, or a range. Each of these five prints Rule added twice, once for IPv4 and once for IPv6, because IPV6=yes is the default. The last one carries a comment, which Step 4 comes back to:
sudo ufw allow http
sudo ufw allow 443/tcp
sudo ufw allow 6000:6100/tcp
sudo ufw allow 53/udp
sudo ufw allow 8080/tcp comment 'Grafana behind nginx'
Ranges always need a protocol. sudo ufw allow 6000:6100 fails with ERROR: Must specify 'tcp' or 'udp' with multiple ports, because UFW refuses to guess for more than one port. Service names and port numbers also resolve to the same rule, so after ufw allow http, running sudo ufw allow 80/tcp prints Skipping adding existing rule and changes nothing. That comparison on the resolved rule is what makes a provisioning script safe to re-run on every deploy: the result stays one rule per port instead of a growing pile of copies.
A single port without /tcp or proto opens both TCP and UDP, which doubles the exposure on a database port for no reason. We added a MySQL rule without a protocol to see it:
sudo ufw allow from 10.0.1.0/24 to any port 3306
The rules file in /etc/ufw/user.rules then held one ACCEPT line per protocol.
-A ufw-user-input -p tcp --dport 3306 -s 10.0.1.0/24 -j ACCEPT
-A ufw-user-input -p udp --dport 3306 -s 10.0.1.0/24 -j ACCEPT
MySQL and MariaDB listen on TCP only, so the UDP line is pure exposure. Remove the rule, which prints Rule deleted, and add it back with proto tcp in Step 3. Do the same for PostgreSQL, Redis and anything else that never speaks UDP:
sudo ufw delete allow from 10.0.1.0/24 to any port 3306
Deleting by repeating the rule works for any rule, and Step 8 covers the other ways to remove one.
Step 3: Allow an IP address or subnet to a specific port
The full form is ufw allow from SOURCE to DEST port PORT proto PROTO, and the word order matters more than it looks. These three rules restrict MySQL to the app subnet, trust one admin host completely, and expose the node exporter only on one interface. Rules with an IPv4 source print a single Rule added, since they cannot match IPv6 traffic:
sudo ufw allow from 10.0.1.0/24 to any port 3306 proto tcp comment 'MariaDB from app subnet'
sudo ufw allow from 10.0.1.50
sudo ufw allow in on eth0 from 10.0.1.0/24 to any port 9100 proto tcp
The lab VMs name their NIC eth0, but many Ubuntu machines use names such as ens18 or enp0s3. UFW accepts any interface name without checking it (in on ens99 on a box with no such NIC was saved without a warning), so a typo gives a rule that never matches. Run ip -br link and use the name it prints.
A specific destination address is useful on hosts with several IPs, for example to accept PostgreSQL on one address only. UFW does not check that the address belongs to the host, so the second rule here was accepted on a box that does not own 10.0.1.200:
sudo ufw allow from 10.0.1.0/24 to 10.0.1.10 port 5432 proto tcp
sudo ufw allow from 10.0.1.0/24 to 10.0.1.200 port 5433 proto tcp
Both printed Rule added. The second one can never match and only clutters the list. The mistake that shows up in search logs for this page is a different one, dropping to any. This rule was meant to open Jellyfin on port 8096 to the subnet:
sudo ufw allow from 10.0.1.0/24 port 8096 proto tcp
UFW accepts it, but port placed after the source is the source port. The rule therefore allows any destination port to a client that connects from port 8096, and a client can pick its own source port. sudo ufw show listening proves it by listing the rule under sshd:
tcp:
22 * (sshd)
[ 1] allow OpenSSH
[ 8] allow from 10.0.1.50
[12] allow from 10.0.1.0/24 port 8096 proto tcp
tcp6:
22 * (sshd)
[13] allow OpenSSH
Rule 12 is the broken one, sitting next to the SSH rules, and Step 8 deletes it. The intended rule needs to any before the port:
sudo ufw allow from 10.0.1.0/24 to any port 8096 proto tcp
Check every new source-restricted rule with ufw show listening the same way, because UFW gives no warning for either mistake on this page.
Step 4: Add a comment to an existing UFW rule
To add or change a comment on an existing rule, run the same rule again with comment. UFW matches it to the existing entry and prints Rule updated and Rule updated (v6) instead of adding a second rule. Here the HTTPS and RTP rules from Step 2 get labels, and an empty comment removes the Grafana label from 8080:
sudo ufw allow 443/tcp comment 'nginx TLS'
sudo ufw allow 6000:6100/tcp comment 'RTP media'
sudo ufw allow 8080/tcp comment ''
Comments appear after a # in every status view, which is the main reason to write them. Six months later, # nginx TLS answers the question an unlabelled 443/tcp leaves open. The top of sudo ufw status now reads:
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere # nginx TLS
6000:6100/tcp ALLOW Anywhere # RTP media
53/udp ALLOW Anywhere
8080/tcp ALLOW Anywhere
3306/tcp ALLOW 10.0.1.0/24 # MariaDB from app subnet
The comment has to go on the exact same rule. A rule with a different source, port or protocol is a different rule to UFW, so the command adds a new line rather than labelling the old one. Copy the rule from ufw show added, which prints each rule in its original command form, and append the comment to that line.
Step 5: List UFW rules
Six commands show the rule set, and each answers a different question, so pick the one that matches what you need to know:
sudo ufw status: active rules and comments, no numbers. The quick check.sudo ufw status verbose: adds the logging level, the three default policies and the new-profile policy.sudo ufw status numbered: the positions thatdeleteandinsertexpect.sudo ufw show added: every rule as the command that created it, and it works while UFW is inactive.sudo ufw show listening: listening sockets and the rules that match each one, as in Step 3.sudo ufw show raw: the loaded iptables chains with packet and byte counters.
The numbered view is the one you need most, because deletes and inserts work by position. IPv4 rules come first and IPv6 rules follow in the same sequence. After Steps 1 to 4 the list looks like this:
sudo ufw status numbered
Nineteen rules, with the IPv6 copies starting at 14.
Status: active
To Action From
-- ------ ----
[ 1] OpenSSH ALLOW IN Anywhere
[ 2] 80/tcp ALLOW IN Anywhere
[ 3] 443/tcp ALLOW IN Anywhere # nginx TLS
[ 4] 6000:6100/tcp ALLOW IN Anywhere # RTP media
[ 5] 53/udp ALLOW IN Anywhere
[ 6] 8080/tcp ALLOW IN Anywhere
[ 7] 3306/tcp ALLOW IN 10.0.1.0/24 # MariaDB from app subnet
[ 8] Anywhere ALLOW IN 10.0.1.50
[ 9] 9100/tcp on eth0 ALLOW IN 10.0.1.0/24
[10] 10.0.1.10 5432/tcp ALLOW IN 10.0.1.0/24
[11] 10.0.1.200 5433/tcp ALLOW IN 10.0.1.0/24
[12] Anywhere ALLOW IN 10.0.1.0/24 8096/tcp
[13] 8096/tcp ALLOW IN 10.0.1.0/24
[14] OpenSSH (v6) ALLOW IN Anywhere (v6)
[15] 80/tcp (v6) ALLOW IN Anywhere (v6)
[16] 443/tcp (v6) ALLOW IN Anywhere (v6) # nginx TLS
[17] 6000:6100/tcp (v6) ALLOW IN Anywhere (v6) # RTP media
[18] 53/udp (v6) ALLOW IN Anywhere (v6)
[19] 8080/tcp (v6) ALLOW IN Anywhere (v6)
Rule 12 shows the source-port mistake from Step 3 in the From column (10.0.1.0/24 8096/tcp), which makes it easy to spot in a long list. Rule 11 is the destination address the box does not own. Neither one produced an error when it was added, so reading this view after a batch of changes is the only check that catches them.
Step 6: Deny or reject traffic
A deny rule drops the packet silently. A reject rule answers, and for a TCP rule UFW answers with a TCP reset, so the client learns immediately that the port is closed (UDP rules get an ICMP port-unreachable instead). Add one of each, then look at what UFW loaded:
sudo ufw deny 7000/tcp
sudo ufw reject 7001/tcp
sudo iptables -S ufw-user-input | grep -E 'dport 700[01]'
The reject rule carries --reject-with tcp-reset:
-A ufw-user-input -p tcp -m tcp --dport 7000 -j DROP
-A ufw-user-input -p tcp -m tcp --dport 7001 -j REJECT --reject-with tcp-reset
From perf-a, a timed connection to each port with a 5 second client timeout shows what that means for the client:
time nc -zv -w 5 10.0.1.10 7000
time nc -zv -w 5 10.0.1.10 7001
The denied port held the client for the full timeout, while the rejected one answered in 3 ms.
nc: connect to 10.0.1.10 port 7000 (tcp) timed out: Operation now in progress
real 0m5.006s
nc: connect to 10.0.1.10 port 7001 (tcp) failed: Connection refused
real 0m0.003s
Use deny on internet-facing rules, where a silent drop slows down scanners and tells them nothing. Use reject on internal networks, where a fast refusal saves your own applications from hanging until their connect timeout. Blocking a whole source address is different: a deny from rule only works above the allow rules it should override, so it belongs at the top of the list with insert, covered in Step 9.
Outgoing rules take out. This one stops a compromised web app from sending spam directly over SMTP. After one test connection to a mail server on port 25, which the client abandoned after 6 seconds, the counter on the UFW output chain showed the drops:
sudo ufw deny out 25/tcp
sudo iptables -L ufw-user-output -n -v
Six SYN packets hit the DROP rule from that single connection attempt, because the client kept retransmitting until its own timeout.
Chain ufw-user-output (1 references)
pkts bytes target prot opt in out source destination
6 360 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:25
In the status views, outgoing rules carry DENY OUT and an (out) tag, so they are easy to tell apart from the inbound rules above them.
Step 7: Rate-limit SSH with ufw limit
ufw limit lets a source open 5 new connections in 30 seconds and refuses the sixth. Applied to an existing allow rule, it converts that rule and prints Rule updated for both address families rather than adding another, so the SSH rule from Step 1 becomes a LIMIT rule in place:
sudo ufw limit OpenSSH
The iptables rules behind it explain the numbers. Every new connection is recorded with the recent module, and a source with 6 entries in the last 30 seconds is sent to the limit chain. The --hitcount 6 and --seconds 30 values are fixed by UFW, with no option to change them:
sudo iptables -S ufw-user-input | grep -- '--dport 22'
Three rules per address family implement the limit.
-A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --set --name DEFAULT --mask 255.255.255.255 --rsource -m comment --comment "\'dapp_OpenSSH\'"
-A ufw-user-input -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 -m comment --comment "\'dapp_OpenSSH\'" -j ufw-user-limit
-A ufw-user-input -p tcp -m tcp --dport 22 -m comment --comment "\'dapp_OpenSSH\'" -j ufw-user-limit-accept
To measure it, perf-a opened one connection per second to port 22, ten times, then waited 31 seconds and tried again:
for i in $(seq 1 10); do printf "attempt %2d " $i; nc -z -w 2 10.0.1.10 22 && echo open || echo blocked; sleep 1; done
sleep 31; printf "after 31 s: "; nc -z -w 2 10.0.1.10 22 && echo open || echo blocked
Attempts 1 to 5 got through and every attempt after that was refused. The extra succeeded! lines are the Ubuntu nc reporting each open connection on stderr.
attempt 1 open
Connection to 10.0.1.10 22 port [tcp/ssh] succeeded!
attempt 2 open
Connection to 10.0.1.10 22 port [tcp/ssh] succeeded!
attempt 3 open
Connection to 10.0.1.10 22 port [tcp/ssh] succeeded!
attempt 4 open
Connection to 10.0.1.10 22 port [tcp/ssh] succeeded!
attempt 5 open
Connection to 10.0.1.10 22 port [tcp/ssh] succeeded!
attempt 6 blocked
attempt 7 blocked
attempt 8 blocked
attempt 9 blocked
attempt 10 blocked
after 31 s: open
Connection to 10.0.1.10 22 port [tcp/ssh] succeeded!
Refused attempts are recorded too, because the --set rule runs before the check. A client that keeps retrying therefore stays locked out until it goes quiet for 30 seconds. The limit chain logs the first few refusals with a [UFW LIMIT BLOCK] prefix and then rejects them, so a legitimate client sees an instant refusal rather than a hang:
2026-10-19T21:51:15.238719+00:00 bench01 kernel: [UFW LIMIT BLOCK] IN=eth0 OUT= MAC=bc:24:11:73:14:1d:bc:24:11:72:0a:90:08:00 SRC=10.0.1.20 DST=10.0.1.10 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=21202 DF PROTO=TCP SPT=52010 DPT=22 WINDOW=64240 RES=0x00 SYN URGP=0
The limit also hits your own automation. Our first lab runner opened a fresh SSH session for every command, and it was refused from its second command on, because earlier sessions had already filled the 30 second window. It stayed locked out until we switched it to a single multiplexed connection. Tools that open many short sessions need SSH multiplexing (ControlMaster and ControlPersist), or an allow rule for the management host placed above the limit rule:
sudo ufw insert 1 allow from 10.0.1.20 to any port 22 proto tcp comment 'mgmt host skips the limit'
With that rule at position 1, perf-a made 8 back-to-back connections and all 8 were accepted, while every other source still faced the limit. The insert also shifts every rule below it down by one, which matters in the next step. For repeated failures over longer windows, pair UFW with Fail2ban or CrowdSec, which read the auth log instead of counting SYNs.
Step 8: Delete UFW rules by number or by rule
Deleting by number is quickest, but take the number from a fresh listing, never from one printed before an insert or a delete. Filtering the numbered view shows where the broken source-port rule from Step 3 sits now:
sudo ufw status numbered | grep 8096
The insert in Step 7 moved it from 12 to 13:
[13] Anywhere ALLOW IN 10.0.1.0/24 8096/tcp
[14] 8096/tcp ALLOW IN 10.0.1.0/24
Delete it by that number. UFW repeats the rule in command form and asks first, which is the last chance to catch a wrong number:
sudo ufw delete 13
The rule shown matches the broken one, so the answer is y.
Deleting:
allow from 10.0.1.0/24 port 8096 proto tcp
Proceed with operation (y|n)? y
Rule deleted
Deleting by rule repeats the original command after delete. It removes the IPv4 and IPv6 copies together, prints Rule deleted for each, and does not depend on positions:
sudo ufw delete allow 6000:6100/tcp
Numbers shift after every delete, and that is where scripted deletes go wrong. Before that command the two PostgreSQL rules sat at 11 and 12; afterwards sudo ufw status numbered | grep -E '5432|5433' showed them one place higher:
[10] 10.0.1.10 5432/tcp ALLOW IN 10.0.1.0/24
[11] 10.0.1.200 5433/tcp ALLOW IN 10.0.1.0/24
A script holding the old listing would run sudo ufw --force delete 12 to remove the useless 5433 rule, skip the prompt because of --force, and delete the Jellyfin rule that now sits at 12 instead. Delete from the highest number down, re-list after each delete, or delete by rule so positions never matter. Removing the 5433 rule that way is safe at any time:
sudo ufw delete allow from 10.0.1.0/24 to 10.0.1.200 port 5433 proto tcp
A rule that does not exist fails cleanly: sudo ufw delete allow 9999/tcp printed Could not delete non-existent rule for both address families and changed nothing. Route rules from Steps 12 and 13 delete the same way, with ufw route delete followed by the original rule.
Step 9: Insert a UFW rule at the top
UFW evaluates rules from the top and the first match wins, so a deny rule added after an allow rule for the same traffic never fires. To block a source address, put the rule at position 1. The ufw insert 1 command does that for an IPv4 rule and prints Rule inserted:
sudo ufw insert 1 deny from 198.51.100.0/24 comment 'scanner range'
IPv6 rules are numbered after all IPv4 rules, so position 1 is not valid for them. Running sudo ufw insert 1 deny from 2001:db8:bad::/48 failed with ERROR: Invalid position '1'. For IPv6, use ufw prepend instead, because it adds the rule at the top of its own address family, whichever family that is:
sudo ufw prepend deny from 2001:db8:bad::/48
UFW answered Rule inserted (v6). A position past the end of the list fails like the IPv6 case: sudo ufw insert 40 allow 9000/tcp on the 24-rule set printed ERROR: Invalid position '40'. The full list after Steps 1 to 9 shows both block rules at the top of their families, the management exception above the LIMIT rule, and the outgoing SMTP deny:
Status: active
To Action From
-- ------ ----
[ 1] Anywhere DENY IN 198.51.100.0/24 # scanner range
[ 2] 22/tcp ALLOW IN 10.0.1.20 # mgmt host skips the limit
[ 3] OpenSSH LIMIT IN Anywhere
[ 4] 80/tcp ALLOW IN Anywhere
[ 5] 443/tcp ALLOW IN Anywhere # nginx TLS
[ 6] 53/udp ALLOW IN Anywhere
[ 7] 8080/tcp ALLOW IN Anywhere
[ 8] 3306/tcp ALLOW IN 10.0.1.0/24 # MariaDB from app subnet
[ 9] Anywhere ALLOW IN 10.0.1.50
[10] 9100/tcp on eth0 ALLOW IN 10.0.1.0/24
[11] 10.0.1.10 5432/tcp ALLOW IN 10.0.1.0/24
[12] 8096/tcp ALLOW IN 10.0.1.0/24
[13] 7000/tcp DENY IN Anywhere
[14] 7001/tcp REJECT IN Anywhere
[15] 25/tcp DENY OUT Anywhere (out)
[16] Anywhere (v6) DENY IN 2001:db8:bad::/48
[17] OpenSSH (v6) LIMIT IN Anywhere (v6)
[18] 80/tcp (v6) ALLOW IN Anywhere (v6)
[19] 443/tcp (v6) ALLOW IN Anywhere (v6) # nginx TLS
[20] 53/udp (v6) ALLOW IN Anywhere (v6)
[21] 8080/tcp (v6) ALLOW IN Anywhere (v6)
[22] 7000/tcp (v6) DENY IN Anywhere (v6)
[23] 7001/tcp (v6) REJECT IN Anywhere (v6)
[24] 25/tcp (v6) DENY OUT Anywhere (v6) (out)
For blocklists, prepend is the safer habit because it never depends on how many IPv4 rules exist, and it works the same for both families.
Step 10: Use UFW application profiles
Application profiles are INI files in /etc/ufw/applications.d/ that name a set of ports. A minimal Ubuntu server has one, installed by openssh-server, and packages such as nginx and Apache add their own:
sudo ufw app list
sudo ufw app info OpenSSH
The OpenSSH profile maps to a single TCP port.
Available applications:
OpenSSH
Profile: OpenSSH
Title: Secure shell server, an rshd replacement
Description: OpenSSH is a free implementation of the Secure Shell protocol.
Port:
22/tcp
Your own services can have profiles too, and one file can hold several. Create one:
sudo vim /etc/ufw/applications.d/custom-apps
This file defines a Netdata profile and a stack that mixes a single port, a range and UDP, joined with |:
[Netdata]
title=Netdata monitoring agent
description=Netdata dashboard and REST API
ports=19999/tcp
[AppStack]
title=App stack with HTTPS, admin range and CoAP
description=Example profile mixing a single port, a range and UDP
ports=8443/tcp|9443:9445/tcp|5683/udp
UFW reads the directory on every command, so ufw app list shows both profiles as soon as the file is saved, and ufw app info AppStack lists all three port specs. Allow them like any rule, including with a source restriction through to any app:
sudo ufw allow from 10.0.1.0/24 to any app Netdata
sudo ufw allow AppStack
Changing a profile later needs one extra step when you work over SSH. We added 8125/udp for StatsD to the Netdata line (ports=19999/tcp|8125/udp) and ran the update:
sudo ufw app update Netdata
UFW updated its saved rules but not the running firewall, and it says so.
Rules updated for profile 'Netdata'
Skipped reloading firewall
The ufw source skips that reload whenever the command runs inside an SSH session, which is how most servers are managed; from a local console it reloads by itself. iptables -S ufw-user-input still showed only port 19999 at that point, so StatsD packets were still being dropped while ufw status already listed the profile as allowed. After sudo ufw reload, the UDP rule for 8125 appeared next to it. Reload after every profile edit made over SSH.
Step 11: Set the UFW logging level
UFW logs at low by default, and on Ubuntu the kernel messages land in /var/log/ufw.log through rsyslog, on both releases. The file is created on the first logged packet. Changing the level is one command, which prints Logging enabled:
sudo ufw logging medium
To size the levels, perf-a ran the same nmap SYN scan of ports 1 to 1000 against bench01 at each level, and we counted the new lines in /var/log/ufw.log:
| Level | What it logs (ufw man page) | Lines for one 1,000-port scan |
|---|---|---|
low | Blocked packets not matching the policy, rate limited, plus logged rules | 10 |
medium | low, plus allowed packets not matching the policy, INVALID packets and new connections, rate limited | 20 |
high | medium without rate limiting, plus all packets with rate limiting | 2,010 |
full | high without rate limiting | 4,049 |
The jump from 20 to 2,010 lines is the rate limit coming off. At full, 2,070 of the 4,049 lines were [UFW AUDIT] entries, including packets from our own SSH session, so the log grows with every byte of traffic the server handles. Keep production on low or medium, and use high for a short troubleshooting window. At low, a probe from perf-a to closed port 7777 left this entry:
2026-10-19T21:52:15.485830+00:00 bench01 kernel: [UFW BLOCK] IN=eth0 OUT= MAC=bc:24:11:73:14:1d:bc:24:11:72:0a:90:08:00 SRC=10.0.1.20 DST=10.0.1.10 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=34475 DF PROTO=TCP SPT=55234 DPT=7777 WINDOW=64240 RES=0x00 SYN URGP=0
Four fields answer most questions about a log line:
- Prefix:
[UFW BLOCK],[UFW ALLOW],[UFW AUDIT]or[UFW LIMIT BLOCK], which says which chain made the decision. - SRC: the client address, the value to feed into a
deny fromrule. - DPT: the destination port, which tells you which service the client was after.
- PROTO and flags: a lone
SYNis a new connection attempt, the usual sign of a scan.
The same lines are in the kernel journal, so sudo journalctl -k -g 'UFW BLOCK' works on a host without rsyslog.
Step 12: Stop Docker from bypassing UFW
Docker publishes container ports with NAT rules that send the traffic through the FORWARD chain, while the UFW allow and deny rules sit on INPUT. A published port is therefore reachable even when UFW has no rule for it. The diagram traces both paths on bench01, with the timings measured in this step:

Only the left branch, for ports owned by host processes, ever meets the rules from Steps 2 to 9. On bench01, with Docker installed from the Docker apt repository, we published nginx on port 8888, a port UFW never allowed, with a restart policy so it comes back after a reboot:
sudo docker run -d --name web --restart unless-stopped -p 8888:80 nginx:alpine
A probe from the perf-a LAN address and from both public test addresses (curl --interface with a 5 second connect timeout) reached the container every time.
10.0.1.20 -> :8888 HTTP 200 in 0.005901s
203.0.113.10 -> :8888 HTTP 200 in 0.001655s
192.0.2.10 -> :8888 HTTP 200 in 0.001323s
As a control, a Python HTTP server on the host itself, on port 8889 with no UFW rule, timed out from all three sources. The firewall works for host processes and is skipped for containers. The fix that holds up is the ufw-docker block, which hooks the DOCKER-USER chain that Docker evaluates before its own forwarding rules. It needs the default iptables firewall backend in Docker: with the experimental nftables backend that Docker 29 added, there is no DOCKER-USER chain. Open the UFW after-rules file:
sudo vim /etc/ufw/after.rules
Append this block at the end of the file. It is the current version from the project README, the one that uses conntrack, while some older guides still carry the variant without it:
# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i docker0 -o docker0 -j ACCEPT
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 172.16.0.0/12
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 192.168.0.0/16
-A DOCKER-USER -j RETURN
-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP
COMMIT
# END UFW AND DOCKER
Run sudo ufw reload so the chain is rebuilt. The same probes then split: both public addresses were dropped, while the LAN address still got through. The log gained [UFW DOCKER BLOCK] entries for the dropped SYNs.
10.0.1.20 -> :8888 HTTP 200 in 0.001662s
203.0.113.10 -> :8888 HTTP 000 in 5.002568s
192.0.2.10 -> :8888 HTTP 000 in 5.002540s
That LAN result is by design. The three RETURN -s lines trust every source in the private ranges, so in a cloud VPC any other instance, and any load balancer with a private address, still reaches the container without a UFW rule. The same trust bit our first run of this test: perf-a still had a MASQUERADE rule loaded from a previous lab pass of the NAT test in Step 13, so the “public” probes arrived with its private address and passed. Publish internal-only containers on 127.0.0.1 or a private interface address if that matters.
Open a container port with ufw route allow
Allowed container traffic goes through ufw route rules, which feed ufw-user-forward. The port in the rule must be the container port, because the Docker DNAT rule has already rewritten the destination by the time the packet reaches DOCKER-USER. The host port looks like the obvious choice, so try it first:
sudo ufw route allow proto tcp from any to any port 8888
The public probes kept timing out, because the packet arrives addressed to 172.17.0.2 port 80 and no longer mentions 8888. Swap it for the container port:
sudo ufw route delete allow proto tcp from any to any port 8888
sudo ufw route allow proto tcp from any to any port 80 comment 'web container'
All three sources answered with HTTP 200, and sudo iptables -L ufw-user-forward -n -v showed the traffic on the accept rule.
Chain ufw-user-forward (2 references)
pkts bytes target prot opt in out source destination
21 1359 ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:80
A rule for port 80 opens every container that listens on 80 inside, to everyone. To open one container to one source range, delete the broad rule first, since it would still match, then target the container address from docker inspect:
sudo ufw route delete allow proto tcp from any to any port 80
sudo ufw route allow proto tcp from 203.0.113.0/24 to 172.17.0.2 port 80 comment 'web container, partner range'
Now only the partner range gets in from outside: 203.0.113.10 answered with HTTP 200 and 192.0.2.10 timed out. We rebooted bench01 with the block and the scoped rule in place, and after the reboot the container was back on 172.17.0.2 and the three probes gave the same three answers. Container addresses can change when a container is recreated, so user-defined networks with fixed subnets suit this better than the default bridge.
Step 13: Forward a port through UFW with NAT
Port forwarding needs three changes, in this order, and skipping any one of them leaves the client with a timeout and no error message:
- Kernel forwarding in
/etc/ufw/sysctl.conf, which UFW applies on every start. - A
*nattable in/etc/ufw/before.ruleswith the DNAT rule. - A
ufw route allowrule that lets the forwarded flow past the routed deny policy.
Here perf-a acts as the gateway and forwards its port 8081 to the web container on bench01, 10.0.1.10:8888. Turn on forwarding in the UFW sysctl file:
sudo vim /etc/ufw/sysctl.conf
Uncomment this line, which is line 10 on both releases:
net/ipv4/ip_forward=1
Next, add the NAT rules. They go in a separate *nat table placed before the *filter line near the top of the file:
sudo vim /etc/ufw/before.rules
Insert this block directly above *filter. Change eth0 to your interface name from ip -br link:
# NAT: forward TCP 8081 on this gateway to the web server at 10.0.1.10:8888
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
-F PREROUTING
-F POSTROUTING
-A PREROUTING -i eth0 -p tcp --dport 8081 -j DNAT --to-destination 10.0.1.10:8888
-A POSTROUTING -d 10.0.1.10 -p tcp --dport 8888 -j MASQUERADE
COMMIT
The two -F lines matter. UFW loads this file without flushing the nat chains, and its stop does not clear them either, so on our first attempt without them three reloads left three copies of the DNAT rule and three of the MASQUERADE rule in sudo iptables -t nat -S. An edited rule would also leave its old copy active. With the flush lines, the same three reloads left exactly one of each. The catch is that they flush every nat rule in those two chains on each reload, not only yours. Use them only when this block is the only thing on the host that writes nat rules. Docker keeps its own -j DOCKER jump in nat PREROUTING, and libvirt, LXD, Tailscale, Kubernetes and WireGuard PostUp scripts add their own nat rules too, so on such a host leave the -F lines out and check sudo iptables -t nat -S after each reload instead.
The MASQUERADE line rewrites the source to the address of the gateway. Our backend sits on the same subnet and its default route does not point at the gateway, so without it the replies would bypass perf-a and the client would drop them. After sudo ufw reload, ufw status verbose reported deny (routed) instead of disabled, and a client still got no answer on port 8081 within 5 seconds, because UFW drops routed traffic by default.
Older guides fix that by setting DEFAULT_FORWARD_POLICY="ACCEPT" in /etc/default/ufw, which forwards everything the host can reach. A route rule opens only the forwarded flow:
sudo ufw route allow in on eth0 out on eth0 to 10.0.1.10 port 8888 proto tcp comment 'DNAT 8081 to web'
The client then received HTTP 200 in 0.011 s. In ufw status numbered on perf-a, which held only the SSH rule and this one, it shows as a forward rule; on a box with more rules the number differs.
[ 2] 10.0.1.10 8888/tcp on eth0 ALLOW FWD Anywhere on eth0 # DNAT 8081 to web
The route rule matches the translated destination, 10.0.1.10:8888, not the port the client dialled, for the same reason as the Docker case: PREROUTING runs before the filter chains. On a real two-NIC gateway the in on and out on interfaces differ, for example the WAN and LAN NICs. A WireGuard gateway needs the same forwarding change plus a route rule for the tunnel interface.
Step 14: Disable or reset UFW
sudo ufw disable unloads the UFW rules and stops UFW starting at boot, and it printed Firewall stopped and disabled on system startup. Every rule stays on disk, so ufw enable restores the same rule set later. ufw reset goes further: it disables the firewall, deletes all user rules and backs up every rules file with a timestamp:
sudo ufw reset
This is the reset on perf-a, the NAT gateway from Step 13.
Resetting all rules to installed defaults. This may disrupt existing ssh
connections. Proceed with operation (y|n)? y
Backing up 'user.rules' to '/etc/ufw/user.rules.20261019_215531'
Backing up 'before.rules' to '/etc/ufw/before.rules.20261019_215531'
Backing up 'after.rules' to '/etc/ufw/after.rules.20261019_215531'
Backing up 'user6.rules' to '/etc/ufw/user6.rules.20261019_215531'
Backing up 'before6.rules' to '/etc/ufw/before6.rules.20261019_215531'
Backing up 'after6.rules' to '/etc/ufw/after6.rules.20261019_215531'
The reset puts the six rules files back to the package defaults: before.rules on perf-a and after.rules on bench01 were byte-identical to the copies in /usr/share/ufw/iptables/ afterwards, and the NAT and Docker blocks survived only in the timestamped backups. It does not undo everything, though. Checked right after the reset on both boxes:
- Live NAT rules:
sudo iptables -t nat -S PREROUTINGon perf-a still showed the DNAT rule from Step 13. - Live DOCKER-USER rules: bench01 still had all three
ufw-docker-logging-denydrop rules loaded, with UFW inactive. - sysctl.conf:
net/ipv4/ip_forward=1stayed uncommented on line 10. - Custom app profiles:
/etc/ufw/applications.d/custom-appswas untouched.
A reboot clears the live leftovers. A Docker restart does not: Docker leaves an existing DOCKER-USER chain alone, and on bench01 all three drop rules were still loaded after sudo systemctl restart docker. Without a reboot, sudo iptables -F DOCKER-USER empties that chain, and sudo iptables -t nat -D followed by the exact rule removes a leftover NAT rule. Until then the host keeps behaving partly like the old firewall even though ufw status says inactive, which is exactly how the stale MASQUERADE rule in Step 12 skewed our first Docker test. Copy the blocks back from the backups before you enable UFW again. sudo ufw --force reset skips the prompt for scripts.
UFW command cheat sheet
Every command from the steps above, in the form that was tested. Replace the addresses, ports and eth0 with your own:
| Task | Command |
|---|---|
| Show status, numbered | sudo ufw status numbered |
| Show policies and logging | sudo ufw status verbose |
| Rules while inactive | sudo ufw show added |
| Rules matching listening ports | sudo ufw show listening |
| Enable / disable | sudo ufw enable / sudo ufw disable |
| Default policies | sudo ufw default deny incoming, sudo ufw default allow outgoing |
| Allow a port | sudo ufw allow 443/tcp |
| Allow a port range | sudo ufw allow 6000:6100/tcp |
| Allow a subnet to a port | sudo ufw allow from 10.0.1.0/24 to any port 3306 proto tcp |
| Allow on one interface | sudo ufw allow in on eth0 from 10.0.1.0/24 to any port 9100 proto tcp |
| Add or change a comment | sudo ufw allow 443/tcp comment 'nginx TLS' |
| Remove a comment | sudo ufw allow 443/tcp comment '' |
| Deny / reject a port | sudo ufw deny 7000/tcp / sudo ufw reject 7001/tcp |
| Block a source (IPv4, at the top) | sudo ufw insert 1 deny from 198.51.100.0/24 |
| Block a source (either family, at the top) | sudo ufw prepend deny from 2001:db8:bad::/48 |
| Block outgoing SMTP | sudo ufw deny out 25/tcp |
| Rate-limit SSH | sudo ufw limit OpenSSH |
| Delete by number / by rule | sudo ufw delete 13 / sudo ufw delete allow 443/tcp |
| List / inspect app profiles | sudo ufw app list, sudo ufw app info OpenSSH |
| Allow a profile from a subnet | sudo ufw allow from 10.0.1.0/24 to any app Netdata |
| Apply an edited profile over SSH | sudo ufw app update Netdata, then sudo ufw reload |
| Logging level | sudo ufw logging medium |
| Allow a container port (after.rules block) | sudo ufw route allow proto tcp from any to any port 80 |
| Allow forwarded traffic | sudo ufw route allow in on eth0 out on eth0 to 10.0.1.10 port 8888 proto tcp |
| Check loaded NAT rules | sudo iptables -t nat -S |
| Reload rules files | sudo ufw reload |
| Reset everything | sudo ufw reset |
Two habits prevent most of the surprises measured above. Check new source-restricted rules with sudo ufw show listening so a misplaced port shows up before an attacker finds it, and probe every published container port from outside the host after any Docker or UFW change. On Rocky Linux and other RHEL-family servers the equivalent tool is firewalld, covered in the firewalld guide for Rocky Linux and AlmaLinux. The rest of the baseline for a new server is in the Ubuntu 26.04 server checklist.
Thank you for 2.7. Allow traffic on Port from a specific Address
No other UFW advice site seems to have this.
Could you elaborate more on why we introduce the extra chains in step 10, fix 1?
Especially ufw-user-forward has no rules added to it, what is its purpose? Is it for a more complex setup where one might already have other rules that go there?
Also why are those lines added in after.rules and not before.rules?
Thanks for the great article, works like a charm.
Hi Power, great questions. The chain interactions are not obvious, so here is the full picture.
Why the extra chains (and why ufw-user-forward looks empty):
ufw-user-forward is UFW’s own internal chain for user-managed routed rules. It is not truly empty in practice. It gets populated when you run commands like:
sudo ufw route allow proto tcp from any to any port 8080
That adds a rule to ufw-user-forward. Because DOCKER-USER jumps to it first (-A DOCKER-USER -j ufw-user-forward), Docker container traffic becomes subject to whatever routed rules you have defined with ufw route. That is exactly the point. It lets you manage Docker container exposure using normal UFW commands instead of editing iptables rules by hand.
The :ufw-user-forward – [0:0] line does not create the chain from scratch. It is a declaration that guarantees the chain exists at rule load time. UFW already creates it during its own rule generation, so the declaration is a safety net that prevents the -A DOCKER-USER -j ufw-user-forward line from failing if the chain is not there yet.
ufw-docker-logging-deny is the opposite, a custom chain defined from scratch because we want rate-limited logging plus a DROP at the end. You could inline those rules into DOCKER-USER directly, but keeping them in a dedicated chain makes the logic cleaner.
DOCKER-USER is declared for the same safety reason. Docker creates it at daemon startup, but if UFW loads its rules before Docker has started (on boot, for example), the reference would fail. The declaration guarantees it exists.
Why after.rules and not before.rules:
UFW processes files in this order at rule generation time:
1. before.rules (before UFW’s generated rules)
2. UFW’s own rules generated from ufw allow/deny/route commands (this is where ufw-user-forward gets populated)
3. after.rules (after UFW’s generated rules)
The Docker fix belongs in after.rules for two reasons. First, by the time after.rules is processed, ufw-user-forward already exists with whatever routed rules you have added via ufw route. Second, the DOCKER-USER filtering logically belongs after UFW’s own rules have been set up, not before them.
before.rules is reserved for things that must run earlier in the packet pipeline: NAT table rules (PREROUTING, POSTROUTING, MASQUERADE), mangle table edits, or low-level filtering that needs to run before UFW. That is why Step 11 puts its DNAT port forwarding rules in before.rules. NAT has to be set up early in the packet flow.
Thanks for the detailed question, and glad the guide helped.
Quick follow-up: I updated Step 10 Fix 1 to cover all of this directly in the article. It now lists what each chain does (DOCKER-USER, ufw-user-forward, ufw-docker-logging-deny), explains why the block goes in after.rules rather than before.rules, and shows how to whitelist a container port with sudo ufw route allow proto tcp from any to any port 8080. Thanks for flagging the gap.
One more update, Power. I spun up a fresh Ubuntu 24.04 VM with Docker 29.1.3 and actually tested the full flow (run nginx with -p 8080:80, verify bypass, apply Fix 1, verify block, test ufw route allow). Two corrections compared to what I posted earlier:
1. The original iptables block had a bug. A plain -A DOCKER-USER -j ufw-docker-logging-deny drops ALL non-DNS non-RFC1918 traffic, including return packets from the container, which breaks bidirectional communication. The corrected block (now in the article) uses –tcp-flags FIN,SYN,RST,ACK SYN so only new TCP connections are dropped, and ends with a final -j RETURN so non-matching packets fall back to the calling chain.
2. ufw route allow must match the containers INTERNAL port, not the host-published port. Docker DNAT rewrites the destination port in PREROUTING before the packet reaches the FORWARD chain, so by the time DOCKER-USER sees it, the destination is already the container port. For docker run -p 8080:80, the correct whitelist is sudo ufw route allow proto tcp from any to any port 80, not port 8080.
3. The file load order I mentioned was also slightly off. UFW actually loads before.rules, then after.rules, then user.rules (where ufw route allow rules live). The block still works because after.rules just creates the DOCKER-USER to ufw-user-forward jump, and user.rules populates ufw-user-forward later. At runtime the chain reference and the user rules meet up correctly.
Article Step 10 Fix 1 is now rewritten and fully tested end to end. Thanks again for pushing on this, it caught real issues.
insightful