Hardening an image for production
Smaller attack surface, no root, and dropped capabilities.
Open this lesson in the learning hubKey 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 debugwith 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-jrechanges 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.