Your Spring Boot app runs fine locally. Now you need to ship it. Docker is the obvious next step, but the naive approach — FROM openjdk, copy your fat jar, done — works until it doesn’t. Cold starts are slow because every layer rebuild re-copies 80MB of dependencies. Your image is 600MB. And when your app crashes inside Docker, the container exits cleanly and nobody knows why.
This guide covers the right way to containerize a Spring Boot application: multi-stage builds, layered jars for fast rebuilds, Docker Compose for local development, and health checks that actually reflect whether your app is ready to serve traffic.
Why the Simple Approach Breaks Down
The first Dockerfile most people write looks like this:
FROM openjdk:21-jdk
COPY target/myapp-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
This works. It also has problems. You’re shipping a JDK (not a JRE) into production. Your image is enormous. Every time you rebuild — even for a one-line change to a controller — Docker re-copies the entire fat jar because it’s a single layer. On a slow connection or in CI with no layer caching, that’s painful.
Spring Boot’s layered jar feature and multi-stage builds solve both problems.
The .dockerignore File (Set This Up First)
Before writing a Dockerfile, create a .dockerignore in your project root. Without it, Docker’s build context includes your entire working directory — node_modules, target, IDE files, everything. That bloats the context and slows every build.
target/
.git/
.idea/
*.iml
*.log
.DS_Store
This keeps the build context small and avoids accidentally baking secrets or local config into your image.
Multi-Stage Dockerfile with Layered Jars
Spring Boot 2.3 introduced layered jars. The idea: instead of one opaque fat jar, the jar is structured so its contents can be extracted into layers that map to how often they change. Dependencies change rarely. Your application classes change constantly. Docker caches each layer independently, so a code change only invalidates the layer containing your classes — not the 80MB of dependency JARs.
Here’s a complete multi-stage Dockerfile for a Spring Boot application:
# Stage 1: Extract layers from the fat jar
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY target/*.jar application.jar
RUN java -Djarmode=layertools -jar application.jar extract
# Stage 2: Build the final image
FROM eclipse-temurin:21-jre
WORKDIR /app
# Add a non-root user
RUN addgroup --system spring && adduser --system --ingroup spring spring
USER spring:spring
# Copy layers in order from least to most frequently changed
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
A few things worth noting here.
eclipse-temurin:21-jre is a JRE image, not a JDK. You don’t need compilation tools at runtime. The JRE image is significantly smaller.
The adduser/addgroup block creates a non-root user. Running as root inside a container is a security risk. If your app can write files or execute subprocesses, a compromised container running as root has far more blast radius. Make this a habit.
The four COPY instructions bring in the extracted layers in order of change frequency. dependencies/ contains your third-party JARs — they almost never change, so Docker caches that layer aggressively. application/ contains your compiled classes and resources — this layer rebuilds on every code change, but it’s small. The result: incremental builds are fast because only that last layer is stale.
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] launches the app using Spring Boot’s loader rather than -jar. This is required because after extraction, there’s no single jar to pass to -jar.
Building the Image
First, make sure your pom.xml or build.gradle has the layered jar configuration enabled. In recent versions of Spring Boot, layered jars are enabled by default. If you’re on an older version or need to verify:
Maven (pom.xml):
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
Gradle (build.gradle):
bootJar {
layered {
enabled = true
}
}
Then build and package your app before building the Docker image:
./mvnw clean package -DskipTests
docker build -t myapp:latest .
You can verify the layers are present in the jar before building:
java -Djarmode=layertools -jar target/myapp-0.0.1-SNAPSHOT.jar list
You should see output listing dependencies, spring-boot-loader, snapshot-dependencies, and application.
Docker Compose for Local Development
Running your app in isolation is fine for a quick test. In practice, your Spring Boot app depends on a database, maybe a message broker, maybe Redis. Standing all of that up manually for each developer is friction. Docker Compose solves this.
Here’s a compose.yml (the modern filename — docker-compose.yml still works but compose.yml is preferred in Compose V2) for a Spring Boot app with PostgreSQL:
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/myapp
SPRING_DATASOURCE_USERNAME: myapp
SPRING_DATASOURCE_PASSWORD: secret
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
The depends_on with condition: service_healthy is important. Without it, Docker starts the app container as soon as the database container starts — but the database process itself might not be ready for connections yet. Your app will try to connect, fail, and either crash or log confusing errors. The health check on the database and the conditional dependency together ensure your app only starts after the database is accepting connections.
The volumes block for postgres_data persists your data between docker compose down and docker compose up. Without it, every restart wipes your local database.
For development, you often want to mount your source directory and skip the build step:
services:
app:
image: eclipse-temurin:21-jre
working_dir: /app
command: java -jar app.jar
volumes:
- ./target:/app
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: local
This skips the Docker build entirely and mounts your compiled jar directly. Combine with Spring Boot DevTools and you get fast iteration without Docker image rebuilds.
Health Checks for Spring Boot
Docker health checks tell the orchestrator whether your container is actually healthy — not just running. A container can be up but stuck in a state where it’s not serving traffic: it hit an out-of-memory error, it’s waiting for a database connection, or Tomcat started but your application context failed.
Spring Boot Actuator provides a /actuator/health endpoint that knows about your application’s dependencies. If your database is down, the health endpoint returns 503. That’s what you want Docker to check.
First, add the Actuator dependency if you don’t have it:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Configure the health endpoint to expose details:
management:
endpoint:
health:
show-details: always
endpoints:
web:
exposure:
include: health,info
Add a health check to your Dockerfile:
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
--start-period=60s gives your application time to start up before Docker starts counting failed health checks. Spring Boot applications can take 10-30 seconds to start depending on what they initialize. Without the start period, Docker will mark your container unhealthy before it even finishes starting.
wget is used here rather than curl because it’s more commonly available in slim base images. If your image has curl, that works fine too:
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
In Docker Compose, health checks defined in the Dockerfile are inherited automatically. You can also define them inline in compose.yml if you want different parameters for local development:
services:
app:
build: .
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 5s
start_period: 45s
retries: 3
JVM Configuration Inside Docker
One thing that trips up a lot of teams: the JVM’s default behavior inside a container is to read the host machine’s memory limits, not the container’s memory limits. Before Java 8u191, the JVM would happily try to allocate heap based on 25% of the host’s total RAM, completely ignoring the container’s memory limit. The result was OOM kills from the container runtime while the JVM thought it had plenty of memory.
Modern JVMs (Java 11+, and backported to 8u191+) handle this correctly by default. But you should still set explicit memory limits on your containers and consider tuning the JVM flags:
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+ExitOnOutOfMemoryError", \
"org.springframework.boot.loader.launch.JarLauncher"]
-XX:+UseContainerSupport is on by default in Java 11+ but explicit is better than implicit.
-XX:MaxRAMPercentage=75.0 tells the JVM to use up to 75% of the container’s available memory for the heap. Leaving the remaining 25% for native memory, Metaspace, and thread stacks is a reasonable default for most applications.
-XX:+ExitOnOutOfMemoryError makes the JVM exit (and the container restart) rather than limping along in a degraded state when it runs out of memory. Combined with a restart: unless-stopped policy in Compose or a liveness probe in Kubernetes, this gives you self-healing behavior.
What to Do Next
If you’re deploying to Kubernetes, the setup here translates directly: your Dockerfile produces the image, your health check becomes a liveness and readiness probe, and your Compose service definitions map to Deployments and Services. The concepts carry over even if the syntax doesn’t.
For production, consider using Spring Boot’s Buildpacks support via ./mvnw spring-boot:build-image. It produces an OCI-compliant image without requiring a Dockerfile at all, and it applies Paketo Buildpacks conventions automatically — including non-root users, layer optimization, and memory calculator configuration. The tradeoff is less control over the final image.
The Dockerfile approach here gives you full control and works with any CI system. Start with this, then evaluate Buildpacks if your team finds Dockerfile maintenance tedious.