Emmanuel Corels
← Blog
Containers & Kubernetes

Choose COPY or ADD in a Dockerfile without surprising the build

Use COPY for ordinary context files, reserve ADD for intentional archive or remote-source behavior, lock remote content, minimize context, and verify the final image.

By Emmanuel Corels

COPY and ADD can place the same local file in an image, but they communicate different intent. COPY moves files from a build context, stage, named context or image. ADD also understands remote URLs, Git repositories and archive extraction. Choose the smallest behavior that makes the build obvious.

Use COPY for ordinary application files

# syntax=docker/dockerfile:1
FROM node:24-alpine AS runtime
WORKDIR /app

COPY --chown=10001:10001 package.json package-lock.json ./
RUN npm ci --omit=dev

COPY --chown=10001:10001 src/ ./src/
USER 10001:10001
CMD ["node", "src/server.js"]

This is explicit: local context files are copied without archive extraction or network retrieval. Separate dependency manifests from frequently changing source so BuildKit can reuse the dependency layer when only application code changes.

Understand the build context boundary

docker build --progress=plain --tag billing-api:test .
docker buildx du

The final dot is the context. Source paths in COPY and local ADD are resolved from its root, not from the Dockerfile’s directory. Parent traversal such as ../secret.env cannot escape the context. If a required file is missing, choose the correct context or named context instead of copying the entire workstation.

Keep irrelevant and sensitive data out of context

# .dockerignore
.git
.env
*.pem
node_modules
coverage
dist
incident-data
Dockerfile*
compose*.yaml

Review patterns against the actual build. Docker supports excluding Dockerfiles and ignore files from being copied even though the builder still receives them. Never rely on a later RUN rm secret: data copied into an earlier layer may remain recoverable from image history.

docker build --check .
docker history --no-trunc billing-api:test

Know the archive surprise in ADD

FROM alpine:3.22
WORKDIR /opt/tool
ADD tool-bundle.tar.gz ./

For a recognized local tar archive, ADD extracts its contents into the destination by default. It does not simply preserve tool-bundle.tar.gz. Directory contents merge with existing destination contents and file conflicts are resolved file by file. That may be exactly what you want—but reviewers should be able to see that intention.

Preserve an archive as a file with COPY

COPY tool-bundle.tar.gz /tmp/tool-bundle.tar.gz
RUN mkdir -p /opt/tool  && tar -xzf /tmp/tool-bundle.tar.gz -C /opt/tool  && rm /tmp/tool-bundle.tar.gz

This makes extraction flags and cleanup visible. It also lets the build validate an expected top-level layout before installation. Do not extract an untrusted archive as root without considering absolute paths, traversal entries, ownership and special files.

RUN tar -tzf /tmp/tool-bundle.tar.gz | sed -n '1,40p'

Use ADD for a remote resource only when pinned

# syntax=docker/dockerfile:1
FROM alpine:3.22
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d   https://example.invalid/releases/tool-1.4.2.tar.gz   /tmp/tool.tar.gz

Current Dockerfile syntax supports a SHA-256 checksum for remote HTTP sources. Without a digest, the same Dockerfile can consume different bytes later, and a compromised origin can feed a build arbitrary content. A versioned URL is not a cryptographic pin.

Remote HTTP archives are downloaded as files rather than automatically extracted by default; local tar archives are the classic automatic-extraction case. Do not infer behavior from the filename alone.

Prefer controlled download stages for richer verification

FROM alpine:3.22 AS fetch
RUN apk add --no-cache curl
ARG TOOL_VERSION=1.4.2
RUN curl --fail --location --proto '=https' --tlsv1.2       --output /tmp/tool.tar.gz       "https://downloads.example.invalid/tool-${TOOL_VERSION}.tar.gz"  && echo 'EXPECTED_SHA256  /tmp/tool.tar.gz' | sha256sum -c -

FROM alpine:3.22
COPY --from=fetch /tmp/tool.tar.gz /tmp/tool.tar.gz

A fetch stage can handle signatures, mirrors, authentication and structured failure output without carrying curl into the final image. Use BuildKit secret or SSH mounts for credentials; build arguments and environment variables can leak through history or provenance.

Use multi-stage COPY for compiled artifacts

FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/api ./cmd/api

FROM scratch
COPY --from=build /out/api /api
USER 10001
ENTRYPOINT ["/api"]

COPY --from selects only the intended artifact. Compilers, caches, source and credentials stay outside the runtime filesystem. Pin the base images by digest where reproducibility policy requires it.

Make destination semantics unambiguous

COPY config.yaml /etc/myapp/config.yaml
COPY config.yaml /etc/myapp/
COPY config/ /etc/myapp/

Trailing slashes matter for the destination: a non-slashed path can be the new filename, while a slashed path denotes a directory. For source directories, Docker copies their contents rather than nesting the source directory itself. Build a tiny test image if a refactor changes path shape.

Set ownership during the copy

RUN addgroup -g 10001 app  && adduser -D -H -u 10001 -G app app
COPY --chown=10001:10001 --chmod=0555 bin/service /usr/local/bin/service
USER 10001:10001

This avoids a large corrective chown -R layer and makes the runtime permission contract clear. Name-to-ID resolution depends on account files in the stage; numeric IDs are often more predictable across minimal images.

Control cache invalidation with narrow copies

# Poor cache boundary
COPY . .
RUN npm ci --omit=dev

# Better boundary
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/

Build cache correctness follows instruction inputs. Copying the entire repository before installing dependencies invalidates that expensive layer for README or source edits. Narrow copies improve speed and make the software supply chain easier to audit.

Verify the image, not merely the successful build

docker run --rm --read-only billing-api:test   node -e 'require("./src/server.js")'
docker image inspect billing-api:test   --format '{{.Id}} {{json .Config.User}}'
docker history --no-trunc billing-api:test
docker scout cves billing-api:test

Test expected files, ownership, executable bits, startup user and absence of source secrets. Generate an SBOM and provenance in the approved pipeline. A green build only proves that instructions executed; it does not prove the intended bytes or minimal contents reached the image.

Use a simple decision rule

NeedInstruction
Copy local files or directoriesCOPY
Copy from another stage or imageCOPY --from
Intentionally unpack a local tar archiveADD, with intent documented
Fetch a pinned public URL or Git sourceADD with checksum/commit, or controlled fetch stage
Authenticated or signature-verified downloadSecret-enabled fetch stage, then COPY --from

Default to COPY because its smaller behavior is easier to review. Use ADD when you need an ADD feature—and make the extraction or remote trust decision explicit.

Sources: the solved Stack Overflow question (question by Steve; accepted answer by icecrime, CC BY-SA), and Docker’s official ADD, COPY, and build-context documentation.

Primary source: Review the official reference ↗