Migrate a Node.js application to Chainguard Containers
Learn how to migrate Node.js applications to Chainguard Containers for reduced vulnerabilities, smaller image sizes, and …
For the complete documentation index, see llms.txt.
Chainguard’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.
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.
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’s migration example builds exactly that.
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.
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.
If you are migrating from Docker Hub’s maven image or from eclipse-temurin, a few differences matter:
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.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’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.This example ports a Spring Boot application from Docker Hub’s maven image to Chainguard’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.
Save the following as Dockerfile.classic. This Dockerfile is a single-stage build on Docker Hub’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 ["java", "-jar", "java-demo-app-1.0.0.jar"]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.
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’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’s Free containers are mirrored there under the chainguard organization, so FROM chainguard/maven:latest also works.
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 ["java", "-jar", "java-demo-app-1.0.0.jar"]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’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’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.
The following video walks through the same migration outlined in the previous steps:
Last updated: 2026-09-09 00:00