Docker Security — Image Scanning, Non-Root Users, and Secrets
A container is not a security boundary. It shares the host kernel, and a misconfigured container running as root with all capabilities is one exploit away from full host compromise. The good news is that Docker gives you layers of defense — most people just never enable them.
The Container Security Stack
Security in Docker is not a single switch. It is layers that stack on top of each other, and you want as many layers as possible between an attacker and your host.
| Layer | What It Protects | Docker Feature |
|---|---|---|
| Image contents | No known CVEs | Image scanning |
| User privileges | No root access | USER directive |
| Filesystem | No tampering | --read-only |
| System calls | Kernel surface area | seccomp profiles |
| Mandatory access | File/network policies | AppArmor/SELinux |
| Capabilities | Granular permissions | --cap-drop / --cap-add |
| Secrets | Credentials exposure | Docker secrets |
| Trust | Image authenticity | Docker Content Trust |
Running as Non-Root
By default, the process inside your container runs as root (UID 0). If an attacker exploits your application, they have root privileges inside the container — and with certain misconfigurations, they can escape to the host.
FROM node:20-alpine
WORKDIR /app
# Create a non-root user with a specific UID
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Switch to non-root BEFORE CMD
USER appuser
CMD ["node", "server.js"]
Always use numeric UIDs in production — some security tools and Kubernetes policies validate UIDs, not usernames.
# Even better: use numeric UID
USER 1001:1001
Verify that your container actually runs as non-root:
docker run --rm myapp whoami
# appuser
docker run --rm myapp id
# uid=1001(appuser) gid=1001(appgroup) groups=1001(appgroup)
Read-Only Filesystem
If an attacker gets into your container, the first thing they do is download tools — curl a reverse shell, write a crypto miner to disk. A read-only filesystem stops this immediately.
# Run with read-only root filesystem
docker run -d --name api \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=100m \
--tmpfs /var/run:rw,noexec,nosuid \
myapp:latest
Your application might need to write to certain directories (temp files, PID files, logs). Use tmpfs mounts for those specific paths — they exist in memory only and disappear when the container stops.
# docker-compose.yml
services:
api:
image: myapp:latest
read_only: true
tmpfs:
- /tmp:size=100m
- /var/run
Image Scanning Tools
Image scanning finds known vulnerabilities (CVEs) in the packages installed in your image. Here is how the major tools compare.
| Tool | Type | Cost | CI Integration | Speed | Database |
|---|---|---|---|---|---|
| Docker Scout | Built-in | Free (limited) | GitHub Actions, GitLab | Fast | Docker advisory DB |
| Trivy | Open source | Free | All CI platforms | Very fast | NVD, GitHub Advisory |
| Grype | Open source | Free | All CI platforms | Fast | Anchore feeds |
| Snyk | Commercial | Free tier | All CI platforms | Medium | Snyk Intel DB |
Docker Scout
# Quick vulnerability overview
docker scout quickview myapp:latest
# Detailed CVE list
docker scout cves myapp:latest
# Compare two images
docker scout compare myapp:v2 --to myapp:v1
# Get recommendations for base image updates
docker scout recommendations myapp:latest
Trivy
Trivy is the most popular open-source scanner. It scans images, filesystems, git repos, and Kubernetes clusters.
# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh
# Scan a local image
trivy image myapp:latest
# Only show HIGH and CRITICAL vulnerabilities
trivy image --severity HIGH,CRITICAL myapp:latest
# Output as JSON for CI parsing
trivy image --format json --output results.json myapp:latest
# Fail CI if critical CVEs found (exit code 1)
trivy image --exit-code 1 --severity CRITICAL myapp:latest
# Scan a Dockerfile (misconfigurations)
trivy config Dockerfile
Grype
# Install Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh
# Scan an image
grype myapp:latest
# Only critical and high
grype myapp:latest --only-fixed --fail-on high
Scanning in CI/CD
Here is a GitHub Actions workflow that blocks deployment if critical vulnerabilities are found:
# .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
Docker Secrets vs Environment Variables
Environment variables are the most common way to pass configuration to containers. They are also the most common way to leak credentials.
# BAD — secrets visible in process listing, docker inspect, and logs
docker run -e DB_PASSWORD=supersecret myapp:latest
# Anyone with Docker access can see them
docker inspect myapp --format '{{json .Config.Env}}'
# ["DB_PASSWORD=supersecret", ...]
Docker Swarm secrets are mounted as files inside the container, never stored in environment variables or image layers.
# Create a secret
echo "supersecret" | docker secret create db_password -
# Use in a Swarm service
docker service create --name api \
--secret db_password \
myapp:latest
Inside the container, the secret is available at /run/secrets/db_password:
# In your application, read the file
cat /run/secrets/db_password
# supersecret
For Docker Compose (non-Swarm), use file-based secrets:
# docker-compose.yml
services:
api:
image: myapp:latest
secrets:
- db_password
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
For build-time secrets (like private NPM tokens), use BuildKit secret mounts:
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .
CMD ["node", "server.js"]
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=.npmrc -t myapp .
The secret is available during the build step but never written to any image layer.
Dropping Capabilities
Linux capabilities split root privileges into granular permissions. By default, Docker containers get a subset, but even that is too much for most applications.
# Drop ALL capabilities, add back only what you need
docker run -d --name api \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
myapp:latest
| Capability | What It Allows | Drop It? |
|---|---|---|
NET_RAW | Raw sockets, ping | Yes, unless you need ping |
SYS_ADMIN | Mount, namespace ops | Always drop |
NET_BIND_SERVICE | Bind to ports < 1024 | Keep if needed |
CHOWN | Change file ownership | Usually drop |
SETUID/SETGID | Change user/group | Drop after USER switch |
SYS_PTRACE | Debug processes | Only for debugging |
# See what capabilities a container has
docker run --rm myapp:latest cat /proc/1/status | grep Cap
# Decode capability bits (install libcap on host)
capsh --decode=00000000a80425fb
Seccomp Profiles
Seccomp (Secure Computing Mode) restricts which system calls a container can make. Docker ships with a default profile that blocks about 44 of the 300+ syscalls.
# Run with the default seccomp profile (already enabled)
docker run -d myapp:latest
# Run with a custom, stricter profile
docker run -d --security-opt seccomp=custom-profile.json myapp:latest
# Disable seccomp entirely (NEVER do this in production)
docker run -d --security-opt seccomp=unconfined myapp:latest
Generate a custom profile by logging which syscalls your app actually uses:
# Use strace to find syscalls (run in development)
strace -c -f -S name ./server 2>&1 | tail -20
# Or use OCI runtime with logging
docker run --security-opt seccomp=log-all.json myapp:latest
AppArmor Profiles
AppArmor provides mandatory access control — restricting file access, network access, and capability usage beyond what standard permissions allow.
# Check current AppArmor profile
docker inspect myapp --format '{{.AppArmorProfile}}'
# docker-default
# Load a custom profile
sudo apparmor_parser -r /etc/apparmor.d/docker-myapp
# Run with custom profile
docker run -d --security-opt apparmor=docker-myapp myapp:latest
Docker Content Trust
Docker Content Trust (DCT) ensures you only pull images that have been signed by trusted publishers. This prevents pulling tampered or man-in-the-middle images.
# Enable content trust globally
export DOCKER_CONTENT_TRUST=1
# Now pulling unsigned images fails
docker pull unsigned-image:latest
# Error: remote trust data does not exist
# Sign your images when pushing
docker trust sign myregistry/myapp:v1.0
# View trust data
docker trust inspect myregistry/myapp:v1.0
Docker Bench for Security
Docker Bench is an automated script that checks your Docker host and containers against CIS benchmarks — over 100 checks covering host configuration, daemon configuration, container runtime, and images.
# Run Docker Bench for Security
docker run --rm --net host --pid host --userns host --cap-add audit_control \
-e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
-v /var/lib:/var/lib:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /etc:/etc:ro \
docker/docker-bench-security
# Example output:
# [PASS] 4.1 - Ensure a user for the container has been created
# [WARN] 4.5 - Ensure Content trust for Docker is Enabled
# [PASS] 4.6 - Ensure HEALTHCHECK instructions have been added
# [WARN] 5.10 - Ensure memory usage for container is limited
Wrapping Up
Container security is not a single checkbox — it is a stack of defenses. Start with the highest impact and lowest effort: scan your images with Trivy, run as non-root, and stop passing secrets as environment variables. Then layer on read-only filesystems, capability dropping, and seccomp profiles. Run Docker Bench periodically to catch configuration drift.
In the next post, we will cover Docker Health Checks — how to tell Docker that your container is actually ready to serve traffic, not just that the process is alive.
