Skip to main content
RunBook Academy

Docker & ContainersXXVII Β· FirewallsVerifying exposure

Verifying actual network exposure

Intermediate⏱ ~24 mindockeriptablesss

What you'll learn

  • Distinguish intent (docker ps, Compose) from reality (an off-host probe)
  • Explain why ss shows no listener for a published port on some hosts
  • Correlate a probe result with the nat DOCKER chain and DOCKER-USER counters
  • Trace an unexpected open port back to the container that owns it
  • Run an exposure audit that is capable of failing

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-12

Not yet marked complete on this device.

docker ps shows what you intended to expose. It reads the container’s configuration back to you. So does the Compose file, so does docker inspect, and so does your firewall ruleset β€” all four are records of intent, written by you, reporting on themselves.

Only one check on a Docker host is capable of returning an answer you did not already put there: a connection attempt from a machine that is not this one.

Four sources, three of them describing intent

SourceWhat it actually tells you
docker ps --format '{{.Ports}}'The port mapping recorded in the container config
docker port "$CONTAINER"The same mapping, one container, machine-readable
ss -tlnpHost sockets in LISTEN β€” and see below, this omits published ports on some hosts
sudo iptables -t nat -S DOCKERThe DNAT rules that actually implement publishing
nc/nmap from another hostReachability. The only one that can contradict you.

Start with the first four to build the expectation. Finish with the fifth to test it.

The listener that is not there

This is the trap that makes ss untrustworthy on a Docker host.

Read-only / Safeuserland proxy enabled
$ docker run -d --name web -p 8080:80 nginx:1.27-alpine
sudo ss -tlnp | grep 8080
LISTEN 0  4096  0.0.0.0:8080  0.0.0.0:*  users:(("docker-proxy",pid=2841,fd=7))

Illustrative output

Now the same container on a host with "userland-proxy": false in daemon.json:

Read-only / Safeuserland proxy disabled
$ sudo ss -tlnp | grep 8080
echo "exit status: $?"
sudo iptables -t nat -S DOCKER | grep 8080
exit status: 1
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

Illustrative output

ss finds nothing. The port is fully open to the internet.

The probe that can fail

Read-only / Safeprobe from elsewhere
TARGET=203.0.113.10

# One port, quickly
nc -z -w 3 "$TARGET" 8080 && echo "OPEN" || echo "closed"

# The ports you believe are open, plus a sample of ones you do not
nmap -Pn -p 22,80,443,3306,5432,6379,8080,9090 "$TARGET"

# The honest version: every TCP port. Slow, and worth it once a quarter.
nmap -Pn -sT -p- "$TARGET"

Reading the disagreement

When the external probe and your expectation differ, the next command tells you which mechanism decided.

Read-only / Safecorrelate
# Did a DOCKER-USER rule participate at all? Zero packets means no.
sudo iptables -L DOCKER-USER -v -n --line-numbers

# What does Docker think is published?
sudo iptables -t nat -S DOCKER

# What does the container config say?
docker ps --format '{{.Names}}\t{{.Ports}}'

# What is the container actually listening on, inside its own namespace?
CONTAINER=web
docker exec "$CONTAINER" sh -c 'ss -tlnp 2>/dev/null || netstat -tlnp'

The four outputs resolve almost every case:

  • Probe OPEN, no rule in nat DOCKER β€” the port is not a published container port. It is a host daemon, or a --network host container. Look in ss -tlnp.
  • Probe OPEN, DOCKER-USER counter zero β€” your rule is not on the path. Appended after the trailing RETURN, or -i names the bridge instead of the external interface.
  • Probe closed, rule present in nat DOCKER β€” something upstream is filtering: a cloud security group, a router ACL, or a FORWARD policy of DROP that Docker did not set.
  • Probe closed, everything correct on the host β€” check what the container binds. A process listening on 127.0.0.1 inside the container is unreachable through DNAT, and docker ps will still cheerfully print 0.0.0.0:8080->80/tcp.

Tracing an unexpected port back to its owner

Read-only / Safewho owns this port
PORT=8080

# Path 1: is it a published container port? Find the target address...
sudo iptables -t nat -S DOCKER | grep -- "--dport $PORT"

# ...then find which container holds that address.
docker ps -q | xargs -r docker inspect \
--format '{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'

# Path 2: a host socket. Identify the process and, if it is containerised,
# the container it belongs to via its cgroup.
sudo ss -tlnp "sport = :$PORT"
PID=$(sudo ss -tlnpH "sport = :$PORT" | grep -oP 'pid=\K[0-9]+' | head -1)
if [ -n "$PID" ]; then
sudo readlink "/proc/$PID/exe"
tr '\0' ' ' < "/proc/$PID/cmdline"; echo
grep -o 'docker-[0-9a-f]\{12,\}' "/proc/$PID/cgroup" | head -1
fi

For a docker-proxy process, /proc/$PID/cmdline is the whole answer on its own β€” it carries the protocol, host IP, host port and container address as flags.

Making the audit routine

Read-only / Safeexposure snapshot
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
OUT="/var/log/docker-exposure/$STAMP"
sudo mkdir -p "$OUT"

sudo iptables -t nat -S DOCKER | sudo tee "$OUT/dnat.txt" > /dev/null
sudo iptables -S DOCKER-USER   | sudo tee "$OUT/docker-user.txt" > /dev/null
sudo ss -tlnH                  | sudo tee "$OUT/host-listeners.txt" > /dev/null
docker ps --format '{{.Names}} {{.Image}} {{.Ports}}' \
| sudo tee "$OUT/published.txt" > /dev/null

# Diff against the previous snapshot; an empty diff is the pass condition.
PREV=$(sudo ls -1 /var/log/docker-exposure | sort | tail -2 | head -1)
[ -n "$PREV" ] && sudo diff -ru "/var/log/docker-exposure/$PREV" "$OUT" || true

The diff is what turns this from a report into a check. A new line in dnat.txt that nobody can explain is the finding.

Sanity check

  • You can state, for one chosen port, whether it is a published container port or a host socket β€” and you found out from iptables -t nat -S DOCKER, not from docker ps.
  • An nc -z from a second machine agrees with your inventory for every port in it.
  • For at least one port you expected to be closed, the probe confirmed it is closed β€” a verification that only ever passes is not a verification.
  • The snapshot from the previous audit exists and the diff is empty or explained.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. On a host with `"userland-proxy": false`, a container is published with `-p 8080:80`. What does `ss -tlnp | grep 8080` show?

  2. Q2. Which single check is capable of contradicting what you believe about a host’s exposure?

  3. Q3. `docker ps` shows `0.0.0.0:8080->80/tcp`, the nat DOCKER chain has the matching DNAT rule, DOCKER-USER is empty, and an external probe gets connection reset. What is the most likely cause?

  4. Q4. Which of these are records of intent rather than observations of reality? Select all that apply.

  5. Q5. A DOCKER-USER rule whose packet counter still reads zero after an external probe did not match that traffic.

Passing score: 75%. Answers are checked in this browser.