1. Runtime stage: the "clean zone"
Runtime stage is the moment when the Dockerfile stops being a "build script" and becomes a "runtime contract." And this is where it is especially important to switch mentally: the builder stage can be noisy (Gradle, source code, cache are allowed there), but the runtime stage should be as calm and predictable as possible. It is like a kitchen and a plate: in the kitchen, you are allowed to make a creative mess, but you do not bring the guest a pot, a cutting board, and the chef. You bring a finished dish.
Docker multi-stage was designed precisely for this separation: in the first stage you do everything needed for the build, and in the second one you run the application, copying the artifact from the previous stage. In Docker documentation this is shown directly as a general pattern: several FROM instructions, named stages, and copying from the build stage into the final stage via COPY --from.
The practical meaning for our Spring Boot service is this: the builder stage produces a runnable jar, and the runtime stage takes it and runs it through java -jar. In the runtime stage we do not need source code, we do not need the Gradle wrapper, we do not need temporary build outputs, and we do not need anything else that does not participate in startup. The fewer "things" there are in the runtime stage, the fewer surprises there are: the image is easier to read, easier to move, easier to debug, and there is simply less chance of accidentally dragging into the final image something that should not be there.
An important technical point: the runtime stage starts with a new FROM, which means it is a new file system, separate from the builder stage. In other words, after the second FROM, you do not get "the same folder continued" but literally another world. And that is exactly why we need a separate bridge between the stages — COPY --from.
2. Named stages and COPY --from
Named stages
When you first see a multi-stage Dockerfile, the beginner's brain often tries to reduce everything to "well, it is just FROM twice." Formally — yes. But from an engineering point of view, the important thing is different: stages should read like roles. That is why Dockerfile lets you give stages names with AS <name>, and that makes the file much clearer: you start to see "builder" and "runtime" rather than "some first step" and "some second step."
Usually it looks like this:
FROM java-build-image AS builder
# Build stage: source code, Gradle, cache, and everything "noisy" will live here
# build here
FROM java-run-image AS runtime
# Runtime stage: only what is needed at runtime is copied here
# run here
Yes, you could name the stages stage1 and stage2, but that is like naming variables a and b in business logic: it compiles, sure, but then you will end up cursing yourself. Role-based names help you avoid mistakes in COPY --from, because you are copying not "from stage number 0" but "from builder." The Dockerfile reader immediately understands what is happening.
Another useful idea: the runtime stage is often the final stage, which means it becomes the resulting image by default. In that case you may see the name final, and in terms of meaning that is very honest: "this is what we actually run." Docker guides often show the AS builder and AS final pattern, where COPY --from=builder transfers the jar into the final runtime image.
Naming stages is not "cosmetics" but a way to reduce cognitive load. We have already made the Dockerfile do more than it did in the single-stage version, so our task is not to turn the file into black magic, but, on the contrary, to make it more readable than it was.
COPY --from: a bridge between stages
The most common question at this point sounds something like this: "Okay, I have a builder stage, and app.jar appeared there. Why can't I just write COPY build/libs/app.jar app.jar in the second stage?" Because ordinary COPY copies files from the build context (that is, from your local directory that Docker received as build input), not from the file system of another stage.
Docker explicitly describes that COPY can copy both from the build context and from a stage in a multi-stage build — but those are two different modes. For copying between stages, you need COPY --from.
A mental model that helps beginners: imagine that each stage is a "temporary container in Docker's head." The builder stage lives, executes RUN ./gradlew bootJar, gets a jar, and if you do not copy anything out of it, it is as if it did its job and left the stage. The runtime stage starts as a new scene. To bring the props (our jar) from the first scene to the second, COPY --from exists.
Basic syntax looks like this:
# Take a file from the builder stage file system...
COPY --from=builder /path/in/builder/app.jar /path/in/runtime/app.jar
# ...and place it in the current stage file system (usually runtime)
And here two things are critically important:
First, --from=builder. This is a reference to the stage name you assigned with AS builder. You can reference the stage by number too, but for a learning project and for a real team this is usually less readable, so we stick with naming.
Second, the source path. It is read inside the builder stage file system, not inside your repository. This is fundamental: the builder stage may have placed the artifact anywhere, and the runtime stage does not "see" your build/ folder on the host, it only sees what you explicitly copy into it.
To make this completely concrete, here is a mini diagram:
flowchart TD
A["builder stage
/build/app.jar"] -->|COPY --from=builder| B["runtime stage
/app/app.jar"]
B --> C["ENTRYPOINT: java -jar app.jar"]
In this diagram, the runtime stage looks "empty and honest": it does not build, does not compile, and does not pull in source code. It receives a ready artifact and runs it. That is exactly how multi-stage should work in spirit: separate the build-time world from the runtime world and transfer exactly what is needed for startup.
3. Paths in COPY --from
When you start writing COPY --from, you are almost guaranteed to hit the "file not found" error at least once. And that is normal: you are just not used to thinking in terms of "paths inside a stage" yet. What matters here is not memorizing one correct example, but internalizing the rule: source and destination live in different worlds.
The source in COPY --from is a path in the builder stage file system. In practice, it is usually better to write it as an absolute path, starting with /. First, it is clearer to read. Second, Docker documentation even separately notes that for copying between stages, absolute source paths are often needed, and there are additional flags around this (--parents) — we do not need them right now, but the idea that inter-stage copying likes absolute paths is useful.
The destination is a path in the current stage file system (runtime stage). And here another "sneaky" nuance appears: if the destination is relative, it is considered relative to the WORKDIR of the current stage. And WORKDIR is not inherited between stages — you have to set it again.
To lay it out clearly, it is useful to look at a table once:
| Command fragment | Where it "lives" | How to read it |
|---|---|---|
| --from=builder | Dockerfile | "Take files from the builder stage" |
| /build/app.jar | builder stage file system | "Find app.jar inside builder by absolute path" |
| app.jar | runtime stage file system | "Put it into the current WORKDIR of the runtime stage" |
| /app/app.jar | runtime stage file system | "Put it by absolute path in the runtime stage" |
Now for a couple of short examples that often appear in practice.
If in the runtime stage you set WORKDIR /app, you can write this:
# Working directory inside the runtime stage
WORKDIR /app
# Source is read in the builder stage: /build/app.jar
# Destination is read in the runtime stage: app.jar => /app/app.jar (because of WORKDIR)
COPY --from=builder /build/app.jar app.jar
This means: "take /build/app.jar from builder and save it as /app/app.jar in runtime." We also stabilized the file name here: even if the artifact in the builder stage had a long versioned name, we still normalize everything to app.jar.
If you prefer explicitness down to the last character, you can do this too:
# Fully absolute paths: WORKDIR no longer affects the destination
COPY --from=builder /build/app.jar /app/app.jar
Here WORKDIR is no longer important (although it is still useful for startup and relative paths in ENTRYPOINT).
And one more nuance that saves nerves: COPY --from does not automatically copy the project directory. It copies exactly what you specified. If you wrote "copy only app.jar", then only app.jar will be in the runtime stage (plus whatever already exists in the base image). That is exactly the "transfer only the build result" we set out to achieve.
4. A mini runtime-stage template
Now we will assemble a small but complete Dockerfile fragment that reflects the whole idea of the lecture. We will not complicate it with extra details, because our goal is to see the mechanics: the runtime stage does not build, it only takes the artifact from the builder stage via COPY --from and defines the startup command.
Suppose that in the builder stage (as discussed in the previous lecture) we made sure that the build result ends up in a predictable place and with a predictable name, for example /build/app.jar. Then the runtime stage may look like this:
FROM java-run-image AS runtime
# IMPORTANT: WORKDIR is not inherited between stages, so set it again
WORKDIR /app
# Take the ready artifact from the builder stage and place it into the runtime stage
COPY --from=builder /build/app.jar app.jar
# Exec form: better for signals and the behavior of the Java process in a container
ENTRYPOINT ["java", "-jar", "app.jar"]
Notice two "disciplines" that make this fragment mature.
The first discipline is WORKDIR /app. Even if Docker can work without it, having WORKDIR makes the Dockerfile readable: the application lives in /app, it starts from /app, and relative paths stop being a lottery.
The second discipline is the exec-form ENTRYPOINT. The Dockerfile reference emphasizes that ENTRYPOINT has exec form and shell form, and exec form is preferred. For a Java process, this is especially important because the process receives signals more correctly (we already discussed this earlier, and now we are just keeping the rule).
If you later build the image and run the container, the logic will be as honest as possible: the runtime stage contains only what is needed to run Container-Ready Catalog Service, and everything "build-related" stayed in the builder stage and did not make it into the final image. That is exactly what multi-stage gives you: separation of the build-time and runtime worlds and transfer of the artifact through COPY --from.
5. Typical mistakes with COPY --from
The mistakes on this topic are usually not "complicated" but rather "you looked at it with the wrong eyes." And that is normal: you are changing the mental model from "I copy files like in an ordinary project" to "I copy between Dockerfile-stage worlds."
Mistake #1: confusing the build context and the builder stage file system.
People often write COPY --from=builder build/libs/app.jar app.jar, thinking that build/libs is a project folder. But in COPY --from, the source is read inside the builder stage. If in the builder stage you were working in /build rather than at the root, then the path must also look like /build/.... The fix is simple: agree on where exactly in the builder stage you place the final artifact, and always copy from there.
Mistake #2: not normalizing the jar name and then suffering from versions.
Gradle likes to give the jar a name like catalog-service-0.0.1-SNAPSHOT.jar. Tomorrow the version changes — and COPY --from suddenly "cannot find the file." In a learning project and in most real projects, it is easier to make one predictable file, for example app.jar, and copy that into the runtime stage. Then the runtime stage does not depend on the versioned artifact name and does not break when the version in Gradle changes.
Mistake #3: accidentally copying the wrong jar (or two at once).
Sometimes in build/libs there is not one jar but two: for example, a runnable bootJar and a "plain" plain.jar. If in the builder stage you write something like cp build/libs/*.jar app.jar, you may accidentally copy the "wrong" file. The symptom will be unpleasant: the container started, but the application does not start because the jar is not runnable. A good habit is either to understand explicitly which file you are copying, or to configure the build so that the artifact is only one, or to verify the result in the builder stage before transferring it.
Mistake #4: forgetting that WORKDIR is not inherited between stages.
In the builder stage you may have set WORKDIR /build, but in the runtime stage you are suddenly at the root of the file system. Then you write ENTRYPOINT ["java","-jar","app.jar"], while app.jar is not where you think it is. The fix is calm: in each stage, set your own WORKDIR and do not rely on inheritance.
Mistake #5: dragging the whole project into the runtime stage "just in case."
Sometimes, after a successful COPY --from, the hand still wants to add COPY . . "just to make sure everything is there." That kills the idea of multi-stage. The runtime stage should be boring: the artifact, the minimal files around it if needed, and the startup command. If you want "to make sure," it is better not to expand the runtime stage, but to learn to see exactly what the application needs at startup.
GO TO FULL VERSION