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.
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
# 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
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
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:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=acme/order-service:1.4.0 \
-Dspring-boot.build-image.env.BP_JVM_VERSION=21Cloud 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
- 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?
Should I use a layered jar or just COPY the fat jar?
Is distroless worth the debugging inconvenience?
Related tutorials
- Microservices Testing StrategiesA testing strategy for distributed systems: where the pyramid changes shape, consumer-driven contract testing, component tests with Testcontainers, and why end-to-end tests fail you.
- Kubernetes for Java MicroservicesRunning Spring Boot on Kubernetes properly: liveness versus readiness probes, JVM memory inside cgroups, resource requests and limits, autoscaling, and zero-downtime rollouts.
- Event-Driven MicroservicesDesigning events that last: domain versus integration events, Avro and schema registry compatibility, event sourcing basics, ordering guarantees and schema evolution.
- Service Mesh with IstioWhat a service mesh moves out of your application: automatic mTLS, VirtualService routing, outlier detection, authorization policies, fault injection and progressive delivery.