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. Never COPY 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 MB dependencies/ 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

dependencies

Third-party JARs (release versions)

New library / version bump

spring-boot-loader

The loader classes

Boot version bump

snapshot-dependencies

SNAPSHOT libraries

Every internal SNAPSHOT rebuild

application

Your .class files + resources

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 (bootBuildImage)

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 .env into the image. Never. Use --env-file at run time or Kubernetes Secrets.

  • latest tag in production. Pin to 21.0.4-jre-alpine, not 21-jre-alpine. latest changes 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:latest before every push. Fix HIGH/CRITICAL CVEs.

  • Building on ARM Mac, deploying to x86 servers. Use docker buildx build --platform linux/amd64,linux/arm64 for multi-arch.


Practice Milestones

  • M6: Build the reference Dockerfile above for a “hello world” Spring Boot app. Confirm image is <200 MB. Run docker stats while hitting /actuator/health — memory should sit under MaxRAMPercentage.

  • 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, trivy in the CI pipeline.


Return to README.md · Next: 06_databases_and_infra_local.md