UFW (Uncomplicated Firewall) is the default firewall management tool on Ubuntu and other Debian-based distributions. It provides a straightforward command-line interface on top of iptables (or nftables on newer kernels) for managing host-based firewall rules. Instead of writing raw iptables rules, UFW lets you allow or block traffic with simple, readable commands.
This guide covers every common UFW operation – from basic allow/deny rules to rate limiting, Docker integration, port forwarding, and logging. All commands were tested on Ubuntu 24.04 with UFW 0.36.2.
Prerequisites
To follow this guide, you need:
- Ubuntu 24.04 or 22.04 (server or desktop)
- Root access or a user with sudo privileges
- SSH access to the server (if remote)
Step 1: Install and Enable UFW
UFW comes pre-installed on Ubuntu. Verify it is present and check the version:
sudo apt install ufw
Check the installed version:
ufw version
Output:
ufw 0.36.2
Copyright 2008-2023 Canonical Ltd.
Before enabling UFW, set default policies. This is important because the defaults determine what happens to traffic that does not match any explicit rule. Deny all incoming connections and allow all outgoing:
sudo ufw default deny incoming
sudo ufw default allow outgoing
If you are connected over SSH, allow SSH traffic before enabling UFW. Skipping this step will lock you out of the server:
sudo ufw allow ssh
Now enable UFW:
sudo ufw enable
Output:
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
Verify the firewall status with verbose output:
sudo ufw status verbose
Output:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
Step 2: Allow Services and Ports
UFW accepts both service names (from /etc/services) and port numbers. Here are the most common ways to open ports.
Allow by service name:
sudo ufw allow http
sudo ufw allow https
Allow by port number and protocol:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
Allow a port with a comment (useful for tracking why a rule exists):
sudo ufw allow 8080/tcp comment 'Web app'
Allow a range of ports. You must specify the protocol when using ranges:
sudo ufw allow 6000:6100/tcp
Allow UDP traffic on a specific port (for example, DNS):
sudo ufw allow 53/udp
Step 3: Allow from Specific IPs and Subnets
For tighter security, restrict access to specific source IPs or subnets instead of opening ports to the entire internet.
Allow all traffic from a single IP address:
sudo ufw allow from 10.0.1.50
Allow all traffic from a subnet:
sudo ufw allow from 10.0.1.0/24
Allow a subnet to connect to a specific port (for example, MySQL on port 3306):
sudo ufw allow from 192.168.1.0/24 to any port 3306
Allow an IP to reach a specific port on a specific network interface:
sudo ufw allow in on eth0 from 10.0.1.0/24 to any port 22
This is useful on multi-homed servers where you want SSH access only on the management interface.
Step 4: Deny and Reject Traffic
UFW supports two ways to block traffic: deny and reject. Both prevent the connection, but they behave differently:
- deny – silently drops the packet. The sender gets no response and eventually times out.
- reject – drops the packet but sends an ICMP “port unreachable” response. The sender immediately knows the connection was refused.
Use deny for external-facing rules (attackers learn nothing) and reject for internal networks (faster feedback for legitimate users hitting the wrong port).
Deny all traffic from a specific IP:
sudo ufw deny from 203.0.113.100
Deny an entire subnet:
sudo ufw deny from 203.0.113.0/24
Deny outgoing traffic on a port (for example, block outbound SMTP to prevent spam):
sudo ufw deny out 25
Reject incoming traffic on a specific interface and port:
sudo ufw reject in on eth0 to any port 80
Step 5: Rate Limiting for Brute Force Protection
UFW has a built-in rate limiting feature that denies connections from an IP address if it attempts 6 or more connections within 30 seconds. This is effective against SSH brute force attacks without needing additional tools like Fail2ban.
Enable rate limiting on SSH:
sudo ufw limit ssh/tcp
Output:
Rule updated
Rule updated (v6)
If you previously had a plain allow rule for SSH, the limit command replaces it. You can verify with sudo ufw status – the Action column will show LIMIT IN instead of ALLOW IN.
For production servers exposed to the internet, rate limiting SSH is one of the first hardening steps you should apply. For more advanced blocking patterns (repeated failures across longer time windows, multiple services), install Fail2ban alongside UFW.
Step 6: Delete UFW Rules
There are two ways to delete rules: by rule number or by rule specification.
First, list all rules with their numbers:
sudo ufw status numbered
Output:
Status: active
To Action From
-- ------ ----
[ 1] 22/tcp LIMIT IN Anywhere
[ 2] 80/tcp ALLOW IN Anywhere
[ 3] 443/tcp ALLOW IN Anywhere
[ 4] 8080/tcp ALLOW IN Anywhere # Web app
[ 5] 6000:6100/tcp ALLOW IN Anywhere
[ 6] 53/udp ALLOW IN Anywhere
[ 7] Anywhere ALLOW IN 10.0.1.50
[ 8] Anywhere ALLOW IN 10.0.1.0/24
[ 9] 3306 ALLOW IN 192.168.1.0/24
[10] 22/tcp (v6) LIMIT IN Anywhere (v6)
[11] 80/tcp (v6) ALLOW IN Anywhere (v6)
[12] 443/tcp (v6) ALLOW IN Anywhere (v6)
[13] 8080/tcp (v6) ALLOW IN Anywhere (v6)
[14] 6000:6100/tcp (v6) ALLOW IN Anywhere (v6)
[15] 53/udp (v6) ALLOW IN Anywhere (v6)
Delete a rule by its number. For example, to remove the port range rule at position 5:
sudo ufw delete 5
Delete a rule by specification (matches the exact rule you originally added):
sudo ufw delete allow 6000:6100/tcp
When deleting by number, rule numbers shift after each deletion. If you need to delete multiple rules, delete from the highest number first to avoid renumbering issues.
Step 7: Insert Rules at a Specific Position
UFW evaluates rules in order – the first matching rule wins. This means rule position matters. If a deny rule appears before an allow rule for the same traffic, the deny takes precedence.
Insert a rule at a specific position using insert:
sudo ufw insert 1 allow from 10.0.1.50
This places the rule at position 1 (top of the list), ensuring it is evaluated before any other rules. This is useful when you have a general deny rule but want to add an exception for a trusted IP.
Step 8: Application Profiles
Application profiles are predefined rule sets stored in /etc/ufw/applications.d/. Packages like OpenSSH and Nginx install their own profiles automatically.
List available application profiles:
sudo ufw app list
Output (on a minimal Ubuntu install):
Available applications:
OpenSSH
View details about a specific profile:
sudo ufw app info OpenSSH
Output:
Profile: OpenSSH
Title: Secure Shell Server
Description: OpenSSH is a free implementation of the Secure Shell protocol.
Ports:
22/tcp
Allow traffic using the application profile name:
sudo ufw allow OpenSSH
Create Custom Application Profiles
You can create custom profiles for your own applications. Create a file in /etc/ufw/applications.d/:
sudo nano /etc/ufw/applications.d/mywebapp
Add the following content:
[MyWebApp]
title=My Custom Web Application
description=Web application running on port 8443
ports=8443/tcp
After creating the profile, reload the app list and allow it:
sudo ufw app update MyWebApp
sudo ufw allow MyWebApp
Step 9: UFW Logging
UFW logging helps you monitor blocked connections, detect scanning attempts, and troubleshoot connectivity issues.
Enable logging:
sudo ufw logging on
UFW supports five logging levels, each progressively more verbose:
- off – no logging
- low – logs blocked packets that do not match the default policy, plus packets matching logged rules
- medium – same as low plus invalid packets, new connections, and rate-limited packets
- high – same as medium plus all packets with rate limiting
- full – logs everything without rate limiting (generates a lot of output)
Set the logging level:
sudo ufw logging medium
Output:
Logging enabled
UFW writes logs to /var/log/ufw.log. Each log entry contains useful information:
sudo tail -5 /var/log/ufw.log
A typical log line looks like:
Mar 23 10:15:42 server kernel: [UFW BLOCK] IN=eth0 OUT= MAC=... SRC=203.0.113.50 DST=10.0.1.10 LEN=44 TOS=0x00 PROTO=TCP SPT=54321 DPT=22 WINDOW=1024 SYN
Key fields to look at:
- [UFW BLOCK] – the action taken (BLOCK, ALLOW, or AUDIT)
- SRC – source IP address
- DST – destination IP address
- DPT – destination port
- PROTO – protocol (TCP, UDP, ICMP)
Step 10: UFW with Docker (Critical Fix)
This is one of the most common pitfalls for Ubuntu servers running Docker. Docker manipulates iptables directly by inserting rules into the DOCKER chain, which bypasses UFW entirely. This means a container exposing port 8080 is accessible from the internet even if UFW has no rule allowing port 8080.
Fix 1: Use the DOCKER-USER Chain
Docker provides a DOCKER-USER chain that runs inside the FORWARD chain before Docker’s own published-port rules. Any packet going through the FORWARD chain (which is how traffic reaches a Docker container) passes through DOCKER-USER first. This gives you a hook to filter container traffic without fighting Docker’s own rules.
The block we install does three things: it creates the filtering chains, routes new TCP connections through UFW’s own user-forward chain so you can allow specific ports with regular UFW commands, and drops everything else with a rate-limited log entry.
Here is what each chain in the block does:
DOCKER-USERis Docker’s own user-editable filtering chain. Docker creates it at daemon startup. We declare it inafter.rulesas a safety net so the rules still load cleanly if UFW reloads before Docker is up.ufw-user-forwardis UFW’s internal chain for user-managed routed rules. It is empty until you runsudo ufw route allow .... By makingDOCKER-USERjump to it first, container traffic becomes subject to whatever routed rules you add later, which lets you whitelist ports with normal UFW commands instead of re-editingafter.rules.ufw-docker-logging-denyis a custom chain that rate-limits log messages and then drops the packet. Keeping the log-and-drop pair in its own chain is cleaner than inlining it intoDOCKER-USER.
The block goes in after.rules because that is where custom filter rules belong. At ufw enable or reload, UFW loads its rule files in this order: before.rules, then after.rules, then user.rules. The Docker block creates the DOCKER-USER to ufw-user-forward jump when after.rules loads, and by the time user.rules runs, any ufw route allow rules you have added get appended to the already-existing ufw-user-forward chain. At runtime, the chain reference and the user rules meet up correctly. Step 11 puts its NAT rules in before.rules instead because NAT table rules must run early in the packet pipeline.
Open the file:
sudo nano /etc/ufw/after.rules
Add the following block at the end of the file:
# 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 -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 -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN
-A DOCKER-USER -p udp -m udp -j ufw-docker-logging-deny
-A DOCKER-USER -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -j ufw-docker-logging-deny
-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
The key lines are the two near the bottom of the DOCKER-USER chain:
-A DOCKER-USER -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -j ufw-docker-logging-denydrops only new TCP connections (packets with just the SYN flag set). Return traffic and data packets of already-established connections have different TCP flags and pass through untouched. This is why the block does not kill bidirectional communication.-A DOCKER-USER -j RETURNat the end of the chain explicitly returns to the calling FORWARD chain for anything that was not dropped. Without this final RETURN, the default chain policy would apply to any unmatched packet.
The three RETURN -s 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 lines mean traffic from any RFC1918 private network bypasses the deny-by-default logic entirely. This is intentional: container traffic to and from other containers, LAN hosts, and the Docker bridge networks all comes from RFC1918 addresses. If you want to block private network traffic to containers as well (strict zero-trust), remove those three RETURN lines.
Reload UFW to apply:
sudo ufw reload
After reload, public traffic to container ports is blocked unless you explicitly allow it. Internal traffic (RFC1918, container-to-container) still works normally so applications keep talking to each other.
Whitelisting a Container Port
To open a container port to public traffic, use ufw route allow. There is an important detail here that trips people up: the rule must match the container’s internal port, not the host-published port. Docker sets up a DNAT rule in PREROUTING that rewrites the destination from the host port to the container port before the packet ever reaches the FORWARD chain. By the time DOCKER-USER sees it, the destination port has already been translated.
For example, if you started a container with docker run -d -p 8080:80 nginx:alpine, the container listens on port 80 internally, and Docker publishes it on the host’s port 8080. To allow public access, you whitelist port 80, not 8080:
sudo ufw route allow proto tcp from any to any port 80
Verify the rule took effect by inspecting ufw-user-forward:
sudo iptables -L ufw-user-forward -n -v
The ACCEPT rule shows up with a packet counter that increases as external traffic hits the container:
Chain ufw-user-forward (2 references)
pkts bytes target prot opt in out source destination
7 454 ACCEPT 6 -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:80
You can restrict the source to a specific IP or subnet so only trusted hosts reach the container:
sudo ufw route allow proto tcp from 203.0.113.50 to any port 80
To revoke the whitelist, use ufw route delete with the exact same specification:
sudo ufw route delete allow proto tcp from any to any port 80
If two containers on the same host both expose port 80 internally (say, docker run -p 8080:80 nginx and docker run -p 9000:80 httpd), a single ufw route allow ... port 80 rule opens both. For per-container access control, use destination IPs: sudo ufw route allow proto tcp from any to 172.17.0.2 port 80 targets the container’s Docker bridge IP directly.
Fix 2: Disable Docker’s iptables Management
An alternative approach is to disable Docker’s iptables manipulation entirely. Edit or create /etc/docker/daemon.json:
sudo nano /etc/docker/daemon.json
Add this configuration:
{
"iptables": false
}
Restart Docker:
sudo systemctl restart docker
With this approach, you manage all port exposure through UFW manually. Container-to-container networking still works over Docker networks, but published ports will not be reachable from external hosts unless you add UFW rules for them. This gives you full control but requires more manual configuration.
Step 11: Port Forwarding and NAT with UFW
UFW can handle port forwarding (DNAT) by adding rules to /etc/ufw/before.rules. This is useful when your server acts as a gateway and you need to forward incoming traffic to an internal server.
First, enable IP forwarding. Edit /etc/ufw/sysctl.conf:
sudo nano /etc/ufw/sysctl.conf
Uncomment or add this line:
net/ipv4/ip_forward=1
Next, add NAT rules to /etc/ufw/before.rules. Add the following block before the *filter section at the top of the file:
sudo nano /etc/ufw/before.rules
Add before the *filter line:
# NAT table rules
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
# Forward port 80 to internal server 10.0.1.20
-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 10.0.1.20:80
# Masquerade outgoing traffic from internal network
-A POSTROUTING -s 10.0.1.0/24 -o eth0 -j MASQUERADE
COMMIT
Also allow forwarded traffic in UFW. Change the default forward policy in /etc/default/ufw:
sudo nano /etc/default/ufw
Change this line:
DEFAULT_FORWARD_POLICY="ACCEPT"
Reload UFW to apply the changes:
sudo ufw reload
Step 12: Reset and Disable UFW
To disable UFW without removing any rules (rules are preserved and will be active again when you re-enable):
sudo ufw disable
Output:
Firewall stopped and disabled on system startup
To completely reset UFW, removing all rules and returning to default settings:
sudo ufw reset
Output:
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.20260323_101542'
Backing up 'before.rules' to '/etc/ufw/before.rules.20260323_101542'
Backing up 'after.rules' to '/etc/ufw/after.rules.20260323_101542'
Backing up 'user6.rules' to '/etc/ufw/user6.rules.20260323_101542'
Backing up 'before6.rules' to '/etc/ufw/before6.rules.20260323_101542'
Backing up 'after6.rules' to '/etc/ufw/after6.rules.20260323_101542'
UFW backs up your rules before resetting, so you can restore them if needed. After a reset, you need to re-enable UFW and add your rules from scratch.
Step 13: UFW vs iptables vs nftables vs firewalld
Linux has several firewall management tools. Here is how they compare:
| Feature | UFW | iptables | nftables | firewalld |
|---|---|---|---|---|
| Ease of use | Very easy | Complex | Moderate | Easy |
| Default on | Ubuntu, Debian | Legacy Linux | Debian 11+, RHEL 9+ | RHEL, Rocky, Alma, Fedora |
| Backend | iptables or nftables | Netfilter (legacy) | Netfilter (modern) | nftables (or iptables) |
| Zone support | No | No | No | Yes |
| Dynamic reload | No (requires reload) | No | Yes | Yes |
| IPv6 support | Yes (automatic) | Separate ip6tables | Yes (unified) | Yes (automatic) |
| Rich rules | Limited | Full control | Full control | Rich rule syntax |
| Best for | Single servers, VPS | Advanced custom rules | Modern custom rules | Enterprise RHEL servers |
For Ubuntu servers, UFW is the right choice for most use cases. If you need zone-based rules or manage RHEL-family systems, see our guide on configuring firewalld on Rocky Linux / AlmaLinux / RHEL. For Ubuntu systems where you prefer firewalld over UFW, we also have a guide on installing firewalld on Ubuntu.
Quick Reference: Common UFW Commands
The table below summarizes every common UFW command covered in this guide for quick reference.
| Action | Command |
|---|---|
| Enable UFW | sudo ufw enable |
| Disable UFW | sudo ufw disable |
| Check status | sudo ufw status verbose |
| Set default deny incoming | sudo ufw default deny incoming |
| Set default allow outgoing | sudo ufw default allow outgoing |
| Allow port (TCP) | sudo ufw allow 80/tcp |
| Allow port (UDP) | sudo ufw allow 53/udp |
| Allow service by name | sudo ufw allow ssh |
| Allow port range | sudo ufw allow 6000:6100/tcp |
| Allow from IP | sudo ufw allow from 10.0.1.50 |
| Allow from subnet to port | sudo ufw allow from 192.168.1.0/24 to any port 3306 |
| Deny from IP | sudo ufw deny from 203.0.113.100 |
| Deny outgoing port | sudo ufw deny out 25 |
| Reject traffic | sudo ufw reject in on eth0 to any port 80 |
| Rate limit SSH | sudo ufw limit ssh/tcp |
| Delete rule by number | sudo ufw delete 5 |
| Delete rule by spec | sudo ufw delete allow 80/tcp |
| Insert rule at position | sudo ufw insert 1 allow from 10.0.1.50 |
| List app profiles | sudo ufw app list |
| Show numbered rules | sudo ufw status numbered |
| Enable logging | sudo ufw logging medium |
| Reset all rules | sudo ufw reset |
| Reload rules | sudo ufw reload |
Conclusion
UFW provides a straightforward way to manage firewall rules on Ubuntu servers. For production deployments, keep these practices in mind:
- Always set default policies (deny incoming, allow outgoing) before adding specific rules
- Allow SSH before enabling UFW on remote servers – getting locked out is painful
- Use rate limiting on SSH (
sudo ufw limit ssh/tcp) as a first line of defense against brute force - If running Docker, apply the DOCKER-USER chain fix – Docker bypasses UFW by default
- Restrict database ports (3306, 5432, 6379) to specific IPs or subnets, never open them to the world
- Enable logging at
mediumlevel for good visibility without excessive noise - Review rules periodically with
sudo ufw status numberedand remove anything no longer needed
For securing SSH access on your Ubuntu server beyond firewall rules, see our guide on installing and configuring SSH server on Ubuntu. If you run a WireGuard VPN, remember to allow the VPN port (typically 51820/udp) in UFW as well.
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.