Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Containerizing Spring Boot with Docker

Building small, fast, secure Spring Boot images: multi-stage builds, BuildKit cache mounts, layered jars, JVM container awareness, distroless bases and vulnerability scanning.

Intermediate5 min readUpdated
On this page

A careless Spring Boot image is 700MB, rebuilds from scratch on every commit, runs as root and ships a package manager to production. Fixing all four is about twenty lines of Dockerfile.

Before writing any of them, it is worth knowing you may not need to. ./mvnw spring-boot:build-image produces a layered, non-root, CNB-based image with no Dockerfile at all, and it stays current with Spring's own recommendations rather than with whatever you copied two years ago. The reason to write the Dockerfile anyway is control — over the base image your security team has approved, over what else lands in the final layer, and over a build that has to slot into an existing CI cache strategy.

Key Takeaways

  • Multi-stage builds keep the JDK, source and build cache out of the final image.
  • Layer ordering decides cache hit rate — dependencies before source, always.
  • BuildKit cache mounts persist the Maven or Gradle cache across builds.
  • Layered jars turn a 60MB push into a 2MB one for a code-only change.
  • Run as non-root on a distroless or Alpine JRE base, and scan in CI.

A Dockerfile worth copying

Dockerfile
# syntax=docker/dockerfile:1.7
 
# ---------- build ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
 
# Copy only the build file first. This layer is invalidated by dependency
# changes, not by source changes, so most builds reuse it.
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
    mvn -B dependency:go-offline
 
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
    mvn -B clean package -DskipTests
 
# ---------- extract layers ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /build/target/*.jar app.jar
# Splits the fat jar into layers by change frequency.
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
 
# ---------- runtime ----------
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
 
RUN addgroup -S app && adduser -S -G app app
USER app
 
# Ordered least-changing first, so a code change invalidates only the last one.
COPY --from=extract --chown=app:app /extract/extracted/dependencies/ ./
COPY --from=extract --chown=app:app /extract/extracted/spring-boot-loader/ ./
COPY --from=extract --chown=app:app /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract --chown=app:app /extract/extracted/application/ ./
 
EXPOSE 8080
ENTRYPOINT ["java", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:+UseG1GC", \
  "-XX:+ExitOnOutOfMemoryError", \
  "-Djava.security.egd=file:/dev/./urandom", \
  "org.springframework.boot.loader.launch.JarLauncher"]

Why the layer order matters

Docker layers are cached by content and invalidated downward. Ordering by change frequency is what makes the cache useful.

The saving compounds. On a service deployed twenty times a day across ten nodes, the difference between pushing 2MB and 60MB per deploy is hours of transfer and a materially faster rollout.

The same principle applies to the build stage. dependency:go-offline runs against pom.xml alone, so it re-executes only when dependencies change. The BuildKit cache mount goes further and keeps the whole ~/.m2 across builds, including across different images — which is what makes CI builds fast rather than merely faster.

JVM settings in a container

Never set a fixed -Xmx. The container limit covers the entire process — heap, metaspace, thread stacks, direct buffers, code cache, GC structures and the JVM itself. -Xmx1g against a 1Gi limit is an eventual OOMKill with no OutOfMemoryError in the logs, because the heap never overflowed.

-XX:MaxRAMPercentage=75.0 sizes the heap relative to the detected cgroup limit and leaves real headroom. -XX:+ExitOnOutOfMemoryError makes the process die on heap exhaustion instead of limping along failing every request while liveness still passes.

Java also detects CPU limits and sizes GC and fork-join pools from them. A container limited to 500m CPU gets one available processor, which may make G1 a poor fit — worth checking with -XX:ActiveProcessorCount if your latency looks wrong under tight CPU limits.

Distroless

Dockerfile (distroless runtime)
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=extract --chown=nonroot:nonroot /extract/extracted/ ./
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", \
            "org.springframework.boot.loader.launch.JarLauncher"]

Distroless contains a JRE and nothing else — no shell, no package manager, no curl, no busybox. An attacker who achieves code execution has no tools to pivot with, and the CVE count drops sharply because most reported vulnerabilities in a base image are in packages you never used.

The trade-off is that kubectl exec gives you no shell. In practice this is solved by ephemeral debug containers, which attach a full toolbox to the running pod's namespaces without changing the image.

Build alternatives

Spring Boot can build an image without a Dockerfile at all:

terminal
./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=acme/order-service:1.4.0 \
  -Dspring-boot.build-image.env.BP_JVM_VERSION=21

Cloud Native Buildpacks produce a well-layered, non-root image with sensible JVM defaults and no Dockerfile to maintain. The cost is less control and a slower first build. Jib, from Google, is a third option that builds without a Docker daemon at all, which is convenient in restricted CI environments.

Use buildpacks when you want a good default across many services and a Dockerfile when you need specific control.

Scanning and signing

.github/workflows/build.yml
- name: Build image
  run: docker build -t ghcr.io/acme/order-service:${{ github.sha }} .
 
- name: Scan
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/acme/order-service:${{ github.sha }}
    severity: 'HIGH,CRITICAL'
    exit-code: '1'
    ignore-unfixed: true       # do not block on CVEs with no available fix
 
- name: Sign
  run: cosign sign --yes ghcr.io/acme/order-service:${{ github.sha }}

ignore-unfixed is the setting that keeps scanning practical. Without it, a base-image CVE with no patch available blocks every build until upstream ships a fix, and teams respond by disabling the scan entirely.

Tag with the immutable git SHA and deploy that. A latest or branch tag can point at different content over time, which makes "which build is running in production" unanswerable — and rollback a guess.

What to take away

Multi-stage build, dependencies resolved in their own layer, BuildKit cache mount on the Maven cache, layered jar extraction ordered by change frequency. Non-root user, JRE or distroless base, heap sized as a percentage. Scan with ignore-unfixed and deploy immutable SHA tags.

Frequently Asked Questions

Why does every build re-download all my dependencies?
Because the layer that runs the build is invalidated by any source change, and the dependency download is inside it. Either copy the build file and resolve dependencies in a separate earlier layer, or use a BuildKit cache mount on the Maven or Gradle cache directory, which survives across builds.
Should I use a layered jar or just COPY the fat jar?
Layered. A fat jar is one 60MB layer that changes entirely on every code change, so every deploy pushes and pulls 60MB. Layered extraction puts dependencies in their own layer that only changes when dependencies do, leaving a small application layer for the usual case.
Is distroless worth the debugging inconvenience?
For production, generally yes — no shell means no shell for an attacker either, and the image is smaller with fewer CVEs. The debugging problem is solved with an ephemeral debug container attached to the running pod, so you are not really giving anything up.

Related tutorials