80% of Spring Boot Dockerfiles in production are wrong
80% of the Spring Boot Dockerfiles I see in production are wrong.
And the result is always the same: a 900MB image, a 10-minute build, and a deploy nobody wants to trigger.
It starts when the team copies a basic Dockerfile from the internet and never questions it again. It works. But it works poorly.
Here’s what I apply to every Java Spring Boot project:
1. Multi-stage build
Stage 1: full JDK to compile and generate the .jar with Maven or Gradle.
Stage 2: just the JRE (or Distroless) to run it.
# Stage 1: build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
# Stage 2: runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
The final image doesn’t need the compiler, Maven wrapper, sources, or tests. None of that belongs in production.
2. eclipse-temurin:21-jre-alpine as the final base
Not the JDK. The JRE. That’s a ~300MB difference in the image.
Want to go further? gcr.io/distroless/java21. No shell, no package manager, no attack surface.
# Leaner and more secure option
FROM gcr.io/distroless/java21
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
3. Intelligent layer caching
With Maven, copy pom.xml first, run mvn dependency:go-offline, then copy the source code. Dependencies get cached.
Only code changed? The build skips dependency resolution entirely. An 8-minute build becomes 90 seconds.
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B # <-- cached layer
COPY src ./src
RUN mvn package -DskipTests -B # <-- only re-runs if src changes
Docker invalidates cache layer by layer, top to bottom. The order of your COPY instructions matters.
4. Non-root user
RUN addgroup --system spring \
&& adduser --system spring --ingroup spring
USER spring
Compromised container, attacker without root. A simple detail with a huge impact on any compliance review or pentest.
5. Proper .dockerignore
Without this, the build context sends the entire target folder to the daemon before anything starts — sometimes hundreds of MB that will never be used.
target/
.git
.mvn
*.md
coverage/
.env
**/*.log
6. JVM tuned for containers
The JVM doesn’t know it’s inside a container by default. -XX:+UseContainerSupport is enabled since Java 11+, but it’s worth verifying.
The most important part: replace a fixed -Xmx with a percentage of the container’s memory limit.
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
With MaxRAMPercentage, the JVM respects the container’s --memory limit instead of trying to use all of the host’s RAM. This prevents silent OOMKill in orchestrated environments.
7. Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
The orchestrator (Kubernetes, ECS, Swarm) knows whether the app is truly alive, not just whether the process started. Without this, traffic can be routed to a container that came up but hasn’t finished initializing.
The --start-period=60s is critical for Spring Boot — the JVM and Spring context take time to load. Without it, the health check fails in the first few seconds and the container restarts in a loop.
Full Dockerfile
# Stage 1: build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
# Stage 2: runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup --system spring \
&& adduser --system spring --ingroup spring
COPY --from=builder /app/target/*.jar app.jar
USER spring
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0"
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
The results
| Metric | Basic Dockerfile | With optimizations |
|---|---|---|
| Image size | ~800–900MB | ~150–200MB |
| Build without cache | ~10 min | ~4–5 min |
| Build with cache (only code changed) | ~8 min | ~90 sec |
| Root user | Yes | No |
| JVM respecting container limits | No | Yes |
A Spring Boot Dockerfile isn’t boilerplate. It’s part of the engineering.