Packing Spring Boot into Lightweight Docker Images

1. Overview

Packing Spring Boot into Docker is easy. Packing so the image is small, builds fast, and runs securely requires a few extra steps. With the familiar “three-line” Dockerfile, the image typically weighs hundreds of MB, every time you change one line of code you have to push the entire JAR file again, and the process runs as root.

This article goes from a naive Dockerfile to a multi-stage Dockerfile using layered jars and a minimal JRE. Then you’ll see how to use Buildpacks as an alternative, and write a test using Testcontainers to verify the built image.

2. Sample Application

We’ll use a small service called order-service with a REST endpoint and Actuator for health checks.

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    record Order(long id, String customer, BigDecimal total) {}

    @GetMapping("/{id}")
    Order find(@PathVariable long id) {
        return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
    }
}

Running ./mvnw package produces target/order-service-1.0.0.jar. This is a “fat jar” containing both your code and all dependencies.

3. Naive Dockerfile and Its Problems

The version many people write first looks like this:

FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

This Dockerfile works, but has four problems:

  • Base image is too large. eclipse-temurin:21 includes the full JDK: compiler, jshell, javadoc… You don’t need them at runtime.
  • One layer for everything. The fat jar is around 20–30 MB, with your code taking only a few hundred KB. Change one line and that entire 30 MB layer changes, requiring a rebuild and push.
  • Depends on build machine. The image needs a pre-built JAR from your machine. JDK and Maven versions vary across machines.
  • Runs as root. If the application is compromised, the attacker has root access inside the container.

Check the size:

docker build -t order-service:naive .
docker images order-service
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   naive   3f1c9a7e2b10   ~480MB

The exact number depends on the base image version, but nearly half a GB for a “hello world” service is too much.

4. Multi-stage Build with Layered JAR

4.1. What is a Layered JAR?

Since Spring Boot 2.3, fat jars are split into layers based on change frequency:

Layer Content Change Frequency
dependencies Release version libraries Rarely
spring-boot-loader Spring Boot’s class loader Very rarely
snapshot-dependencies -SNAPSHOT libraries Sometimes
application Your code and resources Often

You copy each part into the image with separate COPY commands, ordering from least to most frequently changed. Docker caches the upper layers. When you only change code, only the application layer rebuilds and gets pushed.

Since Spring Boot 3.3, extract layers using jarmode=tools:

java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application  dependencies  snapshot-dependencies  spring-boot-loader

The application directory contains a “lightweight” order-service-1.0.0.jar that references the lib/ directory from the dependencies layer. When you merge the layers back, you just run java -jar normally.

Note: With Spring Boot 3.2 and earlier, use java -Djarmode=layertools -jar app.jar extract. The entrypoint is org.springframework.boot.loader.launch.JarLauncher instead of -jar.

4.2. Complete Dockerfile

# ---------- Stage 1: build with Maven ----------
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

# ---------- Stage 2: extract layers ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---------- Stage 3: runtime ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./

USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
  CMD wget -qO- http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "app.jar"]

Breaking down each part:

  • build stage: COPY pom.xml and run dependency:go-offline before copying src. When you only change code, Docker reuses the layer with downloaded dependencies. You don’t have to fetch the entire Maven Central again.
  • extract stage: splits the JAR into four layer directories. This stage is intermediate and doesn’t appear in the final image.
  • Runtime stage: uses 21-jre-alpine, containing only the JRE on Alpine Linux.
  • Four COPY commands: each creates a Docker layer, ordered from least to most frequently changed. Because the “lightweight” JAR (app.jar) is in application and the lib/ directory is in dependencies, after merging, /app has the structure app.jar + lib/.
  • USER spring:spring: the Java process no longer runs as root.
  • JAVA_TOOL_OPTIONS: the JVM reads this variable automatically. MaxRAMPercentage=75.0 lets the heap use up to 75% of the container’s memory limit. ExitOnOutOfMemoryError makes the JVM exit cleanly when out of memory, so the orchestrator can restart the container instead of leaving it in a zombie state.
  • HEALTHCHECK: Alpine includes BusyBox’s wget, so you don’t need to install curl.
  • ENTRYPOINT as exec form (JSON array): java becomes PID 1 and receives SIGTERM when the container stops. This lets Spring Boot perform graceful shutdown.

4.3. Don’t Forget .dockerignore

The build command sends your entire directory (build context) to the Docker daemon. Exclude what you don’t need:

target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile

Without this file, the target/ or .git/ directory can make the context hundreds of MB. Each build becomes noticeably slower.

4.4. Results

docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY      TAG     IMAGE ID       SIZE
order-service   1.0.0   9b2e44d1c0af   ~200MB
order-service   naive   3f1c9a7e2b10   ~480MB

0B      ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB    COPY /extract/extracted/application/ ./
0B      COPY /extract/extracted/snapshot-dependencies/ ./
290kB   COPY /extract/extracted/spring-boot-loader/ ./
22MB    COPY /extract/extracted/dependencies/ ./

The image is more than half the size. More importantly, the application layer is only tens of KB. When you modify OrderController and rebuild, only that layer changes. A push to the registry takes just seconds.

5. Alternative: Cloud Native Buildpacks

If you don’t want to maintain a Dockerfile, Spring Boot’s Maven plugin can build images directly with Buildpacks:

./mvnw spring-boot:build-image \
  -Dspring-boot.build-image.imageName=shop/order-service:1.0.0

You can also configure it permanently in pom.xml:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <image>
            <name>shop/order-service:${project.version}</name>
            <env>
                <BP_JVM_VERSION>21</BP_JVM_VERSION>
            </env>
        </image>
    </configuration>
</plugin>

Buildpacks automatically do what we did manually: split layers, choose a JRE, run as a non-root user, and calculate JVM memory. You don’t need a Dockerfile, just Docker daemon running.

In exchange, you have less control. The base image is chosen by the buildpack, the first build is slow because it downloads the builder image, and deep customization is harder than editing a Dockerfile. Teams wanting fast standardization should use Buildpacks. Teams needing fine control—for example, using an in-house base image vetted for security—should use Dockerfile.

6. Testing the Image with Testcontainers

A “lightweight” image that doesn’t start is pointless. You can write a test that builds the image from your Dockerfile, runs a container, and verifies it works correctly.

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot’s BOM already manages versions for both, so you don’t need <version> tags.

@Testcontainers
class OrderServiceImageIT {

    static final ImageFromDockerfile IMAGE =
            new ImageFromDockerfile("order-service-it", false)
                    .withDockerfile(Path.of("./Dockerfile"));

    @Container
    static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
            .withExposedPorts(8080)
            .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
            .withStartupTimeout(Duration.ofMinutes(2));

    @Test
    void shouldServeOrdersAndReportHealthy() throws Exception {
        String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
        HttpClient client = HttpClient.newHttpClient();

        HttpResponse<String> response = client.send(
                HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
                HttpResponse.BodyHandlers.ofString());

        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
    }

    @Test
    void shouldNotRunAsRoot() throws Exception {
        var result = APP.execInContainer("id", "-u");

        assertThat(result.getStdout().trim()).isNotEqualTo("0");
    }
}

This test does the following:

  • ImageFromDockerfile builds the image from the Dockerfile in your project root. The build context is the directory containing the Dockerfile. The false parameter keeps the image after the test so the next build benefits from cache.
  • Wait.forHttp("/actuator/health") waits until the application is actually ready, not just until the container starts.
  • First test calls the endpoint through a randomly mapped port on the host and verifies the returned JSON.
  • Second test runs id -u inside the container. If someone accidentally removes the USER spring:spring line, the result will be 0 and the test fails immediately.

Name the class with IT suffix and run it with maven-failsafe-plugin. This test builds the image, so it takes tens of seconds—not suitable for regular unit test execution.

7. Common Mistakes and When Not to Apply

  • Using ENTRYPOINT java -jar app.jar in shell form. Then /bin/sh becomes PID 1 and doesn’t forward SIGTERM to the JVM. The container gets killed hard after the timeout, losing graceful shutdown. Always use JSON array form.
  • Setting -Xmx as a fixed value. If you set -Xmx2g but the container limit is 1 GB, the container gets OOM-killed with no stack trace. Use MaxRAMPercentage so the heap scales with the limit.
  • Alpine and native libraries. Alpine uses musl instead of glibc. Some libraries with native code (Netty native transport, some drivers, image processing libraries) may fail or run slowly. Switch to eclipse-temurin:21-jre-jammy or -noble. The image is larger by tens of MB but more stable.
  • Copying layers in the wrong order. If you copy application before dependencies, every code change invalidates the dependency layer cache. You lose all benefits of layered jars.
  • Using latest tag for base images. Builds today and next week might produce different images. Pin specific tags like 21.0.5_11-jre-alpine, or pin the digest for truly reproducible builds.

Note: For even smaller images, you can use jdeps and jlink to create a JRE with only the modules your app needs, or compile native code with GraalVM. Both significantly reduce size but complicate the build and break easily with reflection. Only use them when image size or startup time is truly critical, like serverless or scale-to-zero.

8. Conclusion

In this article, you’ve transformed a three-line Dockerfile into a multi-stage Dockerfile with these steps:

  • Build inside a container so it doesn’t depend on your build machine.
  • Split the layered jar so only the code-containing layer rebuilds after edits.
  • Use Alpine JRE to reduce image size.
  • Run as a non-root user, include health checks, and tune JVM memory according to the container limit.

You’ve also seen Buildpacks as an alternative that doesn’t require a Dockerfile. Finally, Testcontainers tests ensure the image still starts correctly and doesn’t run as root after each change.

spring-bootdockerjavadevopscontainer