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:21includes 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 isorg.springframework.boot.loader.launch.JarLauncherinstead 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:
buildstage:COPY pom.xmland rundependency:go-offlinebefore copyingsrc. When you only change code, Docker reuses the layer with downloaded dependencies. You don’t have to fetch the entire Maven Central again.extractstage: 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
COPYcommands: each creates a Docker layer, ordered from least to most frequently changed. Because the “lightweight” JAR (app.jar) is inapplicationand thelib/directory is independencies, after merging,/apphas the structureapp.jar+lib/. USER spring:spring: the Java process no longer runs as root.JAVA_TOOL_OPTIONS: the JVM reads this variable automatically.MaxRAMPercentage=75.0lets the heap use up to 75% of the container’s memory limit.ExitOnOutOfMemoryErrormakes 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’swget, so you don’t need to installcurl.ENTRYPOINTas exec form (JSON array):javabecomes PID 1 and receivesSIGTERMwhen 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:
ImageFromDockerfilebuilds the image from theDockerfilein your project root. The build context is the directory containing the Dockerfile. Thefalseparameter 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 -uinside the container. If someone accidentally removes theUSER spring:springline, the result will be0and 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.jarin shell form. Then/bin/shbecomes PID 1 and doesn’t forwardSIGTERMto the JVM. The container gets killed hard after the timeout, losing graceful shutdown. Always use JSON array form. - Setting
-Xmxas a fixed value. If you set-Xmx2gbut the container limit is 1 GB, the container gets OOM-killed with no stack trace. UseMaxRAMPercentageso the heap scales with the limit. - Alpine and native libraries. Alpine uses
muslinstead ofglibc. Some libraries with native code (Netty native transport, some drivers, image processing libraries) may fail or run slowly. Switch toeclipse-temurin:21-jre-jammyor-noble. The image is larger by tens of MB but more stable. - Copying layers in the wrong order. If you copy
applicationbeforedependencies, every code change invalidates the dependency layer cache. You lose all benefits of layered jars. - Using
latesttag for base images. Builds today and next week might produce different images. Pin specific tags like21.0.5_11-jre-alpine, or pin the digest for truly reproducible builds.
Note: For even smaller images, you can use
jdepsandjlinkto 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.