Post

Docker Multi-Stage Builds for Java Applications

Using Docker multi-stage builds to produce lean production images for Java applications without shipping your build toolchain.

Docker Multi-Stage Builds for Java Applications

The first time I dockerised a Java application properly, I based the image on maven:3.8-jdk-11, copied the source in, ran mvn package, and shipped that image. It worked perfectly and it was 780MB.

Nobody said anything for a while. Then someone ran docker images on the production server and asked why the application was the same size as a Linux distribution.

Multi-stage builds are how you fix this.

The idea is simple: you use one image to build, and a completely different image to run. The final image never contains Maven, the JDK, the source code, or the downloaded dependencies — just the jar and a JRE.

A basic two-stage build

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Stage 1: Build
FROM maven:3.8.6-eclipse-temurin-11 AS builder

WORKDIR /build
COPY pom.xml .
COPY src ./src

RUN mvn clean package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:11-jre-jammy

WORKDIR /app
COPY --from=builder /build/target/my-service.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

--from=builder copies the jar from the named first stage. The final image is based on eclipse-temurin:11-jre-jammy — just the JRE. Typical size drops from 700MB+ to around 200MB.

Making dependency caching work properly

The above is slow for iterative development because mvn package re-downloads every dependency whenever anything changes. The trick is to copy pom.xml separately first:

1
2
3
4
5
6
7
8
9
10
11
FROM maven:3.8.6-eclipse-temurin-11 AS builder

WORKDIR /build

# Docker caches this layer as long as pom.xml hasn't changed
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Only then copy source — this layer rebuilds when code changes
COPY src ./src
RUN mvn package -DskipTests -o

dependency:go-offline downloads everything into the local Maven cache. -o tells Maven to use only cached artifacts on the actual build — no network calls. When you change source files, Docker reuses the cached dependency layer and only rebuilds from the source copy onwards. Cuts rebuild time significantly.

JVM memory and containers

By default, the JVM calculates heap size based on the host machine’s total RAM, not the container’s limit. Run a 512MB container on a 32GB server, and the JVM thinks it has 32GB. You’ll get OOM kills at seemingly random times under load.

1
2
3
4
5
ENTRYPOINT ["java", \
  "-XX:+UseContainerSupport", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:InitialRAMPercentage=50.0", \
  "-jar", "app.jar"]

UseContainerSupport (default in JDK 11+, but explicit doesn’t hurt) reads the cgroup memory limit. MaxRAMPercentage=75.0 caps the heap at 75% of the container’s limit, leaving room for the JVM’s non-heap usage (metaspace, code cache, threads).

This one setting has saved me from several confusing production incidents.

Running it

1
2
3
4
5
6
7
8
9
10
11
$ docker build -t my-service:1.0 .

$ docker run -d \
  --name my-service \
  --memory 512m \
  -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=production \
  my-service:1.0

$ docker logs my-service
$ docker stats my-service

Docker Compose for local dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DB_URL=jdbc:postgresql://db:5432/mydb
      - DB_USER=myuser
      - DB_PASSWORD=mypassword
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
1
$ docker compose up --build

depends_on waits for the DB container to start — not for PostgreSQL to be ready to accept connections. If your app starts fast and tries to connect before the DB is ready, you’ll see connection errors on startup. In that case, add a startup retry in your connection pool config or use a wait-for-it.sh script.

This post is licensed under CC BY 4.0 by the author.