How to Dockerize a Spring Boot Application, and What Each Choice Costs
The three-line Dockerfile works and gives you a 785MB image. Switching the base from JDK to JRE takes that to 548MB for one word changed. Extracting the jar into layers keeps it at 548MB — layering does not shrink an image — but drops what a code change rebuilds from 21.6MB to 20.5kB. And Spring Boot will build the whole thing itself, at 531MB and non-root, with no Dockerfile at all.
Putting a Spring Boot application in a container takes three lines, and those three lines are how you end up shipping 785 megabytes and rebuilding all of it every time you change a string. This article builds four images from one application and puts the measured number beside each, including the one where the popular advice turns out not to do what people think it does.
The application is the REST API from earlier in this series; its executable jar is 21,566,434 bytes. Every figure below came from docker images or docker history on this machine.
The Dockerfile everyone writes first
FROM eclipse-temurin:21-jdk
COPY target/books-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
It works. docker build, docker run, the API answers. It is also 785MB, and that number is not a curiosity — it is what your registry stores, what CI pushes after every merge, and what each node pulls on every deploy.
Almost all of it is not your application. Your jar is about 21MB of the 785.
One word, 237 megabytes
The base image is the largest thing in that file, and the default choice is the wrong one:
| Base image | Size |
|---|---|
eclipse-temurin:21-jdk |
750MB |
eclipse-temurin:21-jre |
513MB |
A JDK contains a compiler and development tools; a JRE contains only what is needed to run compiled code. Your application is already compiled by the time it reaches the image — the jar is built before docker build runs — so nothing in the container ever calls javac. If that distinction is fuzzy, what each of the JDK, JRE and JVM actually does is worth ten minutes; it is the same distinction, applied to a filesystem.
Change one word:
FROM eclipse-temurin:21-jre
548MB. Same application, same jar, 237MB less, and the only thing you gave up was a compiler you were not using.
Tip
If you build the jar inside Docker rather than before it, you still want the JDK — in a builder stage. Use
FROM eclipse-temurin:21-jdk AS builderto compile andFROM eclipse-temurin:21-jrefor the image you actually ship. That is what multi-stage builds are for.
What is actually inside the jar
A Spring Boot executable jar is not an opaque blob. It knows how it is layered, and it will tell you:
java -Djarmode=tools -jar target/books-0.0.1-SNAPSHOT.jar list-layers
dependencies
spring-boot-loader
snapshot-dependencies
application
Four layers — and the reason this matters is that their sizes are nothing like each other. Extract them and measure:
21M dependencies/
4.0K spring-boot-loader/
4.0K snapshot-dependencies/
16K application/
21 megabytes that change when you edit pom.xml, and 16 kilobytes that change when you edit your code. Right now your Dockerfile treats those as one indivisible thing.
That ratio is roughly a thousand to one, and it is not a property of this particular application — it is what every Spring Boot project looks like. The framework, the embedded server, the JSON library and everything they depend on are large and stable; your controllers are small and change hourly. A fat jar is a single file precisely so that it can be copied anywhere without ceremony, which is exactly the wrong shape for a container layer, where the useful question is not "how big is this" but "how much of this is new since last time".
The jarmode your tutorial is using no longer exists
Before going further, the trap. Most Dockerfiles you will find online extract layers like this:
java -Djarmode=layertools -jar app.jar list
On Spring Boot 4 that produces:
Error: Unsupported jarmode 'layertools'
It was replaced in Spring Boot 3.3 by tools, which offers extract, list-layers and help. The tool ships inside the jar — spring-boot-jarmode-tools is one of the dependencies packaged into it — so there is nothing to install and nothing to add to pom.xml.
Warning
This failure is easy to misread in CI, because the build stage that runs it exits non-zero with a message about a "jarmode" that most people have never had to think about. If a Dockerfile that used to work stopped after a Spring Boot upgrade, this line is the first place to look.
A layered Dockerfile — and what it does not do
FROM eclipse-temurin:21-jre AS builder
WORKDIR /build
COPY target/books-0.0.1-SNAPSHOT.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./
ENTRYPOINT ["java","-jar","app.jar"]
The order is deliberate, and it is the only thing in this file that requires thought: least likely to change first, most likely last.
Docker's build cache is positional. Each instruction produces a layer, and a layer is reused only if that instruction and everything before it are unchanged — a cache miss invalidates every layer that follows. Put application before dependencies and you get the worst of both worlds: the 20kB layer changes, and the 21MB one behind it is rebuilt anyway. The shape Spring documents exists to keep the volatile thing at the end of the queue.
Build it and measure: 548MB.
That is exactly the same as the flat JRE image. Layering made no difference to the size at all, and it never will — the image holds the same bytes either way, just divided differently. A good deal of writing on this subject implies otherwise, and it is worth being clear before we go on: if your goal is a smaller image, this is not the technique.
The measurement that justifies it anyway
Here is what layering is actually for. docker history on each image:
| Naive | Layered | |
|---|---|---|
| jar / dependencies | 21.6MB | 21.4MB |
| spring-boot-loader | — | 4.1kB |
| snapshot-dependencies | — | 4.1kB |
| application | — | 20.5kB |
Now change one line of application code, rebuild the jar, and rebuild each image once.
Naive:
#6 [2/2] COPY target/books-0.0.1-SNAPSHOT.jar app.jar
#7 exporting layers 0.4s done
Layered:
#10 [stage-1 3/6] COPY --from=builder /build/extracted/dependencies/ ./
#10 CACHED
#11 [stage-1 4/6] COPY --from=builder /build/extracted/spring-boot-loader/ ./
#11 CACHED
#12 [stage-1 5/6] COPY --from=builder /build/extracted/snapshot-dependencies/ ./
#12 CACHED
#13 [stage-1 6/6] COPY --from=builder /build/extracted/application/ ./
#14 exporting layers 0.0s done
Three layers came back CACHED; only the application layer was rebuilt. 21.6MB of new layer data became 20.5kB — a thousandfold difference in what gets written, pushed to the registry, and pulled by every node that runs it.
Two details in that transcript are worth reading properly. The exporting layers line is Docker writing the new layers out, and it fell from 0.4s to 0.0s — that is the local half of the saving, and the registry half is larger still, because every push and every pull of an unchanged layer is skipped entirely.
The other is that the builder stage did not come back cached: the jar changed, so the COPY and the RUN ... extract both re-ran. That costs nothing, because nothing from a builder stage ends up in the final image — it exists only to produce the four directories the second stage copies from. Paying for an extraction you throw away, to avoid moving 21MB you would otherwise move, is the trade the whole technique rests on.
Both images are 548MB. Only one of them costs 548MB of movement when you fix a typo.
Your container is running as root
Check the image you just built:
docker run --rm --entrypoint id books:layered
uid=0(root) gid=0(root) groups=0(root)
The eclipse-temurin images set no user, so root is the default and nothing anywhere warns you about it. A process that only needs to read its own jar and open a socket has no business owning the filesystem it runs on, and container escapes are considerably more interesting to an attacker when the process inside was already root.
The fix costs four words and no megabytes:
FROM eclipse-temurin:21-jre
RUN useradd --system --create-home --uid 1001 spring
USER spring
WORKDIR /app
COPY --from=builder --chown=spring:spring /build/extracted/dependencies/ ./
uid=1001(spring) gid=999(spring) groups=999(spring)
The application still answers HTTP 200. The --chown on each COPY is the part people forget: without it the files are owned by root and the new user may not be able to read what it needs.
The option with no Dockerfile at all
There is a fourth route, and it is in Spring Boot itself:
./mvnw spring-boot:build-image
There is no Dockerfile in the project. Spring Boot's Maven plugin hands the jar to Cloud Native Buildpacks, which work out what it is and assemble an image. The result, for this same application:
- 531MB — smaller than the 548MB Dockerfile I wrote by hand
- User 1002:1001 — non-root, without anyone configuring it
- Stack
io.buildpacks.stacks.noble, entrypoint/cnb/process/web - A JVM you did not choose: the build metadata names
paketo-buildpacks/bellsoft-liberica, so the runtime inside is BellSoft Liberica, not the Temurin your Dockerfile named
It is also stripped down further than a base image is. Try to look around inside it:
exec: "id": executable file not found in $PATH
There is no id, and there is not much else either — java is not callable as a command in there, only startable as the process the entrypoint runs. That is a security property and a debugging inconvenience in the same sentence.
Interview tip
This is a genuine fork, not a shortcut. Buildpacks give you smaller, non-root, sensibly layered images for free, and take away the file where you could read exactly what is in your image and change one line of it. A team that wants to audit its images picks the Dockerfile; a team that wants twenty services containerised consistently without twenty Dockerfiles drifting apart picks this.
Where that leaves you
| Approach | Size | Rebuild cost of a code change |
|---|---|---|
| JDK base, fat jar | 785MB | 21.6MB |
| JRE base, fat jar | 548MB | 21.6MB |
| JRE base, layered | 548MB | 20.5kB |
| Buildpacks | 531MB | handled for you |
Two of those numbers do the real work. The base image decides how much you ship; the layering decides how much you re-ship. They are independent choices and it is worth knowing which one you are making.
None of this is about Docker specifically, either — an image built this way is the same artifact whether something runs it directly or a scheduler does, which is the actual difference between Docker and Kubernetes. Get the image right once and both are cheaper.
If you are starting from nothing, create the project first — the Dockerfiles above assume a jar in target/, and everything else follows from that.
Frequently asked questions
- Does a layered Dockerfile make my image smaller?
- No, and this is the most common misunderstanding about it. The flat image and the layered image measured exactly the same, 548MB, because they contain the same bytes. What layering changes is how those bytes are split: a code change replaces a 20.5kB layer instead of a 21.6MB one, so rebuilds, pushes and pulls move far less data.
- Why does jarmode=layertools not work any more?
- It was replaced in Spring Boot 3.3. Running java -Djarmode=layertools now prints "Unsupported jarmode". The current command is -Djarmode=tools, which offers extract, list-layers and help. Any Dockerfile you find using layertools predates that change.
- Should I use the JDK or the JRE base image?
- The JRE, unless you have a specific reason. The JDK image is 750MB against the JRE's 513MB, and the compiler in it is used at build time, not while your application runs. If you build inside Docker, use the JDK in a builder stage and the JRE in the final one.
- Do I need a Dockerfile at all?
- No. ./mvnw spring-boot:build-image builds an image using Cloud Native Buildpacks with no Dockerfile in the project. For the application in this article it produced a smaller image than the hand-written one and ran as a non-root user by default. What you give up is a file you can read and change line by line.
- Is my container running as root?
- Almost certainly, unless you did something about it. The eclipse-temurin images set no user, so the default is uid 0 and nothing warns you. Adding a useradd line and a USER directive fixes it, and costs nothing in image size.