Skip to content

Dockerfile and Build Security

Overview

The security of your container images starts with how they are built. A poorly written Dockerfile can introduce vulnerabilities, expose secrets, include unnecessary attack surface, and run processes as root. Since container images are the foundation of every Kubernetes workload, hardening your Dockerfiles is a critical first step in supply chain security.

CKS Exam Relevance

The CKS exam may present you with an insecure Dockerfile and ask you to identify or fix security issues. You should be able to spot common problems instantly: running as root, using ADD instead of COPY, embedding secrets, using bloated base images, and missing multi-stage builds.

Secure Build Pipeline

Bad vs Good Dockerfile: Side by Side

Insecure Dockerfile

dockerfile
# BAD: Insecure Dockerfile with multiple security issues

# Issue 1: Using 'latest' tag - unpredictable, mutable
FROM ubuntu:latest

# Issue 2: Secrets embedded in the image layer
ENV DB_PASSWORD=supersecret123
ENV API_KEY=ak_live_12345abcde

# Issue 3: Running package manager without cleanup (bloated image)
RUN apt-get update && apt-get install -y \
    curl \
    wget \
    vim \
    net-tools \
    gcc \
    make \
    python3 \
    python3-pip

# Issue 4: Using ADD instead of COPY (ADD can fetch remote URLs, extract archives)
ADD https://example.com/app.tar.gz /app/
ADD . /app/

# Issue 5: No .dockerignore - may include .git, .env, secrets
WORKDIR /app

# Issue 6: Installing dependencies with no version pinning
RUN pip3 install flask requests

# Issue 7: No HEALTHCHECK instruction
# Issue 8: Exposing unnecessary ports
EXPOSE 22 80 443 8080

# Issue 9: No USER instruction - runs as root (UID 0)
CMD ["python3", "app.py"]

Security Issues Count: 9

This Dockerfile contains 9 distinct security issues that would be flagged by image scanning and static analysis tools. Each one increases the attack surface or exposes sensitive data.

Secure Dockerfile

dockerfile
# GOOD: Secure Dockerfile following best practices

# Best Practice 1: Use specific, versioned base image with digest
FROM python:3.11-slim-bookworm@sha256:abc123def456 AS builder

# Best Practice 2: Set working directory
WORKDIR /app

# Best Practice 3: Copy only dependency files first (layer caching)
COPY requirements.txt .

# Best Practice 4: Install dependencies with pinned versions, no cache
RUN pip install --no-cache-dir -r requirements.txt

# Best Practice 5: Copy application code
COPY . .

# --- Multi-stage build: final production image ---
FROM gcr.io/distroless/python3-debian12:nonroot

# Best Practice 6: Copy only built artifacts from builder stage
COPY --from=builder /app /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages

WORKDIR /app

# Best Practice 7: Expose only required ports
EXPOSE 8080

# Best Practice 8: Run as non-root user (distroless 'nonroot' = UID 65532)
USER 65532:65532

# Best Practice 9: Use exec form for CMD (proper signal handling)
CMD ["python3", "app.py"]

Detailed Best Practices

1. Use Minimal Base Images

The smaller the base image, the smaller the attack surface.

Base ImageSizePackagesUse Case
ubuntu:22.04~77 MBFull OS, apt, shellDevelopment only
debian:bookworm-slim~52 MBMinimal Debian, aptWhen you need apt
alpine:3.19~7 MBMinimal, musl libc, apkSmall footprint, some compatibility issues
gcr.io/distroless/static~2 MBNo OS, no shellGo static binaries
gcr.io/distroless/base~20 MBglibc, libssl, ca-certsC/C++ applications
gcr.io/distroless/python3~52 MBPython runtime onlyPython applications
scratch0 MBNothingStatically compiled binaries

Distroless Images

Distroless images from Google contain only your application and its runtime dependencies. They have:

  • No package manager (apt, apk)
  • No shell (sh, bash)
  • No utilities (curl, wget, ls)

This makes them extremely secure -- even if an attacker gains access to the container, there are no tools to exploit.

2. Multi-Stage Builds

Multi-stage builds allow you to use one image for building and a different, minimal image for running.

dockerfile
# Stage 1: Build (includes compilers, dev tools)
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .

# Stage 2: Production (minimal image)
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /app/server /server
USER 65532:65532
ENTRYPOINT ["/server"]

Benefits:

  • Build tools (compilers, package managers) are not in the final image
  • Dramatically reduces image size and attack surface
  • Secrets used during build (e.g., private repo keys) do not persist in the final image
  • Separate caching for build and runtime layers

3. Non-Root USER Instruction

Running containers as root is one of the most common and dangerous security mistakes.

dockerfile
# Create a non-root user and switch to it
FROM node:20-slim

# Create a non-root user with specific UID/GID
RUN groupadd -r appgroup --gid=10001 && \
    useradd -r -g appgroup --uid=10001 --home-dir=/app --shell=/bin/false appuser

WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm ci --only=production

# Switch to non-root user
USER 10001:10001

EXPOSE 3000
CMD ["node", "server.js"]

Why Non-Root Matters

If a container running as root is compromised, the attacker has root privileges inside the container. Combined with other misconfigurations (e.g., hostPath mounts, privileged mode), this can lead to full node compromise. Always use USER to switch to a non-root user.

UID best practices:

  • Use a UID above 10000 to avoid conflicts with host UIDs
  • Use a dedicated group with explicit GID
  • Set --shell=/bin/false to prevent interactive login
  • Use --chown in COPY to set proper file ownership

4. COPY vs ADD Security Implications

FeatureCOPYADD
Copy local filesYesYes
Fetch remote URLsNoYes (security risk)
Extract archivesNoYes (auto-extracts .tar, .gz)
Predictable behaviorYesNo (varies by input)
RecommendedYesOnly for local tar extraction
dockerfile
# BAD: ADD fetches from URL (could be tampered, no checksum)
ADD https://example.com/config.tar.gz /app/

# GOOD: Download explicitly with checksum verification
RUN curl -sLO https://example.com/config.tar.gz && \
    echo "expected_sha256  config.tar.gz" | sha256sum -c - && \
    tar xzf config.tar.gz && \
    rm config.tar.gz

# GOOD: Use COPY for local files
COPY config/ /app/config/

ADD Security Risks

ADD with a URL can:

  • Download content from a compromised or redirected URL
  • Auto-extract archives that may contain unexpected files
  • Make builds non-reproducible if the URL content changes Always prefer COPY for local files and explicit RUN curl with checksum verification for remote content.

5. Avoiding Secrets in Dockerfiles

Secrets embedded in Dockerfiles persist in image layers and can be extracted by anyone with access to the image.

dockerfile
# BAD: Secrets in ENV (visible in image layers and docker inspect)
ENV DB_PASSWORD=supersecret123
ENV AWS_SECRET_KEY=wJalrXUtnFEMI/K7MDENG

# BAD: Secrets in RUN (cached in image layer)
RUN echo "password123" > /app/config/db.conf

# BAD: Secrets passed as build args (visible in docker history)
ARG API_KEY
RUN curl -H "Authorization: $API_KEY" https://api.example.com/data

Proper approaches:

dockerfile
# GOOD: Use Docker BuildKit secrets (not stored in layers)
# syntax=docker/dockerfile:1
FROM python:3.11-slim
RUN --mount=type=secret,id=db_password \
    cat /run/secrets/db_password > /dev/null && \
    pip install --no-cache-dir -r requirements.txt

# Build with: docker build --secret id=db_password,src=./password.txt .
dockerfile
# GOOD: Use multi-stage builds to discard secrets
FROM alpine AS downloader
ARG PRIVATE_REPO_TOKEN
RUN apk add --no-cache git && \
    git clone https://${PRIVATE_REPO_TOKEN}@github.com/org/private-repo.git /src

FROM python:3.11-slim
# Only copy the code, not the token
COPY --from=downloader /src /app
yaml
# GOOD: Mount secrets at runtime via Kubernetes
apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
    - name: app
      image: myapp:v1.0
      env:
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: password

Checking for Leaked Secrets

bash
# View all layers and commands in an image
docker history myapp:v1.0 --no-trunc

# Inspect environment variables
docker inspect myapp:v1.0 --format='{{range .Config.Env}}{{println .}}{{end}}'

# Use Trivy to scan for secrets
trivy image --scanners secret myapp:v1.0

6. The .dockerignore File

The .dockerignore file prevents sensitive or unnecessary files from being included in the Docker build context.

# .dockerignore

# Version control
.git
.gitignore

# Environment and secrets
.env
.env.*
*.pem
*.key
*.crt
secrets/
credentials/

# IDE and editor files
.vscode/
.idea/
*.swp

# Dependencies (rebuild inside container)
node_modules/
vendor/
__pycache__/
*.pyc

# Documentation
README.md
docs/
*.md

# CI/CD configuration
.github/
.gitlab-ci.yml
Jenkinsfile

# Docker files
Dockerfile
docker-compose*.yml
.dockerignore

# Test files
test/
tests/
*_test.go
*.test.js
coverage/

Without .dockerignore

Without a .dockerignore file, the entire directory is sent as the build context, potentially including:

  • .git/ directory (full repository history, potentially with secrets in old commits)
  • .env files with credentials
  • Private keys and certificates
  • Development dependencies (node_modules -- can be hundreds of MB)

7. Package Management Security

dockerfile
# BAD: No version pinning, no cleanup
RUN apt-get update && apt-get install -y curl wget vim

# GOOD: Pin versions, clean up cache, minimize layers
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl=7.88.1-10+deb12u5 \
      ca-certificates=20230311 && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
dockerfile
# For Alpine
RUN apk add --no-cache \
    curl=8.5.0-r0 \
    ca-certificates=20240226-r0

Why this matters:

  • --no-install-recommends avoids pulling unnecessary packages
  • Version pinning ensures reproducible builds
  • Cleaning the cache reduces image size
  • --no-cache (Alpine) avoids storing the package index

8. HEALTHCHECK Instruction

dockerfile
# Add a health check for container orchestration
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

While not strictly a security control, HEALTHCHECK helps Kubernetes (via livenessProbe/readinessProbe) detect compromised or malfunctioning containers and restart them.

Dockerfile Security Checklist

Security Checklist for Every Dockerfile

CheckCommand/Action
Use specific base image tag with digestFROM image:tag@sha256:...
Use minimal/distroless base imageFROM gcr.io/distroless/...
Use multi-stage buildsSeparate builder and production stages
Run as non-root userUSER 10001:10001
Use COPY instead of ADDReplace all ADD with COPY
No secrets in imageNo ENV, ARG, or RUN with secrets
Create .dockerignoreExclude .git, .env, keys, etc.
Pin package versionsapt-get install pkg=version
Clean package cachesrm -rf /var/lib/apt/lists/*
Drop unnecessary capabilitiesHandle in Kubernetes securityContext
Use --no-install-recommendsMinimize installed packages
Scan with Trivytrivy image myapp:v1.0

Analyzing Dockerfiles with Trivy

Trivy can scan Dockerfiles for misconfigurations:

bash
trivy config --file-patterns "dockerfile:Dockerfile" .

Sample Output:

Dockerfile (dockerfile)
========================
Tests: 23 (SUCCESSES: 17, FAILURES: 6, EXCEPTIONS: 0)
Failures: 6 (HIGH: 2, MEDIUM: 3, LOW: 1)

HIGH: Specify at least 1 USER command in Dockerfile
══════════════════════════════════════════════════════
Add a USER instruction to run the container as a non-root user.

HIGH: Do not use 'latest' tag for base images
══════════════════════════════════════════════
Use a specific version tag to ensure reproducible builds.

MEDIUM: Add HEALTHCHECK instruction
════════════════════════════════════
Add a HEALTHCHECK to allow Docker/K8s to verify container health.

MEDIUM: Use COPY instead of ADD
════════════════════════════════
COPY is more transparent. ADD has extra features (URL fetch, tar extract) that increase risk.

MEDIUM: Do not expose port 22 (SSH)
════════════════════════════════════
Exposing SSH in a container is almost always unnecessary and dangerous.

LOW: Package cache not cleaned
══════════════════════════════
Run apt-get clean and rm -rf /var/lib/apt/lists/* to reduce image size.

Analyzing Dockerfiles with Hadolint

Hadolint is a specialized Dockerfile linter:

bash
# Install
wget -O /usr/local/bin/hadolint \
  https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64
chmod +x /usr/local/bin/hadolint

# Lint a Dockerfile
hadolint Dockerfile

Sample Output:

Dockerfile:1 DL3006 warning: Always tag the version of an image explicitly
Dockerfile:3 DL3020 error: Use COPY instead of ADD for files and folders
Dockerfile:5 DL3009 info: Delete the apt-get lists after installing something
Dockerfile:8 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT
Dockerfile:10 DL3002 warning: Last USER should not be root

Real-World Dockerfile Transformations

Node.js Application

Before (insecure):

dockerfile
FROM node:20
WORKDIR /app
ADD . /app
RUN npm install
ENV SESSION_SECRET=mysecret
EXPOSE 3000 22
CMD node server.js

After (secure):

dockerfile
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM gcr.io/distroless/nodejs20-debian12:nonroot
COPY --from=builder /app/node_modules /app/node_modules
COPY --chown=65532:65532 . /app
WORKDIR /app
EXPOSE 3000
USER 65532:65532
CMD ["server.js"]

Go Application

Before (insecure):

dockerfile
FROM golang:1.22
ADD . /go/src/app
WORKDIR /go/src/app
RUN go build -o /app
EXPOSE 8080
CMD ["/app"]

After (secure):

dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags='-w -s -extldflags "-static"' \
    -o /server .

FROM scratch
COPY --from=builder /server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/server"]

Key Takeaways

Summary

  1. Use minimal base images -- distroless or alpine significantly reduce attack surface
  2. Multi-stage builds separate build tools from the production image
  3. Always include a USER instruction -- never run containers as root
  4. Use COPY, not ADD -- ADD has hidden behaviors that create security risks
  5. Never embed secrets in Dockerfiles -- use BuildKit secrets or mount at runtime
  6. Create a .dockerignore to prevent sensitive files from entering the build context
  7. Pin base image versions (ideally with digests) for reproducible, secure builds
  8. Scan Dockerfiles with Trivy (trivy config) or Hadolint before building

Common Exam Pitfalls

  • Not recognizing ADD as a security issue (it can fetch remote URLs)
  • Missing that ENV DB_PASSWORD=secret persists in image layers
  • Forgetting that running as root (no USER instruction) is a critical issue
  • Not knowing that :latest tag is mutable and unpredictable
  • Confusing Dockerfile security (build-time) with Pod security context (run-time)

Released under the MIT License.