Fix: Docker Multi-Stage Build COPY --from Failed
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 formatOr 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 existOr 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 foundOr 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 directoryWhy 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 . # MatchesFix 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/distMatch 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 pathCommon build output locations by tool:
| Tool | Default 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 buildAdd 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/distEnable 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
.nextPro 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 buildThis 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
distrolessoralpineimage, make sure you compile withCGO_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:alpineRun 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.htmlCheck 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Docker Secrets Not Working — BuildKit --secret Not Mounting, Compose Secrets Undefined, or Secret Leaking into Image
How to fix Docker secrets — BuildKit secret mounts in Dockerfile, docker-compose secrets config, runtime vs build-time secrets, environment variable alternatives, and verifying secrets don't leak into image layers.
Fix: Docker Compose Healthcheck Not Working — depends_on Not Waiting or Always Unhealthy
How to fix Docker Compose healthcheck issues — depends_on condition service_healthy, healthcheck command syntax, start_period, custom health scripts, and debugging unhealthy containers.
Fix: docker-compose.override.yml Not Working — Override File Ignored or Not Merged
How to fix docker-compose.override.yml not being applied — file naming, merge behavior, explicit file flags, environment-specific configs, and common override pitfalls.
Fix: Docker Build ARG Not Available — ENV Variables Missing at Runtime
How to fix Docker ARG and ENV variable issues — build-time vs runtime scope, ARG before FROM, multi-stage build variable passing, secret handling, and .env file patterns.