Skip to content

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:

Checking my Docker image size
docker images myapp:latest
REPOSITORY TAG SIZE
myapp latest 512MB

512MB 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:

My bad Dockerfile
FROM openjdk:21
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "target/myapp.jar"]

This approach has several problems:

Problems with this Dockerfile
1. Base image is huge (openjdk:21 is ~470MB)
2. No layer caching - rebuilds everything on each change
3. Contains build tools not needed at runtime
4. No security hardening

Let 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:

Multi-stage Dockerfile
# Stage 1: Build the application
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN ./mvnw clean package -DskipTests
# Stage 2: Run the application
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

The key change is using two stages:

Multi-stage build explanation
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 image

Let me check the size now:

Checking improved image size
docker images myapp:latest
REPOSITORY TAG SIZE
myapp latest 285MB

Better! 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 order in Spring Boot JAR
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:

Check layer support
java -Djarmode=layertools -jar target/myapp.jar list
dependencies
spring-boot-loader
snapshot-dependencies
application

Now I update my Dockerfile to extract these layers:

Dockerfile with layered JAR
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN ./mvnw clean package -DskipTests
# Stage 2: Extract layers
FROM eclipse-temurin:21-jdk-alpine AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract --destination extracted
# Stage 3: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --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 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Now let me test the caching. First build:

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 image

Now I change one line of code and rebuild:

Second build after code change
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 image

The 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.

Dockerfile with distroless image
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN ./mvnw clean package -DskipTests
# Stage 2: Extract layers
FROM eclipse-temurin:21-jdk-alpine AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract --destination extracted
# Stage 3: Runtime with distroless
FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY --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 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Let me check the size:

Final image size
docker images myapp:latest
REPOSITORY TAG SIZE
myapp latest 189MB

From 512MB to 189MB. That’s 63% smaller.

Image size comparison
openjdk:21 base image: 512MB
Multi-stage build: 285MB
Layered + distroless: 189MB

Solution 4: Even Smaller with BuildKit Cache

I can make builds even faster using Docker BuildKit’s cache mounting:

Dockerfile with BuildKit cache
# syntax=docker/dockerfile:1.4
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
./mvnw dependency:go-offline -B
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
./mvnw clean package -DskipTests
FROM eclipse-temurin:21-jdk-alpine AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract --destination extracted
FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY --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 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

To use BuildKit, build with:

Build with BuildKit enabled
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:

Build with 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:

What Buildpacks do for you
- Creates a layered container image
- Uses distroless base image
- Optimizes for best practices
- Works out of the box

The 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:

.dockerignore
target/
!target/*.jar
.git
.gitignore
.idea
*.iml
*.md
*.log
.env

This makes builds faster and avoids accidentally copying sensitive files.

Kubernetes Health Checks

When running in Kubernetes, add health check endpoints:

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
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:

application.yml
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true

Common Mistakes to Avoid

I made these mistakes so you don’t have to:

Mistake 1: Running as root

Don't run as root
# Add non-root user
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# ... rest of Dockerfile

Mistake 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.

Layer order matters
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:

ApproachImage SizeBuild TimeComplexity
Naive Dockerfile512MBSlowLow
Multi-stage285MBMediumLow
Layered JAR285MBFastMedium
Layered + Distroless189MBFastMedium
Buildpacks~180MBFastVery Low

My recommendation:

  1. For beginners: Use Buildpacks (./mvnw spring-boot:build-image)
  2. For production: Use layered JARs with distroless images
  3. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments