
Docker Security Audit: What I Actually Found
April 11, 2026
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.
- Containers running as root (the default, and almost always unnecessary)
- Published ports bound to 0.0.0.0 instead of 127.0.0.1
- Secrets passed as environment variables instead of mounted files
- Base images with known CVEs that haven't been rebuilt in months
- Docker socket mounted into containers (gives full host access)
- 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 runufw statusand see your ports blocked, that does not mean Docker respects those rules. Runiptables -L -n -t natto 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.
services:
postgres:
image: postgres:16
ports:
- "5432:5432" # exposed to the internet
redis:
image: redis:7
ports:
- "6379:6379" # exposed to the internet
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.
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 yoursecrets/directory to.gitignoreand.dockerignoreimmediately. 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:18is a moving target.node:18@sha256:abc123...is not. - Use slim or distroless images.
node:18-slimhas ~80% fewer packages thannode: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
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.
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.
# 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 .
# 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).
CI/CD Integration Examples
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, Sysdig: runc Container Escape CVEs