11.05 · Docker & Containers for Java¶
When you touch this: M6 (JVM tuning under containers), M7-M8 (Spring Boot in Docker), M10-M11 (deploy portfolio).
By 2026 nobody ships a Spring Boot service without a container. The interesting question is not “should I use Docker” — it is “am I building a 4 GB fat image with the entire OS or a 150 MB layered image that JVM ergonomics actually understand?” Most junior devs ship the first one. You will not.
This file covers the multi-stage build pattern, layered JARs, buildpacks vs Dockerfile, distroless base images, and the container-aware JVM flags you will forget exist until production OOMs.
The Baseline Rule¶
Never
FROM openjdk:latest. Never single-stage. NeverCOPY target/*.jar.
Those three sins ship in every tutorial Docker file and every intern’s first PR. They produce 800 MB images with build tools baked in, no reproducibility, and slow startup. Do it right from day one.
Multi-Stage Dockerfile for Spring Boot (Reference)¶
Copy this and adapt. Save as Dockerfile at your project root.
# ---------- STAGE 1: build ----------
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
# Cache dependencies (rebuilds are fast if pom.xml unchanged)
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
COPY src ./src
RUN ./mvnw clean package -DskipTests -B
# ---------- STAGE 2: extract layers ----------
FROM eclipse-temurin:21-jre-alpine AS extractor
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# ---------- STAGE 3: runtime ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Why this shape:
Stage 1 has the JDK + Maven; stage 3 has only the JRE. Saves ~250 MB.
Stage 2 extracts Spring Boot’s layered JAR so Docker caches dependencies separately from your code. A one-line code change now invalidates only the small
application/layer, not the 60 MBdependencies/layer.Non-root user — mandatory. Rootful containers fail every security scan (Trivy, Snyk, Zoho ISMS).
Alpine base — smaller (~5 MB vs ~80 MB Debian slim). If you hit glibc issues (rare, mostly native libs like Netty tcnative), switch to
eclipse-temurin:21-jre-jammy.
Layered JAR (Spring Boot 2.3+, Default in 3.x)¶
Spring Boot’s spring-boot-maven-plugin produces a JAR with a layers.idx file describing four layers, from most stable to least:
Layer |
What’s in it |
Changes on |
|---|---|---|
|
Third-party JARs (release versions) |
New library / version bump |
|
The loader classes |
Boot version bump |
|
SNAPSHOT libraries |
Every internal SNAPSHOT rebuild |
|
Your |
Every code change |
You never edit layers.idx. You do use it — via the java -Djarmode=layertools extraction command in Stage 2 above. The result: docker build after a typo-fix pushes a ~1 MB delta, not 60 MB.
Buildpacks vs Dockerfile¶
Spring Boot ships mvn spring-boot:build-image (uses Paketo Buildpacks under the hood). One command, no Dockerfile, produces an OCI image.
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest
Aspect |
Buildpacks ( |
Dockerfile |
|---|---|---|
Setup cost |
Zero — comes with Spring Boot |
Write & maintain the file |
Reproducibility |
High (locked builder version) |
Depends on your discipline |
Base image |
Paketo bionic-tiny / jammy-tiny |
Whatever you choose |
Custom install (curl, tini, cert) |
Painful — requires custom buildpack |
Trivial |
First build |
~2-3 min (downloads builder) |
~30-60 sec |
Image size |
~250 MB typical |
150 MB with distroless |
Rebuild speed |
Excellent (layer caching built-in) |
Great with the Stage 2 trick above |
Verdict: Use buildpacks for M7-M8 demos where “one command → image” removes friction. Switch to your own multi-stage Dockerfile for the M10-M11 portfolio project — you want the control and it demonstrates competence.
Distroless (For the Paranoid)¶
Google’s gcr.io/distroless/java21-debian12:nonroot has no shell, no package manager, no apt. Attack surface tiny, image ~80 MB. Debug becomes impossible without kubectl debug or nerdctl --shell. Use for production if security team demands it, otherwise Alpine JRE is fine.
FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Container-Aware JVM Flags¶
The JVM before Java 10 could not see cgroup limits. It saw the host’s 256 GB and set -Xmx to 64 GB inside a 512 MB container, then OOM-killed on first GC. Every senior Java dev has this scar.
Java 10+ respects cgroups by default. Java 17+ does it well. But you still need to set your intent:
java \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:+UseG1GC \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp \
-XX:+ExitOnOutOfMemoryError \
-jar app.jar
MaxRAMPercentage=75.0— heap is 75% of the container memory limit. Leave 25% for metaspace, direct buffers, code cache, thread stacks. If you set 100%, the kernel kills you.ExitOnOutOfMemoryError— die on OOM instead of limping. Kubernetes will restart you; a half-dead JVM won’t.HeapDumpOnOutOfMemoryError+ a mounted volume — you get the dump for post-mortem.
Do not set -Xmx in bytes when running in Kubernetes. Let percentage flags read the cgroup limit.
docker-compose for Local Dev (Preview — Details in File 06)¶
version: "3.9"
services:
app:
build: .
ports: ["8080:8080"]
environment:
SPRING_PROFILES_ACTIVE: docker
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/app
depends_on: [db]
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
volumes:
pgdata:
docker compose up -d → running Spring Boot + Postgres locally in ~15 seconds after first pull. See file 06 for the full local infra stack (Redis, Kafka, Testcontainers).
OrbStack (Mac) vs Docker Desktop¶
If you are on macOS, switch to OrbStack (orbstack.dev). Free for personal use, ~$8/mo (~₹680) for commercial. Reasons:
Boots in 2 seconds vs Docker Desktop’s 30-60.
3-5× less RAM (matters when IntelliJ already ate 6 GB).
Native Docker CLI compatibility, plus Linux machines and Kubernetes on demand.
Docker Desktop’s Business licence ($21/user/mo, ~₹1,750) is often what Zoho IT flags. OrbStack sidesteps that.
On Linux use native Docker. On Windows use Docker Desktop with WSL2 backend — no shortcut.
What You Will Get Wrong First¶
Copying
.envinto the image. Never. Use--env-fileat run time or Kubernetes Secrets.latesttag in production. Pin to21.0.4-jre-alpine, not21-jre-alpine.latestchanges silently.Forgetting
HEALTHCHECK. Kubernetes has its own probes, but standalone Docker needs it.curl -f http://localhost:8080/actuator/health || exit 1.Ignoring image scan reports. Trivy is free. Run
trivy image myapp:latestbefore every push. Fix HIGH/CRITICAL CVEs.Building on ARM Mac, deploying to x86 servers. Use
docker buildx build --platform linux/amd64,linux/arm64for multi-arch.
Practice Milestones¶
M6: Build the reference Dockerfile above for a “hello world” Spring Boot app. Confirm image is <200 MB. Run
docker statswhile hitting/actuator/health— memory should sit underMaxRAMPercentage.M7: Add Postgres via compose. Kill the DB container mid-request; watch the Spring Boot circuit breaker/retry behaviour (or lack thereof) surface.
M10-M11: Portfolio project deploys via GitHub Actions → GHCR → some runner (Fly.io / Railway / Hetzner). Multi-arch build, buildpacks OR Dockerfile,
trivyin the CI pipeline.
Return to README.md · Next: 06_databases_and_infra_local.md