“It works on my machine” stops being funny the moment it has to run on someone else’s. Dockerizing a Spring Boot app gives you one artifact that runs identically on your laptop, in CI, and on Kubernetes or AWS — same JDK, same dependencies, same behavior. The trick is doing it so the image is small, secure, and cached well, not a 600 MB blob that rebuilds from scratch on every code change. Here’s the approach I actually use.
The multi-stage Dockerfile
A multi-stage build compiles in one stage and ships from a slim runtime in another, so build tools never end up in the final image:
# --- Stage 1: build ---
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw -q clean package -DskipTests
# --- Stage 2: runtime ---
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The runtime stage uses a JRE, not a JDK, so you’re not shipping a compiler to production. Two refinements worth making:
- Run as a non-root user. Add a user and
USERdirective so a container breakout doesn’t land as root. - Pin the base image to a digest or specific tag for reproducible builds.
Layered JARs for faster rebuilds
Here’s the gotcha: copy a single fat JAR and every code change busts the Docker layer that contains all your dependencies — so each build re-downloads/re-layers ~50 MB of libraries that didn’t change. Spring Boot’s layered JAR splits the archive into dependencies, spring-boot-loader, snapshot-dependencies, and application, so Docker caches the dependency layers and only the tiny application layer changes when you edit code:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw -q clean package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-dependencies/ ./
COPY --from=build /app/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Version heads-up
[needs source]: the loader launcher class moved toorg.springframework.boot.loader.launch.JarLauncherin Spring Boot 3.2+. Older versions useorg.springframework.boot.loader.JarLauncher. Match your Boot version.
Order matters: copy the layers that change least (dependencies) first so they cache.
Or skip the Dockerfile with Buildpacks
Don’t want to maintain a Dockerfile at all? Spring Boot’s build plugin produces an OCI image via Cloud Native Buildpacks with one command:
./mvnw spring-boot:build-image
No Dockerfile required. The resulting image is layered, runs as non-root, and follows sensible defaults out of the box. For many projects this is the better default — you only drop to a hand-written Dockerfile when you need custom OS packages or fine-grained control.
Configuration and local stacks
A container should get its configuration from the environment, not baked into the image. Externalize database URLs, secrets, and feature flags as env vars (Spring maps SPRING_DATASOURCE_URL to spring.datasource.url automatically) or a config server. The same image then runs in dev, staging, and prod with different env.
For local development, use Docker Compose to bring up your app plus its dependencies in one command:
services:
app:
build: .
ports: ["8080:8080"]
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/app
depends_on: [db]
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_PASSWORD: secret
docker compose up and you have the full stack — no “install Postgres locally” instructions in your README.
Common gotchas
- Shipping a JDK at runtime. Use a JRE base image; it’s smaller and has less attack surface.
- No layered JAR. Without it, every code change re-layers all your dependencies — slow builds, slow pushes.
- Running as root. Add a non-root
USER. Many base images still default to root. - Hardcoded config. Baking URLs/secrets into the image breaks the “one image, many environments” promise (and leaks secrets).
- Ignoring
.dockerignore. Without it you copytarget/,.git, and IDE files into the build context, bloating it and busting caches.
FAQ
Dockerfile or Spring Boot Buildpacks?
spring-boot:build-image) are the easiest sane default — layered, non-root, no file to maintain. Write a Dockerfile when you need custom OS packages, a specific base image, or tighter control.
Why is my image so large?
.dockerignore usually cuts it dramatically.
What's a layered JAR and why use it?
How do I pass config into the container?
SPRING_DATASOURCE_URL to spring.datasource.url, etc. Keep secrets in the orchestrator’s secret store, not the image.
Key takeaway: Dockerize Spring Boot with a multi-stage build on a JRE base, use the layered-JAR layout for fast cached rebuilds (or skip the Dockerfile entirely with spring-boot:build-image), run as non-root, and feed config through environment variables.
Got the app built first? See Surviving Day 1 with Spring Boot 3.