SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

Why Docker dey bypass UFW and how to fix am

Docker iptables DNAT rules fit skip UFW, so port 8080 still dey answer internet after deny. See why e happen and the two fixes wey work.

Why Docker dey bypass UFW

Docker dey bypass UFW because published container ports no dey pass through the firewall rules wey UFW dey manage. When you run docker run -p 8080:80, Docker dey write DNAT (destination network address translation) rule inside PREROUTING chain for kernel's nat table. That rule dey rewrite destination of each packet to the container private address before kernel decide where the packet dey go. The rewritten packet then dey forward enter the container through FORWARD chain, wey Docker dey control. UFW rules dey inside INPUT chain, and the packet no ever enter there. So ufw status dey show default deny, sudo ufw deny 8080 dey report success, and port 8080 still dey answer the whole internet.

This one no be Docker bug, and UFW no spoil. Both tools dey program the same kernel firewall. Docker rules simply dey act earlier for the packet path, so UFW no ever get the packet. This guide go demonstrate the bypass, explain how e work, then cover the two fixes wey work: publish ports on 127.0.0.1, and filter for DOCKER-USER chain. If UFW still new to you, set am up first with UFW firewall basics guide, because default-deny firewall still be the correct foundation for everything else on the server.

See how bypass dey work for your own server

Start with a VPS wey UFW dey active and default policy dey deny incoming traffic. Run one web container with 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 active, and no rule dey for port 8080. According to the firewall report itself, the port dey closed. Now test am from another machine, no be from the server itself:

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

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

sudo ufw deny 8080/tcp

The port still answer, because the deny rule dey for chain wey the packet no dey pass through. UFW no fail. E no ever get consulted. Na this one make the problem hide well: no error dey print anywhere, deployment work, and firewall status output look exactly like healthy server wey lock down properly.

The mechanism: PREROUTING dey run before INPUT

Kernel dey process incoming packet in fixed order, and na this order dey cause the whole problem.

  1. PREROUTING dey run first. Rules for here fit rewrite packet destination, and Docker rule for published port dey do exactly that.
  2. Routing decision dey come next. Packet wey address the host itself dey go INPUT chain. Packet wey address any other machine dey go FORWARD chain.
  3. UFW rules dey inside INPUT. Docker rules dey inside 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

Na DNAT line explain everything. Any packet wey arrive for port 8080 go get destination rewritten to 172.17.0.2:80, wey be the container address for Docker private bridge network. After the rewrite, packet no longer address the host, so routing decision go send am through FORWARD path, where Docker don already add rules wey accept traffic enter its own networks. Your deny 8080/tcp rule dey wait inside INPUT for packet wey no go ever reach am.

For Ubuntu 24.04, iptables command na front end for nftables, but chain order and result dey the same. UFW and Docker both dey write into the same kernel packet pipeline, and Docker entry point dey earlier. This matter no concern UFW alone: firewalld for Rocky or AlmaLinux VPS dey filter for the same point inside that pipeline, and the same DNAT rule dey bypass am, so na the fixes below you go use there too.

The everyday fix: publish ports on 127.0.0.1

Most containers no need public access from the beginning. Database, app server wey dey behind reverse proxy, admin panel, and metrics endpoint no 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 for Compose file:

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

This one work because Docker DNAT rule now dey match only packets wey target 127.0.0.1. Packet from internet no fit legitimately carry that destination, so kernel go drop am before any firewall rule run. Host fit reach the port, but nothing else fit. Verify the binding:

sudo ss -tlnp | grep 8080

You suppose see 127.0.0.1:8080 for the output, no be 0.0.0.0:8080 or [::]:8080. Then confirm from another machine say curl http://your-vps-ip:8080/ dey refused.

For services wey suppose face internet, run one reverse proxy wey own ports 80 and 443 and route request by hostname. Publish nothing else. Na this pattern Traefik reverse proxy guide dey build. Na so self-hosted app like Nextcloud for VPS dey remain unreachable except through its proxy. How ports: entries dey declared, plus the rest of Compose workflow, dey covered for Docker Compose basics guide.

When every internal container dey use loopback, UFW fit return to im normal work: guarding ports wey the host itself dey serve. Build that rule set here, then run the commands in order:

ToolUFW rule generator

Real filtering: the DOCKER-USER chain

Sometimes container port suppose remain published to network but you still restrict am, like database replica port wey only one office address fit reach. For this one, Docker dey provide DOCKER-USER chain. Every packet wey dey go any container pass through DOCKER-USER before Docker own accept rules, and Docker no dey write rules inside am. Na for your own use this chain dey exist, and Docker leave wetin dey inside am untouched even when daemon restart.

Before you run the command, make you note one trap: by the time packet reach DOCKER-USER, DNAT rewrite don already happen. The packet destination port don become the container port (80 for our example), not the published port (8080). So rule wey match --dport 8080 no go match anything. The reliable way na to match the port wey client originally dial, because kernel connection tracker remember am:

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

Read am like this: 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 limit the rule to client-to-container direction, so reply packets no go enter by mistake. Replace eth0 with your public interface; ip route | grep default show the interface name. Test am the same way as before: curl from the allowed address go succeed, but from anywhere else the connection go time out. That hang na sign say DROP rule dey work, not say port no get anything behind am. The difference between connection wey dem refuse and connection wey time out na the fastest way to know whether port dey filtered or service simply no dey listen.

Rules wey you add with iptables command go disappear when system reboot. Since UFW already dey manage this firewall, the clean place to save dem permanently na /etc/ufw/after.rules. Add one block for the 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 go replay that file every time e reload and every time system boot, so your container filtering go dey the same place with the rest of your firewall, and e go survive both reboot and Docker upgrade.

Why you no suppose disable Docker's iptables integration

Older answers for this problem dey suggest make you set { "iptables": false } for /etc/docker/daemon.json. No do am. Docker firewall rules dey do plenty things pass port publishing. The masquerade rule na wetin give containers outbound internet access through the host address. So, if you turn off the integration, containers no fit pull images, reach package mirrors, or call any external API (application programming interface). The DNAT rules na wetin make -p work at all, so published ports go stop working completely. The isolation rules wey keep separate Compose networks apart go disappear too. You go fix the bypass by breaking container networking, and you go need write and maintain every one of those rules by hand. Docker own documentation describe the setting as one for people wey intend to do exactly that. The DOCKER-USER chain dey exist precisely so nobody need this switch.

The IPv6 side of the same problem

First check wetin the published port look like for IPv6:

sudo ss -tlnp | grep 8080

Since Docker Engine 27, Docker dey manage ip6tables by default. For Docker network wey get IPv6 enabled, published port dey get the same DNAT treatment for the IPv6 tables, so the same bypass dey there and the same fix apply: the DOCKER-USER chain dey also exist for ip6tables, so mirror your rule with sudo ip6tables -I DOCKER-USER ... and test from outside with curl against your server public IPv6 address, for example curl -6 http://[2001:db8:2a::1]:8080/.

For network wey no get IPv6, IPv6 clients dey handled instead by docker-proxy, a normal user-space process wey dey listen on [::]:8080 and forward the traffic enter the container over IPv4. Traffic wey go host process dey pass through INPUT, so UFW fit filter that path, but only when UFW dey manage IPv6 at all. Whether e dey manage am, and the other ways IPv6 gap fit open for VPS, na wetin the UFW and IPv6 guide dey explain.

Publishing for loopback dey avoid the whole question: -p 127.0.0.1:8080:80 dey bind IPv4 loopback only, so no IPv6 listener dey and nothing fit reach am from outside for either stack.

Pattern wey go hold

  • Publish every internal port for 127.0.0.1, so e no go ever expose am from the start.
  • Give the public side to one reverse proxy wey dey control ports 80 and 443.
  • Keep UFW default deny for the host, and allow SSH plus the proxy ports.
  • Filter container ports wey truly public for DOCKER-USER, match am against the original destination port, then persist am for /etc/ufw/after.rules.
  • Leave Docker iptables integration on.

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

FAQ

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

Because Docker dey publish the port with one DNAT rule for the PREROUTING chain. This rule dey rewrite the packet destination go the container address before any filtering happen. The packet then dey follow the FORWARD path, while UFW rules dey inside INPUT, and the packet no dey enter that chain. Firewall no ever check the packet, so its deny rules no get effect on published container ports.

How I fit make UFW block Docker published ports?

UFW by itself no fit do am, because its rules dey for the wrong chain. Either stop exposing the port by publishing am as 127.0.0.1:8080:80, so na only the host fit reach am, or filter am for the DOCKER-USER chain with an iptables rule wey matches the original destination port through conntrack. Persist that rule for /etc/ufw/after.rules so e go survive reboots and ufw reload.

I suppose set "iptables": false for Docker daemon.json?

No. That setting dey remove all Docker firewall and NAT rules, and e dey break plenty things beyond this bypass. Containers go lose outbound internet access because the masquerade rule don disappear. Published ports go stop working because the DNAT rules don disappear. Use loopback publishing and the DOCKER-USER chain instead. Dem go fix the exposure without breaking container networking.

Docker dey bypass UFW for IPv6 too?

For Docker Engine 27 and later, ip6tables management dey enabled by default. So, when you publish a port for an IPv6-enabled Docker network, Docker dey rewrite am around UFW exactly like IPv4. You need the same DOCKER-USER rule, mirrored with ip6tables. For networks wey no get IPv6, the docker-proxy process dey listen on [::]. That traffic dey pass through INPUT, where UFW fit filter am if UFW dey manage IPv6. Publishing on 127.0.0.1 avoids both cases, because nothing dey listen on IPv6 at all.