Skip to content
Blog

COPY vs ADD in a Dockerfile: When Each One Is Right

COPY and ADD look interchangeable but are not. The exact behavioral differences - URL fetching, tar extraction, --chown, --from, cache invalidation - and why the guidance is to prefer COPY.

Published on • October 17, 2026

AI Assistant

“Always use COPY, never ADD” is one of the most repeated Dockerfile rules, and it is roughly half the story. COPY and ADD are functionally similar for the case people see most often - dropping a file from the build context into the image - but ADD quietly does three extra things that COPY does not, and sometimes you want exactly one of them.

This post lays out the behavioral differences as they exist in the current Dockerfile reference, explains why the guidance still lands on COPY, shows the rare cases where ADD is the right instruction, and covers how to enforce the rule in CI with hadolint’s DL3020 instead of relying on code review memory.

The behavioral differences

Both instructions share the same destination semantics: an absolute path is interpreted from the root of the build stage, a relative path from WORKDIR, a trailing slash on the destination is significant (ADD test.txt /abs creates a file named /abs, ADD test.txt /abs/ creates /abs/test.txt), a missing destination is created along with its parent directories, and for a directory source the contents are copied, not the directory itself. Wildcards follow Go’s filepath.Match rules in both.

What differs:

BehaviorCOPYADD
Files from build contextYesYes
Files from a build stage, named context or image (--from)YesNo
Remote URL download (HTTP/HTTPS)NoYes
Git repository as sourceNoYes (HTTP or SSH address)
Local tar archive auto-extractionNo - copies the archive fileYes, by default
Remote tar auto-extractionNoNo by default - requires --unpack
--chown, --chmod, --link, --excludeYesYes
--checksum verificationNoYes (HTTP: sha256:<hash>; Git: commit SHA)
--parentsYes (Dockerfile 1.20)No
--keep-git-dirNoYes (1.11 default: .git excluded)
Authenticated downloadsN/ANo - use RUN wget/curl or HTTP_AUTH_HEADER_<host> / HTTP_AUTH_TOKEN_<host> build secrets

Source selection is the clearest split. The reference describes COPY sources as build context, build stage, named context, or image; ADD sources as build context, a remote URL, or a Git repository. In a build with no context at all (docker build - < Dockerfile), COPY can only reach other stages via --from, while ADD can only reach remote files.

URL fetching

ADD https://example.com/archive.zip /usr/src/things/
ADD git@github.com:user/repo.git /usr/src/things/

BuildKit detects the source type: local file or directory, local tar archive, URL, or Git repository. Details that matter in practice:

  • For a URL, the destination file gets permissions of 600, and a Last-Modified response header sets the file’s mtime - but mtime is explicitly not part of the cache decision, so a remote file whose content did not change does not bust the cache.
  • If the destination ends with a trailing slash, the filename is inferred from the URL path (ADD http://example.com/foobar / creates /foobar); otherwise the destination path becomes the filename.
  • URL authentication is not supported by the instruction itself.
  • For Git sources, the repository is cloned to the destination, .git is excluded by default (--keep-git-dir=true preserves it), and file permission bits are normalized: 644 for files (755 if the executable bit is set), 755 for directories.

Tar extraction

A local tar archive in a recognized compression format - gzip, bzip2, xz, zstd, or uncompressed - is decompressed and extracted into the destination by default, behaving like tar -x: the result is the union of whatever already existed at the destination and the source tree, with conflicts resolved in favor of the added content. Whether a file counts as an archive is decided by contents, not filename - an empty file named .tar.gz is just copied.

Remote tars are the opposite: downloaded without unpacking, unless you pass --unpack=true. The flag exists to make the asymmetry explicit in both directions:

# syntax=docker/dockerfile:1
FROM alpine
ADD --unpack=true https://example.com/archive.tar.gz /download   # download and extract
ADD --unpack=false my-archive.tar.gz .                            # local archive, copied as-is

Shared flags

--chown and --chmod behave the same on both instructions (the ADD section simply defers to the COPY docs for them), as does --link, which creates a cache-friendly linked layer rather than a full parent-dependent one. Multi-source syntax is identical too: with several sources or a wildcard, the destination must be a directory ending in / - hadolint flags violations as DL3021.

--from is the flag that belongs to COPY alone, and it is the backbone of multi-stage builds:

# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

There is no ADD --from=.... If you find yourself reaching for one, you wanted COPY.

Cache invalidation

Both instructions invalidate downstream RUN steps when their inputs change - the Dockerfile reference notes that the cache for RUN instructions can be invalidated by ADD and COPY. The practical advice from Docker’s Intro Guide to Dockerfile Best Practices is about scope, not choice: “Only copy what’s needed… any changes to the files being copied will break the cache,” which is why the guide pushes back on COPY . and recommends copying only the built artifact when that is all the image needs.

Where ADD differs is remote sources: because mtime is excluded from cache determination for URL fetches, an unchanged remote file keeps the cache warm. And because ADD with a URL is a single cacheable instruction (as opposed to a RUN curl that depends on the layer below it), Docker’s best-practices docs argue it “ensures a more precise build cache” than replicating download-and-extract with shell commands.

Why the guidance is “prefer COPY”

Docker’s current best-practices page frames it as a default rather than a prohibition:

  • COPY supports basic copying of files into the container, from the build context or from a stage in a multi-stage build.
  • ADD supports fetching files from remote HTTPS and Git URLs, and extracting tar files automatically when adding files from the build context.
  • “You’ll mostly want to use COPY.”

The reasoning behind “mostly”: COPY does only what its name says, so a reader of the Dockerfile knows a file came from the repository without having to wonder whether a URL was fetched or an archive was silently unpacked. ADD’s extras are hidden behavior - the same line can produce a directory tree or a single compressed blob depending on what the source turns out to be.

The best-practices page also offers a third option that beats both for a common case: when files only need to exist during a RUN step, use a bind mount instead of either instruction.

# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
    pip install --requirement /tmp/requirements.txt
COPY . .

Bind-mounted files exist only for that single RUN and never land in the final image or its layer history.

The rare legitimate ADD use

There are two, and both are documented.

1. Downloading a remote artifact during the build. This is the case Docker itself highlights: ADD beats a hand-rolled RUN wget plus RUN tar because it is one cacheable instruction, supports --checksum for integrity, and parses branches, tags and subdirectories from Git URLs. The canonical example from the docs pulls a .NET runtime tarball in a throwaway stage:

# syntax=docker/dockerfile:1
FROM scratch AS src
ARG DOTNET_VERSION=8.0.0
ADD --checksum=sha256:270d731bd08040c6a3228115de1f74b91cf441c584139ff8f8f6503447cebdbb \
    https://dotnetcli.azureedge.net/dotnet/Runtime/$DOTNET_VERSION/dotnet-runtime-$DOTNET_VERSION-linux-arm64.tar.gz \
    /dotnet.tar.gz

FROM mcr.microsoft.com/dotnet/runtime-deps:8.0 AS installer
RUN --mount=from=src,target=/src <<EOF
mkdir -p /dotnet
tar -oxzf /src/dotnet.tar.gz -C /dotnet
EOF

FROM mcr.microsoft.com/dotnet/runtime-deps:8.0
COPY --from=installer /dotnet /usr/share/dotnet

Note the shape: ADD --checksum in a scratch stage, extraction in an intermediate stage, and plain COPY --from into the final image - so no tarball or download artifacts ship in the runtime layer. Pinning the SHA-256 means a compromised or silently re-cut upstream file fails the build instead of being pulled in.

2. Auto-extracting a local tar archive that is part of your context. This is hadolint’s own stated exception for DL3020: “the best use for ADD is local tar file auto-extraction into the image.” If you deliberately commit a vendored tarball and want its contents in the image, ADD assets.tar.gz /app/assets/ is exactly right - the alternative is a RUN tar that adds a shell layer and a dependency on tar in the base image.

Everything else - application code, config files, go.mod, compiled artifacts from another stage, static assets - is a COPY.

One extra caution from the best-practices page: avoid relying on ADD (or COPY) inside ONBUILD. A child image whose build context lacks the referenced resource fails catastrophically, which is why ONBUILD images should carry their own tags.

Enforcing it with hadolint DL3020

Rules are easier to keep than to review. hadolint implements the preference as DL3020 - “Use COPY instead of ADD for files and folders” with a default severity of Error, and its wiki rationale quotes Docker’s own line: for items that do not require ADD’s tar auto-extraction capability, always use COPY. It has a mirror-image rule too: DL3010 - “Use ADD for extracting archives into an image” (Info), which nudges the other way when someone COPYs an archive they clearly meant to unpack.

Run it locally by piping the file to the container image:

$ docker run --rm -i hadolint/hadolint < Dockerfile
Dockerfile:12 DL3020 Use COPY instead of ADD for files and folders

Or straight from the CLI:

$ hadolint --ignore DL3021 Dockerfile

Two legitimate ways to suppress it when you really do mean ADD:

# hadolint ignore=DL3020
ADD https://example.com/tool-1.4.2.tar.gz /opt/tool/
# .hadolint.yaml, discovered from $PWD upward
ignored:
  - DL3020

Global CLI suppression is --ignore DL3020 (which discards the config file’s ignore list), or HADOLINT_IGNORE=DL3020 as an environment variable. For a CI gate, combine the rule with --failure-threshold error so the job exits non-zero only on error-level findings - DL3020 qualifies by default, so a stray ADD in a pull request fails the pipeline while Info-level nits still print.

Related rules worth turning on at the same time: DL3021 (multi-source COPY whose destination does not end in /), DL3022 (COPY --from referencing an undefined stage), DL3045 (COPY to a relative destination without WORKDIR) and DL3067 (copying an entire filesystem out of another stage).

Checklist

  • Default every file placement to COPY - code, config, artifacts from other stages.
  • Reach for ADD only for a remote URL/Git fetch (with --checksum) or local tar auto-extraction.
  • Anything copied only for a RUN step uses RUN --mount=type=bind instead of COPY/ADD.
  • You know the extraction asymmetry: local tars unpack by default, remote tars need --unpack=true.
  • --from is only ever written on COPY; multi-stage handoffs never use ADD.
  • Destination paths are absolute, backed by WORKDIR, and multi-source destinations end in /.
  • COPY . is scoped down (or .dockerignore trims the context) so unrelated file changes do not bust the cache.
  • hadolint runs in CI with DL3020 as an error, and intentional ADDs carry a # hadolint ignore=DL3020 comment.

Sources: Dockerfile reference - ADD and COPY, Docker docs - Building best practices (ADD or COPY), Intro Guide to Dockerfile Best Practices, hadolint README - rules and ignoring rules, hadolint wiki - DL3020