Docker Bypasses UFW: Why Your Closed Port Is Still Open (and the Fix)
|

Docker Bypasses UFW: Why Your Closed Port Is Still Open (and the Fix)

I wanted to close one port on a VPS. The dashboard of the self-hosting tool Coolify was reachable on port 8000 straight from the internet, even though it also has its own domain behind a reverse proxy. I removed the allow rule from UFW, the firewall front end, and checked again. The port still answered. This post shows how to see that for yourself, why Docker bypasses UFW for published ports, and how I actually closed it.

The symptom: the rule is gone, the port still answers

Terminal output: ufw delete allow 8000/tcp prints Rule deleted, but curl to port 8000 from outside still returns 200, and iptables nat DOCKER shows a DNAT rule for port 8000

These are the real commands and results. The server address is masked:

ufw delete allow 8000/tcp
Rule deleted
Rule deleted (v6)

curl -s -o /dev/null -m 8 -w '%{http_code}' http://SERVER-IP:8000/login
200

After the rule was deleted, a request from outside still returned HTTP 200. Always test a firewall change from outside the server, with a different machine or network. ufw status alone will tell you what UFW believes, not what is reachable.

Why UFW does not stop it

Docker publishes container ports by adding its own rules to the kernel’s packet filter. Docker’s documentation on packet filtering and firewalls explains that for UFW, published container ports bypass its rules, because Docker routes the traffic in the nat table and the packets are diverted before they reach the chains UFW manages (“Packets are routed before the firewall rules can be applied”). The same page warns that you should not modify the rules Docker creates.

The rule that Docker added for this port is visible in the nat table:

iptables -t nat -S DOCKER | grep dport
-A DOCKER ! -i br-8e882fe3090e -p tcp -m tcp --dport 8000 -j DNAT --to-destination 10.0.1.4:8080

That line says: traffic to port 8000 on the host is rewritten to the container at 10.0.1.4 on port 8080. It happens before UFW gets a say. The Docker port-publishing documentation adds that publishing container ports is insecure by default: without a host address, a published port is reachable from other machines, not only from the host.

Find which ports Docker publishes on your server

docker ps --format '{{.Names}}  {{.Ports}}'
ss -ltnp | grep docker-proxy
iptables -t nat -S DOCKER | grep dport

An entry that starts with 0.0.0.0: (or [::]:) is published on all of the server’s network interfaces, so it can be reachable from outside whatever UFW says (a firewall in front of the server, such as your provider’s, can still block it). An entry that starts with 127.0.0.1: is meant to be reachable only from the server itself; Docker’s documentation notes that releases before 28.0.0 could expose such ports to machines on the same local network segment, so keep Docker up to date.

Your options for really closing it

  1. Bind the port to localhost. Publish it as 127.0.0.1:8000:8080 instead of 8000:8080. The Docker documentation describes this form: only the Docker host can access the published container port. This is the cleanest fix when something on the same machine, such as a reverse proxy or an SSH tunnel, is the only client.
  2. Do not publish the port at all. If a reverse proxy runs on the same Docker network, it can reach the container directly by name, with no host port needed.
  3. Filter in Docker’s own chain. With Docker’s iptables backend, Docker provides a DOCKER-USER chain for your own rules. It is an advanced option that I did not use here and have not tested, so I will not give commands for it.

How I did it for Coolify

Before closing a published port, check that nothing depends on it. Here, port 8000 was published only by the coolify container (0.0.0.0:8000->8080), and the dashboard also had its own domain served by Coolify’s reverse proxy, Traefik, over Docker’s internal network. So the host port was only a convenience shortcut. Check your own setup the same way, and keep a way in (the domain, or an SSH tunnel to localhost) before you close anything.

Coolify’s own upgrade script refers to a file called docker-compose.custom.yml when it exists, which makes it the natural place for an override. I have not yet watched a Coolify update pick it up, so check your version’s script before you rely on it. I created the file next to the other compose files:

# /data/coolify/source/docker-compose.custom.yml
services:
  coolify:
    ports: !override
      - "127.0.0.1:8000:8080"
      - "6001:6001"
      - "6002:6002"

The !override tag replaces the original list of ports instead of adding to it. It worked on Docker Compose v5.6.0; check that your Compose version supports it. Before recreating anything, print the merged configuration from the stack’s folder (/data/coolify/source):

docker compose --project-name source --env-file .env \
  -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.custom.yml config

In the merged output, port 8000 should show host_ip: 127.0.0.1 and the other two ports should be unchanged. Then recreate only that one service:

docker compose --project-name source --env-file .env \
  -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.custom.yml \
  up -d --no-deps --pull never --force-recreate coolify

Use the project name your stack was created with. Mine was source, the name of the folder, which you can read from the container’s com.docker.compose.project label. The dashboard is briefly unavailable while the container restarts.

Check the result from outside

Terminal output: the coolify container shows 127.0.0.1:8000->8080, loopback curl returns 200, curl from outside returns 000, and the dashboard domain returns 200
curl -s -o /dev/null -m 10 -w '%{http_code}' http://127.0.0.1:8000/login      # on the server
200
curl -s -o /dev/null -m 8  -w '%{http_code}' http://SERVER-IP:8000/login      # from outside
000
curl -s -o /dev/null -m 20 -w '%{http_code}' https://coolify.example.com/login
200

A result of 000 from curl means it got no HTTP response at all. A closed port is one cause and a timeout is another, so run curl -v if you need to tell them apart. The container was healthy, the dashboard still worked through its domain, and both websites kept returning 200.

What I left alone

Ports 6001 and 6002 are still published to all addresses. They may not need to be public either, but I have not tested what depends on them, so I left them as they were.

Checklist

  1. Test every firewall change from outside the server.
  2. List what Docker publishes: docker ps, ss -ltnp | grep docker-proxy, iptables -t nat -S DOCKER.
  3. Treat any 0.0.0.0: published port as potentially public, whatever UFW shows, and test it from outside.
  4. Prefer binding to 127.0.0.1 or not publishing the port at all.
  5. Validate a compose change with docker compose config before you recreate the container.
  6. Keep the override in a file your tool preserves (for Coolify, docker-compose.custom.yml), and note how to undo it: remove the override (or restore the original port list), then recreate the container with the same compose file list minus the custom file. That brings the public port back.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *