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 .m2 cache.
  • Poor caching: COPY . . comes before mvn package. Modifying even one .java file 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 build names the first stage. You reference this name in COPY --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, not javac or Maven.
  • COPY --from=build copies only the jar file to the final image.
  • ENTRYPOINT uses exec form (JSON array). This ensures java runs as PID 1 and receives the SIGTERM signal 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-offline doesn’t always fetch 100% of the plugins Maven needs during the package step. 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:1 line enables the full new Dockerfile syntax.
  • /root/.m2 is cached on the builder side. Even if pom.xml changes, 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 extract stage extracts the jar into four directories: dependencies, spring-boot-loader, snapshot-dependencies, application.
  • The four COPY commands create four layers, ordered from least to most frequently changed.
  • When you modify code, only the application layer changes. This layer is typically only a few hundred KB.
  • The --launcher option preserves the launcher. So ENTRYPOINT calls JarLauncher instead 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’s org.springframework.boot.loader.launch.JarLauncher, earlier it’s org.springframework.boot.loader.JarLauncher. Using the wrong class name causes ClassNotFoundException when 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. Copy pom.xml first, src afterward.
  • Using JDK in the runtime stage: it works but wastes tens to hundreds of MB. Production needs only JRE, unless you need tools like jcmd for diagnostics.
  • Wildcard matches multiple jars: COPY --from=build /workspace/target/*.jar app.jar fails if target/ contains *-plain.jar or *-sources.jar. Set a fixed <finalName> in pom.xml or disable extra artifacts.
  • Shell form for ENTRYPOINT: ENTRYPOINT java -jar app.jar runs through /bin/sh -c. The java process won’t receive SIGTERM, so Spring skips graceful shutdown and the container gets forcibly killed after the timeout.
  • Using latest tag 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 via docker history. For tokens needed to download private dependencies, use RUN --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.

dockerdockerfilemulti-stage buildspring bootjava