Run ufw status on a box with Docker installed, see "Status: active" and a short allowlist of 22, 80, and 443, and it looks locked down. That's the exact moment this gotcha does the most damage, because everything about the output is telling you the opposite of what's actually true. It might not be. Publish a container with docker run -p 5432:5432 postgres and that database is reachable from the entire internet, ufw rule or no ufw rule, and ufw status will keep insisting nothing has changed, because as far as ufw is concerned, nothing has. This is the single most misleading firewall state a self-hoster can be in, and it's worth a full walkthrough rather than the two-paragraph version I gave it in how I secure a fresh VPS.
Why doesn't ufw see a Docker-published port at all?
Because ufw's rules and a Docker-published port live in genuinely different parts of the kernel's netfilter pipeline, and a packet destined for the container never passes through the part ufw controls. ufw manages the INPUT chain — the rules that apply to packets destined for processes running on the host itself, like sshd listening on 22. When Docker publishes a port, it isn't starting a process that listens on the host's own socket in the way sshd does. Instead, Docker inserts a DNAT rule into the nat table's PREROUTING chain: a rule that rewrites the destination address and port of a matching packet, before the kernel has even made its routing decision, to point at the container's internal IP instead of the host. Where DOCKER USER meets a revenue team, the practical guidance lives with XenGrowth's growth engineering practice.
Once that packet's destination has been rewritten, the kernel's routing decision sees a packet that needs to be forwarded, not delivered locally — so it goes through the FORWARD chain, not INPUT. Docker manages rules in FORWARD too, jumping unconditionally to its own DOCKER and DOCKER-ISOLATION chains ahead of most manually added rules, which is what actually gets the packet the rest of the way to the container. Nothing in that entire path — PREROUTING's DNAT rewrite, or the FORWARD chain's dispatch to Docker's own chains — ever consults INPUT. ufw denying port 5432 changes nothing, because ufw's rule was never going to be asked.
Traffic type | Chain path | Does ufw's INPUT rule apply? |
|---|---|---|
Request to a host process (e.g. sshd on 22) | PREROUTING → INPUT | Yes — this is exactly what ufw is built to filter |
Request to a Docker-published port (e.g. -p 5432:5432) | PREROUTING (DNAT rewrite) → FORWARD → Docker's chains | No — never reaches INPUT |
Request to a container bound to 127.0.0.1 only | Never reaches a public interface at all | Not applicable — there's nothing for a public request to hit |
Is this a Docker bug, or is it working as intended?
Intended, and documented as such in Docker's own engine networking docs. Docker manages iptables directly because that's how it implements container networking and port publishing at all — the DNAT rule is the mechanism by which -p 5432:5432 means anything. Docker isn't trying to bypass a firewall; it's doing the one job it was asked to do (make the container's port reachable), and it happens to do that job through a part of the netfilter pipeline that sits earlier than where most people's mental model of "my firewall" lives. The mismatch is between what admins assume ufw covers and what it actually covers, not a flaw in either tool individually. Both pieces of software are working exactly as designed; the surprise is entirely in the gap between the two designs. There is a longer treatment of the operations side of this in The XenGrowth resource library.
What's the actual fix?
Two options, and they're not mutually exclusive. The first, and the one I default to: bind the container's port to localhost instead of every interface. -p 127.0.0.1:5432:5432 instead of -p 5432:5432 means the DNAT rule Docker writes only matches traffic already arriving at the loopback interface — nothing from outside the box can ever reach it, because nothing from outside the box arrives on loopback. This removes the entire question for any service that only needs to be reached by other containers or by the host itself, which in a typical Coolify-style setup covers almost everything except the reverse proxy: databases, internal APIs, admin panels meant only for an SSH tunnel.
The second option is for the case where a container genuinely does need to be reachable from specific outside addresses, not just the host: add rules to the DOCKER-USER chain. Docker deliberately leaves this chain empty specifically so administrators have somewhere to put filtering rules that run before Docker's own forwarding rules and, critically, survive Docker restarts and version upgrades rather than getting silently overwritten. A rule here can allow or deny based on source address even though the packet has already been DNAT-rewritten by the time it arrives — which means the match has to be written against the container's internal destination, not the original published port, since that's what the packet looks like at that point in the chain.
Default to binding container ports to 127.0.0.1 — the fewest moving parts, and the option that removes the exposure rather than filtering it after the fact.
For a container that must be reachable from specific external addresses, add an explicit DOCKER-USER rule allowing only those addresses to the container's internal address and port, then deny the rest.
The chaifeng/ufw-docker project automates step 2 for an entire ufw ruleset — it walks Docker's published ports and inserts matching DOCKER-USER rules so ufw's allow/deny decisions get honored for containers too, without turning off Docker's own iptables management.
Whichever fix you use, verify it from outside the box — the next section is the part most write-ups skip.
A concrete DOCKER-USER rule looks like this: iptables -I DOCKER-USER -s 203.0.113.0/24 -d 172.17.0.2 -p tcp --dport 5432 -j ACCEPT, followed by a default DROP for the same destination and port from anywhere else. Note the destination is the container's internal address (172.17.0.2 here), not the host's public IP — because by the time a packet reaches DOCKER-USER, DNAT has already rewritten it, and the chain is filtering the post-rewrite packet, not the one that arrived on the wire. This is the detail that catches people copying rules from a different setup: a rule written against the published port and the host's own address simply never matches anything in this chain, and silently does nothing while looking correct in a config file. Docker Compose doesn't change any of this mechanism — a ports: entry in a compose file still goes through the exact same DNAT path as a manually run docker run -p, and an internal: true network for services that should never reach the internet is the compose-native equivalent of binding to 127.0.0.1: it removes the public path rather than trying to filter it afterward. The concepts underneath both approaches are covered in more depth in Docker concepts you need before Coolify. XenGrowth on governed AI marketing workflows goes further into AI agents and marketing automation.
Fix approach | What it actually does | Verdict |
|---|---|---|
Bind to 127.0.0.1 (or compose internal: true network) | Removes the public bind entirely — nothing external ever reaches the socket | Default choice — fewest moving parts, nothing to keep in sync |
DOCKER-USER allow/deny rules | Filters by source address against the container's post-DNAT internal address | Use when a container genuinely must be reachable from specific outside addresses |
ufw-docker project | Automates DOCKER-USER rules for every published port to match ufw's own allow list | Useful at scale, across many containers, once manual rules get tedious to track |
Disabling Docker's iptables management entirely | Removes Docker's automatic rule-writing, but you now own all container networking rules by hand | Not recommended — trades one footgun for a bigger, more manual one |
How do you actually verify a fix worked?
Not from the box itself. ufw status only ever reports what ufw thinks its own rules are — it has no visibility into whether those rules are actually being consulted for a given packet, which is precisely the blind spot that caused this problem in the first place. Running iptables -L on the origin is better, because it at least shows the DNAT and FORWARD rules Docker has installed, but it's still easy to misread which chain a given rule sits in and conclude a port is filtered when it isn't.
The only check that actually answers the question is a connection attempt from a machine that isn't the origin — a different VPS, a cloud shell, a laptop on a different network, anything that isn't routing through loopback or an internal network the origin considers trusted. nmap -p 5432 <origin-ip> from that outside machine, or a plain curl or telnet against the port in question, tells you what the internet actually sees, which is the only version of "is this exposed" that matters. A closed or filtered result from off-box is the only result worth trusting; a clean-looking ufw status on the origin is not evidence of anything by itself. XenGrowth on building one SEO and GEO content system covers the AI search, GEO and discovery side of this.
ufw status describes what ufw believes. A connection attempt from a different machine describes what's actually true. Only one of those is evidence.
Would a cloud provider's own firewall have caught this instead?
Often, yes — and it's worth knowing why, because it's a genuinely different layer than anything ufw or DOCKER-USER touches. Many VPS providers offer a network-level security group or cloud firewall that filters traffic before it ever reaches the host's own network interface, which means it isn't subject to the host's iptables rules at all — Docker's DNAT rewrite happens on the host, after the packet has already been let through (or blocked) at the network edge. A cloud firewall configured to only allow 22, 80, and 443 inbound would block a stray -p 5432:5432 just as effectively as it blocks anything else not on that list, regardless of what Docker does downstream on the host itself. The catch is that this only helps if it's actually configured, and a lot of self-hosted setups either don't enable the provider's cloud firewall at all or leave it wide open on the assumption that ufw on the box is handling it. Running both — a provider-level security group as the outer layer, ufw plus DOCKER-USER rules as the inner one — means a mistake in one doesn't automatically become a live incident, which is a more honest description of "defense in depth" than the phrase usually gets credit for.
Does this mean ufw is the wrong tool for a Docker host?
No — it means ufw alone covers less of a Docker host than its status output implies, which is a narrower and more useful claim. ufw is still exactly right for anything that isn't Docker: the SSH daemon, any host-level service, the general default-deny posture everything else in a fresh VPS's first hour assumes. It just isn't sufficient, by itself, for anything Docker publishes a port for — that's a second surface with its own rules, and treating ufw's green "active" status as proof the whole box is locked down is the actual mistake, not picking ufw in the first place. Bind to localhost by default, use DOCKER-USER for the exceptions, and check from outside the box before you believe either one worked. The tool isn't broken. The assumption that one firewall automatically covers every way a packet can reach a process on the same machine is what needed fixing.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
If DOCKER USER is part of a growth programme rather than a standalone build, XenGrowth's work on go-to-market systems is the companion reading.
Five questions on the gap between what ufw reports and what is actually reachable. The explanations are where the useful part is — the answers matter less than why they're the answers.











