A Docker Image for Java

Write a multi-stage Dockerfile that builds with a JDK, runs on a JRE as a non-root user, caches dependencies and receives stop signals.

SyntaxFROM eclipse-temurin:25-jdk AS build ... FROM eclipse-temurin:25-jre COPY --from=build /src/target/app.jar /app/app.jar USER app ENTRYPOINT ["java", "-jar", "/app/app.jar"]

A container image for a Java service needs a Java runtime and your packaged application, and nothing else. The usual shape is a multi-stage build: the first stage has the full JDK and the build tool, the second stage starts from a smaller runtime image and copies only the finished JAR across. Compilers, the Maven cache and your source code never reach production.

Choosing a base image

The Eclipse Temurin images are a common choice: eclipse-temurin:25-jdk to build and eclipse-temurin:25-jre to run. Pin at least the Java feature version. A tag like latest can move you to a new major release on an unrelated rebuild. For fully repeatable builds, pin the image digest as well and update it on purpose. A JRE image is enough for most services; when you also want to drop modules you do not use, build your own runtime with jlink.

Details that matter in production

  • Exec-form ENTRYPOINT. ENTRYPOINT ["java", "-jar", "app.jar"] makes the JVM process 1 in the container, so it receives SIGTERM on docker stop or a Kubernetes rollout. The shell form (ENTRYPOINT java -jar app.jar) runs under /bin/sh -c, the shell gets the signal, and your shutdown hooks may never run before the kill.
  • A non-root user. Create a system user and switch to it with USER. If the process is compromised, it cannot write over the image's files.
  • Layer order. Each instruction is a cached layer. Put what changes least first. With BuildKit, a cache mount on ~/.m2 keeps downloaded dependencies between builds without baking them into the image.
  • A .dockerignore. Without one, target/, .git and local .env files are sent to the builder and can end up in a layer.

Mistakes people make

Copying the source into the runtime stage and building there ships the JDK and the source. Hard-coding -Xmx in the ENTRYPOINT ties the image to one memory size; use -XX:MaxRAMPercentage or an environment variable instead. ENV DB_PASSWORD=... stores the secret in the image history for anyone who can pull it. Configuration and secrets are passed when the container starts.

A Docker HEALTHCHECK is used by Docker and Compose, but Kubernetes ignores it and uses its own probes. Also note that a Temurin image is not the only route: Jib builds images from Maven or Gradle without a Dockerfile, and Spring Boot's spring-boot:build-image goal uses Cloud Native Buildpacks.

Example

Example · dockerfile

# syntax=docker/dockerfile:1

# ---- Build stage: full JDK plus the Maven wrapper ----
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
COPY src/ src/
# The cache mount keeps ~/.m2 between builds without storing it in a layer
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q package -DskipTests

# ---- Runtime stage: JRE only ----
FROM eclipse-temurin:25-jre
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --from=build /src/target/orders-service.jar app.jar
USER app
EXPOSE 8080
# Exec form: java is process 1 and receives SIGTERM directly
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]

When to use it

  • Containerise a payments API so the same image runs in CI, staging and production with only environment variables changing.
  • Cut image push time for a busy team by splitting a Spring Boot JAR into dependency and application layers.
  • Pass a security review by running the service as a non-root user on a JRE image with no compiler or source code inside.

More examples

A .dockerignore for a Maven project

Everything not ignored is sent as build context, so a large target folder slows every build and a stray .env file can be copied into a layer.

Example · text
# Build output and tool state
target/
.idea/
*.iml

# Never send these to the builder
.git/
.env
*.pem

Spring Boot: separate layers for dependencies and code

Each COPY becomes its own layer, so a code-only change re-pushes the small application layer while the dependency layer is reused. The tools jar mode replaced the older layertools mode in Spring Boot 3.3.

Example · dockerfile
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q package -DskipTests
# Split the Boot JAR into layers: dependencies change rarely, application often
RUN java -Djarmode=tools -jar target/orders-service.jar extract --layers --destination /extracted

FROM eclipse-temurin:25-jre
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --from=build /extracted/dependencies/ ./
COPY --from=build /extracted/spring-boot-loader/ ./
COPY --from=build /extracted/snapshot-dependencies/ ./
COPY --from=build /extracted/application/ ./
USER app
ENTRYPOINT ["java", "-jar", "orders-service.jar"]

Build, run with limits and stop cleanly

Running locally with the same memory and CPU limits shows how the JVM sizes itself before production does it for you. docker stop is a quick way to check that the shutdown hooks run and the exit is clean.

Example · bash
docker build -t orders-service:1.4.0 .

# Limits like the ones the service will get in production
docker run --rm --name orders -p 8080:8080 \
  --memory=512m --cpus=1 \
  -e DB_URL=jdbc:postgresql://db:5432/orders \
  orders-service:1.4.0

# In another terminal: sends SIGTERM, then SIGKILL after 10 s by default
docker stop orders

# Who does the process run as?
docker run --rm --entrypoint id orders-service:1.4.0

Discussion

  • Be the first to comment on this lesson.