AuthonAuthon Blog
debugging8 min read

Docker Bypasses UFW: Your Firewalled Database Is Probably Public. Here's the Fix

ufw status will say everything is denied while -p 5432:5432 is open to the internet. One docker ps line shows it; one daemon.json change stops it.

AW
Alan West
Authon Team
Docker Bypasses UFW: Your Firewalled Database Is Probably Public. Here's the Fix

If you run ufw default deny incoming on a VPS and then docker run -p 5432:5432 postgres, your Postgres is reachable from the whole internet. UFW will still tell you everything is denied. Docker has bypassed UFW since at least 2014, and the fix is a one-line change most people never hear about.

This week's Docker is weird essay made the rounds on Hacker News and Lobsters. It's a fun tour of internals: containers are just processes, images are just folders, runc re-execs /proc/self/exe because Go can't fork. All of it is true and weird, and none of it will get your database dumped by a bot at 3am.

The weird default that will is buried in the comments. One HN commenter put it plainly: "Whats interesting and dangerous is how docker just overrides ufw rules like they do dont exist." On Lobsters, people were still arguing about whether containers are reachable from outside by default. They are, if you published the port.

My take: Docker's defaults are tuned for a five-minute demo on a laptop. Running them unchanged on a server with a public IP is negligent.

Why Docker ignores UFW

UFW is a frontend for iptables, and it puts its rules in the filter table on the INPUT chain. That's where packets addressed to the host itself get checked.

When you publish a port, Docker writes DNAT rules in the nat table. The packet gets rewritten to the container's IP before it ever reaches INPUT, then goes out through FORWARD. UFW never sees it. Docker's own packet filtering docs say so directly: "Docker and ufw use firewall rules in ways that make them incompatible with each other."

The port publishing docs open with "Publishing container ports is insecure by default." Fair enough, it's documented. But -p 5432:5432 is what every README, every Compose example and every Stack Overflow answer tells you to type.

People have been complaining about this for over a decade. moby/moby#4737, "docker and ufw serious problems", was opened on March 18, 2014. In 2023 a maintainer wrote that the behavior is "what the feature was designed to do and has been this way for over 10 years now." In November 2025 another maintainer said they'd like to get custom portmappers done for v30, to give UFW users a proper way to publish ports. The newest Engine release as I write this is 29.9.2, from October 20, 2026, so assume nothing changed.

Check if your Docker ports are public in one command

On the host:

bash
docker ps --format '{{.Names}}\t{{.Ports}}' | grep -E '0\.0\.0\.0|\[::\]|:::'

Any line that comes back is a container listening on every interface. 5432, 6379, 27017, 9200 and 3306 showing up here should ruin your afternoon.

Then confirm from a machine outside your network, because that's the view that matters:

bash
nmap -Pn -p 5432,6379,27017 your.server.ip
ufw status is useless for this. It only reports the rules UFW owns.

Also watch IPv6. Per the docs, if you don't specify a host IP and the bridge network is IPv4-only, ports on the host's IPv6 addresses still map to the container through the userland proxy, which is on by default. If your hosting provider handed you a public v6 address, that's a second door.

Fix 1: bind to 127.0.0.1 (do this today)

Most databases have no business listening on a public interface anyway. Bind the published port to loopback:

bash
docker run -p 127.0.0.1:5432:5432 -p '[::1]:5432:5432' postgres

In Compose:

yaml
services:
  db:
    image: postgres
    ports:
      - "127.0.0.1:5432:5432"

If the app talking to Postgres is also in Compose, drop ports: entirely. Containers on the same network reach each other by service name. Published ports are only needed for traffic coming from outside Docker.

To change the default so a forgotten 127.0.0.1: doesn't burn you, set it in /etc/docker/daemon.json. The ip key covers the default bridge; default-network-opts covers user-defined bridges, which is what Compose creates:

json
{
  "ip": "127.0.0.1",
  "default-network-opts": {
    "bridge": {
      "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
    }
  }
}

Restart the daemon. Now publishing to the world takes an explicit 0.0.0.0:, which is how it should have worked from the start. Swarm services ignore this setting and still publish on 0.0.0.0. I'd put this in every server's base image.

Fix 2: filter in the DOCKER-USER chain

When a port really must be public but only to some sources, Docker gives you the DOCKER-USER chain. Per the iptables docs, it's a placeholder for your own rules, processed before Docker's DOCKER-FORWARD and DOCKER chains.

Allow only one subnet in on the external interface:

bash
iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.0/24 -j DROP
iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT

Watch the order. Both use -I, so the second rule ends up on top, and that's what you want, because return traffic has to be accepted before the DROP. Replace ext_if with your real interface. These rules don't persist by themselves, so wire them into whatever restores your iptables on boot.

Fix 3: ufw-docker, if your team lives in UFW

chaifeng/ufw-docker adds a DOCKER-USER section to /etc/ufw/after.rules that sends forwarded traffic through UFW's ufw-user-forward chain and drops new public connections to private ranges. After that, ufw route rules decide what the internet can reach:
bash
ufw-docker install
ufw-docker allow httpd 80
# or by hand:
ufw route allow proto tcp from any to any port 80

One gotcha from the README: you allow the container port. For -p 8080:80, the rule uses 80, not 8080. That trips everyone once.

Fix 4: rootless Docker or rootless Podman

Rootless runtimes can't write the host's iptables NAT rules, so published ports are served by a userspace listener on the host side (RootlessKit's port docs show the parent-side address mapping). Traffic then hits a normal host socket, and your host firewall is back in the path. In the HN thread one commenter said they switched to Podman because "it doesn't need to run as root" and has no daemon, and a few others said they prefer rootless Podman for the same reasons.

Rootless has its own problems, though. Ports below 1024 need CAP_NET_BIND_SERVICE on rootlesskit or net.ipv4.ip_unprivileged_port_start=0 (rootless tips), and source IP propagation depends on which port driver you use. Test with nmap before you trust it.

| Fix | Effort | Covers | Catch |
|---|---|---|---|
| 127.0.0.1: binding | One line | Internal services | Doesn't help for public ports |
| daemon.json default IP | One file, daemon restart | Every future container | Explicit 0.0.0.0: still wins |
| DOCKER-USER rules | Raw iptables | Source-restricted public ports | You maintain persistence |
| ufw-docker | Install script | Teams that think in UFW | Third-party rules in after.rules |
| Rootless Docker / Podman | Migration | Host firewall applies again | Low ports, networking quirks |

Two more defaults built for demos

First, logs grow forever. The default json-file driver has max-size set to -1, unlimited, according to the docs. A chatty container will fill /var/lib/docker eventually. Set this in daemon.json:

json
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

It only applies to containers created after the change, so recreate the old ones.

Second, build cache eats the disk. BuildKit does garbage-collect by default, but the default limits are sized in gigabytes, which is a lot on a small VPS. docker system df shows where the space went, and docker buildx prune clears the cache for the current builder. docker system prune takes unused build cache too, along with stopped containers, unused networks and dangling images, but not volumes. --volumes adds unused anonymous ones; named volumes stay. That part I think Docker got right.

And if you test on a Mac: your containers don't run on macOS at all. Docker Desktop runs them in a Linux VM, which by default gets 50% of your host's memory. There's no UFW in that picture, so a port that looks fine on your laptop tells you nothing about the Ubuntu box.

The checklist

  • Run the docker ps | grep 0.0.0.0 check on every server you own.
  • Add 127.0.0.1: to every database, cache and admin UI port.
  • Set the default host binding IP in daemon.json.
  • Use DOCKER-USER or ufw-docker for anything that must be public.
  • Cap log size while you're editing daemon.json.
  • To be fair to Docker, every one of these is in the docs, stated plainly. The defaults still assume you're on a laptop behind NAT, and plenty of us aren't.

    Have you ever found a container port open to the internet that you thought was firewalled? What was it, and how did you find out? And if you think Docker is right to own the firewall here, I'd like to hear that argument too.

    Sources

    Docker Bypasses UFW: Your Firewalled Database Is Probably Public. Here's the Fix | Authon Blog