Dockerfile and Container Image Security: Hardcoded Secrets, Root Execution, and Base Image Risks

Insecure Dockerfiles introduce vulnerabilities that survive into production. This guide covers the most common container image security mistakes — hardcoded secrets in layers, running as root, bloated base images with unpatched CVEs, and COPY misuse — with fixes for each.

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:

BaseSizePackagesTrade-offs
gcr.io/distroless/python3~15 MB~5No shell, no package manager. Best security, harder debugging
python:3.12-alpine~50 MB~15Small, but glibc vs musl can cause compatibility issues
python:3.12-slim~130 MB~60Good balance — Debian-based, no dev tools
python:3.12~350 MB~300Includes build tools, debug utilities — don’t ship to prod
ubuntu:24.04 + python~400+ MB~400Only 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.