DEV Community

jidonglab
jidonglab

Posted on

Docker Bypasses UFW: 3 Ports I Denied Were Open to the Internet

sudo ufw status said 5432 DENY IN Anywhere. My laptop, sitting on a coffee shop network, connected to that Postgres port on my VPS on the first try.

That was the moment I learned that Docker bypasses UFW. Not because of a bug, not because I misconfigured UFW, but because of where each tool hooks into the Linux firewall. I ran nmap against my own box and found three ports I had explicitly denied, all wide open: Postgres, Redis, and an internal admin dashboard.

Here is exactly why it happens, how to reproduce it in five minutes, and the fixes I now apply to every server.

TL;DR

  • When you publish a port with docker run -p 5432:5432 or Compose ports: - "5432:5432", Docker binds it on all interfaces and writes its own iptables rules.
  • Those rules rewrite the packet's destination to the container's IP before UFW looks at it, so the packet goes through the FORWARD chain. UFW's deny rules live on the INPUT chain. They never see it.
  • ufw status keeps lying to you with a straight face. Test from another machine, never from localhost.
  • Fixes, in order of preference: don't publish internal ports at all, bind to 127.0.0.1:5432:5432, set "ip": "127.0.0.1" in daemon.json as a safety net, and filter public ports in the DOCKER-USER chain.
  • Don't "fix" it with "iptables": false. That breaks container networking in quieter, worse ways.

Why does Docker bypass UFW?

Docker bypasses UFW because published ports are handled with NAT, and NAT happens before UFW's input rules ever run. The packet stops being "traffic to this host" and becomes "traffic being forwarded to a container", which is a different chain entirely.

Walk one packet through it. Someone on the internet sends a SYN to your-server:5432.

  1. It hits the nat table's PREROUTING chain first. Docker put a jump there to its own DOCKER chain.
  2. The DOCKER chain has a DNAT rule: "anything to port 5432, rewrite destination to 172.18.0.3:5432".
  3. The kernel now makes a routing decision. The destination is a container IP on a bridge, not the host. So the packet is forwarded, not delivered locally.
  4. Forwarded packets traverse the filter table's FORWARD chain. Docker added accept rules there for published ports.
  5. UFW's rules (ufw-user-input and friends) are attached to INPUT. The packet never enters INPUT. Your deny 5432 is never evaluated.

UFW is guarding the front door. Docker built a tunnel to the back.

How do I check if my Docker ports are exposed despite UFW?

Scan your server from a machine that is not the server. Localhost tests prove nothing here, because loopback traffic doesn't take the same path.

Here's the five-minute reproduction on a throwaway VM:

# On the server
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw deny 8080/tcp      # belt and braces, surely

docker run -d --name leak -p 8080:80 nginx
sudo ufw status              # 8080 DENY IN Anywhere. Looks great.
Enter fullscreen mode Exit fullscreen mode
# From your laptop
curl -s http://SERVER_IP:8080 | head -n 4
# <!DOCTYPE html>
# <html>
# <head>
# <title>Welcome to nginx!</title>
Enter fullscreen mode Exit fullscreen mode

Now look at what Docker actually did:

sudo ss -tlnp | grep 8080
# LISTEN 0 4096 0.0.0.0:8080 ... users:(("docker-proxy",...))

sudo iptables -t nat -L DOCKER -n
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80

sudo iptables -L FORWARD -n --line-numbers | head
# 1  DOCKER-USER ...
# ... Docker's own accept chains follow
Enter fullscreen mode Exit fullscreen mode

That 0.0.0.0:8080 is the whole story in one line. Then run the real audit against your production box:

nmap -Pn -p 1-65535 SERVER_IP
Enter fullscreen mode Exit fullscreen mode

Every port in that output that you didn't mean to serve is a port the internet can reach. On my box: 5432, 6379, 8081.

Why did my docker-compose ports end up public?

Because ports: - "5432:5432" in Compose means exactly the same thing as -p 5432:5432: bind on every interface. I had added those lines months earlier so I could point a GUI client at the database during development. The file got copied to the VPS. Nobody, meaning me, removed them.

The Redis one was worse. No password, because "it's only on the Docker network." It was not only on the Docker network. The Postgres logs had failed login attempts for user postgres from IP addresses I had never seen, which is what bots scanning for default credentials look like.

Nothing was breached, as far as I can tell. That's luck, not engineering.

How do I stop Docker from bypassing UFW?

The most reliable fix is to stop publishing ports that only other containers need, and bind the rest to 127.0.0.1. Firewall tricks come after that. Here are the four fixes I use, ranked.

Fix 1: Don't publish internal services at all

Containers on the same Compose network already reach each other by service name. Your app connects to postgres:5432 over the internal bridge. No ports: needed.

services:
  app:
    build: .
    ports:
      - "80:8000"          # the one thing the world should see
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
  db:
    image: postgres:16
    # no ports: section. Reachable as "db" from app, invisible from outside.
Enter fullscreen mode Exit fullscreen mode

If you see expose: in old Compose files, that one is harmless. It documents a port for other containers and publishes nothing on the host.

Fix 2: Bind to loopback when you need host access

When I genuinely want to hit Postgres from my laptop, I bind it to localhost on the server and tunnel in:

  db:
    ports:
      - "127.0.0.1:5432:5432"
Enter fullscreen mode Exit fullscreen mode
ssh -L 5432:localhost:5432 me@SERVER_IP
# now psql -h localhost works from the laptop
Enter fullscreen mode Exit fullscreen mode

The DNAT rule still exists, but it only matches traffic addressed to 127.0.0.1. Nothing arriving on the public interface matches it.

Fix 3: Make loopback the default in daemon.json

Fixes 1 and 2 depend on me remembering. I don't trust me. So the daemon gets a safety net:

{
  "ip": "127.0.0.1"
}
Enter fullscreen mode Exit fullscreen mode

Put that in /etc/docker/daemon.json and restart Docker. Now a bare -p 5432:5432 binds to 127.0.0.1 instead of 0.0.0.0. To expose something publicly you have to say so explicitly with -p 0.0.0.0:80:80. Public exposure becomes a decision you type, not a default you forget.

Recreate your containers after the change. Existing ones keep the bindings they were created with.

Fix 4: Filter public ports in DOCKER-USER

Some ports must be public but restricted, like an admin panel only your office IP should reach. UFW can't help, but the DOCKER-USER chain can. Docker jumps to it at the top of FORWARD and leaves its contents alone.

The catch: by the time a packet reaches FORWARD, DNAT already rewrote the destination port. Match the original port with conntrack:

# Allow one source IP to the published port 8081, drop everyone else
sudo iptables -I DOCKER-USER -p tcp -m conntrack \
  --ctorigdstport 8081 --ctdir ORIGINAL -j DROP
sudo iptables -I DOCKER-USER -p tcp -m conntrack \
  --ctorigsrc 203.0.113.10 --ctorigdstport 8081 --ctdir ORIGINAL -j ACCEPT
Enter fullscreen mode Exit fullscreen mode

-I inserts at the top, so the ACCEPT added second lands above the DROP. These are plain iptables rules, so persist them with whatever your distro uses. There's also a community project called ufw-docker that wires UFW into this chain if you'd rather keep managing rules through ufw.

Why not just set "iptables": false?

Because it trades one surprise for several. With Docker's iptables management off, containers lose the NAT rules that let them reach the internet, published ports stop working the way Compose expects, and you're now hand-maintaining masquerade rules for every bridge network. People do this, then spend a weekend debugging why apt-get inside a container can't resolve anything.

The other layer worth knowing: if your VPS provider offers a network firewall or security groups, those sit outside the host. Docker can't punch through them because Docker never touches them. I now keep a provider-level rule set that only allows 22, 80 and 443, as a second wall that doesn't care what iptables thinks.

My new checklist for every Docker host

  • nmap -Pn -p- SERVER_IP from outside, after every deploy that touches ports:.
  • grep -rn "ports:" -A3 docker-compose*.yml and justify every line that isn't 127.0.0.1:.
  • "ip": "127.0.0.1" in daemon.json on day one.
  • Databases, caches and queues never get a ports: entry in production.
  • Provider firewall allows only what the public is supposed to use.

The scary part of this bug is that every tool reported success. UFW said deny. Compose said healthy. The app worked. Only a scan from the outside told the truth.

So, does Docker bypass UFW? Yes. Any port published with -p or Compose ports: is bound on all interfaces and routed through Docker's NAT and FORWARD rules, which run before UFW's INPUT rules can see the traffic, so ufw deny has no effect on it. Fix it by not publishing internal services, binding needed ones to 127.0.0.1, setting "ip": "127.0.0.1" in daemon.json, and putting any source filtering for public ports in the DOCKER-USER chain, then verify with a port scan from a different machine.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)