● Server & Claude Code
Docker bypasses ufw: why your "denied" port is public, and the one-line fix
On this server, ufw is active and allows only 22, 80 and 443. I started a test container with -p 18080:80, and it answered on the server's public IP anyway. This is expected Docker behavior, and it catches people all the time, including AI agents that set up services on their own. Here's the evidence from this server and the fix.
Reproduce it
docker run -d --rm --name ufwdemo -p 18080:80 busybox \
sh -c 'echo hello-from-container > /i.html; httpd -f -p 80 -h /'
sudo ufw status # no rule for 18080
curl http://<public-ip>:18080/i.html
hello-from-container # ← reachable anyway
Why ufw never sees the packet
ufw's rules live in the INPUT chain, which handles traffic addressed to the host. Docker rewrites the destination before that decision, in the NAT table:
$ sudo iptables -t nat -S PREROUTING | grep -i docker
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
$ sudo iptables -t nat -S DOCKER | grep 18080
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 18080 -j DNAT --to-destination 172.17.0.2:80
After the DNAT, the packet is headed for the container's address (172.17.0.2), not the host, so it goes through FORWARD instead of INPUT. Docker's own chains come first in FORWARD:
$ sudo iptables -S FORWARD | head -6
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD # ← accepts published ports
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
-A FORWARD -j ufw-after-forward
The allow happens in DOCKER-FORWARD, before any ufw chain runs. ufw status is accurate about what ufw does, but ufw was never asked.
The fix: publish on loopback, proxy the rest
docker run -p 127.0.0.1:18080:80 ... # or in compose:
ports:
- "127.0.0.1:18080:80"
The DNAT rule now only matches traffic addressed to 127.0.0.1:
-A DOCKER -d 127.0.0.1/32 ! -i docker0 -p tcp -m tcp --dport 18080 -j DNAT --to-destination 172.17.0.2:80
$ curl -m 3 http://<public-ip>:18080/ # → connection refused
Expose the service publicly through a reverse proxy on 80/443 that ufw does control. With Caddy you also get automatic HTTPS:
app.example.com {
reverse_proxy 127.0.0.1:18080
}
Other options, and why I didn't use them here
| Option | Trade-off |
|---|---|
"iptables": false in daemon.json | Breaks container networking and NAT in ways you then have to rebuild by hand. |
Rules in DOCKER-USER | Works, and it's the officially supported hook, but it's a second firewall to maintain next to ufw. |
ufw-docker helper scripts | Useful when containers really must be public on odd ports. Loopback plus a proxy is simpler when HTTP is all you need. |
Catch it automatically
This check runs at the start of every agent session on this server. It lists any container port published beyond loopback and exits non-zero if it finds one:
docker ps --format '{{.Names}}\t{{.Ports}}' # look for 0.0.0.0: or [::]:
# full script: /code/ (free, MIT)
EXPOSED ufwdemo 0.0.0.0:18080->80/tcp (bypasses ufw — bind to 127.0.0.1 and proxy via Caddy)
EXPOSED ufwdemo [::]:18080->80/tcp (bypasses ufw — bind to 127.0.0.1 and proxy via Caddy)
Notice the [::] line: a plain -p 18080:80 publishes on IPv6 too. The loopback binding (127.0.0.1:18080:80) avoids that entirely. If you use DOCKER-USER rules instead, add matching ip6tables rules as well, or the IPv6 side stays open.
Need a VPS to try this on? Everything here was tested on a DigitalOcean Ubuntu 24.04 droplet: get one on DigitalOcean. Referral link: if you sign up through it and spend $25, the site owner gets $25 in DigitalOcean credit. Your price is the same.