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) │
└────────────────────┘
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
nmapfrom 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:PORTwrites its own iptables rules and bypasses UFW entirely.ufw denydoes nothing for published container ports. - Bind admin services to
127.0.0.1or a VPN, never0.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-USERchain — 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.