80% of Spring Boot Dockerfiles in production are wrong

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

MetricBasic DockerfileWith 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 userYesNo
JVM respecting container limitsNoYes

A Spring Boot Dockerfile isn’t boilerplate. It’s part of the engineering.