Skip to main content
RunBook Academy

Docker & ContainersXXVII Β· FirewallsExposure design

Limiting exposure by design

Intermediate⏱ ~20 min

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

Not yet marked complete on this device.

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:

Read-only / Safewhat is bound
$ docker port grafana
3000/tcp -> 127.0.0.1:3000

The 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.

Destructivehost networking
$ docker run -d --network host --name node-exporter \
-p 9100:9100 prom/node-exporter:v1.8.2
WARNING: Published ports are discarded when using host network mode
c3f0a1e8b47d5c2a9e6f10b3d8c47a5e2f9b0c13d6a8e4f27b5c19d0e3a6f841

Illustrative output

Auditing what a host actually publishes

Two commands, run on every host, on a schedule:

Read-only / Safepublish audit
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

  1. Q1. What does -p 8080:80 bind on the host?

  2. Q2. A container is started with --network host and -p 9100:9100. What happens to the published port?

  3. Q3. Which of these reduce the externally reachable surface of a stack? Select all that apply.

  4. 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.