Definition
- Multi-stage build – a builder stage with JDK and Maven compiles the code; a runtime stage with a slim JRE copies only the artifact.
- Layered JAR – Spring Boot extracts the fat JAR into dependency layers and an application layer so Docker caches the large dependencies separately.
- Instruction order – Docker images are stacked cached layers; any changed instruction invalidates that layer and everything below it.
- Non-root user – create a dedicated user and switch to it with USER so a compromise does not give root inside the container.
- MaxRAMPercentage – sizes the heap as a percentage of the container's memory limit, not the host.
- Slim JRE – eclipse-temurin:21-jre or distroless JRE; no compiler in production.
Building a container for National Bank of India's account-service is like preparing a secure cash-delivery van. The multi-stage build assembles the van in a fully-equipped workshop but sends only the finished van out. The layered JAR packs heavy, rarely-changing equipment first so you can swap the daily paperwork without repacking. The slim JRE is a lean van with only what it needs. Non-root is giving the driver only van keys, not the depot master key. MaxRAMPercentage tells the van how much of its own fuel tank to use.
Understanding the Concepts
Why does instruction order matter for layer caching?
A Docker image is a stack of read-only layers. Each instruction that adds files creates a layer. Docker caches these layers. When one layer's inputs change, that layer and every layer below it are rebuilt. This single fact drives the whole Dockerfile design.
# WRONG: copying all source first invalidates everything on every code change
COPY . .
RUN mvn package
# Every code change rebuilds from scratch, including downloading all dependencies
# CORRECT: copy pom first (rarely changes), then source (frequently changes)
COPY pom.xml .
RUN mvn dependency:resolve
COPY src/ src/
RUN mvn package -DskipTests
# Dependencies are cached; only the code compilation reruns on code changes
How does a multi-stage build work?
The builder stage compiles the code. The runtime stage copies only the artifact. The builder is discarded.
# === Builder Stage ===
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
# Copy pom first for dependency caching
COPY pom.xml .
RUN mvn dependency:resolve
# Copy source and build
COPY src/ src/
RUN mvn package -DskipTests
# Extract layered JAR
RUN java -Djarmode=layertools -jar target/bank-account-service.jar extract --destination extracted
# === Runtime Stage ===
FROM eclipse-temurin:21-jre
WORKDIR /app
# Create non-root user
RUN groupadd -r bankapp && useradd -r -g bankapp bankapp
# Copy layers in order: least-changing first
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./
# Switch to non-root user
USER bankapp
# JVM flags for container
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseG1GC", \
"-XX:MaxMetaspaceSize=256m", \
"-XX:+HeapDumpOnOutOfMemoryError", \
"-XX:HeapDumpPath=/tmp/heapdumps/", \
"-XX:+ExitOnOutOfMemoryError", \
"-Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tags", \
"org.springframework.boot.loader.launch.JarLauncher"]
How does the Spring Boot layered JAR work?
A naive COPY of the fat JAR puts everything into one layer. Changing one line of code re-ships the entire JAR including hundreds of megabytes of unchanged dependencies.
Naive approach (single layer): ┌──────────────────────────────┐ │ app.jar (150 MB) │ ◀── rebuilt on every code change │ - dependencies (140 MB) │ │ - your code (10 MB) │ └──────────────────────────────┘ Layered approach (four layers): ┌──────────────────────────────┐ │ application (10 MB) │ ◀── rebuilt on code change (small) ├──────────────────────────────┤ │ snapshot-dependencies (0 MB)│ ◀── cached (rarely changes) ├──────────────────────────────┤ │ spring-boot-loader (0.5 MB) │ ◀── cached (rarely changes) ├──────────────────────────────┤ │ dependencies (140 MB) │ ◀── cached (rarely changes) └──────────────────────────────┘
# Extract layered JAR
java -Djarmode=layertools -jar target/bank-account-service.jar list
# Output:
# dependencies
# spring-boot-loader
# snapshot-dependencies
# application
java -Djarmode=layertools -jar target/bank-account-service.jar extract --destination extracted
Why run as non-root?
Running as root inside a container means a compromised application has root privileges, enabling container-escape attacks and lateral movement. Creating a dedicated user limits the blast radius.
# WRONG: running as root (default)
FROM eclipse-temurin:21-jre
COPY app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]
# If compromised, attacker has root inside the container
# CORRECT: dedicated non-root user
FROM eclipse-temurin:21-jre
RUN groupadd -r bankapp && useradd -r -g bankapp -d /app -s /sbin/nologin bankapp
WORKDIR /app
COPY --chown=bankapp:bankapp app.jar /app/app.jar
USER bankapp
CMD ["java", "-jar", "/app/app.jar"]
Why use a slim JRE instead of JDK?
The JDK includes the compiler (javac), diagnostic tools, and development headers that are not needed in production. Removing them reduces image size and attack surface.
| Base Image | Size | Contains | Use Case |
|-----------|------|----------|----------|
| eclipse-temurin:21-jdk | ~400 MB | JDK + compiler + tools | Builder stage |
| eclipse-temurin:21-jre | ~200 MB | JRE only | Runtime stage |
| eclipse-temurin:21-jre-alpine | ~100 MB | JRE on Alpine | Minimal footprint |
| gcr.io/distroless/java21 | ~150 MB | JRE, no shell, no package manager | Maximum security |
How does MaxRAMPercentage work in containers?
-XX:MaxRAMPercentage=75.0 sizes the heap as a percentage of the container's cgroup memory limit, leaving 25% for non-heap memory.
Container: 2 GB memory limit
MaxRAMPercentage: 75%
Heap: 2048 * 0.75 = 1536 MB
Non-heap: 512 MB (Metaspace, thread stacks, code cache, NIO buffers)
# Build and run
docker build -t bank/account-service:abc123 .
docker run --memory=2g -p 8080:8080 bank/account-service:abc123
# Verify heap inside the container
docker exec <container> jcmd 1 VM.flags | grep MaxHeapSize
# Expected: MaxHeapSize = 1610612736 (approximately 1536 MB)
What does the complete production Dockerfile look like?
# Production Dockerfile for National Bank of India Account Service
# === Builder ===
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:resolve
COPY src/ src/
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/bank-account-service.jar extract --destination extracted
# === Runtime ===
FROM eclipse-temurin:21-jre
WORKDIR /app
# Non-root user
RUN groupadd -r bankapp && useradd -r -g bankapp -d /app -s /sbin/nologin bankapp
# Layered copy: dependencies first (cached), application last (changes often)
COPY --from=builder --chown=bankapp:bankapp /build/extracted/dependencies/ ./
COPY --from=builder --chown=bankapp:bankapp /build/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=bankapp:bankapp /build/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=bankapp:bankapp /build/extracted/application/ ./
USER bankapp
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseG1GC", \
"-XX:MaxMetaspaceSize=256m", \
"-XX:+HeapDumpOnOutOfMemoryError", \
"-XX:HeapDumpPath=/tmp/heapdumps/", \
"-XX:+ExitOnOutOfMemoryError", \
"org.springframework.boot.loader.launch.JarLauncher"]
What are common Dockerfile mistakes?
# WRONG: copying everything first
COPY . .
RUN mvn package
# Every change rebuilds from scratch -- no layer caching benefit
# WRONG: shipping the JDK to production
FROM eclipse-temurin:21-jdk
# Larger image, more CVEs, compiler in production
# WRONG: using ADD instead of COPY
ADD app.jar /app/
# ADD has extra behaviors (URL fetch, tar extraction) -- COPY is simpler and preferred
# WRONG: fixed Xmx that does not match container limit
ENTRYPOINT ["java", "-Xmx4g", "-jar", "app.jar"]
# Breaks when container limit changes. Use MaxRAMPercentage instead.
Visual Overview
Multi-Stage Build Flow
=======================
┌─────────────────────────────────────┐
│ Builder Stage (eclipse-temurin:jdk) │
│ │
│ COPY pom.xml ──▶ mvn dependency:resolve (cached if pom unchanged)
│ COPY src/ ──▶ mvn package (rebuilds on code change)
│ Extract layered JAR │
│ │
│ This stage is DISCARDED after build │
└──────────────────┬──────────────────┘
│ COPY --from=builder
▼
┌─────────────────────────────────────┐
│ Runtime Stage (eclipse-temurin:jre) │
│ │
│ Layer 1: dependencies (140 MB) ◀── cached across code changes
│ Layer 2: spring-boot-loader ◀── cached
│ Layer 3: snapshot-dependencies ◀── cached
│ Layer 4: application (10 MB) ◀── rebuilt on code change
│ │
│ USER bankapp (non-root) │
│ MaxRAMPercentage=75% │
│ │
│ This is what ships to production │
└─────────────────────────────────────┘
Layer Caching Principle
========================
Changed ──▶ this layer + all below = REBUILT
Unchanged ──▶ CACHED (reused from previous build)
Put least-changing layers FIRST:
dependencies ──▶ loader ──▶ snapshots ──▶ application
(rarely) (rarely) (sometimes) (every commit)
Key Takeaway
A production Dockerfile for Spring Boot uses a multi-stage build where the builder stage compiles the code and the runtime stage ships only the artifact on a slim JRE. The builder, with its hundreds of megabytes of compiler and Maven, is discarded and never ships, which makes the image smaller and removes build tools from production.
The layered JAR makes rebuilds fast. A naive copy puts the entire fat JAR into one layer, so changing one line re-ships everything including 140 MB of unchanged dependencies. Spring Boot's layered extraction splits the JAR into dependency and application layers, so Docker caches the large dependencies and only rebuilds the tiny application layer on each code change. Instruction order matters because any changed layer invalidates all layers below it.
Running as non-root limits the blast radius of a compromise, and MaxRAMPercentage sizes the heap correctly for the container rather than the host. At National Bank of India, switching to this Dockerfile for our account-service made builds dramatically faster, cleared image-scan findings by removing the JDK, and stopped OOM-kills by using percentage-based heap sizing.
Remember one sentence: build with a JDK, ship with a slim JRE, layer dependencies before code, run as non-root, and size the heap with MaxRAMPercentage.
---