Skip to content

Fix: Docker Multi-Stage Build COPY --from Failed

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix Docker multi-stage build errors — COPY --from stage not found, wrong stage name, artifacts not at expected path, and BuildKit caching issues.

The Error

A Docker multi-stage build fails when copying artifacts from a previous stage:

COPY --from=builder /app/dist ./dist
----
failed to solve: failed to read dockerfile: failed to parse stage name "builder": invalid reference format

Or the stage exists but the file isn’t there:

COPY failed: file not found in build context or excluded by .dockerignore: stat app/dist: file does not exist

Or a more subtle failure where the stage name isn’t recognized:

failed to solve: failed to compute cache key: failed to calculate checksum of ref abc123::xyz456: "/app/dist": not found

Or the build succeeds but the final image is missing the expected files:

docker run myapp ls /app/dist
# ls: cannot access '/app/dist': No such file or directory

Why This Happens

Multi-stage builds copy files from one stage to another using COPY --from=<stage>. Failures occur when the source stage doesn’t produce the expected artifacts, the reference to the stage is wrong, or the build cache serves stale results.

The core concept is that each FROM instruction in a Dockerfile starts a new stage with a clean filesystem. Nothing carries over between stages unless you explicitly COPY --from. This is intentional — it keeps the final image small by discarding build tools, source code, and intermediate artifacts. But it means every path reference in COPY --from must exactly match what exists in the source stage’s filesystem at the end of that stage’s last RUN instruction.

Common triggers include: stage name typo or case mismatch (COPY --from=Builder won’t find a stage named builder), build step in the source stage failed silently (exit code 0 but no output files), wrong file path in the source stage (build output lands in /app/build but you copy from /app/dist), .dockerignore excluding needed files from the build context, stage index used incorrectly (COPY --from=0 shifts when you reorder stages), BuildKit cache serving stale artifacts, and multi-platform builds with architecture mismatches.

How Other Build Tools Handle This

Docker’s multi-stage build is one approach to producing minimal container images, but other tools take fundamentally different approaches to the same problem. Understanding the alternatives helps you choose the right tool for your pipeline and debug issues that are specific to Docker’s model.

Buildah (from the Podman ecosystem) builds OCI images without a daemon. It uses the same Dockerfile syntax as Docker, so COPY --from works identically. The difference is operational: Buildah runs rootless by default and doesn’t require a long-running daemon process. Multi-stage builds in Buildah have the same pitfalls (stage name casing, path mismatches) but Buildah’s buildah bud output is more verbose by default, making silent failures easier to catch. If your Docker multi-stage build works locally but fails in CI, switching to Buildah is not a fix — the Dockerfile semantics are the same.

Kaniko builds container images inside a container itself, designed for Kubernetes CI environments where you can’t run a Docker daemon. Kaniko parses the Dockerfile and executes each instruction in userspace. Multi-stage builds work, but Kaniko’s cache behavior differs from BuildKit: it uses a remote registry as a layer cache (--cache-repo), and cache invalidation follows different rules. A COPY --from that works with Docker BuildKit may fail with Kaniko if the cache layer for the source stage was pushed with a different base image digest. Kaniko also doesn’t support all Dockerfile instructions (e.g., some RUN --mount variants), so complex multi-stage Dockerfiles may need adjustment.

Bazel rules_oci takes a completely different approach: instead of a Dockerfile, you declare image layers in BUILD files. Each layer is a build target with explicit inputs and outputs. There’s no COPY --from because there are no stages — you compose layers declaratively. This eliminates the “file not found” class of errors entirely because Bazel tracks every input file and refuses to build if a dependency is missing. The trade-off is a steep learning curve and the need to express your build in Bazel’s dependency graph rather than sequential shell commands.

Paketo Buildpacks and Nixpacks skip Dockerfiles entirely. They auto-detect your language and framework, install dependencies, compile the app, and produce an OCI image. There’s no multi-stage build to debug because the buildpack handles the build-to-runtime transition internally. Paketo produces reproducible images with well-defined layers (dependency layer, app layer, launcher layer). Nixpacks (used by Railway) is simpler but less configurable. Both eliminate multi-stage COPY --from errors at the cost of reduced control over the build process.

Base image choices also differ across tools. Docker’s FROM scratch produces a zero-byte base with no OS, no shell, no libc — only statically compiled binaries run in it. Distroless images (from Google) include a minimal libc and CA certificates but no shell or package manager, suitable for Go, Java, and Node.js apps. Alpine is a full Linux distribution in ~5 MB, but its musl libc causes subtle compatibility issues with glibc-compiled binaries. Chainguard images are like distroless but updated daily with CVE patches and signed with cosign. The choice of base image affects which multi-stage COPY --from artifacts will actually run — a CGO-enabled Go binary won’t execute in a scratch or distroless image that lacks glibc.

Cache semantics per builder: Docker BuildKit uses local layer cache by default and supports --cache-from to pull cache from a registry. Kaniko uses --cache-repo for registry-based caching. Buildah follows BuildKit’s caching model. Bazel has hermetic caching (remote or local) that is content-addressable. When a multi-stage build fails only in CI, the caching backend is usually the culprit — CI environments start with cold caches, and --cache-from requires that the cache image was pushed with --cache-to in a previous build.

Fix 1: Use Named Stages

Always name your build stages with AS <name>. Using numeric indices (--from=0) breaks when you add or reorder stages:

# Bad — using index, breaks when stages are reordered
FROM node:20-alpine AS 0
RUN npm ci && npm run build

FROM nginx:alpine
COPY --from=0 /app/dist /usr/share/nginx/html  # Fragile
# Good — named stage, resilient to reordering
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/dist .   # Clear and stable
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Check that stage names are consistent and lowercase:

# Wrong — case mismatch
FROM node:20 AS Builder    # Defined as "Builder"
# ...

COPY --from=builder /app/dist .  # Looking for "builder" — NOT FOUND
# Correct — consistent casing (lowercase recommended)
FROM node:20 AS builder
# ...

COPY --from=builder /app/dist .  # Matches

Fix 2: Verify the Build Output Path

If the COPY --from path doesn’t match where the build actually writes its output, the copy silently fails or errors:

# Debug the builder stage — run it interactively to check what's there
docker build --target builder -t myapp-builder .
docker run --rm myapp-builder find /app -type d | head -20
# /app
# /app/node_modules
# /app/build          ← output is here, not /app/dist

Match the COPY --from path to the actual output:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Check where CRA puts output: /app/build, not /app/dist
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html  # Corrected path

Common build output locations by tool:

ToolDefault output
Create React App/app/build
Vite/app/dist
Next.js/app/.next
Angular CLI/app/dist/<project-name>
Go go build/app/<binary-name>
Maven mvn package/app/target/<name>.jar
Gradle ./gradlew build/app/build/libs/<name>.jar

Fix 3: Check for Silent Build Failures

If the build command exits with a non-zero code, Docker stops the step and the output files don’t exist. But sometimes the command exits 0 despite a failed build:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# If npm run build fails with exit code 0, no output is produced
RUN npm run build

Add explicit verification:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Verify the output exists before the final stage tries to copy it
RUN test -d /app/dist || (echo "Build failed — /app/dist not found" && exit 1)

Or build the stage in isolation and inspect it:

# Build only the builder stage
docker build --target builder -t debug-builder .

# Check what files were produced
docker run --rm debug-builder ls -la /app/dist
# If this fails, the build output didn't land in /app/dist

Enable BuildKit verbose output to see each step:

DOCKER_BUILDKIT=1 docker build --progress=plain .

Fix 4: Fix .dockerignore Excluding Source Files

If .dockerignore excludes files that your build step needs, the build fails inside the container even though the files exist on your machine:

# .dockerignore — overly aggressive
*
!package.json
!package-lock.json
# src/ is excluded — COPY . . won't include it, build fails
# .dockerignore — balanced approach
node_modules
.git
.env
*.log
dist
build
.next

Pro Tip: Use COPY selectively instead of COPY . . to control exactly what goes into each stage:

FROM node:20-alpine AS builder
WORKDIR /app

# Layer 1: dependencies (cached separately)
COPY package*.json ./
RUN npm ci

# Layer 2: source code only
COPY src/ ./src/
COPY public/ ./public/
COPY tsconfig.json vite.config.ts ./

RUN npm run build

This approach also improves caching — source code changes don’t invalidate the dependency installation layer.

Fix 5: Reference External Images with —from

COPY --from can copy from any Docker image, not just stages in the same Dockerfile. This is useful for pulling binaries from official images:

# Copy a specific binary from an official image
FROM ubuntu:22.04
COPY --from=golang:1.22 /usr/local/go /usr/local/go

ENV PATH="/usr/local/go/bin:${PATH}"
RUN go version
# Copy compiled binary from a build stage, then use a minimal runtime image
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server

FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Common Mistake: When copying a Go binary into a distroless or alpine image, make sure you compile with CGO_ENABLED=0. A binary compiled with CGO links against glibc — it won’t run in distroless images that don’t have glibc.

Fix 6: Fix BuildKit Cache Serving Stale Data

Docker’s layer cache can serve a cached version of a build stage that no longer reflects your latest code. Force a fresh build:

# Bypass cache for the entire build
docker build --no-cache .

# Or invalidate cache only from a specific stage onward using a build argument
docker build --build-arg CACHE_BUST=$(date +%s) .

ARG-based cache invalidation:

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Add a build arg to bust cache at a specific point
ARG CACHE_BUST=1
RUN echo "Cache bust: $CACHE_BUST"

COPY . .
RUN npm run build
# Force re-run from the CACHE_BUST point
docker build --build-arg CACHE_BUST=$(date +%s) .

Fix 7: Full Working Multi-Stage Dockerfile Examples

Node.js (React/Vite) to Nginx:

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM nginx:1.25-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Go application to Distroless:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

Java Spring Boot to JRE:

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml ./
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Still Not Working?

List available stages in your Dockerfile:

grep -n "^FROM" Dockerfile
# 1: FROM node:20-alpine AS builder
# 2: FROM nginx:alpine

Run each stage individually to isolate the failure:

# Test stage 1
docker build --target builder -t stage1-test .
docker run --rm stage1-test ls /app/dist

# If stage 1 is fine, test the copy manually
docker run --rm stage1-test cat /app/dist/index.html

Check Docker and BuildKit versions — older versions have known multi-stage bugs:

docker version
# Ensure Docker Engine 18.09+ for BuildKit support
# Ensure Docker 20.10+ for full multi-stage stability

# Enable BuildKit explicitly
DOCKER_BUILDKIT=1 docker build .

# Or set in Docker daemon config
# /etc/docker/daemon.json: { "features": { "buildkit": true } }

Debug multi-platform builds. If you’re building for linux/amd64 and linux/arm64 simultaneously with docker buildx build --platform linux/amd64,linux/arm64, verify that each stage’s base image supports both architectures. A COPY --from that works on amd64 may fail on arm64 if the builder stage used an amd64-only image. Check architecture support with docker manifest inspect <image>.

Check for COPY --from with symlinks. If the source stage creates symlinks, COPY --from follows them and copies the target file. But if the symlink target is outside the copied directory tree, the file is silently omitted. Use RUN ls -la /app/dist/ in the source stage to verify that no critical files are actually symlinks pointing elsewhere.

Verify .dockerignore doesn’t have a stale entry. If you recently renamed your output directory (e.g., from build/ to dist/) but .dockerignore still excludes dist, the source files needed to produce the output are present, but the output directory itself is excluded from the build context if a previous local build left it in the project root.

For related Docker issues, see Fix: Docker COPY Failed — File Not Found, Fix: Docker Build ARG Not Available, Fix: Docker Layer Cache Invalidated, and Fix: Docker Exec Format Error.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles