---
title: "Your Firewall Says Blocked. Docker Publishes It Anyway."
url: "https://florea.xyz/docker-ufw-published-ports/"
date: "2026-11-11T09:00:00+02:00"
updated: "2026-11-10T13:51:26+02:00"
author: "Bogdan Florea"
topic: "Security"
---

# Your Firewall Says Blocked. Docker Publishes It Anyway.

You check UFW. It says active, and incoming traffic is denied unless you allow it. Then you add a small admin tool to a Docker Compose file, publish its port, and assume the firewall keeps it private.

It might not. That one assumption can put the tool on the internet.

This isn't a bug, and it isn't new. Docker's own [firewall documentation](https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw) warns that published container ports can bypass UFW. This post explains why it happens, how to check what you actually expose, and how to fix it. It covers the usual setup: Docker Engine running as root on Linux, with containers on bridge networks and ports published with `ports:`. Docker Desktop, rootless Docker and host networking take different paths, so they aren't covered here.

**TL;DR**

A published Docker port skips UFW's rules, so it can be public even when UFW says incoming traffic is blocked. Check what's open, and close what shouldn't be:

```bash
# Check what you actually expose
docker ps --format 'table {{.Names}}\t{{.Ports}}'
docker compose port demo 80
sudo ufw status verbose

# Test from another machine (use your server's address)
curl --noproxy '*' --connect-timeout 3 --max-time 5 \
  -I http://203.0.113.10:18080/

# After editing compose.yaml, recreate the container
docker compose up -d

# When a port really has to be public
sudo iptables -I DOCKER-USER -i eth0 -p tcp \
  -m conntrack --ctorigdstport 18080 \
  ! -s 198.51.100.0/24 -j DROP
```

## The line that changes your exposure

Here is a small Compose service:

```yaml
services:
  demo:
    image: nginx:stable-alpine
    ports:
      - "18080:80"
```

The mapping reads host port on the left, container port on the right: port 18080 on the server goes to port 80 inside the container. Notice what's missing, though. There's no IP address in front of `18080`, and in that case Docker listens on every address the host has, not just localhost. On a server with a public IP, that's a public service, unless something outside the server blocks it, such as your provider's firewall. The [Compose reference](https://docs.docker.com/reference/compose-file/services/#ports) has the full syntax.

So treat a `ports:` line like a firewall change, because that's what it is. Before you add one, ask who actually needs to connect.

This matters most for the things that feel internal: database consoles, metrics dashboards, admin panels and development tools. Running "in Docker" doesn't make a service private. One line of YAML can make it public.

## Why UFW doesn't see it

Linux handles a packet in stages, and firewall rules attach to those stages, which netfilter calls chains. Two of them matter here:

- `INPUT` handles packets addressed to the host itself, like SSH or a web server installed directly on the machine.
- `FORWARD` handles packets the host passes on to somewhere else.

A published container port doesn't go through `INPUT`. When a packet arrives for port 18080, Docker's rules rewrite its destination to the container's internal address and port 80. This rewrite is called destination NAT, or DNAT. After it, the packet is no longer meant for the host. It's forwarded to the container, through `FORWARD` and through the rules Docker installs there, which [Docker's iptables documentation](https://docs.docker.com/engine/network/firewall-iptables/) describes.

Side by side:

```text
Host service:     client -> host address -> INPUT -> host process
Published port:   client -> host address -> DNAT -> FORWARD -> container
```

That's simplified, but it explains the surprise. UFW's familiar "deny incoming" rules are written for traffic to the host. A packet forwarded to a container never meets them. So "Status: active" in UFW doesn't tell you that your container ports are closed.

UFW is still useful. It's a friendly front end for the Linux firewall. Its status output just isn't a list of every rule on the machine, and Docker adds its own rules next to UFW's without UFW showing them. [Ubuntu's firewall overview](https://documentation.ubuntu.com/security/security-features/network/firewall/) explains how the pieces fit together.

## Check what you actually expose

There are two separate questions here: what did Docker publish, and what can someone else actually reach?

Start on the server:

```bash
docker ps --format 'table {{.Names}}\t{{.Ports}}'
docker compose port demo 80
sudo ufw status verbose
```

Look at the ports column in the `docker ps` output:

- `0.0.0.0:18080->80/tcp` means port 18080 is open on every IPv4 address of the host.
- `[::]:18080->80/tcp` means the same for IPv6.
- `127.0.0.1:18080->80/tcp` means only the host itself can connect.

`docker compose port` answers the same question for a single service, as the [Compose networking guide](https://docs.docker.com/compose/how-tos/networking/#inspect-port-mappings) shows. Every `0.0.0.0` or `[::]` you didn't mean to have is something to fix.

Then test from a different machine, outside the server's network. Use your server's real addresses; the ones below are reserved for documentation examples:

```bash
curl --noproxy '*' --connect-timeout 3 --max-time 5 \
  -I http://203.0.113.10:18080/

curl --noproxy '*' -6 --connect-timeout 3 --max-time 5 \
  -I 'http://[2001:db8::10]:18080/'
```

Run the second command as well if the server has IPv6. It's easy to lock down IPv4 and forget the other one.

A few things keep this test honest:

- **Test from outside.** A test from the server itself, or through localhost, answers a different question.
- **Use the IP address and the port, not your site's domain name.** The domain usually goes through a reverse proxy, which isn't what you're testing.
- **Read the answer carefully.** Any HTTP response, even a `401` or a `403`, means the service is reachable from where you ran the test. A timeout only means this one attempt failed: a provider firewall, a routing problem or a stopped container could all cause it.

Run it from the places whose access you care about, for example your office network and a cloud machine somewhere else.

## Fix 1: don't publish what only containers use

Containers on the same Compose network can already talk to each other, using the service name as the host name. Your app can reach its database at `db:5432` without any `ports:` line at all; this is [service discovery in Compose](https://docs.docker.com/compose/how-tos/networking/#default-network-and-service-discovery). Publishing a database port only makes sense if something outside Docker needs it.

This example shows only the networking. A real setup would also have the app's settings and the database password:

```yaml
services:
  app:
    build: ./app
    networks: [frontend, backend]

  db:
    image: postgres:18
    networks: [backend]
    # No ports mapping.

networks:
  frontend: {}
  backend:
    internal: true
```

The app joins two networks, the database only one. Because `backend` is marked `internal: true`, containers on it can't reach the outside world, and the outside world can't reach them. The app keeps `frontend` for anything it needs beyond the server. The [network options in Compose](https://docs.docker.com/reference/compose-file/networks/) cover the details. Network isolation isn't a replacement for a database password, though. Keep both.

One common misunderstanding: `expose: ["5432"]` isn't access control. It doesn't publish anything on the host, but it doesn't restrict anything either, because every container on the same network can still connect. See [the `expose` reference](https://docs.docker.com/reference/compose-file/services/#expose). What controls access is which networks a container joins and which ports you publish.

## Fix 2: bind host-only tools to localhost

Sometimes a tool really must be reachable from the Docker host itself: by a reverse proxy installed on the host, say, or through an SSH tunnel. Say so in the mapping by putting the address in front:

```yaml
services:
  demo:
    image: nginx:stable-alpine
    ports:
      - "127.0.0.1:18080:80"
```

Now Docker listens only on the host's IPv4 loopback address, and nothing outside the machine can connect. If you also need local IPv6 access, add a second line, `"[::1]:18080:80"`.

There's one caveat for older versions. According to [Docker's port publishing documentation](https://docs.docker.com/engine/network/port-publishing/#publishing-ports), before Docker Engine 28.0.0, other machines on the same local network segment, such as hosts on the same switch, could still reach ports published to localhost. `docker version` tells you which one you run. If it's older than 28.0.0, upgrade instead of relying on the localhost binding alone.

If your reverse proxy itself runs in a container, the app doesn't need a published port at all. Put the proxy and the app on a shared Docker network and let the proxy connect by service name. Only the proxy publishes ports 80 and 443.

After any of these changes, recreate the container with `docker compose up -d`, check the mapping again with `docker ps`, and repeat the test from outside. Editing the Compose file changes nothing until the container is recreated.

## When a port really has to be public

Some ports are meant to be reachable: a web server, or a service that one office needs. Then make the rule explicit, and put it in the right place.

The cleanest place is often a firewall in front of the server, at your provider or on your router, because it drops traffic before it reaches the machine at all. Check that it covers IPv6 too, and test it from outside as above.

If you filter on the host, first check which firewall backend Docker uses. By default it's iptables. Docker 29.0.0 added an experimental nftables backend. You'd know if you use it, because it has to be switched on with `"firewall-backend": "nftables"` in `/etc/docker/daemon.json` or the `--firewall-backend=nftables` flag ([Docker with nftables](https://docs.docker.com/engine/network/firewall-nftables/)).

With the iptables backend, Docker gives you a chain called `DOCKER-USER` for your own rules, and it runs before Docker's own forwarding rules. There's a catch: by the time a packet reaches it, DNAT has already rewritten the destination. The port you see there is the container's port (80), not the published one (18080). To match the port people actually connect to, use connection tracking, which remembers the original destination. For example, to let only one office network reach port 18080:

```bash
sudo iptables -I DOCKER-USER -i eth0 -p tcp \
  -m conntrack --ctorigdstport 18080 \
  ! -s 198.51.100.0/24 -j DROP
```

Read it from left to right:

- **`-i eth0`:** for packets coming in from outside on `eth0`. Replace it with your server's external interface; `ip route show default` names it.
- **`--ctorigdstport 18080`:** that were originally sent to port 18080.
- **`! -s 198.51.100.0/24 -j DROP`:** drop them unless they come from 198.51.100.0/24, the office network in this example.

Docker's documentation shows both building blocks: [restricting external connections by source address](https://docs.docker.com/engine/network/firewall-iptables/#restrict-external-connections-to-containers), and [matching the original IP and ports](https://docs.docker.com/engine/network/firewall-iptables/#match-the-original-ip-and-ports-for-requests).

Two practical notes. Rules added with the `iptables` command are gone after a reboot, so put them in a script or a service that runs after Docker starts. And if your containers are reachable over IPv6, they need the same rule with `ip6tables` and an IPv6 range.

The nftables backend has no `DOCKER-USER` chain. It uses separate tables with their own priorities, which its documentation explains, so don't copy iptables recipes into it.

Finally, don't fix this by stopping Docker from managing the firewall. The `"iptables": false` setting exists, but [Docker warns](https://docs.docker.com/engine/network/packet-filtering-firewalls/#prevent-docker-from-manipulating-firewall-rules) that it can break container networking. Leave Docker's rules in place and build your policy around them.

## The short version

Every `ports:` entry should have an owner, a reason and a test from outside. Publish only what has to be reached from outside, bind the rest to localhost or keep it on internal networks, and check the result from another machine. Do that before you trust the word "active" next to your firewall.
