Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 081 / 100

Dockerizing a Spring Boot app: best practices explained

Small, fast-starting images with layered jars.

Expona Admin6 min read

Why Docker matters for Spring Boot

Running a Spring Boot application in production without containerization forces you to manage JVM versions, dependencies, and deployment inconsistencies across environments. Dockerizing a Spring Boot app: best practices explained means building images that are small, start fast, and remain reproducible across your entire team and deployment pipeline. When we get this right, we eliminate the "works on my machine" problem and cut deployment time significantly.

Multi-stage builds: Separate build and runtime concerns

The most impactful optimization for Spring Boot Docker images is the multi-stage build. Your build stage needs the full JDK with Maven or Gradle to compile and package your application. Your runtime stage only needs a JRE to execute the compiled jar—nothing more. This distinction cuts your final image size by 30–40% and removes unnecessary tools that could introduce security vulnerabilities.

Here's a practical Dockerfile that demonstrates the pattern:

java
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw -q package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/app.jar /app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

The first stage, labeled AS build, compiles your entire Maven project using the JDK. We suppress test execution with -DskipTests because tests run in your CI pipeline, not during the Docker build. The second stage pulls only the compiled jar from the build stage and runs it on a lightweight JRE. The final image contains no Maven cache, no source code, no test dependencies.

We typically use eclipse-temurin over the official OpenJDK images because it's actively maintained, regularly patched, and smaller than the official builds. Pinning to version 21 ensures reproducibility; avoid latest tags in production.

Layered jars: Cache dependencies separately from code

Docker builds layer every instruction. When you change your source code and rebuild, Docker reuses all cached layers that haven't changed. If you copy your entire Maven project and run package in a single layer, any code change invalidates the cache and forces a full rebuild. For a large Spring Boot application, this recompilation can take 2–3 minutes every time you deploy.

Layered jars solve this by extracting your jar into separate filesystem layers: dependencies, spring-boot-loader, application code. Dependencies change infrequently; your business logic changes often. By extracting and copying them separately, you get cache hits on dependencies while only rebuilding the application layer.

Enable layered jars in your pom.xml:

java
<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <layers>
      <enabled>true</enabled>
    </layers>
  </configuration>
</plugin>

Then extract and layer these in your Dockerfile:

java
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw -q package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
RUN mkdir -p /app/layers
COPY --from=build /app/target/app.jar /app/app.jar
RUN cd /app && java -Djarmode=layertools -jar app.jar extract --destination layers

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/layers/dependencies/ ./
COPY --from=build /app/layers/spring-boot-loader/ ./
COPY --from=build /app/layers/snapshot-dependencies/ ./
COPY --from=build /app/layers/application/ ./

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

This three-stage build extracts the jar into its layers, then copies each layer separately. Dependencies and spring-boot-loader rarely change, so they're cached. Only the application layer rebuilds when you push new code. On subsequent deployments, the build completes in 10–15 seconds instead of 2–3 minutes.

Running as non-root: Security by default

By default, Docker containers run as root. If an attacker exploits a vulnerability in your application or a dependency, they gain root access to the container and potentially the host. Running as a non-root user doesn't eliminate the risk, but it significantly limits the damage an attacker can do.

Create a dedicated user in your Dockerfile and switch to it before launching the application:

java
FROM eclipse-temurin:21-jre
WORKDIR /app

RUN groupadd -r appuser && useradd -r -g appuser appuser

COPY --from=build /app/layers/dependencies/ ./
COPY --from=build /app/layers/spring-boot-loader/ ./
COPY --from=build /app/layers/snapshot-dependencies/ ./
COPY --from=build /app/layers/application/ ./

RUN chown -R appuser:appuser /app

USER appuser

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

The groupadd and useradd commands create a system user with no login shell and no home directory. We then transfer ownership of the application directory to this user before switching to it with USER. This ensures the Java process runs with minimal privileges and cannot modify system files or escape to the host.

In Kubernetes environments, enforce this at the pod level using securityContext.runAsNonRoot: true, which will reject any image running as root.

Health checks: Let orchestrators know when your app is ready

Docker and Kubernetes rely on health checks to determine whether a container should receive traffic. Without proper health checks, your orchestrator may route requests to a container that's still starting up or has encountered an internal error, causing request failures and degraded user experience.

Spring Boot exposes readiness and liveness endpoints through Actuator. The readiness endpoint returns 200 only when the application has completed initialization and dependencies like databases are accessible. The liveness endpoint indicates whether the application process is alive.

Add a health check to your Dockerfile:

java
FROM eclipse-temurin:21-jre
WORKDIR /app

# ... user creation and layer copying ...

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health/readiness || exit 1

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

The HEALTHCHECK instruction tells Docker to query the readiness endpoint every 30 seconds. If three consecutive checks fail, Docker marks the container as unhealthy. Set --start-period to the worst-case startup time for your application; during this window, failed checks don't count against the retry limit, preventing premature container replacement.

In Kubernetes, define readiness and liveness probes in your deployment manifest:

java
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 10
  failureThreshold: 3

livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

Ensure your application.properties or application.yml includes the necessary Actuator configuration:

java
management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=true

Common mistakes

  • Running in development mode in production: Don't pass -Dspring.profiles.active=dev to your production containers. Use environment variables or external config servers to manage profiles.
  • Copying the entire jar without extraction: If you're not using layered jars, every code change invalidates your dependency cache. Extract and layer your jars explicitly or use Spring Boot 2.3+.
  • Using the full JDK for runtime: A 400MB JDK image plus your application is much larger and slower to start than a 200MB JRE image. The only reason to use the JDK at runtime is if you're doing AOT compilation or runtime code generation, which most Spring Boot applications don't require.
  • Ignoring startup time: If your health check interval is too aggressive or your start-period too short, Kubernetes will kill your container before it finishes booting. Test your local startup time and set probes accordingly.
  • Not setting resource limits: Without CPU and memory limits, a single container can consume all host resources. Always define requests and limits in your Kubernetes deployment.

Key takeaways

  • Use multi-stage builds to separate JDK compilation from JRE runtime, cutting image size by 30–40%.
  • Enable and layer your Spring Boot jars to cache dependencies separately from application code, reducing rebuild time from minutes to seconds.
  • Run your containers as a non-root user to limit the blast radius of security vulnerabilities.
  • Expose readiness and liveness endpoints and configure health checks so orchestrators can manage traffic and restarts intelligently.
  • Pin your base image versions and test your Dockerfile regularly to catch breaking changes early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles