Docker Security Audit: What I Actually Found

DockerSecurityDevOpsLinuxContainers

April 11, 2026

Cover image for Docker Security Audit: What I Actually Found

I ran a security audit on my self-hosted stack after reading about a Docker container escape on Reddit. Every "hardened" guide I had followed missed at least two of the issues I found. Here are the 6 things that were actually wrong, the exact fixes, and the scanning tools that catch them automatically.

The Audit Checklist#

Before running any scanner, I went through every service in my docker-compose.yml by hand. The checklist is shorter than you'd expect. Most Docker security issues fall into six categories.

  1. Containers running as root (the default, and almost always unnecessary)
  2. Published ports bound to 0.0.0.0 instead of 127.0.0.1
  3. Secrets passed as environment variables instead of mounted files
  4. Base images with known CVEs that haven't been rebuilt in months
  5. Docker socket mounted into containers (gives full host access)
  6. No network segmentation between services that don't need to talk to each other

I had four of these six wrong. The two I got right were the Docker socket (I never mount it) and root users (I use USER directives in my Dockerfiles). Everything else needed fixing.

Network Exposure: The Docker Bridge Trap#

This one surprised me the most. Docker rewrites your iptables rules when you publish a port. It inserts its own ACCEPT rules before UFW or firewalld gets a chance to evaluate the packet. Your firewall thinks the port is closed. Docker already opened it.

The technical reason: Docker routes container traffic through the nat table using DNAT. Packets get redirected before they hit the INPUT chain that your firewall controls. I had UFW configured to deny all incoming traffic, but docker compose up silently punched holes through it for every published port.

Warning: If you run ufw status and see your ports blocked, that does not mean Docker respects those rules. Run iptables -L -n -t nat to see what Docker actually configured.

The fix has two parts. First, bind every published port to localhost unless it genuinely needs external access. Second, use the DOCKER-USER iptables chain to enforce firewall rules that Docker cannot bypass.

docker-compose.ymlbefore
services:
  postgres:
    image: postgres:16
    ports:
      - "5432:5432"   # exposed to the internet
  redis:
    image: redis:7
    ports:
      - "6379:6379"   # exposed to the internet
docker-compose.ymlafter
services:
  postgres:
    image: postgres:16
    ports:
      - "127.0.0.1:5432:5432"   # localhost only
  redis:
    image: redis:7
    expose:
      - "6379"                   # internal only, no host binding

The difference between ports with a 127.0.0.1 bind and expose matters. expose makes the port available to other containers on the same Docker network but never binds it to the host at all. Use expose for anything that only needs container-to-container communication.

Secrets in Environment Variables#

I had database passwords, API keys, and JWT secrets all defined as environment variables in my compose file. This is what every tutorial teaches. It is also wrong.

Environment variables are visible in docker inspect, in /proc/<pid>/environ inside the container, and in your shell history if you ever passed them with -e. They get logged by crash reporters. They show up in CI build logs. A single docker inspect command from anyone with Docker socket access exposes every secret in the container.

Docker Compose supports a secrets directive that mounts sensitive values as files at /run/secrets/ inside the container. The secrets exist in memory only during the container's lifetime. They do not persist in image layers.

docker-compose.yml
services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - jwt_secret
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  jwt_secret:
    file: ./secrets/jwt_secret.txt

Your application needs to read from the file path instead of an environment variable. Most databases and many frameworks support the _FILE suffix convention (Postgres, MySQL, and MariaDB official images all do). For custom apps, read the file at startup: fs.readFileSync('/run/secrets/db_password', 'utf8').trim().

Tip: Add your secrets/ directory to .gitignore and .dockerignore immediately. Accidentally committing a secrets file to git is the most common way this goes wrong.

Image Supply Chain Risks#

In August 2025, researchers at Binarly discovered that dozens of official Debian-based Docker Hub images still contained the XZ Utils backdoor (CVE-2024-3094, CVSS 10.0) months after public disclosure. That backdoor was in images people were pulling into production every day.

I scanned my own images with Trivy and found 23 high-severity CVEs across 8 containers. Most came from base images I hadn't rebuilt in 3+ months. The node:18 image alone had 14 vulnerabilities, 3 of them critical.

  • Pin your base image digests, not just tags. node:18 is a moving target. node:18@sha256:abc123... is not.
  • Use slim or distroless images. node:18-slim has ~80% fewer packages than node:18, which means 80% fewer things that can have CVEs.
  • Rebuild on a schedule. I set a weekly GitHub Action that rebuilds and pushes all my images, pulling fresh base layers each time.
  • Check Docker Scout or Trivy before deploying. A 30-second scan catches what months of manual review won't.
CVEs Found by Base Image Type
Each row scaled independently — bar lengths compare within a row, not across rows.
CriticalHighMedium
node:183
14
47
node:18-slim1
5
12
node:18-alpine0
2
6
gcr.io/distroless/nodejs0
0
1

The numbers above are from Trivy scans I ran in April 2026 against the latest available tags. Alpine and distroless images are not magic, but the reduced attack surface is measurable.

The Fixes: Exact docker-compose Changes#

Here is the complete hardened compose file I ended up with. Every change maps to one of the six checklist items from earlier.

docker-compose.yml
services:
  app:
    image: myapp:latest
    user: "1000:1000"                     # non-root
    read_only: true                        # immutable filesystem
    tmpfs:
      - /tmp                               # writable tmp only
    security_opt:
      - no-new-privileges:true             # block privilege escalation
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    networks:
      - frontend
      - backend
    ports:
      - "127.0.0.1:3000:3000"

  postgres:
    image: postgres:16-alpine
    user: "999:999"
    read_only: true
    tmpfs:
      - /tmp
      - /run/postgresql
    security_opt:
      - no-new-privileges:true
    secrets:
      - db_password
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend                            # no frontend access
    expose:
      - "5432"                             # internal only

  redis:
    image: redis:7-alpine
    user: "999:999"
    read_only: true
    security_opt:
      - no-new-privileges:true
    networks:
      - backend
    expose:
      - "6379"

networks:
  frontend:
  backend:
    internal: true                         # no external access

secrets:
  db_password:
    file: ./secrets/db_password.txt

volumes:
  pgdata:

The key additions: read_only: true makes the container filesystem immutable so malware cannot write to it. no-new-privileges prevents a process from gaining more privileges than its parent. The backend network is marked internal: true, which blocks all outbound internet access from Postgres and Redis.

Automated Scanning with Trivy and Dockle#

Manual audits are useful once. Automated scanning is what keeps you safe after the first week. I use two tools: Trivy for vulnerability scanning and Dockle for Dockerfile best-practice checks. Both are open source and run in seconds.

terminal
# Install Trivy
brew install trivy          # macOS
sudo apt install trivy      # Debian/Ubuntu

# Scan a running image
trivy image postgres:16-alpine

# Scan and fail on HIGH or CRITICAL
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest

# Scan your docker-compose project
trivy fs --scanners vuln,misconfig .
terminal
# Install Dockle
brew install goodwithtech/r/dockle

# Check Dockerfile best practices
dockle myapp:latest

Dockle checks things Trivy doesn't: whether you set a HEALTHCHECK, whether you run as root, whether you use ADD instead of COPY, and whether you left sensitive files in the image. Together they cover vulnerabilities (Trivy) and misconfigurations (Dockle).

  • GitHub Actions (fail the build on critical CVEs). Use the official trivy-action from Aqua Security. Set severity to HIGH,CRITICAL and exit-code to 1. The action caches the vulnerability database between runs, so subsequent scans take about 10 seconds. Add it as a step after your docker build but before your push.
  • GitLab CI (scan on every merge request). Run Trivy as a Docker container in your pipeline: docker run aquasec/trivy image your-image:tag. Store the JSON output as a pipeline artifact so you can review findings in the merge request. GitLab also supports Trivy natively through its container scanning template.
  • Local pre-commit hook (catch issues before push). Add a git pre-commit hook that runs trivy fs --scanners secret . to catch accidentally committed secrets. This takes under 2 seconds on most projects and catches .env files, private keys, and API tokens before they hit your repository. Pair it with dockle for Dockerfile linting.

If you want to go further, Docker Scout (built into Docker Desktop since 2024) provides SBOM generation and real-time CVE monitoring. But Trivy and Dockle are free, work in any CI system, and cover 90% of what you need. For the CLI tools I run daily, these two earned a permanent spot.


The honest takeaway: my stack was not as hardened as I thought. Default Docker settings are optimized for developer convenience, not security. The bridge network bypasses your firewall. Environment variables leak your secrets. Base images accumulate CVEs while you sleep. But the fixes are not complicated. An afternoon of work and two scanning tools turned a vulnerable stack into one I actually trust.


Sources: OWASP Docker Security Cheat Sheet, Docker Docs: Packet Filtering and Firewalls, Docker Docs: Manage Secrets, Trivy, Dockle, runc container escape (CVE-2024-21626)

Share