Containerising Spring Boot: Layers, Buildpacks and Base Images
Why a fat JAR in one COPY wastes your layer cache, how layered extraction fixes it, and when buildpacks beat a hand-written Dockerfile.
The Naive Dockerfile and Its One Flaw
FROM eclipse-temurin:25-jre
COPY build/libs/shop-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]This works, and it wastes almost all of your layer cache.
A Spring Boot fat JAR is maybe 60 MB, of which your own code is perhaps 500 KB — the rest is Spring, Jackson, Hibernate, the JDBC driver. Those dependencies change when you edit build.gradle, which is rarely. Your code changes every commit.
By copying the whole JAR as one layer, every commit invalidates all 60 MB. Every build pushes 60 MB to the registry and every node pulls 60 MB, to deliver a 500 KB change.
Layered Extraction
Spring Boot's JARs are layered by default — the build records which files belong in which layer, ordered by how often they change. Unpack by layer and each becomes its own Docker layer:
# ---- build ----
FROM eclipse-temurin:25-jdk AS build
WORKDIR /build
COPY gradle/ gradle/
COPY gradlew settings.gradle build.gradle ./
RUN ./gradlew dependencies --no-daemon # cached unless the build files change
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test
# ---- extract ----
FROM eclipse-temurin:25-jdk AS extract
WORKDIR /extract
COPY --from=build /build/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher
# ---- runtime ----
FROM eclipse-temurin:25-jre
WORKDIR /app
RUN groupadd -r spring && useradd -r -g spring spring
COPY --from=extract --chown=spring:spring /extract/app/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/app/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/app/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/app/application/ ./
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]Four things in there are deliberate.
Resolving dependencies before copying src/. The dependencies task downloads everything into the image layer, which then caches until build.gradle or settings.gradle changes — the same cache-ordering principle applied to the build stage. --no-daemon matters in a container: a Gradle daemon has nothing to serve after the build step and only costs memory.
-Djarmode=tools ... extract is the Spring Boot 3.3+ form. Older material uses -Djarmode=layertools, which still works but is deprecated.
COPY order matches change frequency — dependencies first, application last. Reversing it defeats the whole exercise.
A non-root user. Covered below, and non-negotiable.
The ENTRYPOINT is JarLauncher, not java -jar. Once extracted there is no JAR to run — the classes and dependencies are loose on disk. Note the class moved to org.springframework.boot.loader.launch.JarLauncher in Spring Boot 3.2; the old org.springframework.boot.loader.JarLauncher fails with ClassNotFoundException.
Buildpacks: No Dockerfile At All
Spring Boot can build an image directly:
./gradlew bootBuildImage --imageName=registry.example.com/shop:1.4.0Cloud Native Buildpacks inspect your project, select a JDK, and produce an image that is layered, rootless, reproducible, and carries an SBOM — with no Dockerfile to maintain.
tasks.named('bootBuildImage') {
imageName = "registry.example.com/shop:${project.version}"
environment = [
'BP_JVM_VERSION' : '25',
'BPE_DELIM_JAVA_TOOL_OPTIONS' : ' ',
'BPE_APPEND_JAVA_TOOL_OPTIONS' : '-XX:MaxRAMPercentage=75'
]
}Buildpacks also include a memory calculator that sizes heap, metaspace and stacks from the container's actual memory limit — one of the more valuable things you get for free, and a common source of OOM-kills when hand-rolling.
| Buildpacks | Dockerfile | Jib | |
|---|---|---|---|
| Maintenance | None | Yours | None |
| Needs a Docker daemon | Yes | Yes | No |
| Base image control | Limited | Full | Moderate |
| Memory calculator | Yes | No | No |
| Image size | Larger | Smallest achievable | Small |
Start with buildpacks. Move to a Dockerfile when you need a specific base image — a distroless runtime, a corporate approved base, or extra native packages. Jib is worth knowing for CI without a Docker daemon, which suits many build agents.
Base Images
FROM eclipse-temurin:25-jre # ~180 MB, full shell
FROM eclipse-temurin:25-jre-alpine # ~110 MB, musl libc
FROM gcr.io/distroless/java21-debian12:nonroot # ~90 MB, no shellDistroless contains a JVM and nothing else — no shell, no package manager, no curl. That removes most of the tooling an attacker would use after a compromise, and most of the CVE surface that OS packages contribute.
It also removes your debugging tools. No kubectl exec ... sh. For a service you operate through logs, metrics and traces — which phase 4 set up — that's an acceptable trade. Keep a :debug variant with a shell available for the day you need it.
Alpine uses musl rather than glibc. Most Java workloads are fine, but native libraries compiled against glibc can fail in ways that are hard to diagnose, and some JVM performance characteristics differ. If you choose Alpine, make sure your load tests run on it — not just your unit tests.
Container Hygiene
Never run as root
RUN groupadd -r spring && useradd -r -g spring spring
USER springA process running as root inside a container is root on the host kernel if it escapes the namespace. Buildpacks and distroless :nonroot handle this; a hand-written Dockerfile must do it explicitly. Enforce it at the platform level too:
securityContext:
runAsNonRoot: true
runAsUser: 1001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]readOnlyRootFilesystem: true needs a writable emptyDir mounted at /tmp, since the JVM writes there.
Let the JVM see the container's limits
Modern JVMs are container-aware and read cgroup limits — but the default heap of 25% of available memory is conservative for a container that exists to run one process:
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+UseZGC"Never set -Xmx to the container's full memory limit. The JVM needs memory beyond the heap — metaspace, thread stacks, code cache, direct buffers, GC structures. -Xmx1g in a 1 GB container is killed by the OOM killer, which appears as an exit code 137 with no Java stack trace and no OutOfMemoryError, because the kernel killed the process rather than the JVM failing. Use MaxRAMPercentage at 70–80% and leave the remainder for non-heap.
No secrets in the image
ENV DATABASE_PASSWORD=supersecret # wrong — baked into a layer foreverImage layers are immutable and distributable; anyone who can pull the image can read that. Secrets come from the environment at runtime, as the configuration guide covered. Also note ARG values appear in image history — a build-time secret needs BuildKit's --mount=type=secret.
Keep the build context small
# .dockerignore
build/
.gradle/
.git/
.idea/
*.iml
.env
**/node_modules
Beyond build speed, this is a leak prevention: .git carries your full history and .env carries local credentials, and both end up in the image if a COPY . . picks them up.
Check yourself
A Dockerfile copies the fat JAR in one COPY. The team notices CI pushes 60 MB per build and deployments are slow to pull, despite most commits touching only application code. What is the fix?
Tagging
registry.example.com/shop:latest # don't deploy this
registry.example.com/shop:1.4.0 # semantic version
registry.example.com/shop:a3f9c21 # git SHA — most usefulDeploy immutable tags, never latest. With latest, two pods started minutes apart can run different code, a rollback has nothing to roll back to, and "what is running in production" has no answer. A git SHA tag makes the deployed artifact traceable to a commit — and pairs with the /actuator/info build metadata from phase 4 so the running service can tell you itself.
A Reasonable Setup
For most teams: buildpacks in CI, tagged with the git SHA.
- name: Build and push image
run: |
./gradlew bootBuildImage \
--imageName=registry.example.com/shop:${GITHUB_SHA::7} \
--publishImageNo Dockerfile to maintain, correct layering, non-root, a sane memory calculator, and an SBOM. Write a Dockerfile when a specific requirement makes you.
The Mental Model, Restated
- One
COPYof a fat JAR wastes the layer cache. Layer by change frequency. -Djarmode=tools ... extract --layers, withJarLauncheras the entrypoint.- Buildpacks are the sensible default — layered, rootless, with a memory calculator.
- Distroless for the smallest attack surface, accepting no shell.
- Never run as root, and enforce it in the platform's security context.
MaxRAMPercentageat 70–80%, never-Xmxat the full limit.- No secrets in images. Runtime environment only.
- Deploy immutable tags, ideally the git SHA.
What's Next
A correctly built image still terminates badly by default: a rolling deployment kills the JVM mid-request, and a load balancer keeps sending traffic to a pod that is shutting down. The next guide covers graceful shutdown and the probe wiring that makes a deployment invisible to users.