List the active firewall rules on Ubuntu
Three views of one ruleset on Ubuntu: nft list ruleset, iptables -L -n -v and ufw status. Find shadowed rules with counters and see why Docker skips ufw.
Which command lists the active firewall rules on Ubuntu?
To list the active firewall rules on Ubuntu, ask the kernel directly: sudo nft list ruleset. That output is what the packet filter enforces right now, whoever wrote the rules. sudo iptables -L -n -v --line-numbers shows the same rules through a compatibility layer, and sudo ufw status verbose shows what the ufw front-end believes it installed. When you add a rule and nothing changes, the three views have stopped agreeing. Read them in that order of authority: the kernel first, then iptables, then ufw.
The three views exist because of how Ubuntu layers its firewall tools. On current Ubuntu the iptables command is iptables-nft, a program that accepts the old iptables syntax and writes nftables rules into the kernel. ufw is a Python script that runs iptables for you. So a ufw rule becomes an iptables call, which becomes an nftables rule, and only the last of those is real. The history of that layering, and which tool you should write new rules with, is covered in iptables vs nftables on Ubuntu. Everything below works the same on Ubuntu 24.04 and 26.04. The three packages are normally present already; if apt reports they are already the newest version, nothing changed.
sudo apt install -y nftables iptables ufw
sudo iptables -Viptables -V should print a version followed by (nf_tables). That word confirms the compatibility layer is in use, which is what makes the three views describe one ruleset. If it prints (legacy) instead, read the note on the legacy backend in the next section, because then nft cannot see the iptables rules at all.
Save a copy of the ruleset before you change anything
Every fix you try from here on changes live rules, and some of those rules belong to Docker or ufw rather than to you. Save the whole ruleset first.
sudo sh -c 'nft list ruleset > /root/ruleset-before.nft'
sudo wc -l /root/ruleset-before.nftThe sh -c wrapper matters. Writing sudo nft list ruleset > /root/ruleset-before.nft fails with Permission denied, because your shell opens the output file as your own user before sudo starts, and your user cannot write inside /root. The saved file holds every family and every table, including the ones iptables cannot display, with the current counters.
To put it back, check it first, then load it as one transaction with a flush ruleset in front:
sudo nft -c -f /root/ruleset-before.nft
printf 'flush ruleset\n' | cat - /root/ruleset-before.nft | sudo nft -f -nft -c -f checks the file without applying it. nft -f - reads the ruleset from standard input. The flush ruleset line is required: loading a saved file on top of the live rules does not replace them. It appends a second copy of every rule under the same chains, and you end up with each rule twice. Because a single nft -f run is applied atomically, the flush and the reload happen together and there is no moment with an empty firewall.
iptables-save is the traditional way to save rules, and it still works, but it covers the ip family only, ip6tables-save covers the ip6 family, and neither can write an inet table. A native nftables table is silently missing from those files. Use nft list ruleset for the copy you would restore from.
View 1: sudo nft list ruleset is what the kernel enforces
sudo nft list rulesetThe output is nested: a table, the chains inside it, and the rules inside each chain. On a box running ufw, the start of it is shaped like this. Your table names, chain names and counters will differ, so read the shape and not the numbers.
table ip filter {
chain INPUT {
type filter hook input priority filter; policy drop;
counter packets 8812 bytes 1204551 jump ufw-before-logging-input
counter packets 8812 bytes 1204551 jump ufw-before-input
counter packets 120 bytes 7200 jump ufw-after-input
}
chain ufw-user-input {
tcp dport 22 counter packets 40 bytes 2400 accept
}
}Three parts of that output decide everything. The line type filter hook input priority filter; policy drop; marks a base chain: a chain attached to a kernel hook, so it sees packets on its own. A chain without that line, like ufw-user-input, is a regular chain. It only sees a packet that another chain sends to it with jump or goto. Then policy drop is the verdict when no rule in the base chain matched. And counter packets 40 bytes 2400 is a hit counter for that rule. iptables-nft attaches a counter to every rule it writes, so all ufw and Docker rules carry one. A rule you wrote with nft yourself counts nothing unless you put the word counter in it.
Two flags change what you see. sudo nft -a list ruleset prints # handle N after each rule, and the handle is what nft delete rule needs. sudo nft -s list ruleset omits the counter values, which gives you a file you can diff against yesterday's copy. To narrow the view, name the object you want:
sudo nft list tables
sudo nft list table ip filter
sudo nft list chain ip filter INPUTChain names are case-sensitive. iptables creates INPUT, FORWARD and OUTPUT in capitals. A hand-written /etc/nftables.conf almost always uses input and forward. nft list chain ip filter input on a ufw box fails with an error containing No such file or directory, because no chain exists under that name in that table.
Empty output from nft list ruleset is a valid answer. It means no table is loaded, so the kernel accepts every packet. Whether a fresh image ships that way depends on the image, so check rather than assume. There is one exception: if iptables -V said (legacy), the rules live in the older xtables backend, which nft cannot read. List them with sudo iptables-legacy -L -n -v. When both backends hold rules at once, iptables -L prints # Warning: iptables-legacy tables present, use iptables-legacy to see them at the top, and both sets are enforced.
Am I reading the right family and table? inet, ip and ip6
Every table belongs to a family. ip handles IPv4 only, ip6 handles IPv6 only, and inet handles both in one table. ufw, through iptables, writes to table ip filter and table ip6 filter. Docker writes to table ip nat and table ip filter among others, and, when its ip6tables option is on, to the ip6 twins. A hand-written /etc/nftables.conf normally creates table inet filter. All of those tables can exist at the same time, and a packet is checked against all of them.
That last point is the one that costs people an afternoon. A packet on the input hook passes through every base chain on that hook, in every table and family that applies to it. An accept verdict ends the chain it is in, and nothing more. The nft manual says it plainly: the packet "can still be dropped later by another hook". A drop is final and stops evaluation immediately. So tcp dport 80 accept in your inet filter input chain does nothing useful while ufw's ip filter INPUT chain still drops port 80.
You can see this happening. The counter on your accept rule rises with every connection attempt, which proves the rule matched, and the connection still fails. The drop lives in a different table. With ufw logging on, the kernel log names it:
sudo journalctl -k --since "10 min ago" | grep 'UFW BLOCK'A line containing [UFW BLOCK] and DPT=80 is ufw's ip filter table dropping the packet your inet rule already accepted. The fix is to open the port in the table that drops it, sudo ufw allow 80/tcp, or to stop running two filters for one job.
Filter the listing by family when the ruleset is long:
sudo nft list ruleset ip
sudo nft list ruleset ip6
sudo nft list ruleset inetIPv6 gets its own copy of every ufw rule, marked (v6) in ufw status, and only when IPV6=yes is set in /etc/default/ufw. A service that answers on IPv4 and times out on IPv6 usually has a rule in ip filter with no twin in ip6 filter. Opening ports for IPv6 with ufw walks through that case.
View 2: iptables -L -n -v --line-numbers, the compatibility view
sudo iptables -L -n -v --line-numbers
sudo iptables -t nat -L -n -v --line-numbers
sudo ip6tables -L -n -v --line-numbers
sudo iptables -SEach flag earns its place. -L lists chains. -n prints addresses and ports as numbers. Without it, iptables performs a reverse DNS (domain name system) lookup on every address, which stalls on a server with no working reverse DNS, and it prints ports as service names, so dpt:22 becomes dpt:ssh. -v adds the packet and byte counters and the in and out interface columns. Without -v, a rule restricted to -i docker0 prints exactly like a rule with no interface at all, and you cannot tell them apart. --line-numbers prints each rule's position, which is the number iptables -D INPUT 3 and iptables -I INPUT 3 operate on. -t nat selects the NAT (network address translation) table. The default is filter, so Docker's port mappings are invisible until you add -t nat. Add -x when a counter shows 12K and you need the exact number. -S prints the rules as the -A commands that would recreate them, which is the form you can copy into a script or a diff.
The counters here are the same objects as the counter packets values in the nft listing, because iptables-nft is reading the same kernel rules. The two views differ in what they can show, and three differences explain most confusion. First, iptables lists one table at a time, and it only knows the table names filter, nat, mangle, raw and security in the ip and ip6 families. A table in the inet family, or a table with any other name, does not exist for it. A rule you added with nft add rule inet filter input ... never appears in any iptables -L output. Second, when a table in the ip family holds a chain that iptables cannot represent, such as a base chain with a non-standard priority, iptables refuses the whole table and prints this in its place:
# Table `filter' is incompatible, use 'nft' tool.Third, the legacy backend from the previous section holds a separate ruleset that neither nft nor iptables-nft shows.
A chain that ufw would have created, but which is missing, produces iptables: No chain/target/match by that name. when you list it by name. That error after sudo iptables -L ufw-user-input -n -v means ufw is not enabled on this box, whatever its config files say.
View 3: ufw status verbose and ufw show raw, the front-end's opinion
sudo ufw status verbose
sudo ufw status numbered
sudo ufw show added
sudo ufw show listening
sudo ufw show rawufw status verbose prints the status line, the logging level, the three default policies (incoming, outgoing and routed), and the rules ufw added. Status: active means ufw has loaded its own chains and set the built-in chain policies. It says nothing about other tables, and nothing about rules ufw did not add. ufw keeps its own list of rules in /etc/ufw/user.rules and reports from that list, after checking that its chains still exist.
ufw status numbered prints the same rules with a number in front of each. That number is the evaluation order inside ufw-user-input, and it is the number ufw delete 3 and ufw insert 3 ... use. ufw show added prints the ufw commands that created the current rules, which is the easiest form to read back or to put in a setup script. ufw show listening lists the sockets that are listening on the box, with the ufw rules that touch each one. It is the fastest way to find out that your service is bound to 127.0.0.1 rather than to the public address, which is a failure no firewall rule can fix. ufw show raw prints the complete firewall as iptables reports it, with counters, for every table. It is view 2 in one dump.
If the basics of ufw's rule syntax and its default policies are new to you, the ufw basics guide for a VPS covers them. What matters here is the limit of what ufw can tell you: it reports on rules it added. Rules that Docker or a hand-written nftables.conf added are outside its view, and Status: active is true whether or not they exist.
How to tell a rule exists but is shadowed
Two facts settle almost every "the rule is there but nothing changed" case. Inside one chain the first matching rule with a verdict wins, and the ufw manual states this directly: "Rule ordering is important and the first match wins." And the counters tell you which rule that was.
Here is the most common shape of it. You allowed SSH some time ago, and today you want to block one address:
sudo ufw allow 22/tcp
sudo ufw deny from 203.0.113.5
sudo ufw status numberedstatus numbered shows the deny as rule 2. Rule 1 accepts every TCP connection to port 22, from any source, so a connection from 203.0.113.5 to port 22 matches rule 1 and is accepted. Rule 2 is never consulted for that packet. The deny still works for every other port, which is why it looks like it "half works". Watch the counters while that host connects:
sudo iptables -L ufw-user-input -n -v --line-numbersThe pkts value on line 1 rises. The value on line 2 stays where it was. A counter that never moves while you send traffic that should match it is the definition of a shadowed rule. Fix the order, because ufw has no other way to express priority:
sudo ufw delete 2
sudo ufw insert 1 deny from 203.0.113.5
sudo ufw status numberedufw prepend deny from 203.0.113.5 puts the rule at the top for its IP type and does the same job as insert 1 here. Both put the specific rule ahead of the general one, which is the rule of thumb the ufw manual gives: specific rules first, general rules later.
Ordering also applies between chains. In sudo iptables -L ufw-before-input -n -v --line-numbers, every rule from /etc/ufw/before.rules is listed, and the jump to ufw-user-input is the last line. So everything in before.rules is evaluated before everything you added with ufw allow, and a DROP placed in before.rules cannot be undone by a later ufw allow. Docker's DOCKER-USER chain works the same way for FORWARD, and the next section is about that.
To read counters live, put the listing under watch:
sudo watch -n 1 iptables -L INPUT -n -v -xFor a rule you wrote in nft syntax, there is no counter unless you asked for one. sudo nft list chain ip filter ufw-user-input shows the ufw counters, because iptables-nft added them. Your own rule needs the counter keyword between the match and the verdict, for example tcp dport 80 counter accept, before nft will count anything for it.
One more shadow that is not in the ruleset at all. Testing from the server itself sends the packet over the loopback interface lo, and ufw's before.rules accepts everything on lo. A local curl succeeding tells you the service is up. It tells you nothing about the public path. Test from another machine, and check whether the port is open from outside with a tool that reports the difference between a refused connection and a timeout, because those two point at different rules.
Why ufw says active while a Docker published port is reachable
You ran docker run -p 8080:80 ..., ufw status verbose says Default: deny (incoming), there is no ufw rule for 8080, and the whole internet can reach the container anyway. ufw is reporting honestly on a path the packet never takes. Docker's own documentation states the cause: Docker routes container traffic in the nat table, which means the packets are diverted before they reach the INPUT and OUTPUT chains that ufw uses.
Follow one packet through the kernel to see it:
- The packet arrives for
203.0.113.10:8080, the public address of the box. In thenattable'sPREROUTINGchain, Docker'sDOCKERchain holds a DNAT (destination NAT) rule that rewrites the destination to the container's address,172.17.0.2:80or similar. - The destination is no longer an address the box owns, so the kernel treats the packet as something to route onward. Routed packets go through the
FORWARDchain. They never enterINPUT, and everyufw allowandufw denyfor incoming traffic lives inINPUT. - In
FORWARD, Docker's jumps toDOCKER-USERandDOCKER-FORWARDcome first, because Docker inserts them at the top. TheDOCKERchain accepts the packet for the published port. ufw's forward chains sit below those jumps and are never reached, so ufw's routed policy never applies.
Every step is visible with view 2, table by table:
sudo iptables -t nat -L DOCKER -n -v --line-numbers
sudo iptables -L FORWARD -n -v --line-numbers
sudo iptables -L DOCKER-USER -n -v --line-numbersRequest the port from outside, then run the first two commands again. The DNAT rule's counter in the nat table rises. In FORWARD, the counters on the jumps to the DOCKER chains rise, and the counters on the jumps to ufw's forward chains stay still for that traffic. Nothing in ufw-user-input moves at all. That is the proof that ufw's incoming policy was never asked.
The exact chain names depend on the Docker version. Older releases jump from FORWARD to DOCKER-ISOLATION-STAGE-1; current releases use DOCKER-FORWARD, DOCKER-BRIDGE, DOCKER-CT and DOCKER-INTERNAL. Look for chains whose names start with DOCKER rather than for one specific name. If Docker is running and no such chains exist in iptables -L output, check sudo nft list tables: as of September 2026 Docker's default backend is still iptables, but its nftables backend can be turned on with the firewall-backend daemon option, and then Docker's rules live in tables of its own that only nft shows.
The only chain Docker creates and then leaves alone is DOCKER-USER. Its jump comes before Docker's other jumps, so a rule you put there is evaluated before any of Docker's own. Note that the packet has already been through DNAT by then, so a rule in DOCKER-USER sees the container's address and port, not the public ones. That chain is where a fix belongs, and why Docker published ports bypass ufw and how to close them gives the rules to put there, along with the simpler option of publishing on 127.0.0.1 only.
When the ruleset is wrong, do not flush it blind
sudo nft flush ruleset empties every table at once. On a box with Docker that removes the DNAT rules, and every published port stops answering. On a box with ufw it removes ufw's chains while /etc/ufw/ufw.conf still says ENABLED=yes, so ufw's own commands fail with No chain/target/match by that name instead of reporting. Restore from the file you saved at the start instead, with the flush ruleset prefix from that section, and keep a second SSH session open while you do it, because a restore that brings back a policy drop chain without its SSH rule locks you out of your own server.
If the damage is to ufw itself, a before.rules edit that will not load or a ufw enable that errors, the repair sequence is in recovering from a broken ufw. Restarting Docker with sudo systemctl restart docker recreates Docker's chains and DNAT rules from its own state, which is the right way to get those back rather than typing them by hand.
FAQ
Why does iptables -L show nothing while nft list ruleset shows rules?
iptables can only display tables in the ip and ip6 families named filter, nat, mangle, raw or security. Rules in a table inet filter, which is what most hand-written /etc/nftables.conf files create, are outside its view, and nft list ruleset is the only command that shows them. The reverse also happens: if iptables -V prints (legacy), its rules are in the old xtables backend and nft cannot show those. sudo iptables-legacy -L -n -v lists them.
Why does ufw say active while a Docker published port is open to everyone?
A published port is a DNAT rule in the nat table. The packet's destination is rewritten to the container's address before filtering, so the kernel forwards it through the FORWARD chain instead of delivering it through INPUT. ufw's incoming rules are in INPUT, and in FORWARD Docker's DOCKER-USER and DOCKER-FORWARD jumps sit above ufw's chains and accept the packet first. sudo iptables -L FORWARD -n -v --line-numbers shows that order, and the counters on Docker's jumps rise while ufw-user-input stays still. Put restrictions in DOCKER-USER, or publish the port on 127.0.0.1 only.
How do I know which firewall rule a packet matched?
Read the counters. sudo iptables -L INPUT -n -v --line-numbers prints a pkts column per rule, and sudo nft list ruleset prints counter packets N on every rule created through iptables. Note the number, send the traffic, read it again. The rule whose counter moved is the one that matched. A rule that should match but never counts is shadowed by an earlier rule, or by a rule in a chain that is jumped to earlier. Rules you wrote with nft only count if they contain the counter keyword.
Which family should I read, inet, ip or ip6?
All of them, because they are all enforced. ip tables apply to IPv4, ip6 tables to IPv6, and inet tables to both. ufw and Docker write to ip and ip6 tables through iptables. A hand-written nftables config normally uses inet. A packet must be accepted by every base chain on its hook across all of those tables, and one drop anywhere is final. sudo nft list tables shows which families are present, and sudo nft list ruleset ip6 narrows the listing to one of them.