What Are Docker Hardened Images and Why Do They Matter?
Most Docker images carry far more than your application needs — extra packages, root access, known CVEs. Hardened images strip that attack surface away. Here's what they are, how to build them, and why they're non-negotiable for production security.

Pull a standard ubuntu:latest base image and you inherit a general-purpose Linux distribution — package manager, shell, system utilities, dozens of libraries your application never touches. Most of it is attack surface you didn't ask for.
Docker hardened images are container images purpose-built to minimize that attack surface: fewer packages, no interactive shells, non-root execution, read-only filesystems, and no known CVEs in the base layer. They're what you reach for when your container needs to survive production in a hostile environment.
What Makes an Image "Hardened"?
Hardening isn't a single technique — it's a set of overlapping practices that each reduce a different class of risk:
| Property | Standard Image | Hardened Image |
|---|---|---|
| Base OS | Ubuntu / Debian (hundreds of packages) | Distroless, Alpine, or scratch |
| Shell access | /bin/bash present | No shell |
| Runtime user | Root (UID 0) by default | Non-root, non-privileged |
| Filesystem | Read-write | Read-only where possible |
| Known CVEs | Often dozens in base layer | Zero or near-zero |
| Image size | 100MB – 1GB+ | 5MB – 50MB |
| Capabilities | Full default set | Explicitly dropped |
A hardened image doesn't mean a restricted application — your code runs normally. It means the underlying environment has been stripped of everything that isn't necessary to run that code.
The Core Techniques
1. Distroless Base Images
Google's distroless images contain only the application runtime and its dependencies — no package manager, no shell, no system utilities. If an attacker gains code execution inside a distroless container, they have almost nothing to work with.
# Standard approach — Node.js on full Debian
FROM node:20
WORKDIR /app
COPY . .
RUN npm ci --only=production
CMD ["node", "server.js"]# Hardened approach — distroless Node.js runtime only
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
CMD ["server.js"]The distroless version has no shell, no package manager, and roughly 80% less attack surface. The application runs identically.
2. Multi-Stage Builds
Build tools — compilers, linkers, test frameworks, dev dependencies — have no place in a production image. Multi-stage builds let you compile in a full environment and copy only the artifact into a minimal runtime image.
# Stage 1: Build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .
# Stage 2: Minimal runtime — scratch has literally nothing
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]The final image is just your compiled binary and TLS certificates. No OS, no shell, no package manager. Final size: typically under 15MB.
3. Non-Root Execution
The single most impactful hardening change you can make: run as a non-root user.
Containers run as root by default. If a vulnerability allows container escape, the attacker lands on the host as root. Running as an unprivileged user doesn't prevent all escapes, but it significantly narrows what an attacker can do inside the container and reduces the blast radius of escape.
FROM node:20-alpine
# Create a non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm ci --only=production
# Switch to non-root
USER appuser
CMD ["node", "server.js"]For Kubernetes deployments, enforce this at the pod level too:
securityContext:
runAsNonRoot: true
runAsUser: 1001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false4. Drop Linux Capabilities
Linux capabilities are fine-grained permissions that root processes inherit by default. Most application containers need none of them.
# In Kubernetes, drop everything and add back only what you need
securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE # Only if binding to ports < 1024The default capability set includes NET_RAW (raw network packets), SYS_PTRACE (process debugging), SETUID/SETGID (privilege escalation), and others. Dropping all of them removes entire classes of post-exploitation technique.
5. Read-Only Root Filesystem
If your application doesn't need to write to the filesystem at runtime (most don't), mount the root as read-only. This prevents an attacker from writing malicious files, installing tools, or persisting modifications.
# If you need writable directories, use tmpfs mounts for specific paths# Kubernetes pod spec
containers:
- name: api
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}6. Regular CVE Scanning
A hardened image isn't permanently hardened — new vulnerabilities are discovered in existing packages constantly. Integrate scanning into your CI/CD pipeline:
# Trivy — fast, accurate, free
trivy image --exit-code 1 --severity HIGH,CRITICAL myorg/myapp:latest
# Grype — alternative with detailed output
grype myorg/myapp:latest --fail-on highSet --exit-code 1 to fail the build on high or critical CVEs. This keeps hardening continuous rather than a one-time exercise.
Alpine vs. Distroless vs. Scratch
All three are valid minimal bases — which you choose depends on the tradeoff between debuggability and attack surface:
| Base | Size | Shell | Package manager | Best for |
|---|---|---|---|---|
| scratch | 0 bytes | No | No | Statically compiled Go, Rust binaries |
| distroless | ~2–20MB | No | No | Java, Python, Node.js, .NET runtimes |
| Alpine | ~5MB | Yes (ash) | Yes (apk) | When you need shell access for debugging |
| Ubuntu/Debian slim | ~30–80MB | Yes (bash) | Yes (apt) | Complex dependencies, dev environments |
One important caveat: Alpine uses musl libc instead of glibc. Applications compiled against glibc can behave unexpectedly on Alpine. For compiled binaries, test thoroughly before using Alpine in production.
Hardened Images Inside TEEs
When you're running containers inside Trusted Execution Environments, image hardening matters even more — and the reasons are slightly different.
TEEs like AWS Nitro Enclaves measure everything loaded into the enclave at boot time, producing PCR hash values that are included in the hardware attestation document. A larger, less deterministic image produces PCR values that are harder to pin and verify. A hardened, minimal image produces a smaller, more reproducible measurement set that's easier to audit and attest.
Additionally, TEEs have constrained I/O by design — no interactive access, no external networking except through controlled channels. Hardened images are a natural fit: they already don't assume shell access or unrestricted filesystem writes.
The workflow on Treza:
import { TrezaClient } from '@treza/sdk';
const treza = new TrezaClient({ baseUrl: 'https://app.trezalabs.com' });
// Deploy your hardened image into an attested enclave
const enclave = await treza.createEnclave({
name: 'hardened-service',
region: 'us-east-1',
walletAddress: '0xYourWallet...',
providerId: 'aws-nitro',
providerConfig: {
// Distroless image: minimal base + your code only
dockerImage: 'myorg/hardened-api:v1.2.3',
cpuCount: '2',
memoryMiB: '1024',
},
});
// The attestation PCRs reflect the exact image content
const attestation = await treza.verifyAttestation(enclave.id, {
nonce: crypto.randomUUID(),
});
// Pin the PCR0 hash to your known-good image digest
const expectedPCR0 = 'a3f8e2...'; // from your CI build
console.log(attestation.pcrs.PCR0 === expectedPCR0); // trueWhen you pin the expected PCR values from your CI/CD pipeline and verify them at runtime, you get end-to-end proof that the exact hardened image you built is the one running in the enclave — not a modified version, not a different tag.
A Practical Hardening Checklist
Before pushing a container image to production, run through these:
| Check | What to verify |
|---|---|
| Multi-stage build | Build tooling is not present in the final image |
| Non-root user | USER instruction set, or runAsNonRoot: true in Kubernetes |
| Minimal base | Distroless, Alpine, or scratch instead of a full OS image |
| No hardcoded secrets | Use environment variables or secret management, not ENV |
| Read-only filesystem | readOnlyRootFilesystem: true where possible |
| Capabilities dropped | drop: [ALL] with only required capabilities added back |
| CVE scan in CI | Trivy or Grype blocking on HIGH/CRITICAL severity |
| Pinned base image digest | FROM node:20@sha256:abc123... not a mutable tag |
No latest tag in production | Tags are mutable — pin to a specific digest or version |
.dockerignore in place | Source maps, tests, .git, and node_modules excluded from context |
Further Reading
- Google Distroless images — distroless base images for major runtimes
- Trivy — container vulnerability scanner
- Docker official image hardening guide — Docker's own best practices
- Treza documentation — deploying hardened images into attested enclaves
Treza runs your containers in hardware-isolated enclaves with cryptographic attestation. Pair a hardened image with an attested enclave and you get both minimal attack surface and verifiable proof of what's running. Learn more.