Every container you ship to production is an attack surface. Debian or Ubuntu-based container images typically bundle hundreds of OS packages and application dependencies, each carrying its own vulnerability profile. Most teams scan images before deployment, but scanning alone is not a defense strategy — it’s a detection mechanism that arrives too late if your build process is already producing bloated, privileged images.
The real work happens at every stage of the container lifecycle: how you build the image, what you include in it, how you scan it, and what permissions it gets at runtime. This post walks through a practical hardening playbook that covers the full pipeline — from Dockerfile to Kubernetes Pod spec.
Start with Multi-Stage Builds
The single most impactful thing you can do for container security is to minimize the image. Multi-stage builds let you use a heavy image for compilation and a minimal image for the final artifact. This means no compiler toolchain, no build dependencies, and no source code in the image that reaches production.
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server .
# Final stage — nothing but the binary
FROM scratch
COPY --from=builder /server /server
ENTRYPOINT ["/server"]
The final image here is a scratch base — literally empty except for your compiled binary. No shell, no package manager, no libc. If an attacker somehow gets code execution inside this container, there’s nothing to work with. No curl to exfiltrate data, no sh to pivot, no apt to install tools.
Not every application can use scratch. If you need a shell for health checks or a runtime like Python or Node.js, use a distroless variant instead. Google’s distroless images contain only the runtime dependencies your application needs — no package manager, no shell.
# Python app with distroless base
FROM python:3.12-alpine AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Distroless Python — no shell, no package manager
FROM gcr.io/distroless/python3
COPY --from=builder /app /app
WORKDIR /app
CMD ["main.py"]
Run as a Non-Root User
Containers default to running as root. This is a holdover from Docker’s early design that most teams never question. Running as root inside a container means an application vulnerability escalates to full control of the container — and with shared kernel access, that can mean more than just the container.
FROM alpine:3.21
# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /server /server
# Drop to non-root before running
USER appuser
ENTRYPOINT ["/server"]
On the Kubernetes side, enforce this with a Pod security context:
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: app
image: myregistry.com/my-app:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
The combination of runAsNonRoot: true, allowPrivilegeEscalation: false, and readOnlyRootFilesystem: true is the gold standard for container runtime hardening. The read-only filesystem prevents an attacker from writing exploits or tools to disk, and disabling privilege escalation blocks setuid binaries from granting elevated access.
Scan at the Gate with Trivy
Trivy is an open-source scanner from Aqua Security that checks container images for known CVEs, misconfigurations, and exposed secrets. It’s fast enough to run in CI/CD without becoming a bottleneck, and its output formats integrate cleanly with most pipeline tools.
Install it, then scan any image:
# Install Trivy
brew install trivy
# Scan an image for vulnerabilities
trivy image myregistry.com/my-app:latest
# Scan with severity filtering — fail on HIGH and CRITICAL only
trivy image --severity HIGH,CRITICAL --exit-code 1 myregistry.com/my-app:latest
# Scan for secrets and misconfigurations too
trivy image --scanners vuln,secret,misconfig myregistry.com/my-app:latest
In a CI pipeline, the key pattern is to scan the newly built image and fail the build if critical vulnerabilities are found. This prevents vulnerable images from ever reaching your registry:
# GitHub Actions example
name: Build and Scan
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t my-app:test .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: 'my-app:test'
severity: 'CRITICAL,HIGH'
exit-code: '1'
Trivy also generates Software Bill of Materials (SBOM) output in SPDX and CycloneDX formats, which is increasingly required for compliance frameworks and supply chain audits:
# Generate an SPDX SBOM
trivy image --format spdx-json --output sbom.spdx.json my-app:latest
Drop Linux Capabilities
Linux capabilities break down the monolithic root privilege into fine-grained units. By default, Docker grants containers a subset of capabilities — enough to be dangerous. You should explicitly drop all capabilities and add back only what your application genuinely needs.
spec:
containers:
- name: app
image: myregistry.com/my-app:latest
securityContext:
# Drop ALL capabilities, then grant nothing back
capabilities:
drop:
- ALL
# If your app binds to port 80 (below 1024), you need NET_BIND_SERVICE
# capabilities:
# add:
# - NET_BIND_SERVICE
Most applications need zero capabilities. If your service only handles HTTP traffic on ports above 1024 and writes to an emptyDir volume, dropping all capabilities costs you nothing and removes entire classes of kernel-level attacks.
Enforce Policies with Pod Security Standards
Kubernetes provides three built-in policy levels that enforce the hardening practices we’ve discussed:
Privileged — unrestricted, for system-level workloads only.
Baseline — prevents known privilege escalations (e.g., disables hostPID, hostNetwork, requires non-root when possible).
Restricted — the strictest level: enforces non-root, read-only filesystem, drops all capabilities, restricts volume types, and blocks privileged containers.
Apply the restricted profile namespace-wide:
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
# Warn mode gives you visibility without blocking
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
The three modes — enforce, audit, and warn — let you roll out policies gradually. Start with warn to see which workloads violate the policy, fix them, then switch to enforce.
Sign and Verify Images
Scanning catches known vulnerabilities, but it doesn’t prevent image tampering. Cosign, part of the Sigstore project, lets you cryptographically sign container images and verify them at deployment time. This ensures the image running in your cluster is the exact image you built — not a tampered substitute.
# Sign an image with a key pair
COSIGN_PASSWORD="" cosign sign --key cosign.key myregistry.com/my-app:v1.2.0
# Verify before deployment
cosign verify --key cosign.pub myregistry.com/my-app:v1.2.0
For a fully keyless workflow, Cosign supports signing with OIDC identities through Fulcio, so you can tie image signatures to your CI pipeline’s identity without managing key material.
Putting It Together
Container security isn’t a single tool or a checklist item — it’s a set of practices that compound. A multi-stage build reduces your attack surface. Trivy catches what slips through. Dropping capabilities and running non-root limits what an attacker can do if they find a vulnerability. Pod Security Standards enforce these practices cluster-wide. Image signing prevents tampering.
None of these practices are exotic or expensive to implement. The hardening playbook shown here works with any container orchestrator, any CI system, and any language. The best time to adopt it was before your first production deployment. The second best time is your next one.