Security Best Practices for Images
Why Image Security Matters
Container images are the foundation of your application’s supply chain. A vulnerable image means every Pod running it is exposed. Security-focused image practices are increasingly tested in Kubernetes certifications and are critical for production workloads.
Core Security Principles
| Principle | What It Means | How to Apply |
|---|---|---|
| Minimal surface area | Fewer packages = fewer vulnerabilities | Use slim/distroless base images |
| Non-root execution | Don’t run as root | Add USER in Dockerfile + securityContext in Pod |
| Immutable tags | Know exactly what you’re running | Pin versions, use digests |
| Scan before deploy | Catch vulnerabilities early | Use trivy, grype, or registry scanning |
| No secrets in images | Don’t bake credentials into layers | Use K8s Secrets, env vars, or mounted volumes |
1. Use Minimal Base Images
The smaller the base image, the fewer packages to exploit.
Base Image Comparison
| Base Image | Size | Packages | Shell | Use Case |
|---|---|---|---|---|
ubuntu:22.04 |
~77 MB | Many | ✅ | Development |
debian:bookworm-slim |
~74 MB | Moderate | ✅ | General purpose |
alpine:3.19 |
~7 MB | Minimal | ✅ (ash) | Production |
gcr.io/distroless/static |
~2 MB | None | ❌ | Go, Rust binaries |
gcr.io/distroless/base |
~20 MB | Minimal C libs | ❌ | C/C++ apps |
gcr.io/distroless/nodejs18 |
~120 MB | Node.js only | ❌ | Node.js apps |
scratch |
0 MB | Nothing | ❌ | Static binaries |
Distroless Images
Google’s distroless images contain only your application and its runtime dependencies — no shell, no package manager, no debugging tools:
# Node.js with distroless
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM gcr.io/distroless/nodejs18-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
USER 1000
CMD ["server.js"]
Trade-off: Distroless images are harder to debug (no shell to exec into), but they minimize the attack surface significantly.
2. Run as Non-Root
By default, containers run as root (UID 0). This is a security risk — if an attacker escapes the container, they have root on the host.
In the Dockerfile
FROM python:3.12-slim
# Create a non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
# Switch to non-root user
USER appuser
CMD ["python", "app.py"]
Or simply use a numeric UID:
USER 1000
In Kubernetes Pod Spec
Even if the Dockerfile sets USER 1000, enforce it in the Pod spec as a defense-in-depth measure:
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: app
image: my-app:v1
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
| Field | Purpose |
|---|---|
runAsNonRoot: true |
Reject Pod if container tries to run as root |
runAsUser: 1000 |
Force UID 1000 |
allowPrivilegeEscalation: false |
Prevent gaining more privileges |
readOnlyRootFilesystem: true |
Make container filesystem read-only |
capabilities.drop: ALL |
Remove all Linux capabilities |
3. Pin Image Versions
Never use latest or untagged images in production.
# Bad — unpredictable
FROM python
FROM nginx:latest
# Good — pinned version
FROM python:3.12-slim
FROM nginx:1.27-alpine
Using Image Digests
For maximum security, pin by digest (SHA256 hash of the image manifest):
containers:
- name: app
image: nginx@sha256:6a5badb6e0e4c0c57f4a5c8c0c1a1c1f8f8e8e8e8e8e8e8e8e8e8e8e8e8e8e
Digests are immutable — even if someone pushes new content to the same tag, the digest doesn’t change.
# Get the digest of an image
docker inspect --format='{{index .RepoDigests 0}}' nginx:1.27
# Output: nginx@sha256:6a5badb6e0e4c0c57...
4. Scan Images for Vulnerabilities
Scan images before deploying to catch known CVEs.
Using Trivy
# Scan a local image
trivy image my-app:v1
# Scan with severity filter
trivy image --severity HIGH,CRITICAL my-app:v1
# Scan and fail on critical vulnerabilities (for CI/CD)
trivy image --exit-code 1 --severity CRITICAL my-app:v1
Using Grype
# Scan an image
grype my-app:v1
# Only show high and critical
grype my-app:v1 --only-fixed --fail-on high
Registry-Level Scanning
Most registries offer built-in scanning:
| Registry | Scanning Feature |
|---|---|
| Docker Hub | Docker Scout |
| Amazon ECR | ECR image scanning |
| Google Artifact Registry | Container Analysis |
| Azure ACR | Microsoft Defender |
| Harbor | Trivy integration |
5. Never Bake Secrets Into Images
# BAD — secret is stored in a layer
ENV DATABASE_PASSWORD=s3cretP@ss
COPY .env /app/.env
# GOOD — no secrets in the image
# Pass secrets at runtime via:
# - Kubernetes Secrets
# - Environment variables
# - Mounted config files
Even if you delete a secret in a later layer, it persists in earlier layers. Anyone with docker history can see it.
# Verify no secrets leaked
docker history my-app:v1
6. Use Multi-Stage Builds
Multi-stage builds exclude build tools, source code, and test files from the final image:
# Build stage — has everything
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o /server .
# Runtime stage — only the binary
FROM gcr.io/distroless/static
COPY --from=builder /server /server
USER 1000
CMD ["/server"]
Security benefit: The final image has no compiler, no source code, no test frameworks — just the compiled binary.
Secure Dockerfile Checklist
| Practice | Example |
|---|---|
| Use minimal base | FROM alpine:3.19 |
| Pin version | FROM python:3.12-slim |
| Run as non-root | USER 1000 |
| Use multi-stage | FROM ... AS builder → FROM distroless |
| No secrets in image | Use K8s Secrets at runtime |
| Minimize layers | Combine RUN commands |
| Remove unnecessary files | RUN rm -rf /tmp/* /var/cache/* |
| Set read-only FS | readOnlyRootFilesystem: true in Pod |
| Scan images | trivy image my-app:v1 |
Use .dockerignore |
Exclude .git, .env, node_modules |
Complete Secure Example
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: Secure runtime
FROM gcr.io/distroless/nodejs18-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
USER 1000
CMD ["server.js"]
# Kubernetes Pod with security hardening
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: app
image: gcr.io/my-project/app@sha256:abc123...
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
limits:
memory: "128Mi"
cpu: "250m"