← Back to the section

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 in COPY --from=.
  • COPY --from=build /app/target/app.jar app.jar — take the file from the build stage 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:

ApproachApproximate size
JDK + Maven + sources in one image700–900 MB
Multi-stage: JDK to build, JRE to run200–300 MB
Multi-stage + distroless/slim JRE100–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).
  • .dockerignore reduces 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.