# Migrate a Java application to Chainguard Containers

URL: https://deploy-preview-3927--ornate-narwhal-088216.netlify.app/chainguard/containers/migration/migration-guides/java.md
Last Modified: September 9, 2026
Tags: Chainguard Containers, Migration

Learn how to port a Java Dockerfile to Chainguard's Maven, Gradle, JDK, and JRE containers, including a worked Spring Boot example and the differences from the Java images on Docker Hub

Chainguard&rsquo;s Java containers fill the same roles as the Java base images found on Docker Hub, such as eclipse-temurin and maven, but are built on Wolfi with a much smaller package set. Chainguard builds its own JDK from source in Wolfi, and rebuilds these containers nightly so security patches land without manual intervention. Porting a Dockerfile takes a handful of changes, mostly around the non-root user and the absent shell, which Differences from the Java images on Docker Hub covers in full. For current CVE data on a specific container and tag, refer to the JRE entry in the Chainguard Containers directory.
What is Distroless?Distroless container images are minimal container images containing only essential software required to build or execute an application. That means no package manager, no shell, and no bloat from software that only makes sense on bare metal servers.What is Wolfi OS?Wolfi is a community Linux undistro created specifically for containers. This brings distroless to a new level, including additional features targeted at securing the software supply chain of your application environment: comprehensive SBOMs, signatures, daily updates, and timely CVE fixes.What are multi-stage builds?Multi-stage builds are a Docker feature that allow you to use multiple FROM statements in a single Dockerfile, where each statement begins a new build stage. In a typical pattern, an early stage uses a full-featured builder image to compile code or generate artifacts, while a later stage uses a minimal runtime image and copies in only what's needed to run the application. Only what you explicitly copy from one stage carries forward — everything else is discarded when that stage completes.
This approach has significant security benefits. Build tools like compilers, shells, and package managers are broadly exploitable general-purpose utilities that expand an image's attack surface. By leaving them behind in the builder stage, the runtime image has fewer packages, fewer potential CVEs, and a smaller blast radius in the event of a compromise. Reducing unnecessary components also improves observability and makes risk assessment easier, since every package in the final image can be directly tied to a runtime requirement.
Chainguard Containers are designed with this pattern in mind. Most have a :latest-dev development variant suited for use as a builder stage, and a corresponding :latest (or -slim) standard image for the distroless runtime. For example, a Go application can be compiled in the go:latest-dev builder stage and its binary copied into a static or glibc-dynamic runtime image — with no Go toolchain in the final container.
This guide is intended to help you port an existing Java Dockerfile to a Chainguard Containers base.
Java Chainguard Containers Chainguard publishes four containers for Java, split by the job they do:
Container Contents Use it for maven JDK plus Apache Maven The build stage of a Maven project gradle JDK plus Gradle The build stage of a Gradle project jdk Full Java Development Kit Compiling inside the container without Maven or Gradle jre Java Runtime Environment only Running a compiled JAR in production The jre container is the production target. It has no compiler, no build tooling, no shell, and no package manager, which keeps it small but also means you cannot extend it in place. The build containers include a shell and a package manager and can be extended freely.
Each of these also comes in a development variant, distinguished by the tag suffix (for example, jre:latest-dev). The development variants add a shell and package manager to a runtime container, which makes them useful for debugging and for the rare application that needs system tooling at runtime.
The recommended approach for migration is to use a multi-stage build: compile in maven or gradle, then copy the JAR into jre. This guide&rsquo;s migration example builds exactly that.
Migrating from other distributions Dockerfiles often contain commands specific to the Linux distribution they are based on. Most commonly this relates to package installation (apt versus yum versus apk), but it also covers the default shell (bash versus ash) and default utilities (groupadd versus addgroup). The high-level guide on Migrating to Chainguard Containers covers distro-based migration and package compatibility for Debian, Alpine, Ubuntu, and Red Hat UBI base images.
Installing further dependencies Java applications sometimes need native libraries at build time, runtime, or both. Wolfi has a large package repository, though package names may differ from other distributions.
The easiest way to search is with apk tools in a wolfi-base container:
docker run -it --rm cgr.dev/chainguard/wolfi-baseapk update apk search freetypeAdd the packages you find to the build stage of your Dockerfile. Note that apk add needs write access, so a Dockerfile that installs packages has to switch to the root user first with USER root. For more searching tips, check the Searching for Packages section of the base migration guide.
Differences from the Java images on Docker Hub If you are migrating from Docker Hub&rsquo;s maven image or from eclipse-temurin, a few differences matter:
The containers run as a non-root user. UID 65532 is the default, where the Docker Hub images run as root. If a build step needs elevated privileges, add USER root before it, and switch back for the production stage. WORKDIR differs by container. It is /app in jre, and /home/build in maven, gradle, and jdk. The entrypoint is the tool, not a shell. jre sets /usr/bin/java and maven sets /usr/bin/mvn, so arguments you pass to docker run go to that program. For example, docker run cgr.dev/chainguard/jre:latest -version prints the container&rsquo;s Java version. To get a shell you need a -dev variant and an explicit entrypoint override: docker run --entrypoint /bin/sh -it cgr.dev/chainguard/jre:latest-dev. JAVA_HOME is /usr/lib/jvm/default-jvm. Scripts that hardcode a Temurin path need updating. There are far fewer libraries and utilities present. An application may turn out to have a dependency the container doesn&rsquo;t carry, which you need to add explicitly. Migration example This example ports a Spring Boot application from Docker Hub&rsquo;s maven image to Chainguard&rsquo;s Maven and JRE containers, in two steps. The application listens on port 8080 and answers /hello. Its source is in the learning-labs-java repository:
git clone https://github.com/chainguard-dev/learning-labs-java.git cd learning-labs-javaEach step writes a new Dockerfile rather than editing one in place, so you can build all three and compare them side by side. The repository ships its own Dockerfile variants from when the accompanying video was recorded, but those pin container digests from 2024; the files you create here track current tags instead, which is what makes the size and CVE comparison meaningful.
The starting point Save the following as Dockerfile.classic. This Dockerfile is a single-stage build on Docker Hub&rsquo;s maven image, which is itself built on Eclipse Temurin:
FROM maven:latest WORKDIR /work COPY src/ src/ COPY pom.xml pom.xml RUN mvn clean package WORKDIR /app RUN cp /work/target/java-demo-app-1.0.0.jar . ENTRYPOINT [&#34;java&#34;, &#34;-jar&#34;, &#34;java-demo-app-1.0.0.jar&#34;]Build and run it:
docker build -f Dockerfile.classic -t java-maven . docker run --rm -d --name java-demo -p 8080:8080 java-maven curl localhost:8080/hello docker stop java-demoThe result is a working application in a large container. Everything Maven needed in order to build the JAR is still sitting in the image that runs it.
Step 1: change the base container Copy Dockerfile.classic to Dockerfile.cg and change a single line, the FROM instruction:
FROM cgr.dev/chainguard/maven:latestThe remainder of the file is unchanged. Build it under a new tag to allow a direct comparison:
docker build -f Dockerfile.cg -t java-maven-cg .That single line cuts the image size substantially, and clears out the operating-system package findings a scanner reports, because there are far fewer packages left to report on. Compare the two directly:
docker images | grep java-maven grype java-maven grype java-maven-cgThis base swap replaces the operating system underneath your application, so findings against distribution packages largely go away. It does not touch your application&rsquo;s own dependencies: the JARs Maven resolved from pom.xml are identical in both images, so any CVEs in those survive the change. Fixing those means updating the dependencies themselves, which is what Chainguard Libraries for Java addresses.
If you prefer Docker Hub to cgr.dev, Chainguard&rsquo;s Free containers are mirrored there under the chainguard organization, so FROM chainguard/maven:latest also works.
Step 2: split the build into two stages The container still carries Maven and a full JDK into production. A multi-stage build compiles in the Maven container and copies only the JAR into the JRE container. Save the following as Dockerfile.cg-multi:
FROM cgr.dev/chainguard/maven:latest AS builder WORKDIR /work COPY src/ src/ COPY pom.xml pom.xml RUN mvn clean package FROM cgr.dev/chainguard/jre:latest AS runner WORKDIR /app COPY --from=builder /work/target/java-demo-app-1.0.0.jar . ENTRYPOINT [&#34;java&#34;, &#34;-jar&#34;, &#34;java-demo-app-1.0.0.jar&#34;]docker build -f Dockerfile.cg-multi -t java-maven-multi-cg . docker run --rm -d --name java-demo -p 8080:8080 java-maven-multi-cg curl localhost:8080/hello docker stop java-demoThe application behaves the same, in a container roughly 250 MB smaller than the single-stage Chainguard build, because the build tooling never reaches the final stage. The jre container also has no shell, so there is nothing for an attacker who reaches the container to run.
This step cuts scanner findings a second time, and for a different reason than the base swap did. A single-stage build ships Maven&rsquo;s own bundled JARs and everything it downloaded into the local repository, and a scanner reports on all of them. Only the application JAR survives the copy into the final stage:
grype java-maven-multi-cgThere are two important things to note on the COPY line. The JAR filename comes from the project&rsquo;s pom.xml, so it differs in your application; a wildcard such as COPY --from=builder /work/target/*.jar app.jar avoids restating the version. Additionally, because jre already sets WORKDIR to /app, the WORKDIR line in the runner stage is explicit rather than required.
Video demonstration The following video walks through the same migration outlined in the previous steps:
Additional resources The JRE container documentation has full details on the Java containers, including usage, provenance, and security advisories. Build Java containers with Jib covers building Java containers without writing a Dockerfile at all, using Jib&rsquo;s Maven and Gradle plugins. Building minimal container images explains the multi-stage pattern this guide uses, and when a runtime container is the right final base. Fully bootstrapping Java from source in Wolfi describes how Chainguard builds its JDK. Debugging distroless container images covers working with a production container that has no shell. How to port a sample application to Chainguard Containers works through porting a legacy application. 
