All news

Docker UFW bypass: why ufw status says closed but the port is open

 |  How-to

A port published with docker run -p 8080:80 is reachable from the internet even when ufw status shows no rule allowing it. Docker writes its own iptables rules, and traffic to a published port is forwarded to the container before it reaches the rules UFW manages. This guide shows the mechanism, how to see it on the host, how to confirm it from outside, and five ways to fix it, safest first. The facts below were checked against the Docker Engine 29 documentation (29.9.2 is the current release) in October 2026.

Why Docker bypasses UFW

UFW keeps its rules in the iptables INPUT chain, which handles traffic addressed to the host itself. When you publish a port, Docker adds two kinds of rules of its own:

  • In the nat table, PREROUTING jumps to Docker's DOCKER chain, which rewrites the destination (DNAT) from the host's port 8080 to the container's address and port 80.
  • In the filter table, the FORWARD chain jumps to Docker's chains, DOCKER-USER and DOCKER-FORWARD, which then accept the forwarded traffic.

Because the destination is rewritten in PREROUTING, the packet is routed to the container and travels through FORWARD. Docker's documentation puts it directly: packets are diverted before they reach the INPUT and OUTPUT chains that ufw uses. A ufw deny 8080 rule sits in a chain this traffic never visits, so ufw status looks correct while the port answers the whole internet.

Two defaults widen the exposure:

  • -p 8080:80 with no host address binds every host address, 0.0.0.0 and [::].
  • IPv6 rules (ip6tables) have been on by default since Docker 27.0, and with the default userland proxy, the host's IPv6 addresses forward to the container even when the container network is IPv4-only.

Older guides mention DOCKER-ISOLATION-STAGE-1 and -2. Docker 29.0 removed those chains, and Docker 28.0.1 moved most of its forwarding rules into DOCKER-FORWARD.

See it on the host

Start with what Docker has published. The PORTS column shows the bind address: 0.0.0.0:8080->80/tcp and [::]:8080->80/tcp mean every interface.

docker ps --format 'table {{.Names}}\t{{.Ports}}'

List listening sockets. With the userland proxy, which is on by default, each published port shows a docker-proxy process:

sudo ss -tlnp

Show the port mappings in the nat table and any filtering you have added in DOCKER-USER:

sudo iptables -t nat -L DOCKER -n
sudo iptables -L DOCKER-USER -n -v
sudo ip6tables -t nat -L DOCKER -n

An empty DOCKER-USER chain means nothing filters traffic to your published ports. Compare this with sudo ufw status verbose: a port can appear in the DOCKER chain with no matching UFW rule.

If you run Docker with the experimental nftables backend ("firewall-backend": "nftables", added in 29.0), the iptables commands show nothing useful. Use sudo nft list ruleset instead.

Check it from outside

The host's own view tells you what should be open. Only a scan from another network shows what is. From the server you want to check, run:

curl -4 qsa.sh

qsa.sh scans only the public IP the request comes from, and it has no way to target any other address. Before anything is sent, it prints the detected IP and starts a 15-second countdown. Ctrl-C cancels. The free scan checks the top 1,000 TCP ports with naabu 2.7.0, fingerprints each open service with nmap 7.993 and the vulners script, runs about 2,000 curated nuclei 3.11.1 checks, and streams the results live. It allows one scan per IP per 24 hours, and results are not stored.

Know the limits before you trust a clean result:

  • A container on an unusual high port can sit outside the top 1,000. The Full tier ($5/month) scans all 65,535 TCP ports, with results in roughly 2 to 12 minutes. See pricing and how it works.
  • IPv6 callers are refused for now, which is why the command uses curl -4. Check IPv6 exposure with nmap from another machine.
  • CGNAT, mobile carrier, proxy, VPN and Tor exits are refused, so run it from the server itself.

All three tools are open source, and you can run them yourself. From a machine on a different network:

nmap -Pn -p- YOUR_SERVER_IP
nmap -6 -Pn -p- YOUR_SERVER_IPV6

Fix it, safest option first

1. Bind published ports to loopback

If only a reverse proxy on the same host needs the container, publish it on 127.0.0.1, and on [::1] if you need IPv6 locally:

docker run -d -p 127.0.0.1:8080:80 nginx

In Compose:

services:
  app:
    image: nginx
    ports:
      - "127.0.0.1:8080:80"

Put your reverse proxy (nginx, Caddy, Traefik) in front and expose only 80 and 443. Run a current Docker Engine: before 28.0.0, hosts on the same L2 segment could reach ports published to localhost, and 28.3.3 fixed CVE-2025-54388, where a firewalld reload could expose loopback-only ports to the LAN.

To change the default for containers that leave out the host address, set "ip": "127.0.0.1" in /etc/docker/daemon.json for the default bridge, or the com.docker.network.bridge.host_binding_ipv4 option on a user-defined network.

2. Publish nothing; use a shared network

Containers on the same user-defined network reach each other by name with no published ports. Only the proxy publishes anything:

services:
  proxy:
    image: caddy
    ports:
      - "80:80"
      - "443:443"
    networks: [web]
  app:
    image: nginx
    networks: [web]
networks:
  web: {}

The proxy forwards to http://app:80, and the app has no host port for anyone to reach.

3. Filter in the DOCKER-USER chain

Docker reserves DOCKER-USER for your rules, and FORWARD jumps to it before Docker's own chains. Packets here have already been rewritten, so a plain --dport match sees the container's port. Match the original destination with conntrack. The rule that keeps established connections working has to come before any DROP, and -I inserts at the top, so add it last:

# Drop internet traffic to the published host port 8080 (eth0 is the public interface)
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 -j DROP

# Or: allow only one admin network to reach any published port
sudo iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.0/24 -j DROP

# Then keep replies to existing connections flowing (inserted last, so it runs first)
sudo iptables -I DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

Repeat the rules with ip6tables for IPv6. Rules added this way are lost on reboot unless you save them with your distribution's persistence tool, such as netfilter-persistent. Docker's documentation notes that conntrack matching can cost some performance. The nftables backend has no DOCKER-USER chain; its documentation describes putting your rules in your own table with a base chain on the forward hook.

4. Use ufw-docker

The third-party ufw-docker project adds a DOCKER-USER block to /etc/ufw/after.rules (and after6.rules) that sends container traffic through UFW's forward rules. You then open container ports with route rules:

sudo ufw-docker install
sudo systemctl restart ufw
sudo ufw route allow proto tcp from any to any port 80

It keeps everything in UFW and survives reboots. It depends on DOCKER-USER, so it cannot work with the nftables backend, and it has open issues about Docker 28 and 29 chain changes. Test the result from outside after installing it.

5. Disable Docker's iptables management (last resort)

Setting "iptables": false in daemon.json stops Docker writing rules. Docker's documentation warns that this is likely to break container networking: containers lose outbound internet access through masquerading, and, with no replacement rules, all container ports are reachable from hosts on the local network. You would have to write the NAT and forwarding rules yourself. Use one of the options above unless you are prepared to do that.

firewalld and other hosts

The same bypass applies to firewalld. Docker creates a firewalld zone named docker with target ACCEPT for its bridges, and a docker-forwarding policy that allows forwarding from any zone to it, so rules on your public zone do not filter published ports. Bind to loopback or filter in DOCKER-USER, exactly as above.

Re-check from outside

After the fix, scan again from outside and confirm the port is gone. The free qsa.sh scan allows one run per IP per 24 hours, so for a same-day re-check use nmap from another machine, or a Full token. To catch a new -p flag the next time someone deploys, run a scheduled scan yourself: qsa.sh sends no alerts and runs no monitoring, and this cron script compares each scan with the last one. If you are wary of piping curl output anywhere, this post explains why curl qsa.sh only prints text.

FAQ

Does UFW block Docker published ports?

No. UFW filters the INPUT chain, and Docker forwards published-port traffic through FORWARD after rewriting its destination in the nat table.

Why does ufw status say the port is closed?

ufw status lists UFW's own rules. Docker's DNAT and forwarding rules live in separate chains that UFW does not report.

Is binding to 127.0.0.1 enough?

On Docker Engine 28.3.3 or later, yes for internet exposure. Older releases let hosts on the same L2 network reach localhost-published ports.

Does this affect IPv6?

Yes. Unbound ports listen on [::], and ip6tables is on by default. qsa.sh does not scan IPv6 yet, so check it with nmap -6 from another network.

Does the free scan cover every port?

It covers the top 1,000 TCP ports. The Full tier scans all 65,535.

All news  |  Pricing  |  How it works