CodeGym /Courses /Docker for Spring /Layered Spring Boot images

Layered Spring Boot images

Docker for Spring
Level 8, Lesson 0
Available

1. Multi-stage and the monolithic COPY app.jar

Multi-stage has already separated the build from runtime, and that was an important step. But right after that, the next pain point quickly appears: the final runtime layer is still often built too coarsely, as one large app.jar.

When you first manage to start a Spring Boot service in a container, you get a very pleasant feeling: “I won.” And that is true — it is a deserved victory. But then comes the second, less romantic part of the job: you start changing code every day, and suddenly you discover that image building and delivery across the team behave as if you were moving your entire apartment every time, even if you only changed one mug.

Let’s look at that same “simple, but working” baseline that often stays in the repository for a long time:

# Minimal runtime image: only JRE and one application artifact
FROM eclipse-temurin:25-jre
WORKDIR /app

# One big layer: any change in the JAR makes the layer new
COPY build/libs/docker-java-catalog-service.jar app.jar

# Start the application via executable jar
ENTRYPOINT ["java", "-jar", "app.jar"]

It is understandable, short, and looks “ideal”. But this is exactly where the problem hides: one COPY = one large Docker layer, and inside that jar Spring Boot stores not only your code.

We have already improved packaging through multi-stage, and that is the right step. For example, here is a very familiar pattern that separates build from runtime:

# Stage 1: build (contains JDK and Gradle Wrapper)
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace

# Copy sources and build files
COPY . .
# Build runnable JAR (bootJar)
RUN ./gradlew bootJar

# Stage 2: run (contains only JRE, no build tools)
FROM eclipse-temurin:25-jre
WORKDIR /app

# Take only the ready artifact from builder
COPY --from=builder /workspace/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

This is indeed better: the final image has no Gradle, no source code, no build waste. But the problem of the monolithic artifact remains: the final layer still contains “the entire application” as a single file.

2. jar and Docker cache

Docker cache works in a very straightforward and even slightly childishly honest way. It looks at the instruction (COPY, RUN, and so on) and at the input data for that instruction. If the input data changed, Docker says, “Okay, the layer is different, the cache does not fit” — and rebuilds the layer from scratch. That is great as long as the input data truly reflects “what changes”.

With COPY app.jar, there is one tricky detail. Inside the jar there are many different things, and Docker does not understand the structure inside the jar — it just sees a binary file. You change one line in a controller, rebuild bootJar, and... the entire jar is different. For Docker, it looks as if “everything changed”.

Sometimes a student says: “So what? I changed the code anyway.” And that sounds logical... until you remember that this jar contains dozens of megabytes of dependencies. From the perspective of container engineering, it is roughly like packing your code, Spring, Jackson, Hibernate, Flyway, and half the internet into one zip archive, and then being surprised that changing one file causes the archive to be recreated entirely.

To feel this concretely, it is enough to compare the jar hash before and after a tiny change:

# Look at the hash "before"
sha256sum build/libs/docker-java-catalog-service.jar
# 9c2e...  build/libs/docker-java-catalog-service.jar

# You changed one line in the Java code, rebuilt bootJar

# Look at the hash "after" — it is already different
sha256sum build/libs/docker-java-catalog-service.jar
# 3a91...  build/libs/docker-java-catalog-service.jar

For Docker, this is a new file, which means the COPY app.jar layer is considered new. And that is it: the cache ends here, and then comes a new layer build, a new push/pull of that layer, and new megabytes over the network.

3. The structure of Spring Boot bootJar

To stop thinking of a jar as “one file”, it helps to briefly remember what a Spring Boot executable jar is. It is not just “your .class files in an archive”. It is a container that holds your code, dependencies, and startup mechanism. It is convenient precisely because you can say java -jar — and the application starts without any classpath gymnastics.

One small command shows the structure at a high level (and yes, it is fine to do this not in Docker, but locally — right now we are just studying the artifact):

# See what folders and files are inside the executable jar
jar tf build/libs/docker-java-catalog-service.jar | head -n 8
# META-INF/
# META-INF/MANIFEST.MF
# org/springframework/boot/loader/
# BOOT-INF/
# BOOT-INF/classes/
# BOOT-INF/lib/
# ...

What matters here is not memorizing the paths by heart, but seeing the main point: there are several logically different parts inside. And that is where the idea of a layered image is born: different parts change at different rates.

For “human” understanding, it is convenient to lay this out in a table:

What is inside bootJar An example of “what it is” How often it changes Why this matters for Docker
Library dependencies Spring, Jackson, the PostgreSQL driver, etc. usually rarely (when you update versions) this is the heavy part you want to reuse
Spring Boot loader utility classes so that java -jar works almost never (changes when Boot is updated) a stable foundation that you also want to cache
Your code and resources controllers, services, application.yml often (every day) this is what should actually change between builds

For now, we do not need specific layer names. What matters is the principle itself: some parts are almost unchanged, while others are constantly moving. And COPY app.jar makes Docker treat everything as one chunk.

4. Change one line and deliver 80 MB

While you are working alone and everything runs on your machine, the problem may seem “theoretical”. But as soon as team collaboration or CI appears, the picture changes. You start not only building the image, but also regularly moving it around: to a local registry, between environments, or at least just rebuilding it so often that you want it to be faster.

Imagine a typical Boot service: dependencies take 60–120 MB, while your own code takes a few megabytes. The numbers depend on the set of starters, but the proportion is usually about the same: “libraries are heavy, code is light.” In Container-Ready Catalog Service this is especially believable, because even a “training” service pulls in real infrastructure libraries (JPA, Flyway, Actuator, etc.), not just a single “Hello” controller.

If we package everything into one jar and copy it with one instruction, then for any code change you are effectively:

  1. building a new jar,
  2. creating a new Docker layer with that jar,
  3. when publishing the image, sending a new layer to the registry (which includes both code and dependencies),
  4. having a colleague/CI download that layer again.

And this creates an unpleasant feeling: “I changed a log message, why are we dragging half the internet again?” That is not hysteria — it is a normal engineering reaction.

To see why this happens, one thought is enough: Docker cannot “partially reuse a zip archive”. It either reuses the layer as a whole, or it does not. So our goal is to make the final image have different layers for different parts of the application, not one “suitcase layer”.

5. The layered image idea: split and copy in parts

Now for the important point: a layered image is not “some kind of magic instead of Docker layers”. Docker layers remain Docker layers. The Spring Boot trick is that it helps prepare the content so that you can create Docker layers more precisely, reflecting the real structure of the application.

The formulation worth keeping in mind is this: Spring Boot can describe layers inside the jar, and Docker can turn those parts into image layers.

If we put it very concretely, the layered path looks like two steps:

  1. we “unpack” the jar into directories (roughly: “dependencies separately”, “code separately”),
  2. in the Dockerfile we use several COPY instructions so each directory becomes a separate layer.

In a Dockerfile, it looks roughly like this (for now, without the details of where the layers/ directory came from — what matters right now is the idea itself):

# Dependencies layer: changes rarely, so Docker can cache it well
COPY --from=extract /build/layers/dependencies/ ./
# Application layer: changes often, so it will be rebuilt more frequently
COPY --from=extract /build/layers/application/ ./

What matters here is not the specific folder, but the idea. Now Docker gets a chance to say: “Oh, dependencies did not change, I will keep the old layer. But application changed — we will update that.”

Notice a subtle but crucial idea. The layered path does not replace multi-stage. It complements it. Multi-stage separates the “build world” from the “run world”. Layered image, in turn, makes the “run world” less monolithic: not one big “whole application” layer, but several layers by meaning.

And yes, the Dockerfile becomes longer. But at this point, length is not the problem. The problem is when a short Dockerfile forces you to regularly pay with time and network traffic for something that actually did not change.

6. What this looks like in our project

To make this less like abstract philosophy, let us imagine the real life of Container-Ready Catalog Service. Today you edit an endpoint, tomorrow you add logging, the day after tomorrow you change the response format because the tester asked you to. All of that is change in your code. At the same time, you update dependencies (Spring Boot, Jackson, the PostgreSQL driver) much less often: usually for the task “update the version”, not “every commit”.

Here is a typical microscopic change that any developer makes. For example, we added one log line in the controller:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class CatalogController {
  // The logger does not affect the layer idea, but it shows a "small" code change
  private static final Logger log = LoggerFactory.getLogger(CatalogController.class);

  @GetMapping("/api/catalog/items")
  String list() {
    // We added one line — and now the JAR is already different, which means the Docker layer with COPY app.jar is different too
    log.info("Listing catalog items");
    return "ok"; // The response is just a placeholder here: what matters is not the business logic, but the fact that the artifact changed
  }
}

From a Java point of view, this is nothing: one line. But after this, the bootJar is different, which means app.jar is different, which means the Docker layer with COPY app.jar is different. And now you are again moving a layer that contains both Spring and everything else.

Layered image is a way to make the “expensive” part (dependencies and runtime support elements) more stable at the image layer level, and the “fast” part (your code) separately changeable.

And it is especially nice that at this stage you do not need to change Java code and you do not need to “rewrite the project for Docker”. We are simply improving packaging. That is exactly why this topic belongs in a Docker course, not in a course about “how to rewrite the application”.

7. Typical mistakes with layered images

Mistake #1: thinking that layered images fully replace multi-stage builds.
Multi-stage solves the problem of “not dragging the build toolchain into runtime”. Layered images solve the problem of “not dragging the whole application in one layer”. If you do layered extraction but still build and run in a single stage, you bring back part of the old problems (size, waste, readability).

Mistake #2: judging the Dockerfile by length rather than by the structure of changes.
A short Dockerfile is pleasant to look at, but Docker is not a minimalism contest. It is about reproducibility and predictability. If a longer Dockerfile clearly expresses “stable separately, changeable separately”, that is a useful length, not “extra noise”.

Mistake #3: confusing “Spring Boot layers” and “Docker layers” as if they were the same thing.
Spring Boot gives you a logical split of the application contents. Docker creates physical image layers. These are two different levels. Boot does not “create Docker layers”; it helps you prepare files so that you can create Docker layers more intelligently.

Mistake #4: expecting an instant miracle without understanding where the gain will come from.
Layered images work especially well when you frequently rebuild and “deliver” images (push/pull, CI, team development). On a single machine without a registry, you will also see the effect in Docker cache, but the main gain is often felt in not having to pull a large layer that hides dependencies every time.

Mistake #5: trying to “manually” split the jar into layers without using Boot features.
Technically you can unzip the jar like a zip file and copy folders manually, but that quickly turns into a fragile homegrown mechanism: Boot version changed — the structure changed; some nuances were added — paths broke. The point of Boot-layering is precisely that the split is standard and supported, not “assembled with duct tape and hope”.

1
Task
Docker for Spring, level 8, lesson 0
Locked
Monolithic runtime layer with one app.jar
Monolithic runtime layer with one app.jar
1
Task
Docker for Spring, level 8, lesson 0
Locked
Hash of the executable jar after a small edit
Hash of the executable jar after a small edit
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION