My Spring Boot Docker Image Was 500MB: Here's How I Fixed It
The Problem
When I first containerized my Spring Boot application, I ran this command:
docker images myapp:latest
REPOSITORY TAG SIZEmyapp latest 512MB512MB for a simple Spring Boot application. I was shocked.
When I pushed this image to my Kubernetes cluster, the deployment took forever. And every time I changed one line of code, I had to rebuild and push the entire 512MB image again.
This is the problem I’ll solve in this post.
What I Did Wrong
My first Dockerfile looked like this:
FROM openjdk:21
WORKDIR /appCOPY . .RUN ./mvnw clean package -DskipTests
EXPOSE 8080ENTRYPOINT ["java", "-jar", "target/myapp.jar"]This approach has several problems:
1. Base image is huge (openjdk:21 is ~470MB)2. No layer caching - rebuilds everything on each change3. Contains build tools not needed at runtime4. No security hardeningLet me show you how I fixed each of these.
Solution 1: Use Multi-Stage Builds
Multi-stage builds let you use one image for building and a smaller one for running. Here’s my improved Dockerfile:
# Stage 1: Build the applicationFROM eclipse-temurin:21-jdk-alpine AS builderWORKDIR /appCOPY pom.xml .COPY src ./srcRUN ./mvnw clean package -DskipTests
# Stage 2: Run the applicationFROM eclipse-temurin:21-jre-alpineWORKDIR /appCOPY --from=builder /app/target/*.jar app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "app.jar"]The key change is using two stages:
Stage 1 (builder): Uses full JDK for compilation Contains Maven, source code, build artifacts Discarded after build
Stage 2 (runtime): Uses JRE only (smaller) Contains only the compiled JAR This is the final imageLet me check the size now:
docker images myapp:latest
REPOSITORY TAG SIZEmyapp latest 285MBBetter! But still too big. The problem? Every code change rebuilds the entire JAR, and Docker has to re-upload everything.
Solution 2: Use Layered JARs
This is where Spring Boot’s layered JAR feature helps. A layered JAR separates your application into logical layers:
Layer 1: dependencies (rarely changes)Layer 2: snapshot-dependencies (occasionally changes)Layer 3: resources (occasionally changes)Layer 4: application classes (frequently changes)The idea is simple: put things that change less often at the bottom. Docker caches each layer, so if only your application classes change, Docker only rebuilds the top layer.
First, let me verify my JAR supports layering:
java -Djarmode=layertools -jar target/myapp.jar list
dependenciesspring-boot-loadersnapshot-dependenciesapplicationNow I update my Dockerfile to extract these layers:
# Stage 1: BuildFROM eclipse-temurin:21-jdk-alpine AS builderWORKDIR /appCOPY pom.xml .COPY src ./srcRUN ./mvnw clean package -DskipTests
# Stage 2: Extract layersFROM eclipse-temurin:21-jdk-alpine AS extractorWORKDIR /appCOPY --from=builder /app/target/*.jar app.jarRUN java -Djarmode=layertools -jar app.jar extract --destination extracted
# Stage 3: RuntimeFROM eclipse-temurin:21-jre-alpineWORKDIR /appCOPY --from=extractor /app/extracted/dependencies/ ./COPY --from=extractor /app/extracted/spring-boot-loader/ ./COPY --from=extractor /app/extracted/snapshot-dependencies/ ./COPY --from=extractor /app/extracted/application/ ./EXPOSE 8080ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]Now let me test the caching. First build:
docker build -t myapp:v1 .
[+] Building 45.2s => [internal] load build definition => [builder 1/4] FROM eclipse-temurin:21-jdk-alpine => [builder 2/4] WORKDIR /app => [builder 3/4] COPY pom.xml . => [builder 4/4] COPY src ./src => exporting to imageNow I change one line of code and rebuild:
docker build -t myapp:v2 .
[+] Building 3.2s => CACHED [builder 1/4] FROM eclipse-temurin:21-jdk-alpine => CACHED [builder 2/4] WORKDIR /app => CACHED [builder 3/4] COPY pom.xml . => [builder 4/4] COPY src ./src => exporting to imageThe build is much faster because most layers are cached. But I still have a 285MB image. Can I make it smaller?
Solution 3: Use Distroless Images
Distroless images contain only your application and its runtime dependencies. No shell, no package manager, no unnecessary tools.
# Stage 1: BuildFROM eclipse-temurin:21-jdk-alpine AS builderWORKDIR /appCOPY pom.xml .COPY src ./srcRUN ./mvnw clean package -DskipTests
# Stage 2: Extract layersFROM eclipse-temurin:21-jdk-alpine AS extractorWORKDIR /appCOPY --from=builder /app/target/*.jar app.jarRUN java -Djarmode=layertools -jar app.jar extract --destination extracted
# Stage 3: Runtime with distrolessFROM gcr.io/distroless/java21-debian12WORKDIR /appCOPY --from=extractor /app/extracted/dependencies/ ./COPY --from=extractor /app/extracted/spring-boot-loader/ ./COPY --from=extractor /app/extracted/snapshot-dependencies/ ./COPY --from=extractor /app/extracted/application/ ./EXPOSE 8080ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]Let me check the size:
docker images myapp:latest
REPOSITORY TAG SIZEmyapp latest 189MBFrom 512MB to 189MB. That’s 63% smaller.
openjdk:21 base image: 512MBMulti-stage build: 285MBLayered + distroless: 189MBSolution 4: Even Smaller with BuildKit Cache
I can make builds even faster using Docker BuildKit’s cache mounting:
# syntax=docker/dockerfile:1.4FROM eclipse-temurin:21-jdk-alpine AS builderWORKDIR /appCOPY pom.xml .RUN --mount=type=cache,target=/root/.m2 \ ./mvnw dependency:go-offline -BCOPY src ./srcRUN --mount=type=cache,target=/root/.m2 \ ./mvnw clean package -DskipTests
FROM eclipse-temurin:21-jdk-alpine AS extractorWORKDIR /appCOPY --from=builder /app/target/*.jar app.jarRUN java -Djarmode=layertools -jar app.jar extract --destination extracted
FROM gcr.io/distroless/java21-debian12WORKDIR /appCOPY --from=extractor /app/extracted/dependencies/ ./COPY --from=extractor /app/extracted/spring-boot-loader/ ./COPY --from=extractor /app/extracted/snapshot-dependencies/ ./COPY --from=extractor /app/extracted/application/ ./EXPOSE 8080ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]To use BuildKit, build with:
DOCKER_BUILDKIT=1 docker build -t myapp:latest .The --mount=type=cache tells Docker to cache Maven dependencies between builds. This is especially useful in CI/CD pipelines.
The Lazy Way: Cloud Native Buildpacks
If you don’t want to write Dockerfiles at all, Spring Boot integrates with Cloud Native Buildpacks:
./mvnw spring-boot:build-image
[INFO] Building image 'docker.io/library/myapp:latest'[INFO] Successfully built image 'docker.io/library/myapp:latest'This automatically:
- Creates a layered container image- Uses distroless base image- Optimizes for best practices- Works out of the boxThe only downside: less control over the exact image configuration.
Don’t Forget: Add a .dockerignore
I almost forgot this. Without a .dockerignore file, Docker copies everything to the build context:
target/!target/*.jar.git.gitignore.idea*.iml*.md*.log.envThis makes builds faster and avoids accidentally copying sensitive files.
Kubernetes Health Checks
When running in Kubernetes, add health check endpoints:
apiVersion: apps/v1kind: Deploymentmetadata: name: myappspec: template: spec: containers: - name: app image: myapp:latest ports: - containerPort: 8080 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 resources: requests: memory: "256Mi" cpu: "100m" limits: memory: "512Mi" cpu: "500m"Enable the health endpoints in your Spring Boot app:
management: endpoints: web: exposure: include: health endpoint: health: probes: enabled: trueCommon Mistakes to Avoid
I made these mistakes so you don’t have to:
Mistake 1: Running as root
# Add non-root userFROM eclipse-temurin:21-jre-alpineRUN addgroup -S appgroup && adduser -S appuser -G appgroupUSER appuser# ... rest of DockerfileMistake 2: Not setting resource limits
Without limits, your container can consume all host resources and crash the node.
Mistake 3: Ignoring layer order
Put frequently changing layers last. Docker reads layers top to bottom.
Wrong order: application/ <- changes most often dependencies/ <- changes rarely (Docker rebuilds everything after first change)
Right order: dependencies/ <- changes rarely application/ <- changes most often (Docker only rebuilds application layer)Mistake 4: Including unnecessary files
Always use .dockerignore to exclude:
- Build directories
- IDE configurations
- Documentation files
- Test files (unless needed)
Summary
Here’s what I learned:
| Approach | Image Size | Build Time | Complexity |
|---|---|---|---|
| Naive Dockerfile | 512MB | Slow | Low |
| Multi-stage | 285MB | Medium | Low |
| Layered JAR | 285MB | Fast | Medium |
| Layered + Distroless | 189MB | Fast | Medium |
| Buildpacks | ~180MB | Fast | Very Low |
My recommendation:
- For beginners: Use Buildpacks (
./mvnw spring-boot:build-image) - For production: Use layered JARs with distroless images
- For CI/CD: Add BuildKit cache mounting
The key insight is that Docker layering matters. By separating dependencies from application code, you get faster builds and smaller uploads. Combined with distroless base images, your Spring Boot containers become lean, secure, and fast.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Boot Containerized Documentation
- 👨💻 Docker Multi-Stage Builds
- 👨💻 Cloud Native Buildpacks
- 👨💻 Distroless Images
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments