Setting Up a Firewall for Self-Hosted Apps (and Docker's Nasty UFW Surprise)
Tutorial

Setting Up a Firewall for Self-Hosted Apps (and Docker's Nasty UFW Surprise)

ufw status can say everything's locked down while a container you published with -p 5432:5432 sits wide open to the internet. This isn't a misconfiguration — it's Docker rewriting your firewall's decisions before ufw ever gets a vote, and the only way to know for sure is to check from a machine that isn't the one you're worried about.

Published November 11, 202510 min readUpdated Sep 6, 2026

Written by · Full-Stack Agentic AI Software Engineer — AI Agents, Automation & Revenue Systems for GTM/RevOps teams

In brief

Why does ufw status show a locked-down firewall while a published Docker container port is still reachable from the internet?

Because a Docker-published port never reaches ufw's INPUT chain to be evaluated against it. Docker writes a DNAT rule into the nat table's PREROUTING chain that rewrites the destination address before routing is even decided, then forwards the rewritten packet through the FORWARD chain, where Docker's own chains — jumped to ahead of most manual rules — hand it to the container. ufw's rules live in INPUT, which only sees packets destined for the host itself, not packets being forwarded to a container. The fix is either binding container ports to 127.0.0.1 so nothing public reaches them at all, or adding rules to the DOCKER-USER chain, which Docker deliberately leaves empty and evaluates before its own forwarding rules. Verifying this requires checking from a machine that isn't the origin — ufw status and even iptables -L on the box itself can look correct while the port is still open to the world.

  • A packet destined for a Docker-published port never enters ufw's INPUT chain — it's DNAT-rewritten in PREROUTING and handed off through FORWARD, a completely different path
  • This is documented, intentional Docker behavior, not a bug and not specific to any one distro's ufw packaging
  • DOCKER-USER is the chain Docker deliberately leaves empty for exactly this purpose — rules placed there run before Docker's own forwarding rules and survive Docker restarts and updates
  • Binding a container's port to 127.0.0.1 instead of 0.0.0.0 is the simplest fix, because it removes the public bind entirely rather than trying to filter it after the fact
  • ufw status proves nothing about container exposure — the only real verification is a connection attempt from a machine outside the box, on the actual published port

Evidence notes

Docker's own documentation on iptables and DOCKER-USER

Docker's engine networking docs describe DOCKER-USER as a chain intentionally left empty for administrator-defined rules, evaluated before Docker's own DOCKER and DOCKER-ISOLATION chains in the FORWARD path. Packets reaching DOCKER-USER have already been through DNAT, so filtering rules there match the container's internal address and port, not the original published port.

The ufw-docker project

chaifeng/ufw-docker is a widely referenced open-source script that automates adding DOCKER-USER rules per published container port so that ufw's allow/deny decisions are actually respected for Docker-published ports, without disabling Docker's iptables management outright.

PREROUTING/FORWARD vs INPUT

A packet arriving for a host process (like sshd) traverses PREROUTING then INPUT. A packet arriving for a container's published port traverses PREROUTING (where DNAT rewrites its destination), then FORWARD (where it's routed to the container), and never touches INPUT at all — the chain ufw's rules are written into.

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.

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

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

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

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

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.

Would you have caught this port?

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.

1 / 5
You run ufw status, everything is denied, and yet a container published with -p 5432:5432 answers from the internet. Why doesn't ufw see it?

Apply this article

How to turn insights into execution

A practical sequence for teams turning concepts into production outcomes.

DockerUFWFirewalliptablesDOCKER-USERSelf-HostingLinuxtutorials

Audit your current state

Map the bottlenecks and constraints connected to the article’s core problem.

Choose one bounded change

Test the most useful recommendation on one workflow before widening the scope.

Measure what changed

Keep the parts that improve the work, document what failed, and make the next decision from evidence.

Next step

Need help applying this in your stack?

I can translate these patterns into a concrete implementation plan for your team.

Discuss implementationBack to blog

Replies usually within 24 hours.

Next Steps

Continue reading

How I Secure a Fresh VPS Before Deploying Anything

A brand-new VPS gets scanned within minutes of getting an IP address. Here's the exact order I run through before a single container touches the box — and which of these steps are real protection versus which ones are just theatre.

Navigate

How to Self-Host Next.js With Coolify: A Complete 2026 Walkthrough

Every Coolify tutorial stops at "push to deploy." This one covers the parts that actually break a real app: standalone output, a build that OOMs on a small box, env vars baked in at the wrong time, and a health check that would have caught all of it.

Navigate

How I Back Up PostgreSQL to S3-Compatible Storage on a Schedule

pg_dump, pg_basebackup and WAL archiving aren't three ways to do the same thing — they answer three different questions about how much data you're willing to lose. Here's which one to run, from a container that has no business having shell access to your host, pushed to storage that makes restoring free instead of expensive.

Navigate

Docker Compose Is More Powerful Than You Think

Most people learn Compose from a five-line docker-compose.yml in a README and stop there. Profiles, real healthchecks, resource limits, and layered override files cover most of what people reach for Kubernetes to get — you're probably one flag away from using the tool you already have.

Navigate

The Docker Concepts You Need Before Coolify Hides Them From You

Coolify's whole pitch is that you shouldn't have to think about Docker, and most days that's true. The days it isn't are the days a container is 'running' but unhealthy, a redeploy quietly ate a volume, or an env var vanished — and the dashboard doesn't explain any of it.

Navigate

Don't Self-Host Until You Understand These 7 Things

This isn't a gate to keep you out. It's a readiness check — seven things worth being honest with yourself about before you're the one holding the pager, because a managed platform is still the right call for a lot of people right now.

Navigate

How I Restore an Entire Server From Backup (And How Long It Takes)

The order matters more than people expect, and the answer to "how long will this take" isn't a number I can hand you — it's dominated by your database size, your download bandwidth, and one DNS setting most people only think to change after they needed to.

Navigate

How to Host Unlimited Domains on One VPS With Free SSL

"Unlimited domains, one server" sounds like a sales pitch, but it's a genuinely accurate description of what SNI-based virtual hosting and Let's Encrypt automation do together. The real ceiling isn't domain count. It's RAM.

Navigate

Wildcard Domains With Coolify and Cloudflare

A wildcard certificate can't be proven the way a normal one is — there's no single page to fetch for a domain that doesn't exist yet. That's why it needs a DNS record instead of an HTTP request, and why Cloudflare's API has to be involved at all.

Navigate

How I Get Alerted the Moment a Self-Hosted App Goes Down

A monitor that checks the wrong thing, sends to a channel you don't watch, and pages you for every 30-second blip is worse than no monitor — it trains you to ignore it. Here's how to build alerting that actually works, for one person who eventually has to sleep.

Navigate