JustAppSec

Container and Image Security

Building minimal, signed, scannable images that run safely in production.

0:00

Containers are the standard deployment unit for modern applications. But a container is only as secure as the image it runs from, the configuration it runs with, and the runtime that hosts it. This lesson covers building minimal, signed, scannable images that run safely in production.

The attack surface of a container image

A container image contains:

  • A base operating system (or a minimal runtime)
  • System libraries and tools
  • Your application runtime (Node.js, Python, Java, Go binary)
  • Your application code
  • Any build tools or dependencies left behind

Every component is a potential vulnerability. The goal is to minimise what is in the image and keep what remains up to date.

Building secure images

Use minimal base images

Base imageSizePackagesUse case
ubuntu:24.04~77 MBFull OS with shell, apt, utilitiesDevelopment, debugging
node:22-slim~180 MBStripped-down Debian, Node.js runtimeNode.js applications
python:3.12-slim~120 MBStripped-down Debian, Python runtimePython applications
gcr.io/distroless/nodejs22~130 MBNo shell, no package manager, just the runtimeProduction Node.js
alpine:3.20~7 MBmusl libc, BusyBoxMinimal Linux
scratch0 MBNothingStatic Go binaries

Distroless images from Google are purpose-built for production: they contain the language runtime and nothing else. No shell, no package manager, no utilities. This dramatically reduces the attack surface — an attacker who gains code execution inside the container cannot easily install tools or explore the filesystem.

Multi-stage builds

Separate build dependencies from runtime dependencies:

# Stage 1: Build
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build

# Stage 2: Runtime
FROM gcr.io/distroless/nodejs22
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]

The final image contains only the built application and production dependencies. Build tools, dev dependencies, and source code are not included.

Do not run as root

By default, containers run as root. If an attacker breaks out of the application, they have root access inside the container (and potentially on the host if there is a container escape vulnerability).

# Create a non-root user
RUN addgroup --system app && adduser --system --ingroup app app
USER app

Distroless images run as a non-root user by default.

Pin base image versions

Do not use latest or mutable tags:

# BAD — changes without warning
FROM node:latest

# GOOD — pinned to a specific version
FROM node:22.5.1-slim

# BEST — pinned to a digest (immutable)
FROM node:22.5.1-slim@sha256:abc123...

Do not leak secrets into layers

Every RUN, COPY, and ADD instruction creates a layer. Secrets included in any layer persist in the image history, even if a later layer deletes them.

# BAD — secret persists in the layer
COPY .env /app/.env
RUN npm run setup
RUN rm /app/.env  # Still in the previous layer!

# GOOD — use build secrets (Docker BuildKit)
RUN --mount=type=secret,id=db_password \
    DB_PASSWORD=$(cat /run/secrets/db_password) npm run setup

Image scanning

Scan images for known vulnerabilities before deploying:

# Trivy (open source, widely used)
trivy image myapp:latest

# Grype
grype myapp:latest

# Snyk
snyk container test myapp:latest

Integrate scanning into your CI pipeline. Fail the build on critical vulnerabilities:

# GitHub Actions
- name: Scan image
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: myapp:${{ github.sha }}
    exit-code: 1
    severity: CRITICAL,HIGH

Scanning is not enough

Scanners check known CVEs in OS packages and language dependencies. They do not find:

  • Vulnerabilities in your application code
  • Misconfigurations (running as root, exposed ports, embedded secrets)
  • Custom binaries without CVE tracking

Scanning is one layer. Combine it with minimal images, non-root execution, and proper configuration.

Image signing and verification

Sign your images to ensure that the image you deploy is the image you built — not a tampered version.

Cosign (Sigstore)

# Sign an image
cosign sign --key cosign.key myregistry.com/myapp:latest

# Verify before deploying
cosign verify --key cosign.pub myregistry.com/myapp:latest

Configure your container runtime or registry to reject unsigned images.

Runtime security

Read-only filesystem

Run containers with a read-only root filesystem. This prevents attackers from modifying binaries or writing malicious files:

# Docker Compose
security_opt:
  - no-new-privileges:true
read_only: true
tmpfs:
  - /tmp

Drop capabilities

Linux capabilities grant specific privileges. Drop all capabilities and add only what is needed:

# Kubernetes
securityContext:
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]  # Only if needed

Network policies

In Kubernetes, use network policies to restrict which pods can communicate:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-allow
spec:
  podSelector:
    matchLabels:
      app: api
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - port: 8080

This prevents lateral movement — a compromised pod cannot reach services it has no business talking to.

Resource limits

Set CPU and memory limits to prevent container-based denial of service:

resources:
  limits:
    memory: "256Mi"
    cpu: "500m"
  requests:
    memory: "128Mi"
    cpu: "250m"

Registry security

  • Use a private registry for your images. Public registries mean public images.
  • Enable vulnerability scanning on the registry (ECR, GCR, and ACR support this natively).
  • Use IAM policies to restrict who can push and pull images.
  • Enable content trust / image signing verification.
  • Set image retention policies to clean up old, unpatched images.

Summary

Build minimal images using multi-stage builds and distroless or slim base images. Run as a non-root user. Pin base image versions to digests. Never embed secrets in image layers. Scan images in CI for known vulnerabilities. Sign images with Cosign and verify before deploying. At runtime, use read-only filesystems, drop unnecessary capabilities, apply network policies, and set resource limits. Every layer removed from the image is one less thing to patch, scan, and defend.


This training content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly. Send corrections to [email protected].