Skip to content
Commitwise
Menu
Java Backend

Docker and deployment

Package a Spring Boot service as a small, secure, layered container image, size the JVM correctly for container memory limits, and deploy without downtime with health probes, rolling updates and expand-and-contract database migrations.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

It works on your machine. In production, the container is killed every few hours with exit code 137, the image is 900 MB and takes four minutes to push, and every deploy returns errors for thirty seconds. None of that is a bug in your Java code. It’s how the code is packaged, how the JVM sees the container it runs in, and how new versions replace old ones.

New to some of these? Open what you need, or skip ahead.

Basics:What is Docker?

Docker runs programs in containers: lightweight, isolated packages that include everything the program needs. A test can start a real PostgreSQL database in a container in a few seconds, use it, and throw it away, so every test run starts from the same clean state on every machine.

Basics:What are clients and servers?

A server is a computer (usually many of them) that runs your backend: it stores data and answers requests. A client is whatever sends the requests: your mobile app, a website, another server.

Big systems put many identical servers behind one address, with a load balancer in front that spreads requests between them. Data lives in a database, and frequently used data is often kept in a fast cache (in memory) so the database isn’t asked every time.

Basics:What is Kubernetes?

Kubernetes runs containers on a cluster of machines. You describe what you want (“3 copies of the checkout service, each with 1 CPU and 1 GiB of memory”), and it starts them, restarts them when they crash, spreads traffic between them, and replaces them one by one when you deploy a new version.

Part Not started

Building a good image

Shipping furniture flat-packed is cheaper than shipping it assembled, and if only one shelf changes, you shouldn’t have to ship the whole wardrobe again. A container image is your service flat-packed: the operating system bits, Java, your libraries, your code. Built well, it’s small, and a code change only ships the part that changed.

Image quality affects deploy speed, startup, attack surface and cost. The essentials: multi-stage builds (build tools never reach the runtime image), a minimal JRE base (or distroless) pinned by version, a non-root user, Spring Boot’s layered extraction so dependency layers stay cached across releases, a .dockerignore, no secrets in any layer, and reproducible tags (git SHA, not latest). Cloud Native Buildpacks (./mvnw spring-boot:build-image, ./gradlew bootBuildImage) do most of this without a Dockerfile.

# 1. Build the jar
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw -q dependency:go-offline           # cached until pom.xml changes
COPY src src
RUN ./mvnw -q package -DskipTests

# 2. Split it into layers (Spring Boot 3.3+)
FROM eclipse-temurin:25-jdk AS extract
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted

# 3. The runtime image: JRE only, non-root, layers from least to most frequently changed
FROM eclipse-temurin:25-jre
RUN useradd --system --uid 10001 app
USER app
WORKDIR /app
COPY --from=extract /app/extracted/dependencies/ ./
COPY --from=extract /app/extracted/spring-boot-loader/ ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/ ./
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
  • Stages: the first stage has Maven and the JDK to build the jar; only the result is copied into the final image, which has just a JRE. The build tools never ship.
  • Layers: Docker caches each step. Libraries (dependencies, ~150 MB) rarely change, your code (application, a few MB) changes every release, so the code goes last: a new release only uploads and downloads the small top layer.
  • Non-root: if someone breaks into the app, they’re not root inside the container.
  • No build tool needed at all: ./mvnw spring-boot:build-image builds a similar image with Buildpacks.
Spot the bugAn image you shouldn't push

Tap the line with the bug.

FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY . .
ENV SPRING_DATASOURCE_PASSWORD=Pr0d-db-2026!
RUN ./mvnw -q package -DskipTests
ENTRYPOINT ["java", "-jar", "target/shop.jar"]

Predict the output

With the layered Dockerfile, you change one line in a controller and rebuild and push. Which layers are rebuilt and uploaded?

Exerciseeasy

Write a Dockerfile

Write a Dockerfile for a Kotlin Spring Boot service built with Gradle (./gradlew bootJar produces build/libs/app.jar). Requirements: build in a separate stage, run on a JRE, as a non-root user, with dependencies in their own layer.

›

The structure is the same as the Maven version; only the build commands and the jar path change. Copy the Gradle wrapper and build files before the sources, so dependency resolution is cached.

SolutionTry it yourself first
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle gradle
RUN ./gradlew --no-daemon dependencies > /dev/null     # cached until build files change
COPY src src
RUN ./gradlew --no-daemon bootJar

FROM eclipse-temurin:25-jdk AS extract
WORKDIR /app
COPY --from=build /src/build/libs/app.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted

FROM eclipse-temurin:25-jre
RUN useradd --system --uid 10001 app
USER app
WORKDIR /app
COPY --from=extract /app/extracted/dependencies/ ./
COPY --from=extract /app/extracted/spring-boot-loader/ ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/ ./
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Add a .dockerignore with .git, build, .gradle, .idea and any .env files, so they never enter the build context. Tag images with the git commit (shop:3f9a2c1), not just latest, so you always know what’s running and can roll back to an exact version.

Supply chain and startup

  • Pin and scan: pin base images by digest for reproducible builds, rebuild regularly for security patches, scan images (Trivy, Grype) in CI, and generate an SBOM (Buildpacks and spring-boot plugins can embed one).
  • Smaller attack surface: distroless or -jre minimal images have no shell or package manager; jlink can produce a custom runtime with only the modules you use.
  • Startup time: Class Data Sharing archives and, since Java 24/25, the AOT cache (-XX:AOTCache, JEP 483 and successors) cut startup substantially; Spring Boot can produce a training run during the build. GraalVM native images start in tens of milliseconds at the cost of build time and reflection configuration.
  • Multi-stage builds, JRE/distroless base pinned by digest, non-root, layered extraction (-Djarmode=tools … extract --layers --launcher, Boot 3.3+) or Buildpacks (spring-boot:build-image).
  • .dockerignore; no secrets in any layer (runtime injection only); immutable tags (git SHA).
  • Scan images, ship SBOMs, rebuild for patches; CDS/AOT cache or native images for startup.

Check this part

  1. Why put dependencies and application code in separate image layers?

  2. Where should the database password come from?

  3. Why tag images with the git commit instead of only latest?

A good image is built in stages, runs on a small JRE base as a non-root user, and copies dependencies and application code as separate layers, so a code change only re-uploads a few megabytes. Secrets never go into the image; they're injected at runtime.

Part Not started

The JVM inside a container

Renting a storage unit, you’d fill it with boxes, but you also need room to walk in, a shelf and a ladder. If you pack boxes up to the ceiling, the door won’t close.

A container has a memory limit. The JVM fills part of it with the heap (your objects), but it also needs memory outside the heap: loaded classes, thread stacks, network buffers, compiled code. If heap plus everything else goes over the limit, the container is killed instantly, with exit code 137 (OOMKilled), and no Java error message at all.

Modern JVMs (10+, backported to 8u191) are container-aware: they read the cgroup memory and CPU limits. Default max heap is 25% of the limit (more for very small limits), which wastes most of a typical container; -XX:MaxRAMPercentage=70–75 is the usual setting. The rest covers metaspace, code cache, thread stacks (≈1 MB per platform thread), direct/NIO buffers (Netty, HTTP clients), GC structures and native libraries. CPU limits affect GC thread counts and availableProcessors() (used to size pools); with fewer than 2 CPUs and under ~1.8 GB the JVM picks SerialGC.

# Kubernetes container spec
resources:
  requests: { memory: "1Gi", cpu: "500m" }
  limits:   { memory: "1Gi" }              # memory limit: the hard ceiling
env:
  - name: JAVA_TOOL_OPTIONS
    value: >-
      -XX:MaxRAMPercentage=75
      -XX:+ExitOnOutOfMemoryError

MaxRAMPercentage=75 gives the heap three quarters of the container’s memory (768 MB of 1 GiB) and leaves a quarter for everything else. ExitOnOutOfMemoryError makes the JVM exit cleanly if the heap does fill up, so Kubernetes restarts it, instead of limping along in a broken state.

Production puzzles

Predict the output

A Spring Boot service runs in a container with a 1 GiB memory limit and no JVM memory flags, on a recent JDK. What's its maximum heap?

Spot the bugKilled every few hours with exit code 137

Tap the line with the bug.

resources:
  requests: { memory: "2Gi", cpu: "1" }
  limits:   { memory: "2Gi" }
env:
  - name: JAVA_TOOL_OPTIONS
    value: "-Xms2g -Xmx2g"

CPU limits

CPU limits throttle the process in short periods, which shows up as latency spikes during GC or JIT compilation; many teams set CPU requests but no CPU limit for latency-sensitive JVMs. The JVM derives availableProcessors() from the limit (or -XX:ActiveProcessorCount), which sizes GC threads, the common ForkJoinPool and many library pools; one CPU leads to SerialGC and tiny pools, so give services at least 2 CPUs, or set the GC explicitly (-XX:+UseG1GC, or ZGC for large heaps and low pause targets).

  • Default heap = 25% of the container limit: set -XX:MaxRAMPercentage=70–75; never -Xmx = limit (non-heap: metaspace, code cache, thread stacks, direct buffers, GC). ExitOnOutOfMemoryError for clean restarts.
  • Exit 137 = kernel OOM kill, not a Java OOM; diagnose with Native Memory Tracking vs cgroup usage.
  • CPU limits throttle (GC/JIT latency spikes) and shrink availableProcessors(); ≥2 CPUs or explicit GC and ActiveProcessorCount.

Check this part

  1. A container exits with code 137 and no Java error in the logs. What usually happened?

  2. Why not give the heap 100% of the container's memory?

  3. What max heap does a recent JVM pick in a 1 GiB container without flags?

The JVM sizes its heap from the container's memory limit (25% by default), but uses memory beyond the heap too: metaspace, thread stacks, buffers, the code cache. Set MaxRAMPercentage around 70–75%, never set -Xmx equal to the limit, and match GC and thread counts to the CPUs you're given.

Part Not started

Deploying without downtime

Changing the tires on a moving bus sounds impossible, but a bus with eight wheels can do it one wheel at a time, as long as the other seven carry the weight. A rolling deployment does that with servers: start one new instance, wait until it’s ready, send it traffic, stop one old instance, repeat. Users never see a moment with no servers.

Zero-downtime deploys need four things to line up: readiness gates traffic to new pods; old pods drain gracefully (see Scaling and load balancing); maxUnavailable/maxSurge keep capacity during the rollout; and every change (API, events, database schema) is compatible with both versions running side by side, because during a rollout they always are. Rollback must be just as boring, which rules out irreversible migrations bundled with code.

apiVersion: apps/v1
kind: Deployment
metadata: { name: checkout }
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }    # never fewer than 4 ready pods
  template:
    spec:
      terminationGracePeriodSeconds: 40
      containers:
        - name: checkout
          image: registry.example.com/checkout:3f9a2c1   # an immutable tag
          ports: [{ containerPort: 8080 }]
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            periodSeconds: 10
            failureThreshold: 6
          startupProbe:                                    # slow JVM start won't trigger liveness restarts
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            periodSeconds: 5
            failureThreshold: 30
          lifecycle:
            preStop: { exec: { command: ["sleep", "10"] } }   # let the load balancer stop sending traffic
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: prod
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom: { secretKeyRef: { name: checkout-db, key: password } }
  • maxUnavailable: 0, maxSurge: 1: Kubernetes adds one new pod at a time and only removes an old one when the new one is ready, so capacity never drops.
  • Readiness decides when a pod gets traffic; liveness restarts a stuck pod; the startup probe gives the JVM time to boot before liveness checks begin.
  • preStop + grace period: an old pod keeps serving for a few seconds while it’s removed from the load balancer, then finishes its in-flight requests (server.shutdown: graceful in Spring Boot).
  • Configuration from the environment: the same image runs everywhere; profiles and secrets come from the deployment.

Database changes with expand and contract

During a rollout, old and new versions run against the same database. A migration that renames a column breaks every old pod the moment it runs. Split breaking schema changes into compatible steps, each its own release:

  1. Expand: add the new column (ADD COLUMN email_address, nullable). Old code ignores it.
  2. Dual write: deploy code that writes both columns and reads the old one.
  3. Backfill: copy existing data in batches (UPDATE … WHERE email_address IS NULL LIMIT 10000), not in one giant transaction that locks the table.
  4. Switch reads: deploy code that reads the new column (still writing both, so a rollback works).
  5. Contract: once no running version uses the old column, stop writing it, then drop it in a later release.
Exercisemedium

Rename a column without downtime

customers.phone (free text) must become customers.phone_e164 (normalized, NOT NULL eventually). The table has 40 million rows; the service runs 8 pods with rolling deploys; Flyway runs migrations on startup. Write the release plan, including the migrations, the code changes per release, and what makes each step safe to roll back.

›

Flyway on startup means the first new pod runs the migration while 7 old pods are serving. Every migration must be compatible with the previous code version, and long-running data changes don’t belong in startup migrations.

SolutionTry it yourself first
  1. Release 1 (expand): Flyway V12__add_phone_e164.sql: ALTER TABLE customers ADD COLUMN phone_e164 TEXT; (nullable, no default: instant on PostgreSQL). Code: writes both phone and normalized phone_e164 on every create/update; reads phone. Rollback: old code ignores the new column.
  2. Backfill (a job, not a startup migration): a batch job normalizes phone into phone_e164 in chunks of ~10,000 rows with short transactions, throttled, idempotent (WHERE phone_e164 IS NULL), logging rows it can’t normalize for manual fixing.
  3. Release 2 (switch reads): code reads phone_e164 (falling back to normalizing phone if null), still writes both. Rollback: Release 1 code still works, since both columns are maintained.
  4. Release 3 (constrain): after verifying no nulls: ALTER TABLE customers ADD CONSTRAINT phone_e164_not_null CHECK (phone_e164 IS NOT NULL) NOT VALID; then VALIDATE CONSTRAINT (avoids a long exclusive lock), and later SET NOT NULL. Code stops writing phone.
  5. Release 4 (contract): once no deployed version reads or writes phone: ALTER TABLE customers DROP COLUMN phone;.

Each release is independently deployable and rollback-safe because at every point, the current and previous code versions both work with the schema. Index creation along the way uses CREATE INDEX CONCURRENTLY (in a non-transactional Flyway migration).

  • Rolling updates: maxUnavailable: 0, maxSurge ≥1; readiness for traffic, liveness for stuck processes, startup probe for slow boots; preStop + graceful shutdown + grace period.
  • Same immutable image everywhere; config and secrets from the environment; rollbacks as boring as deploys.
  • Every change compatible across two versions at once: expand → dual write → backfill (batched job) → switch reads → contract. No long-locking DDL in startup migrations (NOT VALID constraints, CONCURRENTLY indexes).
  • Canary/blue-green for risky releases, with automated rollback on SLO regression.

Check this part

  1. What does maxUnavailable: 0 with maxSurge: 1 do during a deploy?

  2. Why does the JVM need a startup probe?

  3. Why can't a migration simply rename a column during a rolling deploy?

Rolling updates replace pods one at a time, using readiness probes to send traffic only to ready ones and graceful shutdown to finish requests on old ones. Configuration comes from the environment. Database changes must work with the old and new version at once: expand first, migrate, then contract.

Key takeaways

  • Build images in stages, on a small JRE base, as a non-root user, with dependencies and code in separate layers.
  • Never put secrets in an image; inject them at runtime.
  • Give the JVM heap about 75% of the container’s memory; exit code 137 means the container went over its limit.
  • Deploy with rolling updates, readiness probes and graceful shutdown so users never see downtime.
  • Layered or Buildpacks images, pinned bases, scanning, immutable tags.
  • Container-aware JVM sizing: MaxRAMPercentage, headroom for non-heap memory, CPU limits vs GC and pools.
  • Probes, surge settings and graceful shutdown make rollouts invisible.
  • Expand-and-contract migrations keep every release compatible with the previous one and rollback-safe.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.