Security Best Practices for Images

4 minute read

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"

Updated: