Skip to main content
RunBook Academy

Docker & ContainersXXXV Β· Production HardeningHardening

Network and storage hardening β€” proving the boundaries hold

Advanced⏱ ~28 min

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

Not yet marked complete on this device.

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

Read-only / Safethe declared mapping
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/tcp
Read-only / Safewhat the kernel has open
ss -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.

Read-only / SafeDOCKER-USER rules in force
sudo iptables -L DOCKER-USER -n -v --line-numbers
Chain 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/0

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

Read-only / Safewhich container is on which network
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-net

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

Read-only / Safeevery bind mount on the host
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=true

Illustrative 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
Read-only / Safeverify the options the kernel applied
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 0

noexec present in that line is the proof.

Volume ownership

Read-only / Safewho owns the volume data
V=$(docker volume inspect db-data --format '{{.Mountpoint}}')
sudo stat -c '%U:%G %a %n' "$V"
sudo find "$V" -maxdepth 1 -perm -o+w -print
999:999 700 /var/lib/docker/volumes/db-data/_data

Illustrative 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

  1. Q1. `ufw status` shows port 8080 denied, yet the port answers from another machine. Why?

  2. Q2. Which single line is the best evidence that a tmpfs mount was created with `noexec`?

  3. Q3. Which are valid ways to keep a management UI off the network? Select all that apply.

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