Docker for Java Devs: Layered Jars & Jib

Friday, 4:47 PM. The release candidate ran perfectly on your laptop all week. You deploy it to the staging server and, within minutes, three unrelated things break at once: a date-formatting test starts failing because the server’s default timezone is UTC while your machine is set to America/Chicago; a PDF export feature throws UnsatisfiedLinkError because the native library it wraps was installed on your laptop months ago and never made it to the server; and — the real head-scratcher — a brand-new language API you used compiles fine locally but crashes on the server, because your laptop runs JDK 21 and the server still has JDK 17.

None of these is a logic bug. Every one of them is an environment bug: the program was correct, but the machine it ran on was different from the machine you tested on. Developers have a name for the collective pain: "works on my machine."

Docker’s answer is blunt and effective: ship the machine, not just the jar. A container image bundles your application together with the exact JDK, the exact native libraries, and the exact timezone data it was tested against. If it runs in the container on your laptop, it runs in the container on the server — because it’s the same container. This post shows you how to build Java container images properly: the layer-caching trick that turns multi-minute rebuilds into seconds, multi-stage builds that keep images small, Jib’s no-Dockerfile alternative, and the one JVM flag every containerized Java app needs.

Decision rule: if your app runs anywhere other than your own laptop — a server, a teammate’s machine, CI, Kubernetes — containerize it. The jar is the what; the image is the what, on which machine, with which JDK.

Dockerfile anatomy for Java

A Dockerfile is a recipe: a short text file of instructions that Docker executes top-to-bottom to assemble an image. For a plain Java app you only need five of them:

  • FROM — the starting image. Everything else builds on top of this. For Java that’s a JDK or JRE image like eclipse-temurin:21-jre.
  • WORKDIR — the working directory inside the image. Every later instruction runs relative to it. Think cd, but permanent.
  • COPY — copies files from your machine (the build context) into the image.
  • RUN — executes a command while the image is being built (install packages, compile code). Runs once at build time, never again.
  • ENTRYPOINT / CMD — what runs when a container starts from the image. ENTRYPOINT is the fixed command, CMD the default arguments you can override.

Here’s a minimal working Dockerfile for a plain Java app. Assume the project already built target/myapp-1.0.jar (the Java Setup post and this track’s Maven vs Gradle: Builds Demystified cover the build itself):

# Dockerfile (minimal)
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/myapp-1.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Build it and run it:

docker build -t myapp:1.0 .
docker run --rm myapp:1.0

That works — and it’s also the version of this Dockerfile that will quietly waste hours of your life. The problem isn’t correctness, it’s layers.

The key insight: layer caching

Every instruction in a Dockerfile creates a layer — a read-only snapshot of the filesystem changes that instruction made. The final image is just the stack of layers, and Docker caches each one. When you rebuild, Docker reuses every cached layer it can and only re-executes from the first layer that changed.

The rule that matters: a COPY layer is a cache hit only if every file it copies is byte-identical to the previous build. Change one byte of one copied file, and that layer — and every layer after it — rebuilds from scratch.

Now look at the naive Dockerfile again. It copies the whole application as one fat jar (your code plus all 180 MB of dependencies) in a single layer:

# Dockerfile.naive — correct, but slow
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/myapp-fat-1.0.jar app.jar   # 198 MB, one layer
ENTRYPOINT ["java", "-jar", "app.jar"]

You fix a typo, rebuild, and watch this:

$ docker build -t myapp:1.0 .
[+] Building 62.4s
 => [internal] load build definition from Dockerfile
 => [1/2] FROM docker.io/library/eclipse-temurin:21-jre
 => [2/2] COPY target/myapp-fat-1.0.jar app.jar
 => => exporting to image
 => => => pushing layer sha256:9f3c…a21e  198.4MB / 198.4MB    # the whole jar, again
 => => naming to docker.io/library/myapp:1.0

One changed line of code → the fat jar’s bytes change → the COPY cache misses → Docker re-exports and re-pushes all 198 MB. Every. Single. Build. On a slow office uplink, that’s your whole coffee break, per typo fix.

The fix exploits one fact: your dependencies change far less often than your code. So put them in separate layers — dependencies low in the stack (cached almost forever), your classes in a thin layer on top (rebuilt, but tiny):

NAIVE: one fat layer LAYERED: dependencies cached, code thin base: eclipse-temurin:21-jre 220 MB — cached app.jar — 198 MB your code + all dependencies in ONE layer ↻ rebuilt + pushed on every change base: eclipse-temurin:21-jre 220 MB — cached lib/ — 180 MB dependencies — CACHED until pom.xml changes classes/ — 214 KB your code — rebuilt, but tiny One-line code change → naive re-pushes 198 MB; layered re-pushes 214 KB.

To make that work, stop shipping a fat jar. Build a thin jar (your classes only) plus a lib/ folder of dependency jars. Two plugin blocks in your pom.xml get you there — one copies the dependencies, and the jar plugin writes the classpath into the manifest:

<plugin>
  <artifactId>maven-dependency-plugin</artifactId>
  <version>3.6.1</version>
  <executions>
    <execution>
      <id>copy-deps</id>
      <phase>package</phase>
      <goals><goal>copy-dependencies</goal></goals>
      <configuration>
        <outputDirectory>${project.build.directory}/lib</outputDirectory>
      </configuration>
    </execution>
  </executions>
</plugin>

Then the Dockerfile copies them in the right order — least-likely-to-change first:

# Dockerfile.layered — dependencies cached, code thin
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/lib ./lib            # 180 MB — cache hit until dependencies change
COPY target/myapp-1.0.jar .      # 214 KB — the only layer that rebuilds on code changes
ENTRYPOINT ["java", "-cp", "myapp-1.0.jar:lib/*", "com.acme.Main"]

Note the -cp instead of -jar: a thin jar needs its dependencies on the classpath explicitly (lib/* expands to every jar in the folder). Now change one line of code and rebuild:

$ docker build -t myapp:1.0 .
[+] Building 3.8s
 => [internal] load build definition from Dockerfile
 => [1/3] FROM docker.io/library/eclipse-temurin:21-jre
 => [2/3] COPY target/lib ./lib                             CACHED
 => [3/3] COPY target/myapp-1.0.jar .
 => exporting to image
 => => pushing layer sha256:41bd…c9e0  214.0kB / 214.0kB     # only your code
 => => naming to docker.io/library/myapp:1.0

Sixty-two seconds and 198 MB down to under four seconds and 214 KB. The mechanism is pure ordering: Docker invalidates a layer (and everything below it in the build, i.e. everything after it in the Dockerfile) the moment a copied file differs. Put the stable stuff early, the volatile stuff late, and rebuilds get nearly free.

Decision rule: always split dependencies from application code in your image layers. If your build produces a single fat jar, you’re paying the full push cost on every change — that’s the single most common Docker-for-Java performance mistake.

Multi-stage builds: don’t ship your workshop

The layered Dockerfile above assumes the jar was built on your laptop — which reintroduces a mini version of "works on my machine" (whose Maven? which JDK compiled it?). The fix is a multi-stage build: one stage compiles, a second, slimmer stage runs, and only explicitly copied artifacts travel between them:

Stage 1 — build maven:3.9-eclipse-temurin-21 full JDK + Maven + ~/.m2 cache COPY pom.xml → resolve deps COPY src → mvn package produces: target/lib + myapp.jar JDK, Maven, ~/.m2 — discarded ✕ COPY --from=build Stage 2 — runtime eclipse-temurin:21-jre slim JRE only — no compiler, no Maven COPY lib/ → COPY myapp.jar (layered, as above) final image: ~240 MB, ships ✓
# Dockerfile.multistage — build inside, ship slim
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q -B dependency:go-offline        # dependency layer: rebuilds only when pom.xml changes
COPY src ./src
RUN mvn -q -B -DskipTests package          # your code compiled with a known JDK

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/lib ./lib
COPY --from=build /app/target/myapp-1.0.jar .
ENTRYPOINT ["java", "-cp", "myapp-1.0.jar:lib/*", "com.acme.Main"]

Two wins here. First, reproducibility: the compile happens inside a pinned JDK image, so "works on my machine" can’t sneak back in through a teammate’s different Maven version. Second, size: the runtime stage carries only a JRE. A full JDK image runs ~500–600 MB; the JRE runtime stage lands around 240 MB — faster pulls, faster deploys, smaller attack surface. (Tests are skipped in the image build with -DskipTests — they already ran in CI, where JUnit 5 & Mockito: Testing That Catches Bugs lives; rebuilding them inside the image just slows the loop.)

Decision rule: never ship the JDK or the build cache. If your final image contains mvn, javac, or a ~/.m2 folder, you have a multi-stage build waiting to be written.

Jib: skip the Dockerfile entirely

Writing and maintaining Dockerfiles is a skill — layer ordering, base-image CVEs, non-root users (trap #2 below). Jib, from Google, asks: what if the build plugin just did it right by default? Add the plugin to your pom.xml — no Dockerfile, no Docker daemon required for registry pushes:

<build>
    <plugins>
        <plugin>
            <groupId>com.google.cloud.tools</groupId>
            <artifactId>jib-maven-plugin</artifactId>
            <version>3.5.2</version>
            <configuration>
                <from>
                    <image>eclipse-temurin:21-jre</image>
                </from>
                <to>
                    <image>myregistry.example.com/myapp:1.0</image>
                </to>
                <container>
                    <creationTime>USE_CURRENT_TIMESTAMP</creationTime>
                    <args>-cp</args>
                </container>
            </configuration>
        </plugin>
    </plugins>
</build>

(Gradle users: com.google.cloud.tools.jib is the equivalent plugin.) Then:

mvn compile jib:dockerBuild

What Jib does for you, automatically: splits your app into the same layers this post teaches by hand (dependencies / resources / classes as separate layers), uses a distroless base image by default (no shell, no package manager — minimal attack surface), builds reproducibly (same inputs → byte-identical image), and pushes straight to a registry with jib:build without needing a local Docker daemon. Sample output:

$ mvn compile jib:dockerBuild
[INFO] Containerizing application to Docker daemon as myapp:1.0...
[INFO] The base image requires auth. Trying again with null credentials...
[INFO]
[INFO] Container entry will be constructed by the following steps:
[INFO]   - Base image: eclipse-temurin:21-jre
[INFO]   - Dependencies: 47 files, 182.3 MB
[INFO]   - Resources: 3 files, 12.1 KB
[INFO]   - Classes: 28 files, 214.0 KB
[INFO]
[INFO] Built image to Docker daemon as myapp:1.0

Notice the three application layers — dependencies, resources, classes — exactly the caching split from the previous section, done without you writing a line of Dockerfile.

Decision rule — Dockerfile vs Jib:

  • Choose Jib when your app is a standard JVM service and you want correct layering, small images, and reproducible builds with zero Dockerfile maintenance. It’s the fastest path from mvn package to a production-grade image.
  • Choose a hand-written Dockerfile when the image needs things Jib doesn’t model: OS-level packages (apt-get install), native agents, custom users and filesystem layouts, or a base image outside Jib’s supported set. If you find yourself fighting the plugin’s configuration, that’s the signal to switch.

.dockerignore: don’t send your junk to the daemon

docker build sends your entire build context to the Docker daemon before reading the Dockerfile — including the .git folder, IDE settings, and any old build output lying around. A .dockerignore file (same syntax as .gitignore) trims it:

.git
target/
.idea/
.vscode/
*.md
Dockerfile*
.dockerignore

Why it matters: a multi-gigabyte context makes every build start with a multi-gigabyte upload — and worse, stray files in the context can accidentally land in your image via a sloppy COPY .. Decision rule: if your project root has anything you wouldn’t git push, it probably doesn’t belong in the build context either.

The JVM in a container needs a memory hint

Here’s the trap that bites Java specifically. The JVM sizes its heap from the memory it thinks the machine has. Run java -jar app.jar on your 32 GB laptop and the JVM happily plans a multi-gigabyte heap. Put that same JVM in a container limited to 1 GB — and, on older Java versions, the JVM still sees the host’s 32 GB, sizes the heap accordingly, and gets OOMKilled by the container runtime when it blows past its 1 GB limit. Your app didn’t leak; it was just told the wrong size of the room.

Modern JDKs (10+, backported to 8u191+) read the container’s cgroup limits automatically, so the "sees the host" failure is mostly history — but the default heap sizing is still too timid or too bold depending on the version, so you set it explicitly with a percentage of the container’s memory:

docker run --rm -m 1g myapp:1.0 \
  java -XX:MaxRAMPercentage=75.0 -cp "myapp-1.0.jar:lib/*" com.acme.Main

(In the Dockerfile, that becomes part of the ENTRYPOINT.) Why 75%? The JVM needs memory beyond the heap: metaspace for class metadata, thread stacks, JIT-compiled code, direct buffers, native libraries. Giving the heap the full 100% leaves nothing for those and the container still dies. 75% heap, 25% headroom is the battle-tested starting split — then measure and adjust for your workload.

Container limit: 1 GB (-m 1g) JVM heap — 75% -XX:MaxRAMPercentage=75.0 your objects live here 25% headroom metaspace · threads · JIT · native Heap at 100% leaves nothing for non-heap memory — the container still gets OOMKilled.

You can verify the JVM picked up the limit before you ever deploy:

$ docker run --rm -m 1g myapp:1.0 java -XX:MaxRAMPercentage=75.0 -XshowSettings:vm -version 2>&1 | grep -i "max. heap"
    Max. Heap Size (Estimated): 768.00M

768 MB ≈ 75% of 1 GB: the JVM sees the container, not the host. For the full story — how the heap is divided, what metaspace and the garbage collectors actually do, and how to read a heap dump when things go wrong — see JVM Memory & Garbage Collection: What Java Developers Need to Know.

When it breaks: 4 Docker-for-Java traps

Trap 1 — COPY . before dependency resolution busts the cache on every build

Symptom: every build takes minutes even though you changed one line — docker build re-downloads all Maven dependencies and recompiles the world each time.

Diagnosis: your Dockerfile does COPY . . before the dependency step. Any source change alters the copied files, invalidates that layer, and everything after it — including the expensive RUN mvn package — re-executes. Watch the build output: if the dependency step never says CACHED, this is your bug.

Fix: copy the stable files first, resolve dependencies, then copy the volatile source — exactly the multi-stage Dockerfile above (COPY pom.xml → mvn dependency:go-offline → COPY src). The dependency layer then rebuilds only when pom.xml changes.

Trap 2 — Running the container as root

Symptom: your security scan flags the image, or worse — a vulnerability in your app hands an attacker root inside the container, which shares the host’s kernel.

Diagnosis: no USER instruction anywhere in the Dockerfile. Docker defaults to root.

Fix: create a dedicated user and switch to it before the entrypoint. Three lines, in the runtime stage:

RUN groupadd -r appuser && useradd -r -g appuser appuser \
 && chown -R appuser:appuser /app
USER appuser

Java apps don’t need root — they listen on high ports and write to their own directories. (Jib’s default distroless images already run as a non-root user, one more thing you don’t have to remember.)

Trap 3 — Shipping a fat JDK image as the runtime

Symptom: your image is 600+ MB, pulls take forever on deploy, and the security team keeps filing CVEs against tools your app never uses — compilers, debuggers, package managers.

Diagnosis: FROM eclipse-temurin:21-jdk (or worse, a full OS image with Java installed on top) as the final stage, with no multi-stage split. Everything the build needed is riding along to production.

Fix: the multi-stage pattern from this post: build in a maven:…-jdk stage, then COPY --from=build only the artifacts into an eclipse-temurin:21-jre (or distroless, via Jib) runtime stage. If your final image contains javac, it’s a build image wearing a runtime costume.

Trap 4 — Old Java ignoring the container’s memory limit

Symptom: the container gets OOMKilled under load even though you "gave it plenty of heap" — or heap dumps show the JVM sized itself for the host’s RAM, not the container’s limit.

Diagnosis: you’re running Java 8 before update 191, or Java 9 — versions that predate container-awareness and read the host’s memory from /proc/meminfo instead of the cgroup limit. Check with java -XshowSettings:vm -version inside the container: if "Max. Heap Size" looks like your laptop’s RAM, not the container limit, you’ve found it.

Fix: run a modern JDK (21/25 detect cgroup limits out of the box) and set -XX:MaxRAMPercentage=75.0 explicitly, as shown above. The modern JDK fixes the detection; the flag fixes the sizing. Belt and suspenders — containers deserve both.

What’s next

You can now take a Java app from "works on my machine" to a slim, layered, reproducible image: the five Dockerfile instructions, the layer-caching split that makes rebuilds nearly free, multi-stage builds that leave the workshop behind, Jib when you’d rather not write Dockerfiles at all, and the memory flag that keeps the JVM honest inside a container. The habit to build: every image you ship should rebuild in seconds after a code change, run as non-root, and carry an explicit MaxRAMPercentage.

From here the track keeps going up the stack: JUnit 5 & Mockito: Testing That Catches Bugs makes sure the code inside the image is correct, Testcontainers: Real Databases in Tests gives your tests a real Postgres to talk to, and CI/CD with GitHub Actions for Java Projects wires the whole thing — build, test, image, push — into a pipeline. When you’re ready to prove the containerized app survives real traffic, Load Testing with Gatling: Prove Your API Survives Traffic is waiting.

Field check before you move on: take any Java project you have, write the layered multi-stage Dockerfile from this post, and build it twice — changing one line of code between builds. Confirm the second build shows CACHED on the dependency layer and pushes only kilobytes. Then run it with -m 512m and -XshowSettings:vm and check the reported max heap is ~384 MB. If both hold, you understand layers and container memory; if not, the traps section above names your bug.

Continue: Java Learning Roadmap 2026

Comments