Docker & ContainersXXVII Β· FirewallsExposure design
Limiting exposure by design
What you'll learn
- Publish to a specific host address instead of every address
- Use internal networks and a single ingress point
- Explain why host networking behaves differently from published ports
- Audit a host for services published on 0.0.0.0
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
The previous three lessons were about controlling traffic to published ports. This one is about not publishing them.
Every DOCKER-USER rule is a piece of state that has to be written,
persisted, verified after a reboot and re-verified after a backend
change. A port that was never published needs none of that. Prefer
the design fix; keep the firewall rule for what the design cannot
cover.
-p publishes to every address by default
-p 8080:80 is shorthand for -p 0.0.0.0:8080:80. Upstream is
explicit that publishing βbecomes available not only to the Docker
host, but to the outside world as wellβ.
The host address form takes an IP:
# Every address on the host, including the public one
docker run -d -p 8080:80 nginx:1.27
# The loopback only β reachable from the host, and from nothing else
docker run -d -p 127.0.0.1:8080:80 nginx:1.27
# One specific interface address, e.g. a management VLAN
docker run -d -p 192.0.2.10:8080:80 nginx:1.27
docker port reports what was actually bound, which is more direct
than reading the PORTS column:
$ docker port grafana3000/tcp -> 127.0.0.1:3000The same thing in Compose
Short syntax takes the same [HOST:]CONTAINER form. The long syntax
names the fields, which is worth the extra lines in a production
file because it makes the bind address impossible to skim past:
services:
grafana:
image: grafana/grafana:11.5.1
ports:
- target: 3000
published: "3000"
host_ip: 127.0.0.1
protocol: tcp
mode: host
host_ip defaults to 0.0.0.0. Omitting it is a decision, not a
default you inherited.
One ingress point, everything else internal
The pattern that removes most of the problem: one container publishes 80 and 443, and nothing else publishes anything. Services talk to each other over a user-defined bridge network by service name.
flowchart LR
Net[Internet] -->|443 only| Proxy[reverse proxy]
subgraph edge["edge network"]
Proxy
end
subgraph backend["backend network - internal"]
App[app]
DB[(postgres)]
Cache[(redis)]
end
Proxy --> App
App --> DB
App --> Cache
services:
proxy:
image: caddy:2.8
ports:
- target: 443
published: "443"
host_ip: 0.0.0.0
protocol: tcp
networks: [edge, backend]
app:
image: registry.example.com/app:1.4.2
networks: [backend]
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
networks: [backend]
networks:
edge:
backend:
internal: true
internal: true creates a network with no external connectivity.
Containers on backend and nothing else cannot reach the internet
and cannot be reached from it. db has no ports: at all, so there
is no DNAT rule for anyone to bypass a firewall through.
The published surface of this whole stack is one TCP port, and that port is governed by a reverse proxy you can put authentication, rate limiting and TLS in front of.
Host networking is the exception
--network host puts the container in the hostβs network namespace.
There is no bridge, no NAT, and no published ports β -p is ignored,
and Docker warns about it.
Because there is no DNAT, the packet is delivered locally and
traverses INPUT. On a host-networked container, ufw deny 9100
works exactly as an operator expects.
$ docker run -d --network host --name node-exporter \
-p 9100:9100 prom/node-exporter:v1.8.2WARNING: Published ports are discarded when using host network mode
c3f0a1e8b47d5c2a9e6f10b3d8c47a5e2f9b0c13d6a8e4f27b5c19d0e3a6f841Illustrative output
Auditing what a host actually publishes
Two commands, run on every host, on a schedule:
docker ps --format '{{.Names}}' | while read -r c; do
docker port "$c" | sed "s|^|$c |"
done
echo '--- bound to every address ---'
docker ps --format '{{.Names}} {{.Ports}}' | grep -E '0\.0\.0\.0|\[::\]' || echo 'none'Anything in the second list is reachable from wherever the host is reachable. That is a short list on a well-designed host, and each entry should be something you can name and justify.
Sanity check
Knowledge check Β· 4 questions
Q1. What does -p 8080:80 bind on the host?
Q2. A container is started with --network host and -p 9100:9100. What happens to the published port?
Q3. Which of these reduce the externally reachable surface of a stack? Select all that apply.
Q4. A service with no ports: entry, attached only to an internal network, still needs a DOCKER-USER rule to be safe from external traffic.
Passing score: 75%. Answers are checked in this browser.