- Published on
Container Image Security — Distroless, SBOM, and Supply Chain Hardening
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Container images are attack surfaces. Most images inherit bloat from base images: package managers, shells, development tools that have no place in production. A compromised image can run with privileges, access sensitive data, and spread laterally through the cluster. This post covers building minimal, hardened images with multi-stage builds and distroless bases, scanning for vulnerabilities, generating software bills of materials (SBOM), signing images with Cosign, and enforcing image signing policies with admission controllers.
- Multi-Stage Builds for Minimal Attack Surface
- Distroless Images
- Running as Non-Root User
- Vulnerability Scanning with Trivy
- Software Bill of Materials (SBOM) with Syft
- Image Signing with Cosign
- Admission Controller to Block Unsigned Images
- Base Image Update Automation
- Checklist
- Conclusion
Multi-Stage Builds for Minimal Attack Surface
Multi-stage builds separate build dependencies from runtime. The final image contains only runtime code, not compilers, build tools, or development headers.
Single-stage (bloated):
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
build-essential \
curl \
git \
python3 \
python3-dev \
postgresql-client
WORKDIR /app
COPY . .
RUN python3 setup.py build
ENTRYPOINT ["python3", "app.py"]
This image includes gcc, git, and development headers—hundreds of MB of attack surface.
Multi-stage (hardened):
# Builder stage
FROM python:3.11-slim as builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY src/ .
RUN python -m py_compile *.py
# Runtime stage
FROM python:3.11-slim
RUN useradd -m -u 1000 appuser && \
mkdir -p /app && \
chown appuser:appuser /app
WORKDIR /app
COPY /build /.
USER appuser
EXPOSE 8080
ENTRYPOINT ["python", "-u", "app.py"]
HEALTHCHECK \
CMD python -c "import requests; requests.get('http://localhost:8080/health')"
This image is much smaller and contains no build tools.
Go example (even smaller):
# Builder
FROM golang:1.21-alpine as builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
# Runtime
FROM scratch
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY /build/app /app
ENTRYPOINT ["/app"]
The scratch base image is empty. For statically-compiled Go, this is the smallest possible image.
Distroless Images
Google's distroless images contain only application runtime—no shell, no package manager, no unnecessary binaries.
FROM node:18 as builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src/ ./src/
RUN npm run build
# Distroless Node runtime
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY /build/node_modules ./node_modules
COPY /build/dist ./dist
COPY /build/package.json ./
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/nodejs/bin/node", "dist/index.js"]
Distroless images:
- No shell (
/bin/shabsent) - No package manager (
apt,yumremoved) - ~20x smaller than full Ubuntu base
- Dramatically reduced vulnerability surface
Common distroless bases:
gcr.io/distroless/base(C, C++ runtime only)gcr.io/distroless/base-debian11(adds glibc)gcr.io/distroless/nodejs18-debian11(Node.js)gcr.io/distroless/python3-debian11(Python)
Running as Non-Root User
By default, containers run as root. Compromise a root container, and the attacker has cluster admin access.
FROM ubuntu:22.04
# Create non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# Copy binaries with ownership
COPY app /app/app
# Switch to non-root
USER appuser
EXPOSE 8080
ENTRYPOINT ["/app/app"]
Kubernetes pod security policies enforce this:
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: my-app:v1.2.3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Vulnerability Scanning with Trivy
Trivy scans images for known CVEs. Integrate into CI/CD.
# Install Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scan local image
trivy image my-app:v1.2.3
# Scan and output JSON
trivy image --format json --output report.json my-app:v1.2.3
# Scan with severity filter
trivy image --severity HIGH,CRITICAL my-app:v1.2.3
# Scan and fail if vulnerabilities found
trivy image --exit-code 1 --severity HIGH,CRITICAL my-app:v1.2.3
GitHub Actions integration:
name: Scan Image
on:
push:
branches:
- main
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@master
with:
image-ref: 'my-app:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
- uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
Helm deployment scanning:
trivy image my-app:v1.2.3 --severity HIGH,CRITICAL
helm template my-app ./chart | trivy config --format json -
Software Bill of Materials (SBOM) with Syft
SBOM documents all software components in an image. Essential for supply chain compliance.
# Install Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
# Generate SBOM in SPDX format
syft my-app:v1.2.3 -o spdx > sbom.spdx.json
# Generate SBOM in CycloneDX format
syft my-app:v1.2.3 -o cyclonedx > sbom.cyclonedx.json
# Scan SBOM with Grype (vulnerability database)
grype sbom:sbom.spdx.json --fail-on high
SBOM output example (partial):
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"components": [
{
"type": "library",
"name": "urllib3",
"version": "2.0.7",
"purl": "pkg:pypi/urllib3@2.0.7"
},
{
"type": "library",
"name": "requests",
"version": "2.31.0",
"purl": "pkg:pypi/requests@2.31.0"
},
{
"type": "library",
"name": "flask",
"version": "3.0.0",
"purl": "pkg:pypi/flask@3.0.0"
}
]
}
Image Signing with Cosign
Cosign signs images cryptographically. Only signed images can be deployed (enforced via admission controller).
# Install Cosign
curl -Lo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
chmod +x /usr/local/bin/cosign
# Generate keys (keyless via Sigstore or with local key)
cosign generate-key-pair
# Sign image
cosign sign --key cosign.key my-app:v1.2.3
# Verify signature
cosign verify --key cosign.pub my-app:v1.2.3
# Keyless signing (Sigstore)
cosign sign my-app:v1.2.3
GitHub Actions: sign on push:
name: Build and Sign
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v2
- uses: docker/build-push-action@v4
with:
push: true
tags: my-app:${{ github.sha }}
- uses: sigstore/cosign-installer@v3
- run: |
cosign sign --yes my-app:${{ github.sha }}
Admission Controller to Block Unsigned Images
Enforce image signing at the cluster level. Only signed images are allowed.
Kyverno policy:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-images
spec:
validationFailureAction: enforce
webhookTimeoutSeconds: 30
rules:
- name: verify-signature
match:
resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "*.my-company.com/*"
- "gcr.io/my-project/*"
attestors:
- count: 1
entries:
- keys:
publicKeys: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----
signatureAlgorithm: sha256
skipImageResolutionFailure: false
- imageReferences:
- "gcr.io/distroless/*" # Trusted public images
Admission webhook (custom):
import Fastify from "fastify";
import * as jose from "jose";
const app = Fastify();
app.post<{ Body: any }>("/verify", async (request) => {
const admission = request.body;
const pod = admission.request.object;
const allowed = await verifyImages(pod.spec.containers);
return {
apiVersion: "admission.k8s.io/v1",
kind: "AdmissionReview",
response: {
uid: admission.request.uid,
allowed,
status: {
message: allowed ? "" : "Image signature verification failed",
},
},
};
});
async function verifyImages(containers: any[]): Promise<boolean> {
for (const container of containers) {
const image = container.image;
const signature = container.env?.find((e: any) => e.name === "IMAGE_SIGNATURE")?.value;
if (!signature) return false;
try {
await jose.jwtVerify(signature, publicKey);
} catch {
return false;
}
}
return true;
}
app.listen({ port: 8443, host: "0.0.0.0" });
Base Image Update Automation
Keep base images up to date. Tools like Renovate automatically bump base image versions.
Renovate config (.renovaterc.json):
{
"extends": ["config:base"],
"dockerfile": {
"enabled": true,
"automerge": false,
"major": {
"enabled": false
}
},
"docker": {
"enabled": true,
"major": {
"enabled": false
}
},
"packageRules": [
{
"matchDatasources": ["docker"],
"automerge": true,
"automergeType": "pr"
}
]
}
When a base image update is available, Renovate creates a PR, runs tests, and auto-merges if tests pass.
Checklist
- Multi-stage Dockerfile: build stage separate from runtime
- Base image as small as possible (distroless preferred)
- Non-root user created and enforced in Dockerfile
- Read-only root filesystem in Kubernetes pods
- Trivy vulnerability scanning in CI/CD; blocking on HIGH/CRITICAL
- SBOM generated and stored with each release
- Cosign or similar used to sign all production images
- Admission controller enforces image signature verification
- Base image updates automated (Renovate or similar)
- Private registry stores all production images (air-gap)
- Image pull policies set to
IfNotPresent(prevent registry outage fallback) - Security scanning results published to SBOM/attestation store
Conclusion
Container image security is a supply chain problem. Start with the smallest possible base image (distroless). Remove unnecessary packages and build tools via multi-stage builds. Run as non-root and enforce read-only filesystems. Scan all images with Trivy before they touch production. Generate SBOMs for compliance and incident response. Sign images with Cosign and enforce signatures at the cluster boundary with admission controllers. Automate base image updates to catch vulnerabilities early. Together, these practices transform container images from black boxes to transparent, verifiable, minimal artifacts.