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
# 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
# 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 Image | Size | Packages | Use Case |
|---|---|---|---|
ubuntu:22.04 | ~77 MB | Full OS, apt, shell | Development only |
debian:bookworm-slim | ~52 MB | Minimal Debian, apt | When you need apt |
alpine:3.19 | ~7 MB | Minimal, musl libc, apk | Small footprint, some compatibility issues |
gcr.io/distroless/static | ~2 MB | No OS, no shell | Go static binaries |
gcr.io/distroless/base | ~20 MB | glibc, libssl, ca-certs | C/C++ applications |
gcr.io/distroless/python3 | ~52 MB | Python runtime only | Python applications |
scratch | 0 MB | Nothing | Statically 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.
# 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.
# 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/falseto prevent interactive login - Use
--chownin COPY to set proper file ownership
4. COPY vs ADD Security Implications
| Feature | COPY | ADD |
|---|---|---|
| Copy local files | Yes | Yes |
| Fetch remote URLs | No | Yes (security risk) |
| Extract archives | No | Yes (auto-extracts .tar, .gz) |
| Predictable behavior | Yes | No (varies by input) |
| Recommended | Yes | Only for local tar extraction |
# 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
COPYfor local files and explicitRUN curlwith 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.
# 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/dataProper approaches:
# 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 .# 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# 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: passwordChecking for Leaked Secrets
# 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.06. 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).envfiles with credentials- Private keys and certificates
- Development dependencies (node_modules -- can be hundreds of MB)
7. Package Management Security
# 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/*# For Alpine
RUN apk add --no-cache \
curl=8.5.0-r0 \
ca-certificates=20240226-r0Why this matters:
--no-install-recommendsavoids 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
# 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 1While 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
| Check | Command/Action |
|---|---|
| Use specific base image tag with digest | FROM image:tag@sha256:... |
| Use minimal/distroless base image | FROM gcr.io/distroless/... |
| Use multi-stage builds | Separate builder and production stages |
| Run as non-root user | USER 10001:10001 |
Use COPY instead of ADD | Replace all ADD with COPY |
| No secrets in image | No ENV, ARG, or RUN with secrets |
Create .dockerignore | Exclude .git, .env, keys, etc. |
| Pin package versions | apt-get install pkg=version |
| Clean package caches | rm -rf /var/lib/apt/lists/* |
| Drop unnecessary capabilities | Handle in Kubernetes securityContext |
Use --no-install-recommends | Minimize installed packages |
| Scan with Trivy | trivy image myapp:v1.0 |
Analyzing Dockerfiles with Trivy
Trivy can scan Dockerfiles for misconfigurations:
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:
# 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 DockerfileSample 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 rootReal-World Dockerfile Transformations
Node.js Application
Before (insecure):
FROM node:20
WORKDIR /app
ADD . /app
RUN npm install
ENV SESSION_SECRET=mysecret
EXPOSE 3000 22
CMD node server.jsAfter (secure):
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):
FROM golang:1.22
ADD . /go/src/app
WORKDIR /go/src/app
RUN go build -o /app
EXPOSE 8080
CMD ["/app"]After (secure):
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
- Use minimal base images -- distroless or alpine significantly reduce attack surface
- Multi-stage builds separate build tools from the production image
- Always include a USER instruction -- never run containers as root
- Use COPY, not ADD -- ADD has hidden behaviors that create security risks
- Never embed secrets in Dockerfiles -- use BuildKit secrets or mount at runtime
- Create a .dockerignore to prevent sensitive files from entering the build context
- Pin base image versions (ideally with digests) for reproducible, secure builds
- Scan Dockerfiles with Trivy (
trivy config) or Hadolint before building
Common Exam Pitfalls
- Not recognizing
ADDas a security issue (it can fetch remote URLs) - Missing that
ENV DB_PASSWORD=secretpersists in image layers - Forgetting that running as root (no
USERinstruction) is a critical issue - Not knowing that
:latesttag is mutable and unpredictable - Confusing Dockerfile security (build-time) with Pod security context (run-time)