Docker Image Security: Multi-Stage Builds, Non-Root Users, Image Scanning, and Dockerfile Best Practices
Container security starts with the image. A poorly constructed Docker image can expose your production environment to vulnerabilities, privilege escalation, and supply-chain attacks. This guide covers the complete lifecycle of secure image development from writing secure Dockerfiles to automating vulnerability scanning in CI/CD.
The Minimal Attack Surface Principle
Every package, binary, and library in your image is a potential attack vector. The smaller the image, the fewer vulnerabilities it can contain.
| Base Image | Compressed Size | Notes |
|---|---|---|
| ubuntu:24.04 | ~30 MB | Full OS |
| debian:bookworm-slim | ~25 MB | Trimmed OS |
| alpine:3.19 | ~3 MB | Minimal Linux |
| gcr.io/distroless/static | ~2 MB | No shell |
| scratch | 0 MB | Empty base |
Distroless images contain only your application and its runtime dependencies with no shell, no package manager, no utilities that attackers could use.
Multi-Stage Builds
Node.js Example
# Stage 1: Install production dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production runtime (distroless)
FROM gcr.io/distroless/nodejs22-debian12 AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json .
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]
Go Example (scratch image)
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" -o server ./cmd/server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /app/server /server
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/server"]
The resulting image is ~10 MB instead of ~300+ MB.
Python Example
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --upgrade pip
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links /wheels /wheels/*.whl \
&& rm -rf /wheels
COPY src/ ./src/
RUN useradd --uid 1001 --create-home appuser
USER appuser
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
Running as Non-Root
By default, Docker containers run as root. If an attacker escapes the container, they have root on the host.
# Debian/Ubuntu
RUN groupadd --gid 1001 appgroup \
&& useradd --uid 1001 --gid appgroup --shell /bin/sh --create-home appuser
USER appuser
# Alpine
RUN addgroup -S -g 1001 appgroup \
&& adduser -S -u 1001 -G appgroup -h /app appuser
USER appuser
# Numeric UID (works with distroless)
USER 1001:1001
Kubernetes Security Context
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1001
capabilities:
drop:
- ALL
Dockerfile Security Hardening
Pin Exact Versions
# Bad: floating tags
FROM node:latest
# Good: pin digest for complete reproducibility
FROM node:22-alpine@sha256:abc123...
# Or pin minor version at minimum
FROM node:22.4.0-alpine3.19
Avoid Secrets in Layers
# BAD: secret baked into image layer
RUN curl -H "Authorization: Bearer ${API_TOKEN}" https://api.example.com/download
# GOOD: use BuildKit secrets (never written to layer)
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=api_token \
curl -H "Authorization: Bearer $(cat /run/secrets/api_token)" \
https://api.example.com/download
Use .dockerignore
.git
.env
*.env
node_modules
__pycache__
*.pyc
.pytest_cache
coverage/
dist/
secrets/
*.key
*.pem
COPY vs ADD
# BAD: ADD can fetch remote URLs and auto-extract archives
ADD https://example.com/package.tar.gz /tmp/
# GOOD: COPY only copies local files
COPY package.tar.gz /tmp/
RUN tar xzf /tmp/package.tar.gz -C /app && rm /tmp/package.tar.gz
Verify Downloads
RUN curl -fsSL https://example.com/tool-v1.2.3-linux-amd64.tar.gz \
-o /tmp/tool.tar.gz \
&& echo "abc123expectedhash /tmp/tool.tar.gz" | sha256sum -c - \
&& tar xzf /tmp/tool.tar.gz -C /usr/local/bin/ \
&& rm /tmp/tool.tar.gz
Image Scanning with Trivy
# Scan a local image
trivy image my-api:latest
# Fail CI if HIGH or CRITICAL CVEs found
trivy image --exit-code 1 --severity HIGH,CRITICAL my-api:latest
# Scan filesystem before build
trivy fs --security-checks vuln,config,secret .
Integrate Trivy in GitHub Actions
- name: Build image
run: docker build -t my-api:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: my-api:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
Content Trust and Signing
# Sign images with cosign (Sigstore)
cosign sign --key cosign.key registry.example.com/my-api:v1.0.0
# Verify signature
cosign verify --key cosign.pub registry.example.com/my-api:v1.0.0
# Keyless signing in GitHub Actions
cosign sign registry.example.com/my-api:${{ github.sha }}
Layer Optimization
# Bad: separate RUN commands create extra layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Good: combine related commands
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Conclusion
Docker image security is a layered discipline. Multi-stage builds reduce the attack surface by 80-90% in most cases. Non-root containers prevent privilege escalation. Image scanning in CI catches known CVEs before they reach production. Dockerfile best practices eliminate common pitfalls like leaked secrets and unpinned dependencies. Implement these practices systematically and your containers will be production-grade from day one.