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.

Alex Daro
Alex Daro
What Are Docker Hardened Images and Why Do They Matter?

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:

PropertyStandard ImageHardened Image
Base OSUbuntu / Debian (hundreds of packages)Distroless, Alpine, or scratch
Shell access/bin/bash presentNo shell
Runtime userRoot (UID 0) by defaultNon-root, non-privileged
FilesystemRead-writeRead-only where possible
Known CVEsOften dozens in base layerZero or near-zero
Image size100MB – 1GB+5MB – 50MB
CapabilitiesFull default setExplicitly 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: false

4. 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 < 1024

The 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 high

Set --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:

BaseSizeShellPackage managerBest for
scratch0 bytesNoNoStatically compiled Go, Rust binaries
distroless~2–20MBNoNoJava, Python, Node.js, .NET runtimes
Alpine~5MBYes (ash)Yes (apk)When you need shell access for debugging
Ubuntu/Debian slim~30–80MBYes (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); // true

When 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:

CheckWhat to verify
Multi-stage buildBuild tooling is not present in the final image
Non-root userUSER instruction set, or runAsNonRoot: true in Kubernetes
Minimal baseDistroless, Alpine, or scratch instead of a full OS image
No hardcoded secretsUse environment variables or secret management, not ENV
Read-only filesystemreadOnlyRootFilesystem: true where possible
Capabilities droppeddrop: [ALL] with only required capabilities added back
CVE scan in CITrivy or Grype blocking on HIGH/CRITICAL severity
Pinned base image digestFROM node:20@sha256:abc123... not a mutable tag
No latest tag in productionTags are mutable — pin to a specific digest or version
.dockerignore in placeSource maps, tests, .git, and node_modules excluded from context

Further Reading


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.

Ready to get started?

Get in touch to learn how Treza can help your team build privacy-first applications.