Writing Good Dockerfiles: Multi-Stage Build for Spring Boot
1. Overview
A hastily written Dockerfile often produces images weighing hundreds of MB. The image contains the JDK, Maven, source code, and the .m2 directory. Each time you modify a line of code, Docker downloads all dependencies from scratch.
This guide shows you how to use multi-stage builds to separate the build step from the run step. After this guide, you’ll be able to write a Dockerfile for a Spring Boot 3.x (Java 21) application with a small image, fast rebuilds thanks to caching, tests run in a separate stage, and the process runs as a non-root user.
2. Problems with Single-Stage Dockerfile
We’ll use a small application with an endpoint that returns order status:
package com.nonla.orders;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
@RestController
class OrderController {
@GetMapping("/orders/{id}/status")
String status(@PathVariable long id) {
return id > 0 ? "CONFIRMED" : "UNKNOWN";
}
}
Dependencies in pom.xml only need web and test:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
A “quick and dirty” Dockerfile typically looks like this:
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]
This Dockerfile has three problems:
- Heavy image: the final image contains Maven, full JDK, source code, and the entire
.m2cache. - Poor caching:
COPY . .comes beforemvn package. Modifying even one.javafile invalidates this layer, forcing a re-download of all dependencies. - Large attack surface: build tools and source code remain in the production container.
3. Basic Multi-Stage Builds
Multi-stage builds allow a Dockerfile to have multiple FROM commands. Each FROM opens a new stage. A later stage can copy files from an earlier stage using COPY --from=<stage-name>. Only the final stage becomes the resulting image. Intermediate stages are discarded.
3.1. Build and Runtime Stages
# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests
# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Breaking this down:
AS buildnames the first stage. You reference this name inCOPY --from=build.- The build stage uses an image with Maven and JDK to compile and package the jar.
- The runtime stage uses
eclipse-temurin:21-jre, which contains only the JRE, notjavacor Maven. COPY --from=buildcopies only the jar file to the final image.ENTRYPOINTuses exec form (JSON array). This ensuresjavaruns as PID 1 and receives theSIGTERMsignal when the container stops.
3.2. Comparing Results
Build and check sizes:
docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders
Example results (actual numbers depend on image versions and dependencies):
REPOSITORY TAG SIZE
orders single ~560MB
orders multi ~290MB
Most of the remaining size is the JRE and base OS. You can reduce this further using a lighter base image like eclipse-temurin:21-jre-alpine. However, test carefully if your application uses native libraries.
4. Leveraging Layer Cache
Each RUN and COPY command creates a layer. Docker reuses a layer if the command and its inputs haven’t changed. When a layer changes, all subsequent layers must be rebuilt. The general principle is to put things that change less often at the top, and things that change more frequently at the bottom.
4.1. Separating Dependency Download
pom.xml changes far less often than source code. Copy it first and download dependencies in a separate layer:
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
Now when you only modify OrderController.java, Docker reuses the dependency:go-offline layer. The build log shows:
=> CACHED [build 3/5] COPY pom.xml .
=> CACHED [build 4/5] RUN mvn -B dependency:go-offline
=> [build 5/5] COPY src ./src
Note:
dependency:go-offlinedoesn’t always fetch 100% of the plugins Maven needs during thepackagestep. Some plugins might still be downloaded later. However, the result is much better than not separating at all.
4.2. Cache Mount with BuildKit
BuildKit (now the default in Docker) supports --mount=type=cache. Mounted directories are preserved between builds but not included in the final image:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
- The
# syntax=docker/dockerfile:1line enables the full new Dockerfile syntax. /root/.m2is cached on the builder side. Even ifpom.xmlchanges, Maven only downloads new dependencies instead of re-downloading everything.
You can combine both techniques. On CI, cache mounts only work if the builder is preserved between runs. Otherwise, the layer-separation approach in section 4.1 combined with registry caching is more reliable.
5. Splitting Layers for Spring Boot Jars
A Spring Boot fat jar contains both your code and tens of MB of libraries. If you copy the entire jar into the image, every code change creates a new multi-MB layer. Registries must store this layer, and servers must pull it entirely.
Spring Boot supports extracting the jar into layers based on change frequency. From Spring Boot 3.3 onwards, use jarmode=tools:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
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 package -DskipTests
FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Key points:
- The
extractstage extracts the jar into four directories:dependencies,spring-boot-loader,snapshot-dependencies,application. - The four
COPYcommands create four layers, ordered from least to most frequently changed. - When you modify code, only the
applicationlayer changes. This layer is typically only a few hundred KB. - The
--launcheroption preserves the launcher. SoENTRYPOINTcallsJarLauncherinstead of using-jar.
Note: With Spring Boot 3.0–3.2, use the older syntax
java -Djarmode=layertools -jar app.jar extract. The launcher package also differs across versions: from 3.2 onwards it’sorg.springframework.boot.loader.launch.JarLauncher, earlier it’sorg.springframework.boot.loader.JarLauncher. Using the wrong class name causesClassNotFoundExceptionwhen the container starts.
6. Running Tests in a Separate Stage
Using -DskipTests in the build stage speeds up builds, but you don’t want to push an untested image to the registry. Multi-stage builds let you separate tests into a dedicated stage and selectively build it with --target.
The controller test uses JUnit 5 and AssertJ (already included in spring-boot-starter-test):
package com.nonla.orders;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
String body = mockMvc.perform(get("/orders/42/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("CONFIRMED");
}
@Test
void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
String body = mockMvc.perform(get("/orders/0/status"))
.andReturn().getResponse().getContentAsString();
assertThat(body).isEqualTo("UNKNOWN");
}
}
Add a test stage based on the build stage:
FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test
On CI, run tests first, then build the runtime image:
docker build --target test -t orders:test .
docker build -t orders:1.0.0 .
The first command only builds up to the test stage. If a test fails, the RUN command returns a non-zero exit code and the build stops:
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1
The second command builds the final stage. With BuildKit, the test stage isn’t referenced by the final stage, so it’s skipped. The production image doesn’t re-run tests. Thanks to caching, shared stages aren’t rebuilt.
Note: Don’t run integration tests that need Docker (like Testcontainers) inside
docker build. The build environment doesn’t have a Docker daemon. Run those tests as a separate CI step outside the build.
7. Security: Non-Root User and .dockerignore
7.1. Don’t Run as Root
By default, processes in a container run as root. If the application is compromised, attackers have root access to the container. Create a dedicated user in the runtime stage:
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Quick check:
docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)
The specific uid/gid may differ, as long as it’s not 0. For Alpine images, use addgroup -S spring && adduser -S spring -G spring instead of groupadd and useradd.
7.2. Add .dockerignore
The build context is the entire directory you pass to docker build. Without filtering, Docker sends target/, .git/, IDE config files, and more to the builder. This slows builds, breaks caching, and risks exposing sensitive information.
target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*
Place this file at the project root, alongside Dockerfile.
8. Common Mistakes
COPY . .too early: breaks dependency caching. Copypom.xmlfirst,srcafterward.- Using JDK in the runtime stage: it works but wastes tens to hundreds of MB. Production needs only JRE, unless you need tools like
jcmdfor diagnostics. - Wildcard matches multiple jars:
COPY --from=build /workspace/target/*.jar app.jarfails iftarget/contains*-plain.jaror*-sources.jar. Set a fixed<finalName>inpom.xmlor disable extra artifacts. - Shell form for
ENTRYPOINT:ENTRYPOINT java -jar app.jarruns through/bin/sh -c. Thejavaprocess won’t receiveSIGTERM, so Spring skips graceful shutdown and the container gets forcibly killed after the timeout. - Using
latesttag for base images: builds today and next month may differ. Pin a version (e.g.,eclipse-temurin:21-jre) or pin the digest for absolute reproducibility. - Putting secrets in
ARG/ENV: values leak viadocker history. For tokens needed to download private dependencies, useRUN --mount=type=secret.
When do you not need all this complexity? For small scripts or images used only in personal dev environments, a single stage is fine. Also, if your team uses mvn spring-boot:build-image (Cloud Native Buildpacks) or Jib, those tools handle layer splitting and non-root automatically. You don’t need to write Dockerfiles manually.
9. Conclusion
Multi-stage builds cleanly separate the “build environment” from the “runtime environment.” The final image contains only what your application actually needs. Combined with logical layer ordering, cache mounts, Spring Boot layer splitting, a dedicated test stage, and a non-root user, you get a Dockerfile that builds fast, produces lean images, and is significantly more secure.
You can start with the Dockerfile from section 3. Then gradually apply each improvement and measure build time and image size after each step to see the benefits clearly.