How To

UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04

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.

Original content from computingforgeeks.com - post 145106

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:

TestResult
deny vs reject on a closed TCP port, seen by the clientdeny: client gave up at its own 5 s timeout. reject: refused in 0.003 s
ufw limit OpenSSH, one client, one attempt per secondAttempts 1 to 5 accepted, attempt 6 refused, open again after 31 s of quiet
Log lines for one 1,000-port SYN scanlow 10, medium 20, high 2,010, full 4,049
Docker container on port 8888, no UFW rule for 8888HTTP 200 from the LAN and from both public addresses
Same container after the after.rules block and a reloadLAN 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 runsThree copies of the DNAT rule loaded
ufw reset on a gateway and a Docker hostDNAT 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 that delete and insert expect.
  • 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:

LevelWhat it logs (ufw man page)Lines for one 1,000-port scan
lowBlocked packets not matching the policy, rate limited, plus logged rules10
mediumlow, plus allowed packets not matching the policy, INVALID packets and new connections, rate limited20
highmedium without rate limiting, plus all packets with rate limiting2,010
fullhigh without rate limiting4,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 from rule.
  • DPT: the destination port, which tells you which service the client was after.
  • PROTO and flags: a lone SYN is 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:

UFW and Docker architecture diagram: host ports pass ufw-user-input, published container ports pass DOCKER-USER after DNAT

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:

  1. Kernel forwarding in /etc/ufw/sysctl.conf, which UFW applies on every start.
  2. A *nat table in /etc/ufw/before.rules with the DNAT rule.
  3. A ufw route allow rule 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 PREROUTING on perf-a still showed the DNAT rule from Step 13.
  • Live DOCKER-USER rules: bench01 still had all three ufw-docker-logging-deny drop rules loaded, with UFW inactive.
  • sysctl.conf: net/ipv4/ip_forward=1 stayed uncommented on line 10.
  • Custom app profiles: /etc/ufw/applications.d/custom-apps was 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:

TaskCommand
Show status, numberedsudo ufw status numbered
Show policies and loggingsudo ufw status verbose
Rules while inactivesudo ufw show added
Rules matching listening portssudo ufw show listening
Enable / disablesudo ufw enable / sudo ufw disable
Default policiessudo ufw default deny incoming, sudo ufw default allow outgoing
Allow a portsudo ufw allow 443/tcp
Allow a port rangesudo ufw allow 6000:6100/tcp
Allow a subnet to a portsudo ufw allow from 10.0.1.0/24 to any port 3306 proto tcp
Allow on one interfacesudo ufw allow in on eth0 from 10.0.1.0/24 to any port 9100 proto tcp
Add or change a commentsudo ufw allow 443/tcp comment 'nginx TLS'
Remove a commentsudo ufw allow 443/tcp comment ''
Deny / reject a portsudo 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 SMTPsudo ufw deny out 25/tcp
Rate-limit SSHsudo ufw limit OpenSSH
Delete by number / by rulesudo ufw delete 13 / sudo ufw delete allow 443/tcp
List / inspect app profilessudo ufw app list, sudo ufw app info OpenSSH
Allow a profile from a subnetsudo ufw allow from 10.0.1.0/24 to any app Netdata
Apply an edited profile over SSHsudo ufw app update Netdata, then sudo ufw reload
Logging levelsudo ufw logging medium
Allow a container port (after.rules block)sudo ufw route allow proto tcp from any to any port 80
Allow forwarded trafficsudo ufw route allow in on eth0 out on eth0 to 10.0.1.10 port 8888 proto tcp
Check loaded NAT rulessudo iptables -t nat -S
Reload rules filessudo ufw reload
Reset everythingsudo 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.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Cheat Sheets APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Set System-Wide Proxy on Ubuntu 26.04 / 24.04 and Debian 13 / 12 Debian Set System-Wide Proxy on Ubuntu 26.04 / 24.04 and Debian 13 / 12

6 thoughts on “UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04”

  1. 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.

    Reply
    • 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.

      Reply
    • 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.

      Reply
    • 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.

      Reply

Leave a Comment

Press ESC to close