Dockerfile security mistakes don’t surface in development. They ship to container registries and run in production. This guide covers the vulnerabilities that appear most consistently in container image security reviews, with the fixes developers can apply before the image leaves their local build.
Hardcoded Secrets in Image Layers
The most critical Dockerfile mistake is embedding secrets in the image. This happens in several ways:
Pattern 1: ARG or ENV for secrets
# BAD: secret visible in image history and any derived layer
ARG DATABASE_URL=postgres://admin:supersecret@db:5432/prod
ENV DB_PASSWORD=hardcoded-production-password
RUN pip install -r requirements.txt
The ARG value is visible in docker history IMAGE and docker inspect. ENV values are embedded in the image manifest. Anyone with pull access to the image can extract these values.
# An attacker retrieves credentials from a "private" image
docker history myapp:latest --no-trunc | grep -i password
docker inspect myapp:latest | python3 -c "
import json, sys
img = json.load(sys.stdin)
env = img[0].get('Config', {}).get('Env', [])
for e in env:
print(e)
"
Fix: inject secrets at runtime, never at build time
# GOOD: no secrets in the image
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
# At runtime, inject via environment (from secrets manager, not hardcoded)
docker run \
-e DATABASE_URL="$(aws secretsmanager get-secret-value --secret-id db-url --query SecretString --output text)" \
myapp:latest
In Kubernetes, use a Secret object mounted as an environment variable or volume — not baked into the image.
Pattern 2: Secrets left in intermediate layers
Multi-stage builds are commonly used to reduce image size, but intermediate layers are not always properly cleaned:
# BAD: secret exists in intermediate layer even though it's "deleted"
FROM ubuntu:24.04
COPY .env /tmp/.env
RUN source /tmp/.env && make build
RUN rm /tmp/.env # This does NOT remove it from the layer filesystem
Each RUN instruction creates a new layer. The rm in the second RUN removes the file from that layer’s filesystem, but the file still exists in the previous layer and is recoverable.
# GOOD: single RUN instruction so the secret never persists in a layer
FROM ubuntu:24.04
RUN --mount=type=secret,id=envfile,target=/run/secrets/.env \
source /run/secrets/.env && make build
Or use multi-stage builds where the secret only touches the builder stage:
FROM ubuntu:24.04 AS builder
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
FROM node:22-slim AS runtime
COPY --from=builder /app/dist /app/dist
CMD ["node", "/app/dist/index.js"]
Pattern 3: .git directory or .env files copied into image
# BAD: copies everything including .env, .git, credentials
COPY . /app
# GOOD: use .dockerignore
# .dockerignore
.env
.env.*
.git
*.pem
*.key
secrets/
node_modules/
Always maintain a .dockerignore that excludes secret files, version control directories, and development artifacts.
Running as Root
Containers run as root by default unless a USER instruction is specified. Root in a container means root in the container’s namespace. While namespace isolation limits direct host access, several escalation paths exist:
- Container escape vulnerabilities: A root process in a container is more impactful if a container escape CVE is exploited (e.g., CVE-2024-21626 runc, CVE-2026-34040 Docker)
- Volume mounts: A root container with a mounted host directory can write to that directory as root
- Shared PID namespace: If the container shares the host PID namespace, root in the container can send signals to host processes
- Privileged containers: Privileged mode with root gives near-full host access
# BAD: runs as root
FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
# GOOD: create non-root user and switch
FROM node:22-slim
WORKDIR /app
# Create user with specific UID
RUN groupadd -r appgroup && useradd -r -g appgroup -u 1001 appuser
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
# Use port above 1024 (non-privileged)
EXPOSE 3000
CMD ["node", "server.js"]
For distroless images, create the user in a builder stage:
FROM debian:bookworm-slim AS builder
RUN groupadd -r app && useradd -r -g app -u 1001 app
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
COPY --from=builder --chown=1001:1001 /app /app
USER 1001
CMD ["/app/server.js"]
Note the USER 1001 — use numeric UIDs in distroless images since user name resolution may not work without /etc/passwd.
Base Image Security Risks
The base image you FROM is the largest attack surface in your container. A bloated base image (ubuntu:latest, debian:latest, python:latest) ships hundreds of packages you don’t need — each a potential CVE.
What ships with common base images:
# Check package count in common base images
docker run --rm ubuntu:24.04 dpkg -l | wc -l # ~400 packages
docker run --rm debian:bookworm dpkg -l | wc -l # ~250 packages
docker run --rm python:3.12 dpkg -l | wc -l # ~300 packages
docker run --rm python:3.12-slim dpkg -l | wc -l # ~60 packages
docker run --rm python:3.12-alpine apk list | wc -l # ~15 packages
Base image options, smallest to largest attack surface:
| Base | Size | Packages | Trade-offs |
|---|---|---|---|
gcr.io/distroless/python3 | ~15 MB | ~5 | No shell, no package manager. Best security, harder debugging |
python:3.12-alpine | ~50 MB | ~15 | Small, but glibc vs musl can cause compatibility issues |
python:3.12-slim | ~130 MB | ~60 | Good balance — Debian-based, no dev tools |
python:3.12 | ~350 MB | ~300 | Includes build tools, debug utilities — don’t ship to prod |
ubuntu:24.04 + python | ~400+ MB | ~400 | Only if you need specific Ubuntu packages |
Use multi-stage builds to separate build and runtime:
# Builder stage: can use full image with build tools
FROM python:3.12 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Runtime stage: minimal image, no build tools
FROM python:3.12-slim AS runtime
WORKDIR /app
# Copy only installed packages from builder
COPY --from=builder /root/.local /root/.local
# Copy application code
COPY --chown=1001:1001 . .
RUN groupadd -r app && useradd -r -g app -u 1001 app
USER 1001
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
Scanning for Vulnerabilities Before Push
Don’t wait for a registry scanner to find CVEs. Scan at build time:
# Trivy: fast, widely used, covers OS packages and language dependencies
trivy image myapp:latest
# Check for critical and high severity only (fail build if found)
trivy image --severity CRITICAL,HIGH --exit-code 1 myapp:latest
# SBOM generation for supply chain compliance
trivy image --format cyclonedx --output sbom.json myapp:latest
# Grype: alternative scanner with good EPSS scoring
grype myapp:latest
In CI/CD (GitHub Actions):
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
Dockerfile Linting
Catch misconfigurations before they reach a registry:
# hadolint: most widely used Dockerfile linter
hadolint Dockerfile
# Common rules it enforces:
# DL3008: pin package versions in apt-get
# DL3009: delete apt-get cache after install
# DL3018: pin package versions in apk
# DL3025: use JSON array form for CMD/ENTRYPOINT
# SC2086: shell quoting issues in RUN commands
A minimal secure Dockerfile combining the above principles:
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN groupadd -r app && useradd -r -g app -u 1001 app
COPY --from=builder /root/.local /home/app/.local
COPY --chown=1001:1001 src/ ./src/
USER 1001
ENV PATH=/home/app/.local/bin:$PATH
EXPOSE 8080
CMD ["python", "-m", "src.app"]
The pattern: multi-stage to keep build tools out of the final image, non-root user with specific UID, no secrets in any layer, slim base image, .dockerignore excluding sensitive files. These four controls eliminate the majority of container image vulnerabilities before the image reaches a registry.