🚧 ufw Guide

A how-to manual: enabling safely, defaults, allow/deny/limit, rule order, app profiles, outgoing control, NAT in before.rules, logging and what ufw writes underneath, all tested on a lab

Video: ufw (sysadmin video)
Prefer to watch? This video runs the guide's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube. It is part of the Networking and Firewalls playlist.
ufw: the Uncomplicated Firewall
sudo ufw status verbose

ufw is the firewall tool that ships with Ubuntu. It exists for one reason: so that "let people reach the web server, and nothing else" is one short sentence you can type from memory, instead of a rule file you have to understand. It does that well, and for a single server it is often all you need. It is also a thin layer on top of iptables, which means that everything iptables can do is underneath it, everything iptables can get wrong is underneath it too, and the day your rules do something odd you will end up reading iptables output after all. This guide covers the sentences you will actually type, what ufw writes underneath, and the handful of places where "uncomplicated" is not quite true.
Every example below ran for real: ufw 0.36.2 on Ubuntu 24.04 (iptables 1.8.10 with the nf_tables backend), in a lab with two virtual clients, one on an "inside" LAN (192.168.60.0/24, fd00:60::/64, interface eth1) and one "outside" (198.51.100.0/24, 2001:db8:2::/64, interface eth2), and real listeners on ports 22, 80, 443, 3306, 8080 and 9000. "probe" in the examples is a small test script: it connects from a client and prints what that client really experienced (open, refused, or silence). The lab ran as root; use sudo on a real server. The lab had no systemd, no rsyslog and no ssh session, so a few things are marked "not run in the lab".

What ufw is, and what is underneath

ufw is a Python program (a "front end") that keeps your rules in plain files under /etc/ufw and turns them into iptables rules. It does not filter packets itself. The stack is: ufw, then iptables (on Ubuntu 24.04 the iptables-nft variant), then nf_tables in the kernel. On Ubuntu it is installed but switched off; on Debian and other distributions you install it (apt install ufw). As installed:
$ ufw version | head -1
ufw 0.36.2
$ iptables -V
iptables v1.8.10 (nf_tables)
$ ufw status
Status: inactive
Two settings files describe what it will do before you give it a single rule. /etc/default/ufw holds the master switches:
$ grep -E '^(IPV6|DEFAULT_|MANAGE_)' /etc/default/ufw
IPV6=yes
DEFAULT_INPUT_POLICY="DROP"
DEFAULT_OUTPUT_POLICY="ACCEPT"
DEFAULT_FORWARD_POLICY="DROP"
DEFAULT_APPLICATION_POLICY="SKIP"
MANAGE_BUILTINS=no
and /etc/ufw holds the rules files (user.rules, before.rules, after.rules and their IPv6 twins). More on them in their own section. The one thing to keep in mind from the start: ufw is just a way to write iptables, so the lessons of the iptables page (first match wins, order matters, IPv6 is separate) all apply, and the two tools must not be mixed carelessly on the same machine.

Enabling it, and the order that keeps you connected

The most important thing about ufw is the order in which you do things. A default of "deny incoming" means that, the moment the firewall is on, new connections to every port are refused until you have said otherwise, including the ssh connection you would use to fix it. Here is what the lab client saw right after enabling, before a single allow rule was added:
$ ufw --force enable
Firewall is active and enabled on system startup
$ ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
$ probe c1 192.168.60.1 22 80 ; probe c1 fd00:60::1 22
  c1  -> 192.168.60.1     port 22    no answer (timeout)
  c1  -> 192.168.60.1     port 80    no answer (timeout)
  c1  -> fd00:60::1       port 22    no answer (timeout)
Nothing gets in, over IPv4 or IPv6. Your CURRENT ssh session survives (an established connection is matched by the connection-tracking rule that ufw puts first), but you cannot open a second one, and if the session drops you are out. So, on a remote machine, always in this order:
  1. sudo ufw allow ssh            (or 22/tcp, or your own ssh port)
  2. sudo ufw status               (is it there?)
  3. sudo ufw enable
On a machine where the allow rule exists and the firewall is off, ufw tells you "inactive" and nothing is enforced; the rules are stored and apply the moment you enable it. If you run enable inside an ssh session, a real ufw asks "Command may disrupt existing ssh connections. Proceed with operation (y|n)?" (not run in the lab: ufw detects an ssh session from the process tree, and the lab had no sshd, so it enabled without asking, even with the answer "n" piped in. Do not rely on being asked.) In scripts and automation, the --force option skips every confirmation: ufw --force enable.
Turning it back off:
$ ufw disable
Firewall stopped and disabled on system startup
$ probe c1 192.168.60.1 22 3306
  c1  -> 192.168.60.1     port 22    open: port 22: you are 192.168.60.20
  c1  -> 192.168.60.1     port 3306  open: port 3306: you are 192.168.60.20
All open again. "Disabled" means unfiltered, not "with the old rules": see the section on iptables below for what is left behind.

The three default policies

Every ufw firewall has three policies, the "everything else" rules, one per direction:
  incoming   traffic arriving for this machine. Default: deny
  outgoing   traffic this machine starts. Default: allow
  routed     traffic passing THROUGH this machine (it is a router). Default: deny
$ ufw status verbose | sed -n 3p
Default: deny (incoming), allow (outgoing), deny (routed)
These defaults are the right ones for a server: nothing in, everything out, nobody routed. Change them with ufw default:
$ ufw default deny incoming
Default incoming policy changed to 'deny'
(be sure to update your rules accordingly)
$ ufw default allow outgoing
$ ufw default deny routed
The "be sure to update your rules accordingly" message is ufw's way of saying that the change takes effect now. Changing the incoming default to allow turns the firewall into a list of exceptions that block things; it is almost never what you want.

Writing rules: allow, deny, reject, limit

A rule is a sentence: an action, then what it applies to. The simplest form is a port:
$ ufw allow 22/tcp
Rule added
Rule added (v6)
$ ufw allow 80/tcp comment "web"
$ ufw allow 443
Look at the second line of the first reply: "Rule added (v6)". ufw wrote the IPv4 rule AND the IPv6 rule (when IPV6=yes in /etc/default/ufw, which is the default), so the common iptables hole of a v4-only firewall does not appear. The listing groups the IPv4 rules first and the IPv6 rules after them:
$ ufw status numbered
Status: active

     To                         Action      From
     --                         ------      ----
[ 1] 22/tcp                     ALLOW IN    Anywhere
[ 2] 80/tcp                     ALLOW IN    Anywhere                   # web
[ 3] 443                        ALLOW IN    Anywhere
[ 4] 22/tcp (v6)                ALLOW IN    Anywhere (v6)
[ 5] 80/tcp (v6)                ALLOW IN    Anywhere (v6)              # web
[ 6] 443 (v6)                   ALLOW IN    Anywhere (v6)
$ probe c1 192.168.60.1 80 443 3306
  c1  -> 192.168.60.1     port 80    open: port 80: you are 192.168.60.20
  c1  -> 192.168.60.1     port 443   open: port 443: you are 192.168.60.20
  c1  -> 192.168.60.1     port 3306  no answer (timeout)
Note "443" with no protocol: a rule without /tcp or /udp covers both. The four actions:
  allow    let it in
  deny     drop it silently (the sender waits and times out)
  reject   drop it and tell the sender (it gets "connection refused" at once)
  limit    allow, but refuse an address that connects too often (see below)
The difference between deny and reject, as the client experiences it:
$ ufw reject 3307/tcp ; ufw deny 3308/tcp
$ probe c1 192.168.60.1 3307 3308
  c1  -> 192.168.60.1     port 3307  Connection refused
  c1  -> 192.168.60.1     port 3308  no answer (timeout)
Which of the two to use is the same decision as in iptables: deny for the internet side (silence gives a scanner nothing), reject for your own network (people get an answer instead of a hang).
The full sentence has the form: ufw ACTION [in|out] [on INTERFACE] [from SOURCE [port N]] [to DEST [port N]] [proto tcp|udp] [comment "text"]. Examples that ran:
$ ufw allow from 192.168.60.20 to any port 3306 proto tcp                 # one host, one port
$ ufw allow in on eth1 to any port 8080 proto tcp                         # only on one network card
$ ufw allow from 192.168.60.0/24 to any port 9000 proto tcp comment "lan only"     # a network
$ ufw allow from fd00:60::/64 to any port 9000 proto tcp                  # an IPv6 network
$ ufw status numbered
[ 4] 3306/tcp                   ALLOW IN    192.168.60.20
[ 5] 8080/tcp on eth1           ALLOW IN    Anywhere
[ 6] 9000/tcp                   ALLOW IN    192.168.60.0/24            # lan only
[ 7] 22/tcp (v6)                ALLOW IN    Anywhere (v6)
...
[10] 8080/tcp (v6) on eth1      ALLOW IN    Anywhere (v6)
[11] 9000/tcp                   ALLOW IN    fd00:60::/64
$ probe c1 192.168.60.1 3306 8080 9000 ; probe c1 192.168.60.1 3306 -s 192.168.60.21 ; probe c2 198.51.100.1 8080 9000
  c1  -> 192.168.60.1     port 3306  open: port 3306: you are 192.168.60.20
  c1  -> 192.168.60.1     port 8080  open: port 8080: you are 192.168.60.20
  c1  -> 192.168.60.1     port 9000  open: port 9000: you are 192.168.60.20
  c1  -> 192.168.60.1     port 3306  no answer (timeout)
  c2  -> 198.51.100.1     port 8080  no answer (timeout)
  c2  -> 198.51.100.1     port 9000  no answer (timeout)
Port 3306 for one address only (the second client address gets nothing), port 8080 only on the LAN card (the client on the outside card is refused), and a rule that names an IPv4 network only creates an IPv4 rule (no "(v6)" line was written for it). More forms, all of which ran:
$ ufw allow 6000,6001,6002/tcp                 # a list of ports (needs a protocol; at most 15 entries)
$ ufw allow 7000:7005/udp                      # a range (needs a protocol)
$ ufw allow ssh                                # a service NAME, looked up in /etc/services
$ ufw allow http
$ ufw allow 53                                 # both TCP and UDP
$ ufw allow proto udp from 192.168.60.0/24 to any port 161
$ grep -E '^(ssh|http|domain)[[:space:]]' /etc/services | head -3
ssh		22/tcp				# SSH Remote Login Protocol
domain		53/tcp				# Domain Name Server
http		80/tcp		www		# WorldWideWeb HTTP
A name works only if it is in /etc/services or is an application profile (next sections). The most frequent mistakes get these answers:
$ ufw allow 22/tcp                 # already there
Skipping adding existing rule
Skipping adding existing rule (v6)
$ ufw allow 99999/tcp
ERROR: Bad port
$ ufw allow 8080-8090              # a dash is not a range; use a colon, and a protocol
ERROR: Could not find a profile matching '8080-8090'
$ ufw allow 8080:8090/tcp          # correct
Rule added
$ ufw allow nosuchservice
ERROR: Could not find a profile matching 'nosuchservice'
$ ufw deny from 999.1.1.1
ERROR: Bad source address
The wording "could not find a profile" is ufw's last guess when the word is neither a port nor a service: a typo in a name, or a range written with a dash, ends up there.

Order, numbers, insert and delete

Rules are checked from the top and the FIRST match wins, as in iptables. New rules go to the bottom of their group. So a "deny" added after an "allow" that already covers the same traffic never does anything:
$ ufw deny from 192.168.60.20 to any port 80 proto tcp
Rule added
$ ufw status numbered | grep -E '^\[ ?[0-9]+\] (80|22)'
[ 2] 80/tcp                     ALLOW IN    Anywhere
[ 7] 80/tcp                     DENY IN     192.168.60.20
$ probe c1 192.168.60.1 80
  c1  -> 192.168.60.1     port 80    open: port 80: you are 192.168.60.20
The address that was denied still gets in, because rule 2 ("anybody may use port 80") comes first. The fix is to insert the exception at the top, with a position number:
$ ufw delete deny from 192.168.60.20 to any port 80 proto tcp
Rule deleted
$ ufw insert 1 deny from 192.168.60.20 to any port 80 proto tcp
Rule inserted
$ ufw status numbered | head -6
     To                         Action      From
     --                         ------      ----
[ 1] 80/tcp                     DENY IN     192.168.60.20
[ 2] 22/tcp                     ALLOW IN    Anywhere
$ probe c1 192.168.60.1 80
  c1  -> 192.168.60.1     port 80    no answer (timeout)
Remember the position numbers are for the IPv4 list; ufw adds the IPv6 twin where it belongs. A position that does not exist is an error:
$ ufw insert 99 allow 9000
ERROR: Invalid position '99'
Deleting works by NUMBER or by the exact rule, and the two are different dangers. By rule text, you say what you want gone, and it does not matter where it is:
$ ufw delete allow 22/tcp
Rule deleted
Rule deleted (v6)
By number, always list first, because the numbers move up after every delete (delete rule 2 and the old rule 3 becomes the new rule 2):
$ ufw status numbered | sed -n 3,8p
[ 1] 80/tcp                     ALLOW IN    Anywhere
[ 2] 443/tcp                    ALLOW IN    Anywhere
[ 3] 3306/tcp                   ALLOW IN    192.168.60.20
$ ufw --force delete 2
Rule deleted
$ ufw status numbered | sed -n 3,8p
[ 1] 80/tcp                     ALLOW IN    Anywhere
[ 2] 3306/tcp                   ALLOW IN    192.168.60.20
Without --force, ufw delete asks for confirmation, and in a script with no keyboard that answer never comes:
$ ufw delete 1
Deleting:
 deny from 192.168.60.20 to any port 80 proto tcp
Proceed with operation (y|n)? Aborted
Nothing was deleted: "Aborted" is the answer to the question nobody could type. In scripts use --force, and in front of a keyboard read the line that says what is about to go.

limit: rate-limiting ssh

ufw limit is allow with a safety catch: it accepts connections, but REJECTS an address that makes six or more new connections within thirty seconds. It is the cheap answer to ssh password guessing:
$ ufw limit 22/tcp
Rule added
Rule added (v6)
$ ufw status | grep 22
22/tcp                     LIMIT       Anywhere
22/tcp (v6)                LIMIT       Anywhere (v6)
$ for i in 1 2 3 4 5 6 7 8 ; do probe c1 192.168.60.1 22 ; done
  c1  -> 192.168.60.1     port 22    open: port 22: you are 192.168.60.20
  c1  -> 192.168.60.1     port 22    open: port 22: you are 192.168.60.20
  c1  -> 192.168.60.1     port 22    open: port 22: you are 192.168.60.20
  c1  -> 192.168.60.1     port 22    open: port 22: you are 192.168.60.20
  c1  -> 192.168.60.1     port 22    open: port 22: you are 192.168.60.20
  c1  -> 192.168.60.1     port 22    Connection refused
  c1  -> 192.168.60.1     port 22    Connection refused
  c1  -> 192.168.60.1     port 22    Connection refused
Five connections get in; the sixth and the rest are refused until the thirty seconds have gone by. Underneath, it is the "recent" match of iptables, which is how you can check what it really does:
$ iptables -S ufw-user-input | grep 22
-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
-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 -j ufw-user-limit
-A ufw-user-input -p tcp -m tcp --dport 22 -j ufw-user-limit-accept
$ iptables -S ufw-user-limit
-A ufw-user-limit -m limit --limit 3/min -j LOG --log-prefix "[UFW LIMIT BLOCK] "
-A ufw-user-limit -j REJECT --reject-with icmp-port-unreachable
Three rules: record each NEW connection, check the count over the last 30 seconds (six or more goes to ufw-user-limit, which logs at most three lines a minute and rejects), everything else is accepted. The numbers 6 and 30 are built in and cannot be changed from the ufw command line (to change them, you would edit the generated rules, which is the point where ufw stops being the right tool). The list of recent addresses is shared by all rules that use limit. Two cautions. One user hammering ssh from behind a shared NAT address blocks everybody behind that address. And "limit" slows down guessing, it does not stop a patient attacker: keys instead of passwords do.
A trap that really happened in the lab: saying allow for the same port REPLACES the limit rule, without any warning other than the word "updated":
$ ufw allow ssh
Rule updated
Rule updated (v6)
$ ufw status | grep -E '^22'
22/tcp                     ALLOW       Anywhere
22/tcp (v6)                ALLOW       Anywhere (v6)
$ ufw limit ssh
Rule updated
$ ufw status | grep -E '^22'
22/tcp                     LIMIT       Anywhere
A port has ONE action in a given direction, and the newest sentence wins. "Rule updated" means "I changed an existing rule", not "I added another one".

Application profiles

Packages that listen on ports can ship a profile: a small file in /etc/ufw/applications.d that gives the service a name and its ports. Then ufw allow uses the name, and the rule survives a change of port in the profile (ufw app update). On Ubuntu, the openssh-server and nginx packages, among others, install profiles such as "OpenSSH" and "Nginx Full" (not run in the lab: it had none, and ufw app list started empty). Writing your own is a ten-line file. The lab used this one, /etc/ufw/applications.d/myapp:
  [MyApp]
  title=My in-house application
  description=Web UI on 8443 and a metrics port
  ports=8443/tcp|9100/tcp

  [MyMail]
  title=Mail
  description=SMTP and submission
  ports=25,587/tcp
The ports line uses a vertical bar between ports that have different protocols or lists, and a comma for several ports of one protocol; ranges use a colon. Then:
$ ufw app list
Available applications:
  MyApp
  MyMail
$ ufw app info MyApp
Profile: MyApp
Title: My in-house application
Description: Web UI on 8443 and a metrics port

Ports:
  8443/tcp
  9100/tcp
$ ufw allow MyApp
Rule added
Rule added (v6)
$ ufw allow from 192.168.60.0/24 to any app MyMail
Rule added
$ ufw status | grep -E 'MyApp|MyMail'
MyApp                      ALLOW       Anywhere
MyMail                     ALLOW       192.168.60.0/24
MyApp (v6)                 ALLOW       Anywhere (v6)
$ probe c1 192.168.60.1 8443
  c1  -> 192.168.60.1     port 8443  open: myapp: you are 192.168.60.20
After you edit a profile's ports, ufw app update MyApp rewrites the rules that use it ("Rules updated for profile 'MyApp'"; "Firewall reloaded"). A profile name that does not exist gets the usual error:
$ ufw allow nosuchapp
ERROR: Could not find a profile matching 'nosuchapp'
A profile name with spaces ("Nginx Full") must be quoted. The setting DEFAULT_APPLICATION_POLICY (and "New profiles: skip" in the status) decides what happens to a profile that appears after ufw was set up: skip it (the default), allow it, or deny it. Keep it on skip: a firewall should not open ports because a package was installed.

Controlling outgoing traffic

The default lets the machine call out freely, and for most servers that is the pragmatic choice. A locked-down server, one that should only talk to its database and the package mirror, sets the outgoing default to deny and lists what is allowed. The lab: the firewall host connects to a service on the outside host. First the default, then deny outgoing, then the one exception:
$ timeout 4 bash -c 'exec 3<>/dev/tcp/198.51.100.50/9100 && head -n1 <&3'
hello from outside
$ ufw default deny outgoing
Default outgoing policy changed to 'deny'
(be sure to update your rules accordingly)
$ timeout 4 bash -c 'exec 3<>/dev/tcp/198.51.100.50/9100 && head -n1 <&3' ; echo "rc=$?"
rc=124
$ ufw allow out to 198.51.100.50 port 9100 proto tcp
Rule added
$ timeout 4 bash -c 'exec 3<>/dev/tcp/198.51.100.50/9100 && head -n1 <&3'
hello from outside
$ ufw status verbose | sed -n 3p
Default: deny (incoming), deny (outgoing), deny (routed)
Think about what a machine needs to function before you do this: DNS (53, over both UDP and TCP), NTP (123/udp), package updates (80 and 443 to the mirror), the monitoring system. A locked-down outgoing policy that forgets DNS makes every name stop resolving, and the failure looks like a network outage. Replies to connections that came IN are always allowed (connection tracking), so an incoming ssh session keeps working with outgoing denied.

Routing and NAT through ufw

ufw can run a router, but only the "routed" traffic is a ufw concept; address translation (NAT) is not, and goes into one of ufw's rule files by hand. Three things are needed:
  1. the kernel must forward: in /etc/ufw/sysctl.conf, uncomment net/ipv4/ip_forward=1 (and net/ipv6/conf/default/forwarding=1 and net/ipv6/conf/all/forwarding=1 for IPv6); ufw reads that file when it starts. The lab's kernel already had it on (a container inherits it), so this step was not exercised.
  2. a "route" rule that allows the traffic between the two cards
  3. NAT rules in /etc/ufw/before.rules
Allow forwarding from the LAN card to the outside card:
$ ufw route allow in on eth1 out on eth2
Rule added
Rule added (v6)
$ ufw status verbose | tail -4
Anywhere on eth2           ALLOW FWD   Anywhere on eth1
Anywhere (v6) on eth2      ALLOW FWD   Anywhere (v6) on eth1
$ probe c1 198.51.100.50 9000
  c1  -> 198.51.100.50    port 9000  no answer (timeout)
Forwarding is allowed, and the LAN client still gets nothing: the outside server has no way to send an answer back to a private address. That is the job of MASQUERADE, and it goes in /etc/ufw/before.rules, in its own *nat section ABOVE the *filter section (the file's own comment says where):
  # NAT table rules
  *nat
  :PREROUTING ACCEPT [0:0]
  :POSTROUTING ACCEPT [0:0]
  -A PREROUTING -i eth2 -p tcp --dport 8443 -j DNAT --to-destination 192.168.60.20:8000
  -A POSTROUTING -s 192.168.60.0/24 -o eth2 -j MASQUERADE
  COMMIT

  # Don't delete these required lines, otherwise there will be errors
  *filter
  ...
The first line is a port forward (the firewall's port 8443 to a web server on the LAN), the second the masquerade. Reload, and test:
$ ufw reload
Firewall reloaded
$ probe c1 198.51.100.50 9000
  c1  -> 198.51.100.50    port 9000  open: outside: you are 198.51.100.1
$ probe c2 198.51.100.1 8443
  c2  -> 198.51.100.1     port 8443  no answer (timeout)
Masquerade works (the outside server sees the firewall's address, 198.51.100.1). The port forward does not yet, for the same reason as in iptables: after the address rewrite the packet is forwarded, and the default for routed traffic is deny. It needs its own route rule, for the DESTINATION after the rewrite:
$ ufw route allow proto tcp from any to 192.168.60.20 port 8000
Rule added
$ probe c2 198.51.100.1 8443
  c2  -> 198.51.100.1     port 8443  open: lan web: you are 198.51.100.50
$ ufw status numbered | grep -E 'FWD'
[11] Anywhere on eth2           ALLOW FWD   Anywhere on eth1
[12] 192.168.60.20 8000/tcp     ALLOW FWD   Anywhere
$ iptables -t nat -S | grep -v '^-P'
-A PREROUTING -i eth2 -p tcp -m tcp --dport 8443 -j DNAT --to-destination 192.168.60.20:8000
-A POSTROUTING -s 192.168.60.0/24 -o eth2 -j MASQUERADE
The ufw "route" rules decide what may pass; the lines in before.rules decide how addresses are rewritten, and neither does the other's job. Edit before.rules with care and back it up first: a typo in it can stop the whole firewall from loading (ufw prints the line of the iptables-restore error).

The files: user.rules, before.rules and /etc/default/ufw

Everything lives in /etc/ufw, and it helps to know which file is for whom:
$ ls -l /etc/ufw/*.rules
-rw-r----- 1 root root 1004 Oct  8 19:48 /etc/ufw/after.rules
-rw-r----- 1 root root  915 Oct  8 19:48 /etc/ufw/after6.rules
-rw-r----- 1 root root 2537 Oct  8 19:49 /etc/ufw/before.rules
-rw-r----- 1 root root 6700 Oct  8 19:48 /etc/ufw/before6.rules
-rw-r----- 1 root root 3167 Oct  8 19:50 /etc/ufw/user.rules
-rw-r----- 1 root root 1293 Oct  8 19:50 /etc/ufw/user6.rules
  user.rules, user6.rules          YOURS, but written by ufw from the commands you type. Never edit them by hand; the next ufw command overwrites the file.
  before.rules, before6.rules      applied BEFORE the user rules: loopback, established connections, ICMP, DHCP. This is where NAT and your own raw iptables go.
  after.rules, after6.rules        applied AFTER the user rules: the logging of what is about to be refused.
  /etc/ufw/sysctl.conf             kernel settings applied when ufw starts (forwarding, redirects, ICMP behavior)
  /etc/ufw/ufw.conf                whether ufw is enabled, and the logging level
  /etc/default/ufw                 IPV6, the default policies, forwarding policy, application policy
  before.init / after.init         optional scripts to run around starting and stopping ufw
The "before" file explains a number of behaviors that look like magic. The best example is ping, which works on a ufw machine with default-deny, because before.rules has an explicit rule for it:
$ grep -n 'icmp-type echo-request' /etc/ufw/before.rules
45:-A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPT
51:-A ufw-before-forward -p icmp --icmp-type echo-request -j ACCEPT
$ ip netns exec c1 ping -c1 -W1 192.168.60.1 | grep received
1 packets transmitted, 1 received, 0% packet loss, time 0ms
To stop answering ping, change ACCEPT to DROP on line 45 (and 51 for routed traffic), then reload:
$ sed -i 's/-A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPT/-A ufw-before-input -p icmp --icmp-type echo-request -j DROP/' /etc/ufw/before.rules
$ ufw reload
Firewall reloaded
$ ip netns exec c1 ping -c1 -W1 192.168.60.1 | grep received
1 packets transmitted, 0 received, 100% packet loss, time 0ms
Leave the other ICMP lines alone (destination-unreachable, time-exceeded, parameter-problem): they are how path MTU discovery works, and blocking them gives you connections that start and then hang. For IPv6, ICMPv6 must work or IPv6 does not, so before6.rules has a long list of ICMPv6 rules; leave it.
Reload versus disable and enable: ufw reload re-reads the files and re-applies them (it was used above to apply every before.rules change). ufw disable followed by ufw enable stops and re-creates everything and is the heavy version. Changes to /etc/default/ufw (IPV6, the default policies) only take effect after a disable and enable, which the next test shows.
IPV6=no is not what you might guess. A common idea is "I do not use IPv6, so I turn it off in ufw, and IPv6 is no longer my business". The lab shows what actually happens:
$ sed -i 's/^IPV6=yes/IPV6=no/' /etc/default/ufw ; ufw disable ; ufw --force enable
$ probe c1 fd00:60::1 3306 22 9000
  c1  -> fd00:60::1       port 3306  no answer (timeout)
  c1  -> fd00:60::1       port 22    no answer (timeout)
  c1  -> fd00:60::1       port 9000  no answer (timeout)
$ ip6tables -S | head -5
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT DROP
-A INPUT -i lo -j ACCEPT
-A OUTPUT -o lo -j ACCEPT
$ ufw status | grep -c v6
0
ufw stopped managing IPv6, and made the whole IPv6 stack drop everything (INPUT, FORWARD and OUTPUT, except loopback). That is a safe result, rather than an open one, but it also means ssh over IPv6 stops working, and so does every IPv6 connection the machine tries to make. (Set it back to yes, then disable and enable again, to return to normal; the lab did, and port 22 over IPv6 answered again.)

Logging

ufw writes to the kernel log through iptables LOG rules, with a level that sets how much:
$ ufw logging on
Logging enabled
$ ufw status verbose | sed -n 2p
Logging: on (low)
  off      no logging
  low      blocked packets that match no rule (the default policy), plus packets of rules marked with log; rate-limited. The default.
  medium   low, plus allowed packets that match no rule, INVALID packets and all new connections; rate-limited
  high     medium without the rate limit, plus all packets (rate-limited): very noisy, for short debugging only
  full     high without any rate limit
(The level descriptions are from ufw's manual; the lab confirmed the rules each level adds, below.)
The rules ufw adds for the levels can be read with iptables, which is how to check what a level really does:
$ ufw logging high
$ iptables -S | grep LOG | head -4
-A ufw-after-logging-forward -j LOG --log-prefix "[UFW BLOCK] "
-A ufw-after-logging-input -j LOG --log-prefix "[UFW BLOCK] "
-A ufw-after-logging-output -j LOG --log-prefix "[UFW ALLOW] "
-A ufw-before-logging-forward -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW AUDIT] "
$ ufw logging medium ; iptables -S ufw-logging-deny
-N ufw-logging-deny
-A ufw-logging-deny -m conntrack --ctstate INVALID -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW AUDIT INVALID] "
-A ufw-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW BLOCK] "
Per rule, the keyword log (or log-all) after the action makes that one rule log: ufw allow log 22/tcp, ufw deny log from 203.0.113.0/24. The messages carry the prefixes [UFW BLOCK], [UFW ALLOW], [UFW AUDIT] and [UFW LIMIT BLOCK], followed by the usual iptables fields (IN=, OUT=, SRC=, DST=, PROTO=, SPT=, DPT=). On Ubuntu a small rsyslog rule sends them to their own file:
$ cat /etc/rsyslog.d/20-ufw.conf | grep -v '^#' | head -4
:msg,contains,"[UFW " /var/log/ufw.log
and then it is grep BLOCK /var/log/ufw.log, or journalctl -k | grep UFW. (Not run in the lab: it had no rsyslog and, as a container, no kernel log of its own, so no log lines could be shown, only the rules that make them.) Expect the log of a machine on the internet to be full of [UFW BLOCK] lines from scanners: that is the firewall working, not an emergency. Keep it at low.

Looking underneath: iptables and nftables

Everything ufw does is visible with iptables, and it is the best way to debug. ufw creates its own chains, all named ufw-something, and hooks them into the standard ones:
$ iptables -S INPUT | head -5
-P INPUT DROP
-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
$ iptables -S ufw-user-input | head -7
-N ufw-user-input
-A ufw-user-input -p tcp -m tcp --dport 80 -j ACCEPT
-A ufw-user-input -p tcp -m tcp --dport 443 -j ACCEPT
-A ufw-user-input -s 192.168.60.20/32 -p tcp -m tcp --dport 3306 -j ACCEPT
-A ufw-user-input -i eth1 -p tcp -m tcp --dport 8080 -j ACCEPT
-A ufw-user-input -s 192.168.60.0/24 -p tcp -m tcp --dport 9000 -j ACCEPT
-A ufw-user-input -p tcp -m tcp --dport 3307 -j REJECT --reject-with tcp-reset
The path of a packet through INPUT is: before-logging, before-input (loopback, established, ICMP, and at its end the jump into ufw-user-input, where YOUR rules are), after-input, after-logging (which logs what falls out at the end), and then the DROP policy. The chains for the other directions are ufw-before-output, ufw-user-output, ufw-before-forward, ufw-user-forward and so on, named the same way. The reject rule above is also instructive: ufw reject becomes "REJECT --reject-with tcp-reset" for TCP, a polite refusal the client sees as "Connection refused".
On Ubuntu 24.04, iptables is iptables-nft, so these rules are really nftables tables, and nft says so:
$ nft list ruleset | grep -E 'Warning|^table'
# Warning: table ip filter is managed by iptables-nft, do not touch!
# Warning: table ip nat is managed by iptables-nft, do not touch!
# Warning: table ip6 filter is managed by iptables-nft, do not touch!
table ip filter {
table ip nat {
table ip6 filter {
That is why a ufw machine and a hand-written nftables ruleset can coexist but not cooperate: two sets of chains on the same hooks (see the nftables page). Mixing ufw with iptables commands is allowed, but anything you add to the top-level INPUT chain runs next to ufw's, and "iptables -F" removes ufw's hooks. After a ufw disable, the chains are still there, empty, with the policies set back to ACCEPT:
$ ufw disable
$ iptables -S | head -4
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N ufw-after-forward

Reset, disable, dry run and other ways to look

ufw reset removes all rules, turns the firewall off, and keeps a dated copy of every rules file, so a reset is undoable:
$ ufw --force reset
Backing up 'user.rules' to '/etc/ufw/user.rules.20261008_195030'
Backing up 'before.rules' to '/etc/ufw/before.rules.20261008_195030'
Backing up 'after.rules' to '/etc/ufw/after.rules.20261008_195030'
Backing up 'user6.rules' to '/etc/ufw/user6.rules.20261008_195030'
Backing up 'before6.rules' to '/etc/ufw/before6.rules.20261008_195030'
Backing up 'after6.rules' to '/etc/ufw/after6.rules.20261008_195030'
$ ufw status
Status: inactive
Note that reset also restores before.rules to the shipped version, so your NAT lines are in the backup copy and not in the live file. After a reset the firewall is OFF, and when you enable it again you start at "deny incoming" with no rules: allow ssh first.
Other ways to look:
$ ufw show added
Added user rules (see 'ufw status' for running firewall):
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow from 192.168.60.20 to any port 3306 proto tcp
ufw allow in on eth1 to any port 8080 proto tcp
ufw allow from 192.168.60.0/24 to any port 9000 proto tcp comment 'lan only'
ufw reject 3307/tcp
ufw limit 22/tcp
...
ufw show added prints your rules as the commands that would recreate them, which makes it the best way to document a machine or copy its firewall to another. ufw show listening matches each listening port with the rule that covers it, and the numbers are the rule numbers:
$ ufw show listening
tcp6:
  22 * (python3.12)
   [18] limit 22/tcp

  3306 * (python3.12)
  443 * (python3.12)
   [13] allow 443/tcp

  80 * (python3.12)
   [12] allow 80/tcp
A listening port with no bracketed rule under it (3306 above) is one that nothing allows: reachable by nobody, which is either exactly right or a service that does not work. ufw show raw prints the complete iptables rule set. And --dry-run shows what a command WOULD do, in iptables-restore format, without doing it:
$ ufw --dry-run allow 5432/tcp | head -3
*filter
:ufw-user-input - [0:0]
:ufw-user-output - [0:0]
$ ufw status numbered | grep -c 5432
0

ufw, Docker, firewalld and friends

  firewalld: do not run both. They fight over the same iptables/nftables and the result depends on who started last. Pick one: ufw on Ubuntu, firewalld on the Red Hat family.
  Docker: published ports (-p 8080:80) are added to iptables by Docker, in its own chains, which are evaluated BEFORE ufw's user rules. A ufw "deny" for that port does not stop a container that Docker published. The fix is Docker's own DOCKER-USER chain, or binding published ports to 127.0.0.1, not another ufw rule. (Not run in the lab: it had no Docker; this is the single most surprising ufw behavior on a Docker host, so test it.)
  fail2ban: has a ufw action; it adds and removes deny rules in ufw. Keep its rules and your own apart (not run in the lab).
  Cloud security groups: a second firewall in front of the machine. A port can be open in ufw and closed in the cloud, and the symptom is "timeout from outside, works from inside".
  Hand-written iptables or nftables: allowed, as long as you know ufw owns the ufw-* chains and the base chains' policies.

Cheat sheet

  ufw status [verbose|numbered]        what is configured (add sudo)
  ufw allow ssh                        BEFORE you enable on a remote machine
  ufw enable  /  ufw disable           start / stop (--force: no questions)
  ufw reload                           re-read the files, no gap
  ufw default deny incoming | allow outgoing | deny routed
  ufw allow 80/tcp                     a port (without /tcp it means both)
  ufw allow 6000,6001/tcp   ufw allow 7000:7005/udp      a list / a range (protocol needed)
  ufw allow from 192.168.60.0/24 to any port 3306 proto tcp
  ufw allow in on eth1 to any port 8080 proto tcp
  ufw allow out to 198.51.100.50 port 9100 proto tcp
  ufw deny|reject|limit|allow ...      the four actions
  ufw insert 1 deny from 203.0.113.9   at the top (first match wins)
  ufw delete allow 22/tcp  /  ufw delete 3  /  ufw --force delete 3
  ufw route allow in on eth1 out on eth2        forwarding
  ufw app list | info NAME | update NAME        profiles
  ufw logging on|off|low|medium|high
  ufw show added | listening | raw
  ufw --dry-run COMMAND
  ufw --force reset                    wipe everything (backups are kept in /etc/ufw)
  /etc/default/ufw  /etc/ufw/user.rules  before.rules  after.rules  sysctl.conf

Common problems

Locked out after ufw enable                      deny incoming with no ssh rule. From the console: ufw allow ssh or ufw disable. Next time: allow first, enable after
The web server works locally, not from outside   no allow rule for the port (ufw status), or the cloud firewall. ufw show listening shows listeners with no rule
My deny rule does nothing                        an earlier allow matches first: ufw status numbered, then ufw insert 1 deny ...
ufw allow 8080-8090 says "Could not find a profile"   ranges use a colon and a protocol: 8080:8090/tcp
ERROR: Bad port                                  a port above 65535 (or a typo), or a list without a protocol
ufw delete in a script does nothing              it waits for y/n and is aborted when no one answers: use ufw --force delete
I deleted the wrong rule                         numbers shift after each delete. Delete by rule text, or list again every time
ufw allow ssh removed my rate limit              a port has one action: the newest sentence wins ("Rule updated"). Re-run ufw limit ssh
IPv6 stopped working                             IPV6=no in /etc/default/ufw makes ufw drop all IPv6; set it to yes and disable then enable
IPv4 is closed but IPv6 is open                  that is the iptables problem, and ufw avoids it when IPV6=yes: check ip6tables -S
ufw reload did not apply a change to /etc/default/ufw   those settings need ufw disable, then ufw enable
Ping stopped, or I cannot stop ping              ICMP is handled in before.rules, not by ufw allow/deny: edit the echo-request line
Docker ports are open although ufw denies them   Docker's own rules run first: see the Docker note
NAT does not work                                forwarding in /etc/ufw/sysctl.conf, a ufw route rule, and the *nat block ABOVE *filter in before.rules, in that order
ufw fails to start after editing before.rules    a syntax error: the message names the line of the iptables-restore input. Restore from the backup or from /etc/ufw/before.rules.TIMESTAMP
Log file /var/log/ufw.log is missing            rsyslog is not installed or the 20-ufw.conf rule is missing: use journalctl -k | grep UFW
ufw status says inactive but rules still block  the ufw chains and some iptables rules may be left from another tool: iptables -S
Two firewalls at once                            ufw plus firewalld, or ufw plus a hand-written script that flushes iptables: one at a time

Tips

Allow ssh before you enable. Say it out loud. Every lockout story starts with the other order.
Look at ufw status numbered before every delete, and at ufw show added after every change: the second is the whole configuration in a form you can paste.
Keep the defaults (deny incoming, allow outgoing, deny routed) and write the exceptions. Be specific: from a network, to a port, on an interface.
Put exceptions above general rules with ufw insert. The first match wins.
Use ufw limit on ssh, and still prefer keys. Use reject on the LAN and deny on the internet side.
Give rules comments (comment "web") so that the status list explains itself.
Never edit user.rules by hand. Put NAT and special cases in before.rules, and keep a copy before editing.
For automation, use --force and check the exit status. For a trial run, --dry-run.
Keep logging at low. Turn it up to debug, then back down.
When ufw does something you cannot explain, read iptables -S and ufw show raw. ufw is a tool for writing iptables, and the rules are the truth.
If you outgrow ufw (sets, per-address meters, several tables of your own), that is the moment to look at nftables or firewalld, not to fight ufw's files.