When you build a Java application in Docker, the first instinct is to put everything into the container: the JDK, Maven, the sources, the compiled classes. The image ends up huge and carries tools that a running container doesn't need. A multi-stage build solves this: you build in one image and run in another, smaller one.
The problem: an image with the whole "kitchen" inside
Imagine you cook a dish and, instead of handing the guest a plate of food, you deliver the entire kitchen — the table, the stove, and the knives. That is exactly what happens when Maven, the JDK, and the sources end up in the final Docker image.
A typical "naive" Dockerfile looks like this:
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
ENTRYPOINT ["java", "-jar", "target/app.jar"]
Problems:
- Size. Eclipse Temurin JDK 21 weighs about 400 MB. Maven downloads hundreds more MB of dependencies. As a result, the image easily exceeds 700 MB.
- Security. The JDK, Maven, and the source code end up in the production container. If a vulnerability is found in a build tool inside the image, it ends up on the production server.
- Slow transfer. Large images take longer to pull onto every server.
Multi-stage: build separately, run separately
A multi-stage build is when a single Dockerfile contains several FROM instructions. Each FROM starts a new stage. From a previous stage you can copy only what you need — Docker discards everything else.
The short formula: build the artifact where the tools are; run the artifact where nothing extra is present.
# ── Stage 1: build ──────────────────────────────────────────
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# dependencies first (cached separately — more on this below)
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -q
# now the sources
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# ── Stage 2: final image ─────────────────────────────────────
FROM eclipse-temurin:21-jre
WORKDIR /app
# copy only the jar from the build stage
COPY --from=build /app/target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
What is happening here:
AS build— the name of the first stage. The name is arbitrary and is used inCOPY --from=.COPY --from=build /app/target/app.jar app.jar— take the file from thebuildstage and place it into the current image.- The final image is built on top of
eclipse-temurin:21-jre— this is only the runtime, without a compiler or build tools. It weighs about 200 MB instead of 700+.
What does not end up in the final image: Maven, the JDK, the sources, .git, the tests, the dependency cache.
How layers work and why instruction order matters
Every RUN, COPY, and ADD instruction in a Dockerfile creates a layer — an immutable snapshot of the filesystem. Docker caches layers: if the instruction and everything it uses are unchanged, the layer is taken from the cache and not executed again.
This is why the order of instructions in a Dockerfile is critical: as soon as one layer changes, all subsequent layers are rebuilt.
Consider a bad example:
# Bad: sources are copied before dependencies
COPY src/ src/
COPY pom.xml .
RUN ./mvnw dependency:go-offline -q
RUN ./mvnw package -DskipTests
Here a change to any file in src/ invalidates the COPY src/ cache, and Maven downloads all dependencies again — even though pom.xml did not change.
The correct order:
# Good: dependencies are cached separately from the code
COPY pom.xml .
RUN ./mvnw dependency:go-offline -q # this layer changes only when pom.xml changes
COPY src/ src/
RUN ./mvnw package -DskipTests # this layer changes when the code changes
Now, if you only changed a Java class, Maven does not download the dependencies again — they are already in the cache. The build speeds up several times over on CI.
.dockerignore: what should not reach the build context
When you run docker build, Docker sends the build context — the contents of the current directory — to its daemon. If you don't set up exclusions, target/, .git/, IDE files, and everything else will be included.
The .dockerignore file works by the same rules as .gitignore and solves three problems:
- it shrinks the context → the build starts faster;
- it prevents cache invalidation caused by unrelated files;
- it keeps sensitive data out of the image.
A minimal .dockerignore for a Maven project:
target/
.git/
.idea/
*.iml
.DS_Store
For Gradle, similarly add build/ and .gradle/.
How to inspect layer sizes
After the build you can check the result:
# image sizes
docker images
# layer history with sizes
docker image history myapp:latest
The docker image history command shows each layer, the command that created it, and its size. This helps you find layers that unexpectedly weigh a lot.
Summary: how much the image shrinks
For a typical Spring Boot application:
| Approach | Approximate size |
|---|---|
| JDK + Maven + sources in one image | 700–900 MB |
| Multi-stage: JDK to build, JRE to run | 200–300 MB |
| Multi-stage + distroless/slim JRE | 100–180 MB |
The numbers depend on the application, but the order of magnitude holds.
In short
- A multi-stage build separates the build image (JDK + Maven) from the runtime image (JRE + jar) — the final image contains no development tools.
FROM ... AS <name>names a stage;COPY --from=<name>copies files from it.- Layers are cached: an instruction is not executed again if it and its inputs have not changed.
- The order of instructions determines the quality of caching: put what changes rarely first (dependencies), then what changes often (sources).
.dockerignorereduces the build context and prevents unnecessary cache invalidations.- After the split, a Spring Boot application image shrinks from 700–900 MB to 200–300 MB.
Further reading
- Images and Dockerfile — how images are structured and the syntax of the main instructions.
- Dockerizing a Spring Boot application — the full path from code to a running container.
- Image best practices — additional ways to reduce image size and improve image security.