SSD Nodes Learn
How to do am Matt ConnorBy Matt Connor · Updated 2026-07-24

Docker dey bypass UFW how to fix am

Docker dey use iptables DNAT rules for kernel table wey bypass UFW rules. Learn how to fix dem so your container ports no go open to internet.

Why Docker bypasses UFW

Docker bypass UFW because the container ports wey you publish no dey pass through the UFW firewall rules. When you run docker run -p 8080:80, Docker dey write DNAT (destination network address translation) rule inside PREROUTING chain for the kernel nat table. That rule dey change the destination address of every packet to the private address of the container before the kernel decide where the packet dey go. After the packet don change, Docker dey forward am into the container through FORWARD chain, which Docker dey control. UFW rules dey inside INPUT chain, and the packet no dey ever enter there. Na why ufw status dey show default deny, sudo ufw deny 8080 dey report success, but port 8080 still dey answer everybody for internet.

This one no be Docker bug, and UFW no broken. Both tools dey program the same kernel firewall. Docker rules dey work at an earlier point for the packet path, so UFW no dey even get chance. This guide go show how this bypass dey happen, explain how the mechanism dey work, and then show two fixes wey dey work: publish ports on 127.0.0.1, and filter inside DOCKER-USER chain. If UFW na new thing to you, set am up first with the UFW firewall basics guide, because default-deny firewall na still the right base for everything else for the server.

See how you fit bypass am for your own server

Start from one VPS where UFW dey active and e set to deny all incoming traffic by default. Run one web container wey get published port:

sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpine

ufw status verbose show say Default: deny (incoming), allow (outgoing) dey there, but no rule for port 8080. Based on wetin firewall report, the port closed. Now test am from another machine, no use the server itself:

curl -I http://your-vps-ip:8080/
HTTP/1.1 200 OK

The container dey answer. Add one explicit deny rule and test am again:

sudo ufw deny 8080/tcp

The port still dey answer, because the deny rule dey one chain wey the packet no dey pass. UFW no fail. E no even see the packet. Na why this problem dey hide well: no error dey print anywhere, the deploy dey work, and the firewall status output look exactly like one healthy server wey lock down.

The mechanism: PREROUTING run before INPUT

Kernel dey process incoming packet for one fixed order, and na that order be the wahala.

  1. PREROUTING run first. Rules for here fit change packet destination, and Docker rule for published port dey do exactly that.
  2. Routing decision follow next. Packet wey address for host itself go enter INPUT chain. Packet wey address for any other machine go enter FORWARD chain.
  3. UFW rules dey INPUT. Docker rules dey FORWARD.

Look Docker rule for the container wey you just start:

sudo iptables -t nat -L DOCKER -n
Chain DOCKER (2 references)
target   prot opt source       destination
RETURN   0    --  0.0.0.0/0    0.0.0.0/0
DNAT     6    --  0.0.0.0/0    0.0.0.0/0    tcp dpt:8080 to:172.17.0.2:80

DNAT line na him be the whole story. Any packet wey arrive for port 8080 go get its destination rewrite to 172.17.0.2:80, which na the container address for Docker private bridge network. After rewrite, packet no be for host again, so routing decision go send am down FORWARD path, where Docker don already add rules to accept traffic enter its own networks. Your deny 8080/tcp rule dey wait for INPUT for packet wey no go ever come.

For Ubuntu 24.04, iptables command na front end for nftables, but chain order and result na the same. UFW and Docker both dey write for the same kernel packet pipeline, and Docker entry point dey early pass.

The everyday fix: publish ports on 127.0.0.1

Most containers no need to dey public at all. Database, app server wey dey behind reverse proxy, admin panel, or metrics endpoint: none of dem suppose answer internet directly. Publish dem for loopback address:

docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpine

Or inside Compose file:

services:
  web:
    image: nginx:1.29-alpine
    ports:
      - "127.0.0.1:8080:80"

Dis one dey work because Docker DNAT rule go match only packets wey dem send to 127.0.0.1. Packet from internet no fit carry dat destination, so kernel go drop am before any firewall rule start to work. You go fit reach di port from host, but no be from anywhere else. Check di binding:

sudo ss -tlnp | grep 8080

You must see 127.0.0.1:8080 for di output, no be 0.0.0.0:8080 or [::]:8080. Den confirm from another machine say curl http://your-vps-ip:8080/ dey refuse connection.

For di services wey suppose face internet, run one reverse proxy wey get ports 80 and 443 and e go route by hostname. No publish anything else. Dat na di pattern wey Traefik reverse proxy guide dey teach, and na so self-hosted app like Nextcloud on a VPS dey stay hidden except through im proxy. How to declare ports: entries, and di rest of di Compose workflow, dey inside the Docker Compose basics guide.

As every internal container dey for loopback, UFW go return to im normal work: guard di ports wey di host itself dey serve. Build dat rule set for here, den run di commands for order:

ToolUFW rule generator

Real filtering: the DOCKER-USER chain

Sometimes you must keep one container port open to the network but restrict am, for example, one database replica port wey only one office address fit reach. For dat, Docker provide the DOCKER-USER chain. Every packet wey dey go to any container must pass through DOCKER-USER before Docker rules take dey accept am, and Docker no dey write rules inside dat chain. The chain dey there for you, and Docker no dey touch wetin dey inside am even if you restart the daemon.

One trap before you run command: by the time packet reach DOCKER-USER, the DNAT rewrite don already happen. The destination port for the packet na the container port (80 for our example), no be the published port (8080). So, any rule wey dey match --dport 8080 no go match anything. The correct way na to match the port wey the client first dial, wey the kernel connection tracker dey remember:

sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP

Read am as: for packets wey enter through eth0 and belong to connection wey original destination port na 8080, drop everything wey no come from 10.0.0.10. The --ctdir ORIGINAL match dey limit the rule to the client-to-container direction, so reply packets no go catch by mistake. Replace eth0 with your public interface; ip route | grep default na the name for am. Test am the same way as before: curl from the allowed address go work, but from anywhere else, the connection go time out.

Rules wey you add with iptables command go vanish when you reboot. Since UFW already dey manage dis firewall, the best place to keep dem na /etc/ufw/after.rules. Add one block for end of the file:

*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMIT

Then run sudo ufw reload. UFW dey read dat file every time e reload or e boot, so your container filtering go dey live for de same place as de rest of your firewall, and e go survive both reboot and Docker upgrade.

Why you no suppose disable Docker's iptables integration

Old answers for dis problem talk say make you set { "iptables": false } inside /etc/docker/daemon.json. No do am. Docker firewall rules dey do more than just publish ports. Masquerade rule na him dey give containers outbound internet access through de host address. If you turn dis integration off, containers no go fit pull images, reach package mirrors, or call any external API (application programming interface). DNAT rules na dem dey make -p work, so if you disable am, published ports go stop to work altogether. Isolation rules wey dey keep different Compose networks separate go disappear too. You go try fix de bypass, but you go break container networking, and you go need to write and maintain every single one of dem rules by hand. Docker documentation talk say dis setting na for people wey want do exactly dat. De DOCKER-USER chain dey exist so that nobody go need dis switch.

The IPv6 side of the same problem

First check how the published port look for IPv6:

sudo ss -tlnp | grep 8080

Since Docker Engine 27, Docker dey manage ip6tables by default. For Docker network wey IPv6 enable, published port dey get the same DNAT treatment for IPv6 tables. So, the same bypass dey there and the same fix go work: the DOCKER-USER chain dey exist for ip6tables too. You must mirror your rule with sudo ip6tables -I DOCKER-USER ..., then test from outside using curl against your server public IPv6 address, like curl -6 http://[2001:db8:2a::1]:8080/.

For network wey no get IPv6, IPv6 clients dey go through docker-proxy. This one na normal user-space process wey dey listen on [::]:8080 and dey forward traffic inside container via IPv4. Traffic wey dey go host process dey pass through INPUT, so UFW fit filter that path, but na only when UFW dey manage IPv6. To know if UFW dey manage am, and other ways IPv6 gap fit open for VPS, see the UFW and IPv6 guide.

Publishing on loopback bypass the whole wahala: -p 127.0.0.1:8080:80 only bind IPv4 loopback, so no IPv6 listener dey and nothing for outside to reach for either stack.

The pattern wey dey hold everything together

  • Publish every internal port for 127.0.0.1, so e no go ever dey exposed from start.
  • Give the public side to one reverse proxy wey get ports 80 and 443.
  • Keep UFW default deny for the host, only allow SSH and the proxy ports.
  • Filter the real public container ports for DOCKER-USER, use original destination port, and save am for /etc/ufw/after.rules.
  • Leave Docker's iptables integration on.

Once you set am up, e go remove surprise: ufw status dey describe the host, and DOCKER-USER dey describe the containers. Nothing go publish by accident, and the next docker run -p wey you type, e go expose exactly wetin you mean.

FAQ

Why I fit reach my Docker container even when UFW block the port?

Because Docker dey publish the port with one DNAT rule inside PREROUTING chain. This rule dey rewrite the packet destination to the container address before any filtering happen. The packet go follow FORWARD path, but UFW rules dey inside INPUT chain, and the packet no dey enter that chain at all. The firewall no dey see the packet, so its deny rules no go work for published container ports.

How I fit make UFW block Docker's published ports?

UFW no fit do am by itself, because its rules dey inside wrong chain. You fit either stop to expose the port by publishing am as 127.0.0.1:8080:80 so only the host fit reach am, or you fit filter am inside DOCKER-USER chain with one iptables rule wey dey match the original destination port through conntrack. Make sure you save that rule for /etc/ufw/after.rules so e go still work after reboot and ufw reload.

I suppose set "iptables": false inside Docker's daemon.json?

No. That setting go remove all of Docker's firewall and NAT rules. This one go cause more wahala than the bypass. Containers go lose internet access because the masquerade rule don vanish, and published ports go stop to work because the DNAT rules don vanish. Use loopback publishing and DOCKER-USER chain instead; dem dey fix the exposure without spoil container networking.

Docker dey bypass UFW for IPv6 too?

For Docker Engine 27 and later, ip6tables management dey on by default. So, any port wey you publish for IPv6-enabled Docker network go bypass UFW exactly like IPv4, and you go need to mirror the same DOCKER-USER rule with ip6tables. For networks wey no get IPv6, the docker-proxy process dey listen on [::] and that traffic dey pass through INPUT, where UFW fit filter am if UFW dey manage IPv6. Publishing on 127.0.0.1 go avoid both cases, because nothing dey listen on IPv6 at all.