Docker security checklist

Docker Network & Supply Chain Checklist: 5 Checks (2026)

Docker writes its own iptables rules, so a published container port is reachable even when your firewall says DENY. This page documents all 5 checks in the CtrlOps Network and Supply Chain audit: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202613 min read
5
checks in this audit
3
rated high severity
12s
automated run time
4
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the docker-security-network-supply-chain audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

Docker's default networking behaviour punches holes in your host firewall without telling you. A container publishing a port on 0.0.0.0 bypasses UFW entirely, because Docker writes its own iptables rules and they are evaluated before your DENY rules. This page documents the 5 checks in the CtrlOps Network and Supply Chain audit, in the order the audit runs them.

The audit covers two surfaces that share a theme: what your containers expose to the network, and what you actually know about the code inside them. Every threshold below is transcribed from the audit script itself, not from general advice.

Key takeaways

Five checks, three HIGH, and the first one is the finding that surprises experienced administrators most often.

  • Three are rated HIGH: published ports, secrets in environment variables and pending engine updates. Two are MEDIUM.
  • Four need root, or docker group membership. The engine update check reads package metadata, which any account can do.
  • Docker bypasses UFW by design. Check 1 is not looking for a bug, it is looking for the documented behaviour that surprises people: your firewall rules and Docker's iptables rules are not the same rule set.
  • Only check 4 can fail on Debian and Ubuntu. Separating security updates from ordinary ones is an apt capability, so on RHEL family, SUSE and Alpine every pending container-stack update reports as a warning regardless of what it fixes.
  • The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection.
#CheckSeverityWhat it catchesRoot needed
1Published container portsHIGHPorts bound to all interfaces, past the host firewallYes
2Inter-container connectivityMEDIUMContainers on the default bridge with ICC onYes
3Secrets in envHIGHCredentials visible in docker inspectYes
4Docker engine updatesHIGHPending docker, containerd or runc patchesNo
5Unpinned running imagesMEDIUMlatest or untagged images in production, and no scannerYes
Check 1 overlaps with the server-side view on purpose. The Firewall and Network audit looks at the same problem from the host: firewall state and what is listening. This one looks at it from the container: which container published which port on which address. Run both on a host that runs Docker, because either one alone leaves the gap the other covers.

Check 1: Are containers publishing ports on all interfaces?

High severityNeeds sudo

When you run docker run -p 8080:80, Docker binds port 8080 on 0.0.0.0, which includes your public interface. It enforces that with iptables rules inserted ahead of the chains UFW manages, so the host firewall can show DENY for that port while the container behind it answers the internet.

The audit reads NetworkSettings.Ports for every running container and flags any host binding on 0.0.0.0 or ::, naming the container and the binding it found.

How to check manually

docker ps --format '{{.Names}} {{.Ports}}' | grep '0.0.0.0:'
Result thresholds this check applies
ResultWhenWhat it means
PASSNo container publishes a port on all interfacesEvery published port is bound to a specific address.
WARNOne or more containers publish on 0.0.0.0 or ::Each container is named with the binding it published. Docker iptables rules bypass UFW, so these are internet-reachable despite host firewall DENY rules. Bind to 127.0.0.1 or install ufw-docker.
SKIPNo running containers to evaluateNothing to check.

This is a HIGH severity check that warns, and the reason is that publishing on all interfaces is correct for some containers: a reverse proxy is supposed to answer the world. The audit cannot know which container is your front door, so it names every one and leaves the judgement with you.

How to fix it

# Binds to every interface, including the public one
docker run -p 8080:80 nginx

# Binds to localhost only
docker run -p 127.0.0.1:8080:80 nginx
services:
  app:
    ports:
      - "127.0.0.1:8080:80"

Put anything that genuinely needs to be public behind a reverse proxy that terminates TLS, and bind the application containers to localhost. For a host-wide fix that makes Docker respect UFW:

sudo wget -O /usr/local/bin/ufw-docker https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
sudo systemctl restart docker
Run docker ps on a production host right now. If you see 0.0.0.0:5432->5432 next to a database container, that database is answering the internet and your UFW deny rule is not protecting it. This is the single most common way a container host ends up with an exposed data store.

Check 2: Are containers sharing the default bridge with inter-container communication?

Medium severityNeeds sudo

The default bridge network connects every container attached to it with inter-container communication enabled. Any container on it can reach any other by IP, with no access control, no segmentation and no logging. One compromised container is then one hop from all the others.

The audit lists which running containers are attached to the bridge network, then reads com.docker.network.bridge.enable_icc on that network. Containers on the default bridge only warn when ICC is actually on.

How to check manually

docker network inspect bridge --format '{{range .Containers}}{{.Name}} {{end}}'
docker network inspect bridge --format '{{index .Options "com.docker.network.bridge.enable_icc"}}'
Result thresholds this check applies
ResultWhenWhat it means
PASSNo container sits on the default bridge, or containers are on it but ICC is disabledTwo distinct passes. The first means user-defined networks are in use. The second names how many containers are on the bridge and confirms inter-container traffic is blocked.
WARNContainers share the default bridge with ICC enabledThe count and up to five container names are listed. Any one of them can reach the others, so move them onto scoped user-defined networks.
SKIPThe daemon is not reachable, or no containers are runningNothing to evaluate.

Anything started by Docker Compose gets a user-defined network automatically, so this check tends to fire on hosts where containers were started by hand with docker run and never moved.

How to fix it

Give each group of containers a network scoped to what it actually needs to talk to:

services:
  web:
    networks: [frontend]
  api:
    networks: [frontend, backend]
  db:
    networks: [backend]

networks:
  frontend:
  backend:

web reaches api, api reaches db, and web cannot reach db at all. To disable ICC on the default bridge globally, add this to /etc/docker/daemon.json and restart the daemon:

{
  "icc": false
}
Bottom line: the default bridge is the equivalent of putting every server on one flat LAN with no firewall between them. User-defined networks are the VLANs, and they also give you DNS-based service discovery, which is usually why teams adopt them before they think about the isolation.

Check 3: Are secrets passed as container environment variables?

High severityNeeds sudo

Environment variables are the most common way to pass secrets to containers and the least protected. docker inspect prints every one in plain text to anyone with Docker access, which includes every member of the docker group. Child processes inherit them, some runtimes log them on crash, and they stay in the container metadata long after the process has exited.

The audit reads Config.Env for every running container and matches names against PASSWORD, PASSWD, SECRET, API_KEY, ACCESS_KEY, TOKEN and PRIVATE_KEY, including prefixed forms. It ignores variables whose value is empty or an obvious placeholder such as changeme or example, and it reports variable names only, never values.

How to check manually

docker ps -q | while read c; do
  name=$(docker inspect --format '{{.Name}}' "$c" | tr -d '/')
  keys=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$c" \
    | grep -iE '^(.*_)?(PASSWORD|PASSWD|SECRET|API_?KEY|ACCESS_?KEY|TOKEN|PRIVATE_?KEY)=' \
    | sed 's/=.*//' | head -3 | tr '\n' ',')
  [ -n "$keys" ] && echo "$name: $keys"
done
Result thresholds this check applies
ResultWhenWhat it means
PASSNo secret-like environment variable names are foundNothing matched the patterns. Verify manually if your naming convention is unusual, because this check matches names rather than detecting secret-shaped values.
WARNSecret-like environment variables are detectedEach container is named with up to three of its matching variable names, never the values. Move to Docker secrets or a secrets manager.
SKIPNo running containers to evaluateNothing to check.

Both directions of error are worth knowing. A variable called DB_CONN holding a full connection string will not match, and a variable called TOKEN_PATH holding a harmless path will. Read the named variables rather than the count.

How to fix it

# Compose with file-based secrets
services:
  app:
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    file: ./db_password.txt

Then read the file rather than the variable in the application:

import os
path = os.environ.get('DB_PASSWORD_FILE', '')
if path and os.path.exists(path):
    with open(path) as f:
        db_password = f.read().strip()

The file path in the environment is the part that is safe to expose. The credential itself stays on a mounted file that only the container can read.

Run docker inspect on one production container. If you can read a database password in the Env array, so can everyone in the docker group, and that group is root-equivalent. This is the check that most often turns up a credential nobody realised was readable.

Check 4: Are Docker engine updates pending?

High severityNo sudo needed

Docker, containerd and runc share the host kernel with every container. A vulnerability in runc, such as the Leaky Vessels family in CVE-2024-21626, gives an attacker inside a container full root on the host. These are not theoretical: they ship with public exploits and get used.

The audit queries whichever package manager the host has, apt, dnf, yum, zypper or apk, for pending updates to docker, containerd and runc.

How to check manually

# Debian and Ubuntu
apt-get -s upgrade 2>/dev/null | grep '^Inst ' | grep -iE 'docker|containerd|runc'

# RHEL family
dnf check-update 2>/dev/null | grep -iE 'docker|containerd|runc'

# Alpine
apk version -l '<' 2>/dev/null | grep -iE 'docker|containerd|runc'
Result thresholds this check applies
ResultWhenWhat it means
PASSNo pending docker, containerd or runc updatesThe container stack is current as far as the package metadata already on the host goes.
FAILPending updates are marked as security updatesOnly reachable on apt-based hosts, where the source is visible. runc escape CVEs give host root, so this is the one to patch first.
WARNPending container-stack updates that are not identified as security updatesThe count is named. On RHEL family, SUSE and Alpine every pending update lands here, because those package managers do not separate security updates in this query.
SKIPDocker is not installed, or no supported package manager was foundNothing to check.

Two caveats carry real weight. A warning on a Rocky or Alma host is not evidence that nothing security-related is waiting: run dnf updateinfo list security to find out. And the check never refreshes package metadata, because every check in this catalog is strictly read-only and apt update writes to disk. Refresh it yourself before trusting a pass.

How to fix it

# Debian and Ubuntu
sudo apt-get update && sudo apt-get upgrade docker-ce containerd.io runc -y

# RHEL family
sudo dnf update docker-ce containerd.io runc -y

sudo systemctl restart docker

Running containers keep using the old runtime until they are recreated, so finish the job:

docker compose down && docker compose up -d
Bottom line: runc is the binary that creates containers. A vulnerable runc means any container on the host can potentially escape to it. The remediation is a package update, a daemon restart and a container recreation, which is minutes of disruption against a complete host compromise.

Check 5: Are running containers using unpinned images?

Medium severityNeeds sudo

A running container on latest or on an untagged image means you do not know exactly what is in production, and the next pull or restart can change it silently. Combine that with no scanner anywhere on the host and you have unknown code running with no way to find out whether it is vulnerable.

The audit counts running containers whose image ends in :latest or carries no tag or digest at all, then looks for trivy, grype, docker scout, clair or snyk on the host. The two signals are graded together, which is why this check has three distinct warnings.

How to check manually

docker ps --format '{{.Image}}' | grep -cE '(:latest$|^[^:@]+$)'

for tool in trivy grype snyk; do command -v "$tool" >/dev/null && echo "$tool available"; done
docker scout version >/dev/null 2>&1 && echo "docker scout available"
Result thresholds this check applies
ResultWhenWhat it means
PASSImages are version-pinned and at least one scanner is installedReproducible deploys plus the ability to check them. The scanners found are named.
WARNUnpinned images, no scanner, or bothThree distinct warnings. Unpinned with a scanner asks you to pin. Pinned with no scanner asks you to add scanning to the pipeline. Unpinned with no scanner names both, and is the combination worth fixing first.
SKIPThe Docker daemon is not reachableNothing to evaluate.

This check reads what is running, which is what distinguishes it from the floating tag check in the Image Vulnerabilities audit. That one reads images on disk, including ones nothing has started for months. This one reads production.

How to fix it

# Resolve the digest for what you are running now, then pin to it
docker pull nginx:1.27-alpine
docker inspect --format '{{index .RepoDigests 0}}' nginx:1.27-alpine

# Install a scanner
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
services:
  web:
    image: nginx:1.27.1-alpine
Bottom line: an unpinned image with no scanner means you cannot say what is running or whether it is vulnerable. Those are two halves of one question, which is why the audit grades them together rather than as separate findings.

What this audit does not cover

This audit reads container networking, secret handling and image hygiene on running containers. It does not check:

  • Container-level hardening. Privileged mode, capabilities, the runtime user and read-only filesystems are the Container Hardening audit.
  • Daemon-level controls. Socket exposure, rootless mode and log rotation are the Daemon and Socket audit.
  • Image contents. CVE scanning, embedded secrets and EOL base images are the Image Vulnerabilities audit.
  • Runtime resource limits. Seccomp, AppArmor and memory, CPU and PID caps are the Runtime and Resources audit.
  • Host firewall configuration. UFW rules, iptables chains and cloud security groups are the Firewall and Network audit.

The manual audit problem at scale

These five checks move between container inspection, network inspection, package manager queries and a PATH search. On one host that is about four minutes. On ten servers with different firewall setups and package managers it is forty minutes of context switching, and the most important finding, a port bound to 0.0.0.0 that your firewall is not protecting, is the one easiest to scroll past.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 5 checksAbout 30 minutesAbout 95 seconds
UFW bypass detectionManual comparison per containerAutomatic, per container and per binding
Package manager supportRemember the right commandAuto-detects apt, dnf, yum, zypper and apk
Get a scored reportNot availableAutomatic
Re-audit after fixingAnother 30 minutesAnother 95 seconds

How CtrlOps runs all 5 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Network and Supply Chain audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH and Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal.

Step 1: Open the Audit tab

Connect to your server in CtrlOps and open the Audit tab in the left sidebar.

Step 2: Select Network & Supply Chain

Choose it from the Docker category. All 5 checks are listed with their descriptions, severity levels and the estimated run time, about 12 seconds in total.

Step 3: Run it

The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.

Step 4: Read the report

You get a summary containing:

  • A hardening score out of 100
  • A severity breakdown: how many HIGH, MEDIUM and LOW findings
  • Pass, warning, failed and skipped counts
  • A findings table naming the container and port combination for every exposed binding

Step 5: Fix what failed

Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here: changing a port binding means recreating the container, and updating runc means restarting the daemon.

Step 6: Re-run and compare

Run the same audit again after applying the fixes and watch the score move. Check 4 is the one that reappears on its own, because new container-stack updates land regardless of what you change on the host.

Conclusion

The theme of this audit is the gap between what you believe is exposed and what actually is. The firewall says the port is closed and the container answers anyway. The secret is "in the environment", which is another way of saying it is in docker inspect output for everyone in the docker group. The image is "the current one", which is only true until the next pull.

Three of these five are HIGH severity and each has a fix that takes minutes. The CtrlOps Security Audit runs the set in about 12 seconds per server and names the specific container, port and variable in every finding.

The neighbouring checklists cover the other container layers: container hardening, daemon and socket, image vulnerabilities, volume permissions and runtime and resources.


Frequently asked questions

Yes. Publishing a port makes Docker write iptables rules that are evaluated before the chains UFW manages, so a container started with -p 8080:80 answers the internet even when UFW denies 8080. Bind to 127.0.0.1 explicitly, or install ufw-docker so Docker's rules respect your firewall policy.

Bind published ports to 127.0.0.1 and put anything public behind a reverse proxy. Use user-defined bridge networks instead of the default bridge, scoped so that containers can only reach what they need. Disable inter-container communication on the default bridge with "icc": false for anything still attached to it.

Because they are visible in docker inspect in plain text, inherited by every child process, present in container metadata for the container's whole life and logged by some runtimes on crash. Anyone with Docker access can read them, and Docker access is root-equivalent. Use Docker secrets, a mounted secret file, or a secrets manager, and pass the path rather than the value.

On Debian and Ubuntu: apt-get -s upgrade | grep '^Inst ' | grep -iE 'docker|containerd|runc'. On RHEL family: dnf check-update | grep -iE 'docker|containerd|runc'. Run sudo apt update or sudo dnf makecache first, because the audit reads existing package metadata and never refreshes it.

Because apt exposes whether an update comes from a security pocket and the others do not in this query. On apt hosts the audit can separate security updates, which fail, from ordinary ones, which warn. On RHEL family, SUSE and Alpine everything lands as a warning. A warning there is not proof that nothing security-related is pending: check dnf updateinfo list security yourself.

Yes. It reads every running container's port bindings and flags any published on 0.0.0.0 or ::, naming the container and the specific binding. It reports this as a warning rather than a failure because some containers, a reverse proxy in particular, are supposed to answer the world, and only you know which one that is.

ICC lets any container on the default bridge reach any other by IP with no access control. If one container is compromised, every other container on that bridge is one hop away. Disable it with "icc": false in daemon.json, or, better, move containers onto user-defined networks scoped to the connections they actually need.

No. It matches variable names against known patterns such as PASSWORD, SECRET, API_KEY and TOKEN, so a credential in a variable called DB_CONN or SERVICE_CFG does not match. It also ignores empty values and placeholders like changeme. Read it as a fast sweep for the obvious cases rather than as proof that no secrets are in your environment.

This one reads the images that running containers were started from, which is production. The image audit reads the images stored on disk, including ones nothing has started for months. A host can pass one and warn on the other, and the version that matters for incident response is this one.

Check 1, if anything other than your reverse proxy is publishing on all interfaces. An exposed database or admin interface is reachable right now, while the other four describe how much damage a compromise causes once it starts. Check 4 is second when it reports a security update, because a runc escape hands over the host.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

Windows

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals