Docker & Kubernetes6 min read·By Liyabona Saki··

Dockerizing a Spring Boot Application: The Right Way

Build small, fast and secure Docker images for Spring Boot using multi-stage builds, layered jars and JVM container tuning.

A small Dockerfile changed our deploy time from 14 minutes to 90 seconds

Most "dockerize Spring Boot" tutorials show you a five-line Dockerfile, run docker build, and call it done. That image is 700MB, rebuilds the world on every code change, and runs as root. None of that is acceptable in production.

This is the Dockerfile I actually ship, why each line is there, and the two changes that gave us a 9x faster deploy.

The starting point everyone copies

dockerfile
FROM openjdk:17
COPY target/app.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

It works. It is also wrong in three ways: the base image is huge, it bakes the full JAR as one layer (so every code change re-pushes the whole thing), and the container runs as UID 0.

Layered JARs — the one optimization that actually matters

Spring Boot 2.3+ ships a layered JAR format. Instead of one fat JAR, it splits dependencies, Spring loader, snapshot deps, and application classes into separate layers. Docker caches each layer independently, so a one-line code change only invalidates the small top layer.

```dockerfile
# Stage 1: extract the layered JAR
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 2: assemble the runtime image FROM eclipse-temurin:21-jre-alpine RUN addgroup -S app && adduser -S app -G app USER app WORKDIR /app COPY --from=extract /app/dependencies/ ./ COPY --from=extract /app/spring-boot-loader/ ./ COPY --from=extract /app/snapshot-dependencies/ ./ COPY --from=extract /app/application/ ./ ENTRYPOINT ["java","org.springframework.boot.loader.launch.JarLauncher"] ```

A pure code change now rebuilds about 2MB. The dependency layer — the slow, multi-hundred-megabyte one — stays cached. This single change took our CI deploy from 14 minutes to 90 seconds.

Picking the base image

I default to eclipse-temurin:21-jre-alpine for three reasons: Temurin is the actively maintained successor to AdoptOpenJDK; jre (not jdk) drops 200MB of compiler tooling you do not need at runtime; alpine cuts another 100MB.

The one case I avoid alpine: native image dependencies that link against glibc. Then I use eclipse-temurin:21-jre-jammy (Ubuntu) and accept the bigger image.

The non-root user is not optional

addgroup -S app && adduser -S app -G app plus USER app is three lines and removes an entire category of container escape risk. If your image runs as root, a CVE in your app becomes a CVE in the container host. Just do it.

JVM memory inside a container — the bit everyone gets wrong

The JVM up through Java 9 did not respect container memory limits and would allocate based on host RAM. This is fixed in modern JREs, but you still need to size it. My default:

dockerfile
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["sh","-c","java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

MaxRAMPercentage=75 tells the JVM to use 75% of the container's memory limit for the heap, leaving headroom for thread stacks, metaspace, and direct buffers. ExitOnOutOfMemoryError makes the container die on OOM instead of hanging — Kubernetes will then restart it, which is what you want.

Health endpoints and the docker layer

Add spring-boot-starter-actuator and a healthcheck:

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

The start-period is the one most people forget — the JVM needs 10–20 seconds to warm up. Without it, the orchestrator marks the container unhealthy during startup and restart-loops you.

Build context — the silent slowness

.dockerignore matters more than the Dockerfile for build speed:

text
target/
!target/*.jar
.git/
.idea/
*.iml
node_modules/

Without this, every docker build ships your entire .git history to the daemon. On a 3-year-old repo that is hundreds of megabytes per build.

What I do differently in production vs locally

Locally I use docker compose with the app + Postgres + Redis, all on a shared network with predictable hostnames. In production I never use the embedded H2/Hsqldb that worked locally — the production image points to a managed database via env vars and fails fast on startup if they are not set.

The single most useful boot-time check I add to every service:

java
@Bean
ApplicationRunner failFast(DataSource ds) {
  return args -> ds.getConnection().close();
}

If the database is unreachable, the container exits immediately. The orchestrator backs off and retries, and the issue is visible in the first health check instead of as a stream of 500s ten minutes later.

What this Dockerfile does not do

It does not run a build step inside the container. The JAR is built by CI and copied in. Building inside Docker is convenient for laptops; in CI it adds 4–5 minutes for no benefit, because your CI runner already has a JDK and Maven cache.

It also does not use distroless. Distroless images are smaller and more secure, but they have no shell — so kubectl exec for emergency debugging does not work. For a service I am still learning the operational shape of, that tradeoff is wrong. Move to distroless once the service is boring.

The summary I would give in a code review

If you are looking at a Spring Boot Dockerfile for the first time, the three things I look for are: layered JAR extraction, non-root user, and a JVM percentage flag. If any one of those is missing, send it back.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#Docker#Spring Boot#JVM#Containers

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Docker & Kubernetes

Kubernetes Basics for Java Developers

Everything a backend developer needs to know about Kubernetes — Pods, Deployments, Services, Ingress and ConfigMaps — explained with a Spring Boot example.

Related tutorials