UFW Isn't Blocking Your Docker Ports, and Docker Says So
Here is a configuration a great many people are running right now:
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw enable
docker run -d -p 5678:5678 n8nio/n8n
That looks correct. ufw status will confirm everything is denied except SSH. And the n8n admin panel is reachable from anywhere on the internet.
This isn’t a bug or a misconfiguration. It’s documented behaviour, and Docker states it plainly:
“Docker routes container traffic in the
nattable, which means that packets are diverted before it reaches theINPUTandOUTPUTchains that ufw uses. Packets are routed before the firewall rules can be applied, effectively ignoring your firewall configuration.”
Docker’s own summary of the relationship: “Docker and ufw use firewall rules in ways that make them incompatible with each other.”
Why the rule never runs
The two kinds of traffic take different paths through the kernel.
Traffic aimed at a service on the host — sshd, say — traverses the INPUT chain, which is where ufw’s rules live. Denied as expected.
Traffic aimed at a published container port is destination-NATed in the nat table and then forwarded to the container. It goes through FORWARD, not INPUT. Your ufw rules are on a road this packet never drives down.
And Docker’s own filter chains handle it. From Docker’s documentation, the custom chains it creates are DOCKER-USER, DOCKER-FORWARD, DOCKER, DOCKER-BRIDGE, DOCKER-INTERNAL, DOCKER-CT and DOCKER-INGRESS — with FORWARD containing rules that “unconditionally jump” into them.
The consequence, quoted exactly, is the sentence that matters most here:
“Packets that get accepted or rejected by rules in these custom chains will not be seen by user-defined rules appended to the FORWARD chain. So, to add additional rules to filter these packets, use the
DOCKER-USERchain. Rules appended to the FORWARD chain will be processed after Docker’s rules.”
One correction worth making, because it’s repeated everywhere: Docker does not write its rules into DOCKER-USER. DOCKER-USER is the empty chain Docker provides for you, and its whole purpose is that it’s “processed before rules in the DOCKER-FORWARD and DOCKER chains.” That ordering is the entire fix.
Confirm you have the hole
Don’t take my word for it, and don’t take ufw status for it either — it will happily report a locked-down firewall. Test from a machine on a different network:
nmap -Pn -p 5678,3000,8080,11434 YOUR.SERVER.IP
open on a port you believe is firewalled is your answer. For a single port with no install:
nc -zv YOUR.SERVER.IP 5678
On the server, see exactly what Docker published:
docker ps --format '{{.Names}}\t{{.Ports}}'
0.0.0.0:5678->5678/tcp is the dangerous form. 127.0.0.1:5678->5678/tcp is safe.
Fix 1: don’t publish it publicly (best)
The cleanest fix isn’t a firewall rule at all — it’s not creating the exposure. Docker publishes to every interface only when you don’t say otherwise:
“By default, when a container’s ports are mapped without any specific host address, the Docker daemon publishes ports to all host addresses (
0.0.0.0and[::]).”
So give it an address:
docker run -d -p 127.0.0.1:5678:5678 n8nio/n8n
services:
n8n:
ports:
- "127.0.0.1:5678:5678"
Better still, if a reverse proxy on the same host is the only thing that needs to reach it, don’t publish at all — use expose and let the proxy reach it over the Docker network:
services:
n8n:
expose:
- "5678"
caddy:
ports:
- "80:80"
- "443:443"
Now exactly one service binds to a public interface, and it’s the one designed to.
This fix is better than the others because it doesn’t depend on you maintaining anything. No rule to get wrong, nothing to re-apply after a reboot, nothing that silently stops matching when an interface name changes.
Fix 2: filter in DOCKER-USER
When a port genuinely must be published — you need it reachable from specific addresses — put the rule where it will actually be evaluated. Docker’s own documented examples:
# allow only one source address, drop the rest
sudo iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.2 -j DROP
# allow a subnet
sudo iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.0/24 -j DROP
# allow a range
sudo iptables -I DOCKER-USER -m iprange -i ext_if ! --src-range 192.0.2.1-192.0.2.3 -j DROP
Replace ext_if with your actual external interface — find it with ip route get 1.1.1.1, and note it’s frequently not eth0 on modern distributions.
Two practical cautions. These rules don’t survive a reboot on their own; persist them with iptables-persistent or a systemd unit, and verify after the next reboot rather than assuming. And check your work from outside afterwards, because a rule that matches the wrong interface fails silently in the direction you least want.
Fix 3: ufw-docker
There’s a well-known community tool, ufw-docker, that rewrites your ufw configuration to handle Docker’s chains and gives you ufw-docker allow <container> semantics.
It works and plenty of people rely on it. Two things to weigh: it’s third-party, so you’re trusting a script with your firewall, and it papers over the mechanism rather than teaching it — which matters the day it behaves unexpectedly and you have no model of what’s underneath. If you take this route, still verify with an external scan.
The order to prefer
-
Bind to
127.0.0.1, or don’t publish at all. No maintenance, no drift. -
DOCKER-USERrules, when a port must be public but restricted by source. - A network firewall outside the host — Hetzner, DigitalOcean and most providers offer one. Docker’s iptables manipulation cannot route around a filter that isn’t on the machine, which makes this an excellent second layer regardless of what you do above.
-
ufw-docker, if you want ufw semantics and accept the dependency.
Whichever you pick, verify from an external machine. This entire class of problem exists because ufw status looks correct while the port is open, so any check that runs on the box is checking the wrong thing.
Why this matters more than usual right now
The stack most affected is self-hosted AI tooling, because so much of it ships as a Docker one-liner with a published port and no authentication.
Research by the Intruder team published in May 2026 probed 5,200+ internet-facing Ollama servers and 31% answered a test prompt with no credentials at all. The same scan found over 90 exposed n8n and Flowise instances across government, marketing and finance organisations. A great many of those will have had a firewall enabled.
If any of this describes your setup, our five-minute exposure audit walks the full check, and securing a VPS that runs an AI agent covers the rest of the posture. For a build with no public ports at all, Tailscale closes even SSH.
How we checked this
The ufw incompatibility, the nat-table routing explanation, the 0.0.0.0 publish default, the list of Docker’s custom filter chains, the statement that DOCKER-USER is “a placeholder for user-defined rules that will be processed before rules in the DOCKER-FORWARD and DOCKER chains,” and the iptables -I DOCKER-USER examples are all quoted from Docker’s own documentation, read in August 2026. The exposure figures are from the Intruder team’s published research.
Worth noting that Docker restructured these pages recently — the ufw explanation and the iptables chain reference now live separately, and DOCKER-USER no longer appears on the page most older articles link to. If you’re checking our work, look at Docker’s iptables page rather than its general packet-filtering page.
What we did not do: we did not run this scan against a live server for the article. The commands are Docker’s documented examples with placeholder addresses, and the one step we’d insist on is the external scan — not because we doubt the mechanism, but because your interface name, your compose file and your provider’s own firewall all affect the outcome, and only a test from outside accounts for all three.
This article has no affiliate links.
FAQ
Why doesn’t ufw block my Docker ports?
Because published container traffic is destination-NATed and forwarded, so it never traverses the INPUT chain where ufw’s rules live. Docker states that packets “are routed before the firewall rules can be applied, effectively ignoring your firewall configuration.”
Does Docker write its rules into DOCKER-USER?
No — that’s a common misstatement. DOCKER-USER is an empty chain Docker provides for your rules, and it’s processed before Docker’s own DOCKER-FORWARD and DOCKER chains. That ordering is exactly why your rules belong there.
What’s the safest fix?
Don’t publish the port publicly. Bind to 127.0.0.1 in the publish flag, or use expose and let a reverse proxy reach the container over the Docker network. It requires no ongoing maintenance and can’t drift.
Will rules appended to FORWARD work?
No. Docker’s documentation is explicit that rules appended to FORWARD “will be processed after Docker’s rules,” so packets already accepted by Docker’s chains never reach them. Use -I DOCKER-USER.
Do DOCKER-USER rules survive a reboot?
Not by themselves. Persist them with iptables-persistent or an equivalent systemd unit, and confirm with an external scan after rebooting rather than assuming they came back.
Is ufw-docker safe to use?
It’s widely used and it works. It’s also third-party code managing your firewall, and it hides the mechanism rather than explaining it. If you use it, still verify the result from an external machine.
How do I know my fix worked?
Scan from a machine on a different network — nmap -Pn -p 5678 YOUR.SERVER.IP. Anything you check on the server itself, including ufw status, is checking the path that was never the problem.