It started as a slow-afternoon habit: scan yourself the way an attacker would. Pull up Shodan, type in your own public IP, and brace.

I expected a clean sheet. UFW was configured. ufw deny on everything that wasn’t 80, 443, and the VPN. I’d checked it a dozen times. Green across the board.

Shodan disagreed.

There it was, indexed and timestamped: an admin/management panel answering on a port that was supposed to be firewalled into oblivion. Banner, title, the works. Anyone with a browser could’ve found it. Some of them probably had.

The investigation

First reaction: Shodan’s cache is stale. It happens. So I scanned from the outside, from a box that had no business reaching anything internal.

nmap -Pn -p 9000 192.0.2.10 --open   # from a cloud box, not my network
# (really: nmap -Pn -p 9000 <my-public-ip>)
nmap -Pn -p 9000 192.0.2.10
PORT     STATE  SERVICE
9000/tcp open   http

Open. From the public internet. Not stale.

So I SSH’d in and asked UFW what it thought it was doing.

sudo ufw status verbose | grep 9000
# (nothing — port 9000 is not allowed)
sudo ufw status | head
# Status: active
# 22/tcp   ALLOW   Anywhere
# 443/tcp  ALLOW   Anywhere

UFW was adamant: 9000 is closed. The internet was equally adamant: 9000 is open. One of them was lying, and firewalls don’t lie. They just get bypassed.

The “aha”

Then I remembered what was actually listening on 9000.

A Docker container.

docker ps --format '{{.Names}}\t{{.Ports}}'
# admin-panel   0.0.0.0:9000->9000/tcp

0.0.0.0:9000->9000/tcp. There it is. That -p 9000:9000 in the compose file. Docker doesn’t ask UFW for permission — it writes its own iptables rules, straight into the DOCKER chain, and they get evaluated before your tidy UFW INPUT rules ever run.

Your ufw deny is a polite note on the front door. Docker built a second door around back and propped it open.

  Inbound packet → :9000
  ┌───────────────────────────┐
  │ iptables nat/PREROUTING   │
  │  DOCKER chain (DNAT)  ◄────┼── Docker put this here.
  │  → 172.17.0.2:9000        │     UFW never gets a vote.
  └───────────────────────────┘
        │            ╲
        ▼             ╲ (never reached)
   container :9000     ┌────────────────────┐
                       │ ufw-user chain     │
                       │  DENY 9000  (moot) │
                       └────────────────────┘
Why the firewall didn't matter

internet

DOCKER chainDNAT :9000 & DNAT winsfirst< ufw,="" host="" rule="">skippedufw DENY 9000 (moot)admin-panel172.17.0.2:9000
Docker's DNAT rule wins the race. UFW's deny never gets to vote.

The fix

The publish target was the bug. 0.0.0.0 means “the whole world.” It almost never should.

I bound the panel to loopback only and put it behind the reverse proxy with auth, reachable just through the VPN.

# docker-compose.yml — before
    ports:
      - "9000:9000"        # = 0.0.0.0:9000, published to the internet

# after
    ports:
      - "127.0.0.1:9000:9000"   # localhost only; proxy/VPN reaches it
docker compose up -d
docker ps --format '{{.Names}}\t{{.Ports}}'
# admin-panel   127.0.0.1:9000->9000/tcp

For anything that genuinely must survive the firewall, lock it at the DOCKER-USER chain — the one place Docker promises not to clobber:

# allow only the tailnet, drop everyone else for published container ports
sudo iptables -I DOCKER-USER -i eth0 -s 100.64.0.0/10 -j RETURN
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 9000 -j DROP

Re-scan from outside:

nmap -Pn -p 9000 192.0.2.10
# PORT     STATE    SERVICE
# 9000/tcp filtered http

filtered. Dark. The panel only answers behind the tunnel now.

Why it happened

Nobody screwed up the firewall. The firewall did exactly what it was told. The trap is the mental model: people assume UFW sits at the front gate and inspects everything inbound. Docker doesn’t go through that gate — it cuts its own keyhole in iptables and your host rules never see the packet.

-p 9000:9000 reads like “expose this locally.” It actually means “publish this to 0.0.0.0.” One missing 127.0.0.1: prefix and an internal admin tool was on the public internet for who-knows-how-long, indexed by Shodan, waiting.

Takeaways

  • Scan yourself the way an attacker would. Shodan plus an external nmap from a box outside your network is the only ground truth. Your config file is a hypothesis, not a result.
  • Docker port publishing ignores your host firewall. -p PORT:PORT writes its own iptables rules and bypasses UFW entirely. ufw deny does nothing for published container ports.
  • Bind admin services to 127.0.0.1 or a VPN, never 0.0.0.0. Always prefix the publish: 127.0.0.1:9000:9000. Default-public is a footgun.
  • Use Docker-aware firewalling. If a container port truly needs outside reach, gate it in the DOCKER-USER chain — the one chain Docker won’t overwrite.
  • Admin panels belong on localhost or a tunnel, behind a reverse proxy and auth. Convenient-but-public is just public.