Docker & ContainersXXVII Β· FirewallsVerifying exposure
Verifying actual network exposure
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
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
| Source | What 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 -tlnp | Host sockets in LISTEN β and see below, this omits published ports on some hosts |
sudo iptables -t nat -S DOCKER | The DNAT rules that actually implement publishing |
nc/nmap from another host | Reachability. 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.
$ docker run -d --name web -p 8080:80 nginx:1.27-alpine
sudo ss -tlnp | grep 8080LISTEN 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:
$ sudo ss -tlnp | grep 8080
echo "exit status: $?"
sudo iptables -t nat -S DOCKER | grep 8080exit status: 1
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80Illustrative output
ss finds nothing. The port is fully open to the internet.
The probe that can fail
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.
# 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 hostcontainer. Look inss -tlnp. - Probe OPEN,
DOCKER-USERcounter zero β your rule is not on the path. Appended after the trailingRETURN, or-inames 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 aFORWARDpolicy ofDROPthat Docker did not set. - Probe closed, everything correct on the host β check what the container
binds. A process listening on
127.0.0.1inside the container is unreachable through DNAT, anddocker pswill still cheerfully print0.0.0.0:8080->80/tcp.
Tracing an unexpected port back to its owner
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
fiFor 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
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" || trueThe 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 fromdocker ps. - An
nc -zfrom 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
Q1. On a host with `"userland-proxy": false`, a container is published with `-p 8080:80`. What does `ss -tlnp | grep 8080` show?
Q2. Which single check is capable of contradicting what you believe about a hostβs exposure?
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?
Q4. Which of these are records of intent rather than observations of reality? Select all that apply.
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.