Hardening an image for production

Docker · lesson 29 of 31 · 6 min read

Smaller attack surface, no root, and dropped capabilities.

Open this lesson in the learning hub

Key points

  • Most of the vulnerabilities a scanner reports are in packages the application never uses. The cheapest fix is to ship less: a distroless or minimal base removes the shell, the package manager and most of the OS.
  • No shell also means no interactive debugging inside the container. That is a real trade - kubectl debug with an ephemeral container is the modern answer.
  • Run as non-root. It is one line, and it turns many container escapes into failed attempts. A numeric uid is preferable to a name so Kubernetes can enforce runAsNonRoot.
  • Drop capabilities. A normal service needs none of them; the default set includes things like CAP_NET_RAW that only enable attacks in this context.
  • Make the root filesystem read-only and mount a tmpfs where the process genuinely needs to write. An attacker who cannot write cannot easily persist.
  • Pin the base image by digest, not by tag. Tags are mutable - eclipse-temurin:21-jre changes over time - so a digest is what makes a build reproducible and prevents silent base substitution.

Example

# syntax=docker/dockerfile:1

# Build stage: full JDK, tools, everything needed to compile.
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY gradle/ gradle/
COPY gradlew build.gradle settings.gradle ./
RUN --mount=type=cache,target=/root/.gradle ./gradlew dependencies --no-daemon
COPY src/ src/
RUN --mount=type=cache,target=/root/.gradle ./gradlew bootJar --no-daemon

# Runtime: distroless, pinned by DIGEST so the build is reproducible.
FROM gcr.io/distroless/java21-debian12:nonroot@sha256:abc123...

USER 65532:65532                       # numeric, so runAsNonRoot can verify
WORKDIR /app
COPY --from=build --chown=65532:65532 /src/build/libs/*.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=70", "-jar", "/app/app.jar"]

---
# Runtime restrictions - the manifest side.
securityContext:
  runAsNonRoot: true
  runAsUser: 65532
  allowPrivilegeEscalation: false      # blocks setuid escalation
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]                      # a web service needs none
  seccompProfile:
    type: RuntimeDefault               # blocks the exotic syscalls

volumeMounts:
  - { name: tmp, mountPath: /tmp }     # the JVM needs a writable /tmp
volumes:
  - { name: tmp, emptyDir: { medium: Memory, sizeLimit: 64Mi } }

---
# What each control removes:
#
#   distroless base        no shell, no apt -> most CVEs and most tooling
#   non-root               file writes and many escapes fail
#   drop ALL capabilities  no raw sockets, no mount, no ptrace
#   read-only rootfs       attacker cannot persist anything
#   pinned digest          base cannot be swapped under you
#   seccomp RuntimeDefault ~44 syscalls blocked

Ship less, run as a numeric non-root uid, drop all capabilities and pin the base by digest - each is one line and removes a whole class of risk.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Docker course, and every lesson in it is listed on the Docker contents page.