Docker bypasses UFW: why published ports stay open, and the fix
Ports published by Docker are reachable from the Internet even when UFW says deny. How it happens, how to test from outside, and a clean fix on Debian 13.
On my Debian 13 VPS, UFW is set to deny incoming and allows exactly three ports: 22, 80 and 443, over both IPv4 and IPv6. sudo ufw status agrees. Everything looks tidy. And yet a container started with -p 5432:5432 answered from the Internet, while UFW calmly reported that 5432 was not allowed.
This is not a bug. It is how Docker works, and the official docs now say it plainly: Docker and UFW are incompatible. The catch is that most people find out after their database has been exposed, not before.
By the end of this post you will know why UFW never sees those packets, how to prove it from another machine (the only test that counts), the fix I put in /etc/docker/daemon.json, the limitation of that fix that the docs barely mention, and how the alternatives compare: DOCKER-USER, ufw-docker, "iptables": false, and simply not publishing anything.
Why UFW never sees Docker’s published ports
UFW filters traffic addressed to the host itself. Its rules live in the INPUT chain of the filter table. A port published by Docker never goes through there.
Here is the path of a packet hitting 203.0.113.10:5432 when a container publishes -p 5432:5432:
nat/PREROUTING: Docker has added a DNAT rule. The destination is rewritten to the container’s address, for example172.18.0.2:5432.- Routing decision: the destination is no longer the host but an address on a Docker bridge. The packet is forwarded, not delivered locally.
filter/FORWARD: Docker inserts its jumps toDOCKER-USER, thenDOCKER-FORWARDand friends, at the top of this chain. They accept the packet, because the port is published.
The INPUT chain, where UFW lives, is never traversed. UFW’s own ufw-*-forward chains sit in FORWARD after Docker’s, so they arrive too late: the packet has already been accepted.
You can see it in the rules. On Debian 13 the iptables command is backed by nftables (iptables-nft), but the output reads the same:
# The address translation Docker adds for each published port
sudo iptables -t nat -S DOCKER
# Jump order in FORWARD: Docker's first, UFW's after
sudo iptables -S FORWARD
In the first output, every published port has its -j DNAT --to-destination rule. In the second, -j DOCKER-USER and -j DOCKER-FORWARD come before -j ufw-before-forward.
Test from outside, not from the server
ufw status will not show you the problem, and testing from the server itself is misleading: a local connection does not take the same path as a packet coming from the Internet. The only test that proves anything runs from another machine.
To reproduce it without exposing a real database, an nginx container published on 5432 will do:
# On the VPS: a throwaway container publishing port 5432
docker run -d --rm --name test-5432 -p 5432:80 nginx:alpine
Then, from your workstation:
# -Pn: skip host discovery, scan straight away
nmap -Pn -p 22,80,443,5432 203.0.113.10
# Same check with netcat (netcat-openbsd on Debian/Ubuntu)
nc -zv -w 3 203.0.113.10 5432
With UFW on deny incoming, a port that is not allowed shows as filtered in nmap: UFW drops the packet without replying. A port published by Docker shows as open, even though it is nowhere in UFW’s rules. If the host has an IPv6 address, repeat over IPv6 (nmap -6 -Pn -p 5432 2001:db8::10): with no host address in the mapping, Docker publishes on 0.0.0.0 and [::].
On the server, two commands complete the picture:
# What Docker published, and on which address
docker ps --format 'table {{.Names}}\t{{.Ports}}'
# What is actually listening on the host
sudo ss -tlnH
docker ps shows 0.0.0.0:5432->80/tcp for a port open to everyone and 127.0.0.1:5432->80/tcp for one restricted to the host. Beware: it also shows a bare 5432/tcp for a Postgres image that published nothing. That is a declarative EXPOSE, not a published port. ss -tlnH shows the docker-proxy process listening for each published port (as long as userland-proxy is left on), and that is the source of truth.
Clean up when you are done: docker stop test-5432.
The fix: bind published ports to 127.0.0.1 by default
Rather than trying to make Docker and UFW agree, I changed the default address for published ports. My /etc/docker/daemon.json:
{
"ip": "127.0.0.1",
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
The ip key sets the host address used when a port mapping does not specify one. -p 5432:5432 effectively becomes 127.0.0.1:5432:5432: reachable from the host (handy for a quick psql while debugging), invisible from the Internet. The other two keys are unrelated to the firewall; they cap container logs at three 10 MB files.
Validate the file before restarting the daemon, or Docker will not come back up:
sudo dockerd --validate --config-file /etc/docker/daemon.json
sudo systemctl restart docker
The restart stops every container; those with restart: unless-stopped come back on their own.
The direct consequence: the one service that must be public, the reverse proxy, now has to ask for it explicitly. From Caddy’s docker-compose.yml:
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
# Explicit 0.0.0.0: published ports bind to 127.0.0.1 by default,
# and Caddy is the one service that must be public.
ports:
- "0.0.0.0:80:80"
- "0.0.0.0:443:443"
- "0.0.0.0:443:443/udp" # HTTP/3
- "[::]:80:80" # IPv6
- "[::]:443:443"
- "[::]:443:443/udp"
networks:
- web
networks:
web:
external: true
A trap I hit: 0.0.0.0 covers IPv4 only. My domain had an AAAA record, but Caddy answered no IPv6 connection at all. Browsers quietly fall back to IPv4, so I only found out while investigating a sitemap Search Console couldn’t fetch. If your server has an IPv6 address in DNS, publish [::] too.
After the change, a test container published on 5432 stayed unreachable from the Internet. I like the principle: opening a port takes a deliberate step, not closing one. A stray ports: line in a Compose file no longer means an open door. (If your site sits behind Cloudflare, I also covered how to block direct access to Caddy.)
The catch: ip only covers the default bridge network
This is what most tutorials miss, and the port publishing docs mention it in a single line: the ip key changes the default binding address for the default bridge network, the one a docker run without --network uses. Docker’s source confirms it: the option is only applied when that one network is created (initBridgeDriver).
Docker Compose creates its own networks (myproject_default, internal…), and external networks such as web are user-defined networks too. The ip key does not apply to them: ports: ["5432:5432"] in a Compose file still binds to every address.
To cover those networks as well, use default-network-opts, available since Docker 24:
{
"ip": "127.0.0.1",
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
},
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Two details:
- The option applies when a network is created. Existing networks keep their options, so recreate them:
docker compose downthenup -dfor a project’s networks,docker network rm webthendocker network create webfor an external network, once its containers are stopped. - To check that a network picked it up:
docker network inspect web --format '{{json .Options}}'
The output should include "com.docker.network.bridge.host_binding_ipv4":"127.0.0.1".
On my VPS, two safeguards cover this gap, and they matter more than any daemon option. First, the project rule: no ports: anywhere except on Caddy, and never a database port, not even on localhost (next section). Second, a weekly check run by a systemd timer (see why I moved from cron to systemd timers) looks for anything listening outside loopback:
# Listening ports not on 127.x or [::1], excluding 22/80/443
ss -tlnH | awk '$4 !~ /^(127\.|\[::1\])/ {print $4}' | grep -vE ':(22|80|443)$'
And an audit reads the actual host address of every port published by running containers:
docker ps -q | xargs -r docker inspect \
--format '{{.Name}} {{range $p, $l := .NetworkSettings.Ports}}{{range $l}}{{$p}}={{.HostIp}} {{end}}{{end}}'
Any port other than 80/443 published on 0.0.0.0 or :: gets flagged. The daemon setting is a safety net; these checks make sure the net is still there.
What changed in Docker 28 and 29
The “Packet filtering and firewalls” docs have moved a lot recently. What matters here:
- Docker 28.0 closed a hole in the fix itself. Before 28.0.0, hosts on the same layer-2 segment (the same switch) could reach ports published to
127.0.0.1(moby#45610). At a hosting provider, your L2 neighbours are other customers. My VPS runs Docker 29.8.1, so it is not affected, but on an older release binding to localhost was not enough. - Docker 28.0.1 reorganised the chains: most of Docker’s rules moved out of
FORWARDinto aDOCKER-FORWARDchain, andDOCKER-USERis still called before it (release notes). - Docker 29 adds an nftables backend, enabled with
"firewall-backend": "nftables". It is experimental, cannot be used in Swarm mode, and no longer enables IP forwarding itself. Most importantly, there is noDOCKER-USERchain any more. Docker creates its ownip docker-bridgesandip6 docker-bridgestables, and your rules must live in a table of your own, with a priority set relative to Docker’s base chains. Anything built onDOCKER-USER(hand-written rules, ufw-docker) is silently ignored.
The default backend is still iptables. The binding-address fix does not depend on the backend and works the same with both.
The alternatives, and why none of them is enough on its own
Publish nothing: go through a shared Docker network
This is the real solution, and it complements the daemon fix. In my project template, the application publishes no ports at all. Caddy reaches it from the inside, over a shared Docker network:
services:
api:
# NO `ports:`. Caddy reaches this container over the `web` network.
networks:
web:
aliases: [myproject-api] # unique alias, targeted by Caddy
internal: {}
db:
image: postgres:18-alpine
networks: [internal] # no Internet, no other projects
networks:
web:
external: true
internal:
internal: true
In the Caddyfile, reverse_proxy myproject-api:8000 is all it takes. An unpublished port creates no DNAT rule, so there is nothing to bypass. The daemon setting only catches the day a ports: line sneaks back in.
The DOCKER-USER chain
Docker reserves DOCKER-USER for administrator rules, evaluated before its own. You can use it to enforce “only 80 and 443 get in”:
# eth0 = the public interface (check with `ip route show default`)
sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
sudo iptables -I DOCKER-USER 2 -i eth0 -p tcp -m conntrack --ctorigdstport 80 -j RETURN
sudo iptables -I DOCKER-USER 3 -i eth0 -p tcp -m conntrack --ctorigdstport 443 -j RETURN
sudo iptables -I DOCKER-USER 4 -i eth0 -p udp -m conntrack --ctorigdstport 443 -j RETURN
sudo iptables -I DOCKER-USER 5 -i eth0 -j DROP
conntrack --ctorigdstport is essential: by the time a packet reaches DOCKER-USER, DNAT has already happened, so --dport would match the container’s port, not the one the client asked for (Docker’s iptables docs). The first rule lets replies to the containers’ outbound connections through.
Why I did not go this way: the rules do not survive a reboot without extra persistence tooling, they need an ip6tables twin, they are a second allow-list to keep in sync with UFW, and they silently stop working the day you switch to the nftables backend.
ufw-docker
ufw-docker automates the previous approach: ufw-docker install adds a block to /etc/ufw/after.rules that hooks DOCKER-USER into UFW’s route rules. You then open a service with ufw route allow proto tcp from any to any port 80.
It is the best option if you want to drive the whole firewall from UFW. Two caveats: rules target the container port, not the host port (for -p 8080:80 you allow 80), which is easy to get wrong; and the tool relies entirely on DOCKER-USER, so it does not work with Docker 29’s nftables backend. I preferred to add nothing: fewer moving parts, less drift.
"iptables": false: the tempting bad idea
Plenty of old threads suggest putting "iptables": false in daemon.json. Docker stops touching the firewall, UFW is back in charge. On paper.
In practice, the docs are blunt: it “is likely to break container networking”. Without masquerading rules, containers on bridge networks lose Internet access, and without filtering rules, all their ports become reachable from hosts on the local network. You trade one leak for an outage plus another leak. The option applies to the nftables backend too. Avoid it.
Side by side
| Approach | Effort | Survives reboot | nftables backend | What it protects |
|---|---|---|---|---|
| Publish nothing + shared network | low | yes | yes | everything except the front door |
ip + default-network-opts |
once | yes | yes | any ports: without an address |
Hand-written DOCKER-USER |
medium | no (without tooling) | no | depends on the rules |
| ufw-docker | medium | yes | no | depends on UFW rules |
"iptables": false |
low | yes | - | nothing, and breaks networking |
Key takeaways
- A port published by Docker goes through
natthenFORWARD, neverINPUT: UFW does not see it, whateverufw statussays. - The only reliable test runs from another machine:
nmap -Pn -p <ports> <ip>, over IPv4 and IPv6. "ip": "127.0.0.1"indaemon.jsononly covers the defaultbridgenetwork. Adddefault-network-optsfor Compose networks, and recreate existing networks.- The reverse proxy publishes its ports explicitly on
0.0.0.0and[::](otherwise no IPv6); everything else goes through a Docker network with noports:. DOCKER-USERand ufw-docker work, but not with Docker 29’s nftables backend."iptables": falsebreaks networking.- A periodic check (
ss -tlnH,docker inspect) makes sure the rule still holds over time.