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:
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:
<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:
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:
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:
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:
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: 3Ensure your application.properties or application.yml includes the necessary Actuator configuration:
management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=trueCommon mistakes
- Running in development mode in production: Don't pass
-Dspring.profiles.active=devto 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.






Comments
Loading comments…