Docker & ContainersXXXV Β· Production HardeningHardening
Network and storage hardening β proving the boundaries hold
What you'll learn
- Prove which ports are reachable from off the host, not which were declared
- Verify network segmentation from inside a container
- Harden bind mounts and volumes with mount options and ownership evidence
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
Network and storage are the two hardening domains where the configuration file is least trustworthy, because in both cases the thing you configured is not the thing the kernel ended up doing.
A ports: entry in Compose says what Docker was asked to publish. It
does not say what is reachable, because Docker writes its own
netfilter rules ahead of the ones you wrote. A volumes: entry says
which path is mounted. It does not say whether a process in the
container can execute a binary out of it.
Both gaps close the same way: check the kernel.
Published ports: what is actually listening
docker ps --format '{{.Names}}\t{{.Ports}}'grafana 127.0.0.1:3000->3000/tcp
prometheus 127.0.0.1:9090->9090/tcp
api 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcpss -tulpn | grep -v '127.0.0.1\|::1'Netid State Local Address:Port Process
tcp LISTEN 0.0.0.0:22 users:(("sshd",pid=1102,fd=3))
tcp LISTEN 0.0.0.0:8080 users:(("docker-proxy",pid=4471,fd=4))Illustrative output
Filtering out loopback is the point of the second command: what remains is the hostβs actual exposure. Anything in that list that is not in your architecture diagram is a finding.
sudo iptables -L DOCKER-USER -n -v --line-numbersChain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 DROP all -- eth0 * !192.0.2.0/24 0.0.0.0/0
2 9821 1.2M RETURN all -- * * 0.0.0.0/0 0.0.0.0/0Illustrative output
A chain containing only the default RETURN is the state most hosts
are in, and it means every published port is open to whatever can
route to the host.
Proving reachability from off the host
Configuration review cannot answer βis this reachable from the internetβ. Only a packet from outside can. From a second machine:
TARGET=192.0.2.10
nmap -Pn -p 22,80,443,2375,2376,3000,8080,9090 "$TARGET"
Include 2375 and 2376 in every such scan. An exposed Docker API is the finding that ends the engagement, and it gets exposed by accident more often than by decision.
Segmentation: proving it from inside
Putting services on separate networks is worth exactly as much as the verification that they cannot reach each other.
docker ps -q | while read -r c; do
docker inspect --format \
'{{.Name}}: {{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$c"
done/caddy: edge-net app-net
/api: app-net db-net
/web: app-net
/db: db-net
/grafana: obs-netIllustrative output
The evidence that segmentation works is a connection attempt that fails:
# From the edge proxy, the database must be unreachable.
docker exec caddy sh -c 'nc -z -w2 db 5432; echo "exit=$?"'
# Expected: exit=1, and DNS resolution of "db" should also fail.
An exit status of 0 there means the edge is one bug away from the
database. Run the negative test as part of the deploy pipeline, not
once during the design review β a later docker network connect, or a
Compose edit that adds a network for a debugging session, silently
reverses the result.
Storage: mount options are the control
A bind mount inherits the hostβs mount options by default, including
exec and suid. That means a writable bind mount is a place an
attacker can drop a binary and run it, and β if the host filesystem
allows setuid β a place to plant one.
docker ps -q | while read -r c; do
docker inspect --format \
'{{$n := .Name}}{{range .Mounts}}{{$n}} {{.Type}} {{.Source}} -> {{.Destination}} rw={{.RW}}\n{{end}}' "$c"
done/api bind /etc/api/config.yml -> /etc/api/config.yml rw=false
/legacy bind /srv/data -> /data rw=true
/monitoring bind /var/run/docker.sock -> /var/run/docker.sock rw=trueIllustrative output
Three findings in three lines. The first is correct: a config file
mounted read-only. The second is a writable host path β check whether
it needs to be writable, and whether it needs noexec. The third is
the Docker socket, which is host root.
Hardening the mount
The long --mount syntax accepts the options the short -v syntax
cannot express:
docker run -d --name api \
--mount type=bind,source=/etc/api,target=/etc/api,readonly \
--mount type=tmpfs,target=/tmp,tmpfs-size=64m,tmpfs-mode=1777 \
--read-only \
registry.example.com/api:1.4.0
For a tmpfs that must hold uploads but must never hold executables,
state the options explicitly rather than relying on the default:
docker run -d --name uploader \
--read-only \
--tmpfs /var/spool/upload:rw,noexec,nosuid,nodev,size=256m \
registry.example.com/uploader:2.1.0
CID=uploader
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
sudo grep 'var/spool/upload ' "/proc/$PID/mounts"tmpfs /var/spool/upload tmpfs rw,nosuid,nodev,noexec,relatime,size=262144k,inode64 0 0noexec present in that line is the proof.
Volume ownership
V=$(docker volume inspect db-data --format '{{.Mountpoint}}')
sudo stat -c '%U:%G %a %n' "$V"
sudo find "$V" -maxdepth 1 -perm -o+w -print999:999 700 /var/lib/docker/volumes/db-data/_dataIllustrative output
Mode 700 owned by the containerβs UID is the target. Anything
world-writable in that find output is a path any container that
mounts the volume can tamper with.
Sanity check
Knowledge check Β· 4 questions
Q1. `ufw status` shows port 8080 denied, yet the port answers from another machine. Why?
Q2. Which single line is the best evidence that a tmpfs mount was created with `noexec`?
Q3. Which are valid ways to keep a management UI off the network? Select all that apply.
Q4. iptables rules added to DOCKER-USER are lost when the host reboots unless they are explicitly persisted.
Passing score: 75%. Answers are checked in this browser.