1. Multi-stage: meaning and benefits
If you are hearing “multi-stage” for the first time, your brain may try to translate it as “okay, sure, another optimization technique for people who have too much free time.” In reality, it is much more down to earth: multi-stage is a way to make a Dockerfile readable and honest when we have two different processes: “build an artifact” and “run an artifact.” And those processes need different things in real life.
Imagine you are making dinner. Build is the kitchen: there is flour, cutting boards, knives, bowls, dirty dishes, noise, and chaos (useful chaos, though). Runtime is the plate on the table: it should have food on it, not a ladle, a butter wrapper, and instructions for “how to turn on the oven.” A Single-stage Dockerfile is an attempt to bring the user both the kitchen and the plate in one package. Multi-stage is a way to leave the kitchen in the kitchen and bring only the result to the table.
In the context of our course, this is especially important because we are containerizing not “any jar,” but a proper Spring Boot service (Container-Ready Catalog Service) on Java 25 and Gradle. It has a clear build result (bootJar) and a clear way to run it (java -jar ...). The only question is where and how those steps should live in the Dockerfile so that the final image is clean and maintainable.
2. A stage in a Dockerfile
The word stage is not philosophy or marketing. In a Dockerfile, it is a very concrete thing: every new FROM starts a new build stage. You can think of each stage as a mini-Dockerfile inside a larger Dockerfile: it has its own file system, its own chain of instructions, its own WORKDIR, its own ENV, and so on. And this is not a continuation of the previous stage, but a new start.
The smallest example looks like this:
# Stage 1: builder — build tools and source code live here
FROM java-build-image AS builder
WORKDIR /workspace
# the build will appear here later (compilation, jar packaging)
# Stage 2: runtime — only what is needed to run remains here
FROM java-run-image AS runtime
WORKDIR /app
# the run will appear here later (copying the jar and ENTRYPOINT)
Right now, there is almost no concrete detail in this example — and that is normal. For now, it is important to understand the mechanics: two FROM instructions mean two stages. The first stage can be “dirty” (that is where the build happens), and the second can be “clean” (that is where the application runs).
And this is where confusion often appears: “But doesn’t Docker build layers? Why do we need stages too?” Docker does indeed build layers inside each stage, because almost every instruction (COPY, RUN, etc.) creates a new layer. But a stage is one level above that: it is a logical block made up of many layers and starting from a new base image (FROM).
To make the difference clear, it helps to look at a small table:
| Term | What it is | What it is like | Where it “lives” |
|---|---|---|---|
| Layer | the result of one Dockerfile instruction (for example, COPY, RUN) | one “step” in history | inside one stage |
| Stage | a separate chain of instructions starting with FROM | a separate “world”/context | a Dockerfile can contain multiple stages |
Stages are needed not instead of layers, but to separate roles: build and run, tests and runtime, artifact preparation and final packaging.
3. Multi-stage build: multiple FROMs
The term multi-stage build means something simple: multiple stages are used in one Dockerfile (that is, multiple FROMs). It is still one file, one build scenario, one artifact in the repository — only the structure becomes more mature.
An important point: Docker builds stages from top to bottom, but the final image is considered to be the result of the last stage in the Dockerfile. That is, if you have two stages, the final image will be the one described in the second; if you have three stages, the third is final. It is useful to remember this right now so that later you do not wonder: “Why am I building the Dockerfile and getting an image without Gradle and source code?” Because you intentionally made the stage without them the final one.
From the build command’s point of view, everything looks just as familiar:
# Build the final image: the result will be the last stage of the Dockerfile
docker build -t docker-java-catalog-service .
The command does not say “build me the builder stage” or “build me the runtime stage” (we will talk about such options later, and we are not going there now). In the standard mode, Docker walks through the Dockerfile and in the end gives you the image that corresponds to the last stage. That is the one you later run with docker run.
If you want to put the multi-stage idea into one sentence, it is this: we can allow ourselves a “dirty” stage inside the Dockerfile because it does not have to end up in the final runtime image.
That really changes how you think. In single-stage, you constantly feel like you are “in one room”: if you add something for the build, it seems to stay in the final image forever (and you start removing it with hacks). In multi-stage, you start thinking like this: “I am in the build room now; I need to build the jar; after that I will leave this room and give the user another room — the run room.”
4. The boundary between build-time and runtime
If you remember only commands (FROM, COPY, RUN) and do not keep the boundary between build-time and runtime in mind, multi-stage will very quickly turn into “two stages just for the sake of two stages.” So we need to talk through the terms in plain language.
Build-time is everything needed to get a runnable artifact. For Java/Spring Boot, this usually means source code, the Gradle wrapper, build scripts, downloading dependencies, compilation, and building bootJar. This is the “production workshop”: noisy, with lots of temporary files, and sometimes heavy in size — and that is fine, because the goal of the workshop is not to be beautiful, it is to produce a result.
Runtime is everything needed to simply run that artifact. In the runtime world, we need the jar itself and a clear launch via java -jar. Everything else there is either unnecessary, dangerous, or simply makes maintenance worse. Runtime is like a theater stage: the audience does not need to see where the hammers are or how the scenery was built.
To keep the boundary from staying abstract, let us apply it to the real things that exist in our docker-java-catalog-service repository.
| Artifact / entity | Needed for build-time? | Needed for runtime? | Comment in simple terms |
|---|---|---|---|
| src/ (Java sources) | yes | no | source code is needed to build the jar, but it does not help the jar run |
| gradlew, gradle/ | yes | no | the wrapper is a build tool; the runtime image does not need it |
| build.gradle.kts, settings.gradle.kts | yes | no | the build will not work without them, but the runtime image does not care |
| build/ (temporary output) | yes (in progress) | no | this is normal in the builder stage, but junk in the runtime stage |
| *.jar (result of bootJar) | yes (as a result) | yes | this is the bridge between stages: what was “born” in the build and is needed at runtime |
| ENTRYPOINT ["java", "-jar", "..."] | no | yes | launching is a runtime responsibility |
Notice that we are not discussing “which base image to choose” or “JDK vs JRE” here — that is a separate topic and it will appear at the right time. What matters now is something else: in any situation, you must be able to ask yourself, “Is this needed to build the artifact, or is it needed to run the artifact?” If the answer is “needed only for the build,” then it is a candidate to live in the builder stage and not make it into the final runtime image.
5. How Docker builds stages
In words, everything sounds nice: “two stages, two roles.” But students usually start trusting the topic when it becomes observable. So let us talk through what exactly Docker does when building a multi-stage Dockerfile, without diving into implementation details.
When Docker executes the first stage, it builds an intermediate result (you can think of it as an intermediate image). That result is not for the user, but for the build itself: it contains everything that helped build the artifact. Then the next stage starts with a new FROM, that is, with a new “clean” file system. And the final image is built only from the instructions in the last stage, because that stage is what counts as the build result.
It is easy to see it schematically:
flowchart TD
A["Dockerfile"] --> B["Stage 1: builder (build-time)"]
B --> C["Intermediate result (not the final image)"]
C --> D["Stage 2: runtime (runtime)"]
D --> E["Final image (what we run)"]
What matters in this picture: Stage 1 does not “flow into” Stage 2 automatically. These are not “layers on top of each other.” They are separate spaces, and to move the result later we will use an explicit bridge (yes, the same COPY --from, but the details are for a separate lecture).
For your own control (and so that you do not take it on faith), after building you can look at the history of the final image:
# Look at the layer history of the final image only (the last stage)
docker image history docker-java-catalog-service
In a multi-stage setup, you will usually see that the final history has become “cleaner”: there are fewer steps related to the build. The build did happen, but it stayed in the builder stage and did not become part of the runtime image. That is the main magic of multi-stage — except without magic: just a different way to organize the file.
6. Stage names (AS name)
While multi-stage remains “two sections with two FROMs,” a beginner often thinks: “Fine, I understand that FROM can appear twice. But how does that help me?” It helps by making the Dockerfile readable like a document: it gains logical blocks, and you can give those blocks names. This is especially important when the file grows and you stop remembering what is in each stage.
A named stage is defined just as simply as it looks:
# Builder stage: the build lives here (source code, Gradle, dependency cache)
FROM java-build-image AS builder
WORKDIR /workspace
# the build will happen here
# Runtime stage: only the build result and the launch command remain here
FROM java-run-image AS runtime
WORKDIR /app
# the run will happen here
The word after AS is just a label. It is not needed by Docker “for beauty,” but by you and your team for clarity. Instead of “first stage” and “second stage,” you get builder and runtime. And that immediately reduces cognitive load: when you read the file, you do not remember the order, you understand the role.
Plus, a small spoiler for exactly one step, without drifting into the next lecture: when we move the build result from builder to runtime, we will refer to the stage by name. That is much nicer than writing something like “copy from stage 0” or “from stage 1” and then getting confused about which is which.
If you are thinking now: “Couldn’t I name them stage1 and stage2?” — technically, yes. But then you lose half the benefit. Names should reflect the role; otherwise they become noise. In our course, the canonical names are simple: builder for build and runtime for run. It looks boring, but it works (and does not make your future self cry into git blame).
7. The model for Container-Ready Catalog Service
We are not writing the full multi-stage Dockerfile by hand “down to the last dot” yet — that will be the result of the day. But it is important that you can already connect the words stage, build-time, and runtime with our specific service, not with some abstract “application.”
For the Container-Ready Catalog Service, the model is very straightforward. In the builder stage, we will ultimately build a runnable bootJar (that is, one executable jar for the Spring Boot application). In the runtime stage, we will take only this jar, place it in the working directory, and set an ENTRYPOINT of the form java -jar .... In other words, at runtime we will keep only what the application actually needs, not what the build once needed.
If you keep that in mind, the next lectures of the day will feel not like “another Dockerfile recipe,” but like a logical filling of two already understood boxes. In the next lecture, we will fill the builder box with concrete files and a build command. After that, we will fill the runtime box and move the result over.
8. Common mistakes when working with multi-stage
Mistake #1: confusing stage and layer.
This is a very common story: a student learns that Docker builds layers and starts thinking that a stage is “just another layer.” As a result, they expect the second stage to inherit files from the first automatically, as if it were a continuation of the same file system. The correct picture is different: layers live inside a stage, and a stage starts with a new FROM, that is, with a new base and a new file world.
Mistake #2: thinking that multi-stage means multiple Dockerfiles.
Sometimes the name makes it seem like “multi-stage” means “multiple files.” Especially if you have seen projects with Dockerfile and Dockerfile.build. In our course, we fix the canonical approach: one Dockerfile, multiple FROMs. That is more convenient for maintenance and easier to read, because the whole packaging story lives in one place.
Mistake #3: expecting files from the builder stage to be available in the runtime stage “by themselves.”
The logic of “but I already copied the source, why did it disappear in the second stage?” is completely normal for a beginner. But that is exactly the point of a stage: a new FROM means a new start. If you need to move the build result, you do it explicitly. Otherwise you end up with the same single-stage chaos, only split into two parts on paper.
Mistake #4: making a multi-stage Dockerfile but still building and running in one stage “out of habit.”
It happens like this: a person adds a second FROM, but still writes ENTRYPOINT in the first stage and tries to run the application where it is being built. As a result, they get neither a clean image nor a clear structure. The model must be strict: the builder stage ends with an artifact, and the runtime stage starts by taking that artifact and running it.
Mistake #5: naming stages something like stage1, stage2 and then getting lost.
Technically, Docker will understand any names (and it can even do without names altogether), but you are making life harder for yourself. Stage names are not a formality; they are a way to read a Dockerfile like a document. For a beginner, this is a real lifeline: builder and runtime immediately tell you what is happening, even if you have forgotten half the details.
GO TO FULL VERSION