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.
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
| Need | Instruction |
|---|---|
| Copy local files or directories | COPY |
| Copy from another stage or image | COPY --from |
| Intentionally unpack a local tar archive | ADD, with intent documented |
| Fetch a pinned public URL or Git source | ADD with checksum/commit, or controlled fetch stage |
| Authenticated or signature-verified download | Secret-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 ↗