← Back to the section

Spring Boot can package itself into an executable jar — a single file that contains your application code, all its dependencies, and an embedded Tomcat. That jar is easy to launch with one command. Adding Docker to the mix is not hard, but there are a few decisions that put you on the right track from the start — and a few mistakes that beginners make most often.

Why you can't just copy the jar into the image and call it a day

Technically, you can. Here is the simplest Dockerfile that works:

FROM eclipse-temurin:21-jre
COPY build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

It will build the image, and the application will start. The problem lies elsewhere: every time you change a single line of code, Docker rebuilds the jar layer from scratch. The jar weighs 50–150 MB and contains your dependencies (Spring Boot, libraries) — they haven't changed, but Docker doesn't know that and reloads the entire file. On a team where images are deployed frequently, this turns into slow builds and wasted bandwidth.

The solution is to split the dependencies and the application code into separate layers.

Base image: JRE, not JDK

The first decision you make when writing a Dockerfile is choosing the base image.

JDK (Java Development Kit) is the full set of development tools: the compiler, debugger, and profiling tools. You need it to write and compile Java code.

JRE (Java Runtime Environment) is only the runtime. It is enough to run an already compiled application.

In production you run a finished jar — the compiler is not needed. So use the JRE:

FROM eclipse-temurin:21-jre

eclipse-temurin is an OpenJDK distribution from the Eclipse Adoptium project, one of the most widely used and well-supported Java images on Docker. Pinning a specific version (21-jre) is good practice: the latest image can jump to the next major version at any moment and break your build.

Using the JDK instead of the JRE in production is a typical mistake. The image becomes 200–300 MB heavier for no benefit, and the attack surface grows.

The right order of instructions: caching dependencies

Docker builds an image layer by layer. Each instruction (COPY, RUN, ENV, and others) creates a new layer. If a layer hasn't changed, Docker takes it from the cache and doesn't rebuild it. Layers are cached top to bottom: as soon as one layer changes, every layer below it is rebuilt.

This means: what changes rarely should go higher; what changes often should go lower.

Dependencies (Spring Boot, libraries) change rarely — usually once every few weeks or months. Application code changes with every commit. If you put them in the same layer, Docker will re-download all the dependencies on every build.

Here is a Dockerfile template with the layers split apart:

FROM eclipse-temurin:21-jre

WORKDIR /app

# Dependencies go in a separate layer (change rarely, cached for a long time)
COPY build/libs/dependencies/ ./dependencies/
COPY build/libs/spring-boot-loader/ ./spring-boot-loader/
COPY build/libs/snapshot-dependencies/ ./snapshot-dependencies/

# Application code comes last (changes on every build)
COPY build/libs/application/ ./application/

ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

But this approach requires Gradle or Maven to unpack the jar into layers before the build. More on that in the next section.

Layered jars: built-in support in Spring Boot

Starting with Spring Boot 2.3, the build plugin has built-in support for layered jars — executable jars whose contents are already arranged into layer folders.

In build.gradle, enable the layered build:

bootJar {
    layered {
        enabled = true
    }
}

After that, ./gradlew bootJar produces a jar from which you can extract the layers with:

java -Djarmode=tools -jar build/libs/app.jar extract --layers --launcher

The working directory will now contain the folders dependencies/, spring-boot-loader/, snapshot-dependencies/, and application/. Now the Dockerfile can copy them separately — and Docker will cache each layer independently.

A full build example with layered jars:

FROM eclipse-temurin:21-jre AS builder

WORKDIR /app

# Copy the jar and extract the layers
COPY build/libs/app.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted

# Final image — only what's needed to run
FROM eclipse-temurin:21-jre

WORKDIR /app

# Copy the layers in the right order: rarely changed ones first
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./

ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

This uses a multi-stage build (FROM ... AS builder): the first stage unpacks the jar, the second takes only the files it needs. The final image contains neither the original jar nor the intermediate tooling.

Passing the profile and environment variables

Spring Boot determines the active profile through the SPRING_PROFILES_ACTIVE variable. In Docker it is convenient to pass it when starting the container — not to hardcode it in the Dockerfile:

docker run -e SPRING_PROFILES_ACTIVE=prod myapp:latest

Or in docker-compose.yml:

services:
  app:
    image: myapp:latest
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_URL: jdbc:postgresql://db:5432/mydb
      DB_PASSWORD: secret

The database connection string, secrets, ports — all of these are better passed through environment variables rather than baked into the image. The image should be universal: the same image runs in a test environment with one set of variables and in production with another.

If you need to set default variables right in the Dockerfile, use ENV:

ENV SERVER_PORT=8080
ENV JAVA_OPTS=""

And you can parameterize the launch through JAVA_OPTS:

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Alternatives to a hand-written Dockerfile

A hand-written Dockerfile is not the only way to package a Spring Boot application into an image.

Cloud Native Buildpacks are built into the Spring Boot plugin starting with version 2.3. They analyze the project and automatically produce an optimized image — without a Dockerfile:

./gradlew bootBuildImage

The image is built following best practices: JRE instead of JDK, a split into layers, JVM memory tuning. This is a good fit if you don't want to maintain a Dockerfile by hand.

Jib is a plugin from Google that builds an image directly from Java sources, requiring neither a Docker daemon on the machine nor a Dockerfile:

./gradlew jibDockerBuild

Jib figures out the layers itself and builds the image incrementally. It is handy for CI/CD pipelines where Docker may not be available.

Both tools work well for standard cases. A hand-written Dockerfile gives you more control — for example, when you need to install system dependencies or fine-tune JVM parameters.

Typical mistakes

The whole project in one layer. COPY . . at the top of a Dockerfile copies everything — sources, tests, Gradle configuration — into the image. Docker rebuilds that layer on any change. Copy only what's needed to run: the built jar or the extracted layers.

A full JDK in production. A JDK image is heavier and carries extra tools. Use eclipse-temurin:21-jre.

Secrets in the image. Don't pass passwords through ENV in the Dockerfile — they end up in the image's layer history and are visible via docker history. Use environment variables at launch or Docker Secrets.

The latest tag. The eclipse-temurin:latest or myapp:latest image is unstable: on the next build you might get a different version. In production, always pin a specific tag.

In short

  • Use eclipse-temurin:21-jre — the JRE is enough to run the app, a JDK is not needed in production.
  • Split dependencies and application code into separate layers — Docker caches layers, and rebuilds get many times faster.
  • ENTRYPOINT ["java", "-jar", "app.jar"] is the basic launch; for layered jars, use JarLauncher.
  • Pass the profile and configuration through environment variables at launch; don't bake them into the image.
  • Layered jars are enabled in build.gradle and give you a layer split with no extra code.
  • Buildpacks and Jib are automatic alternatives to a hand-written Dockerfile and suit most cases.
  • Don't put secrets in the image: they end up in the layer history.
  • Images and the Dockerfile — how images are structured, what layers are, and how Docker uses the cache during a build.
  • Multi-stage builds and layer optimization — a detailed look at multi-stage Dockerfiles and strategies for reducing image size.
  • The JVM inside a container — how the JVM sees container resources, memory tuning, and cgroups issues.