Skip to content
Spring Boot sb production 4 min read

Dockerizing Spring Boot

Shipping a Spring Boot app as a container image gives you a reproducible artifact that runs identically on a laptop, in CI, and in Kubernetes. A naive Dockerfile that copies the fat jar works, but it rebuilds the entire image on every code change and bloats the layers. This page shows a fast, secure approach using multi-stage builds, layered jars, and Cloud Native Buildpacks. After building, see Running & Packaging for the jar itself.

A multi-stage Dockerfile

A multi-stage build compiles inside a JDK image, then copies only the runtime into a slim JRE image, so the final image carries no Maven, no source, and no build cache.

# ---- build stage ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B          # cache dependencies
COPY src ./src
RUN ./mvnw clean package -DskipTests -B

# ---- runtime stage ----
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
RUN addgroup --system spring && adduser --system --ingroup spring spring
USER spring:spring
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
docker build -t order-service:2.4.1 .
docker run -p 8080:8080 order-service:2.4.1

This already follows two key rules: a JRE (not JDK) runtime base, and a non-root user.

Layered jars for fast rebuilds

A Spring Boot fat jar is mostly unchanging dependencies plus a tiny slice of your own classes. If you copy it as one blob, any code change invalidates the whole layer. Spring Boot can extract the jar into layers that change at different rates, so Docker caches the dependency layers and only rebuilds your code layer.

The Boot jar supports a layer-aware launcher. Extract it and copy each layer separately:

# ---- build stage ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests -B
RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted

# ---- runtime stage ----
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
RUN addgroup --system spring && adduser --system --ingroup spring spring
USER spring:spring
COPY --from=build /app/extracted/dependencies/ ./
COPY --from=build /app/extracted/spring-boot-loader/ ./
COPY --from=build /app/extracted/snapshot-dependencies/ ./
COPY --from=build /app/extracted/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

The default layers, ordered from least to most frequently changed:

LayerContentsChange frequency
dependenciesreleased third-party jarsrare
spring-boot-loaderthe loader classesalmost never
snapshot-dependenciesSNAPSHOT librariesoccasional
applicationyour compiled classes & resourcesevery build

Because application is copied last, a code-only change reuses the cached dependency layers and the build (and image push) is dramatically faster.

Note: The launcher class is org.springframework.boot.loader.launch.JarLauncher in Spring Boot 3.2+ (it moved from org.springframework.boot.loader.JarLauncher in 3.1 and earlier).

Cloud Native Buildpacks — no Dockerfile

Spring Boot integrates Cloud Native Buildpacks (via Paketo) so you can build an optimized, layered, OCI-compliant image with no Dockerfile at all.

./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=order-service:2.4.1

Or with Gradle:

./gradlew bootBuildImage --imageName=order-service:2.4.1

The buildpack picks a JRE, applies layered jars automatically, runs as non-root, and sets sensible JVM memory defaults based on the container’s memory limit. Configure it in pom.xml:

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

Output (build summary):

[INFO] Successfully built image 'docker.io/library/order-service:2.4.1'
[INFO]   > Running creator
[INFO]   > Setting default process type 'web'
[INFO]   > Saving order-service:2.4.1...
ApproachDockerfile?Best for
Hand-written multi-stageyesfull control, custom base images, extra OS packages
Buildpacks (build-image)nooptimized defaults with zero maintenance

Image best practices

  • Use a JRE base, not a JDK — a JRE image is smaller and reduces attack surface. Consider -jre-alpine or distroless for minimal size.
  • Run as a non-root user so a container breakout can’t act as root on the host.
  • Pin base image tags (eclipse-temurin:21-jre) rather than latest for reproducible builds.
  • Don’t bake secrets into layers; inject configuration via environment variables or mounted files — see Externalized Configuration.
  • Add a health check so the orchestrator knows when the app is ready — wire it to /actuator/health.
HEALTHCHECK --interval=15s --timeout=3s \
  CMD wget -qO- http://localhost:8080/actuator/health/readiness || exit 1

Warning: Skipping tests with -DskipTests is fine inside an image build where CI already ran them, but never make it your only build path. Let the pipeline run the full test suite before the image build stage.

Best Practices

  • Prefer buildpacks (spring-boot:build-image) unless you need a custom base image.
  • For hand-written Dockerfiles, use multi-stage builds with extracted layered jars.
  • Always run as non-root on a pinned JRE base image.
  • Externalize config and secrets; never bake them into layers.
  • Wire a container health check to the Actuator readiness probe.
Last updated June 13, 2026
Was this helpful?