A secure Java Docker container starts from a multi-stage build that compiles with a full JDK and ships only a JRE, runs as a non-root user, and uses a slim or distroless base image so the JVM is the only large thing inside. The naive Java dockerfile, FROM openjdk, copy the fat jar, java -jar, produces a 600 MB image running as root with a shell, a package manager, and hundreds of OS packages you never use, each a potential CVE. This walkthrough builds up the good version and explains every decision.
Why the Obvious Dockerfile Is Wrong
Here is what most people write first:
FROM openjdk:17
COPY target/app.jar /app.jar
CMD ["java", "-jar", "/app.jar"]
Three problems. The openjdk image bundles the full JDK (compiler, debugger, tooling) when you only need the runtime. The container runs as root by default. And the base carries a complete OS userland, so a scanner reports dozens of unrelated CVEs in libraries you will never call. The official openjdk Docker Hub images have also been deprecated in favor of vendor images like Eclipse Temurin, so that tag is a poor starting point in itself.
A Multi-Stage Build
Separate the build environment from the runtime environment. The heavy toolchain never ships.
# ---- build stage ----
FROM eclipse-temurin:17-jdk AS build
WORKDIR /src
COPY . .
RUN ./mvnw -q -DskipTests package
# ---- runtime stage ----
FROM eclipse-temurin:17-jre AS runtime
WORKDIR /app
COPY --from=build /src/target/app.jar app.jar
RUN useradd --system --uid 10001 --no-create-home appuser
USER 10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
What each part buys:
17-jdkfor build,17-jrefor runtime. The JDK compiles; the JRE runs. Shipping only the JRE removes the compiler and dev tooling from the final image.COPY --from=buildpulls only the built artifact across, leaving source,.m2cache, and build tools behind.- A dedicated non-root user with a fixed UID. If the app is compromised, the attacker is UID 10001 with no home directory and no sudo, not root.
Going Smaller: jlink and Distroless
If you want to run java in docker with the smallest safe footprint, two techniques stack on top of the multi-stage build.
Custom runtime with jlink. Instead of the full JRE, build a trimmed runtime containing only the modules your app uses:
FROM eclipse-temurin:17-jdk AS jre-build
RUN "$JAVA_HOME/bin/jlink" \
--add-modules java.base,java.logging,java.sql,java.naming \
--strip-debug --no-man-pages --no-header-files \
--compress=2 --output /javaruntime
Distroless base. Google's distroless images contain your runtime and nothing else, no shell, no package manager, no ls. That removes an entire class of post-exploitation tooling:
FROM gcr.io/distroless/java17-debian12 AS runtime
COPY --from=build /src/target/app.jar /app/app.jar
USER nonroot
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
The tradeoff is debuggability. With no shell you cannot kubectl exec into a distroless container and poke around; you debug via logs, an ephemeral debug container, or a separate non-distroless image built from the same stages.
Make the JVM Container-Aware
A subtle but important point specific to Java containers. Older JVMs read the host's total memory and CPU rather than the container's cgroup limits, then sized the heap for a machine far larger than the container, and got OOM-killed. Modern JVMs (JDK 10+ with UseContainerSupport, on by default) respect cgroup limits, but you should still be explicit:
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
MaxRAMPercentage sizes the heap as a fraction of the container's memory limit, which is what you want under Kubernetes memory limits. Avoid hardcoding -Xmx512m in a Dockerfile that runs across environments with different limits.
The Security Layer That the Dockerfile Cannot Fix
A clean Dockerfile shrinks your OS attack surface, but your fat jar still bundles every Java dependency, and that is where the real vulnerabilities live. Log4Shell (CVE-2021-44228) was not an OS package; it was a library inside application jars. A minimal distroless image with a vulnerable log4j-core shaded into the app jar is still exploitable.
So pair the image hardening with dependency scanning. Scan the base image for OS CVEs, and scan the application's Java dependencies (via the SBOM or the jar contents) for known-vulnerable libraries. An SCA tool resolves the Maven or Gradle dependency tree including transitive packages, which is exactly where something like a vulnerable Jackson version hides; we cover that specific case in the Jackson databind security guide. Do both in CI so a new advisory against a shipped library fails the build.
A Checklist Before You Push
- Multi-stage build; final image is JRE or distroless, never JDK.
- Runs as a non-root user with a fixed UID.
- Pinned base image tag with a digest, not
latest. .dockerignoreexcludestarget/,.git, secrets, and local config.- No secrets baked into layers (they persist even if deleted in a later layer).
- Image and dependencies both scanned in CI, build fails on high-severity findings.
FAQ
What base image should I use for a Java Docker container?
Use a vendor-maintained image like Eclipse Temurin rather than the deprecated openjdk tag. Build with the -jdk variant and ship the -jre variant, or go smaller with a jlink runtime or a distroless Java base for the runtime stage.
Should a Java container run as root?
No. Create a dedicated non-root user with a fixed UID and switch to it with USER before the entrypoint. If the application is compromised, running as non-root limits what the attacker can do inside the container.
Why does my Java container get OOM-killed in Kubernetes?
Usually because the JVM sized its heap for the host rather than the container limit. Modern JVMs honor cgroup limits, but set -XX:MaxRAMPercentage explicitly so the heap scales to the container's memory limit instead of a hardcoded -Xmx.
Does a minimal image protect me from library vulnerabilities?
No. A slim or distroless base reduces OS-level CVEs, but your application jar still contains its Java dependencies. Vulnerabilities like Log4Shell live in those libraries, so you must scan the dependency tree separately from the base image.