Home DevOps Docker COPY vs ADD: 5 Critical Rules Pros Follow Daily
Beginner 3 min · September 07, 2026
Docker COPY vs ADD Instruction

Docker COPY vs ADD: 5 Critical Rules Pros Follow Daily

ADD auto-extracts tarballs and fetches URLs — COPY doesn't.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
Before you start⏱ 11 min
  • You can write and build a basic Dockerfile
  • You understand image layers and build cache basics
  • You've used multi-stage builds at least once
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • COPY copies local files verbatim; ADD adds tar auto-extraction, remote URL fetching, and checksum validation on top
  • Use COPY for everything local by default; reach for ADD only for remote artifacts with --checksum or local tarballs you truly want extracted
  • Performance insight: one ADD of a changing remote URL invalidates every layer below it — teams have seen 1.2 GB rebuilds from a single ADD line
  • Production insight: ADD's silent tar extraction once overwrote /app/config with archive contents in a deploy — explicit RUN curl + tar is auditable, magic is not
  • Rule: COPY first, ADD on exception, bind mounts for build-only files, and always pin remote artifacts with checksums
✦ Definition~90s read
What is Docker COPY vs ADD Instruction?

COPY and ADD are Dockerfile instructions that place files into image layers. COPY takes files from the build context (or a prior build stage) and copies them verbatim to a destination path. It does one thing, predictably.

Think of building a Docker image like packing a shipping container.

ADD does everything COPY does plus two extras: if the source is a local tar archive, it automatically extracts it at the destination; if the source is a remote HTTPS or Git URL, it downloads the file into the image. Modern Docker also lets ADD verify downloads with --checksum.

The official docs now recommend COPY by default and ADD only when you need its extras — a position this article follows with concrete production reasoning.

Plain-English First

Think of building a Docker image like packing a shipping container. COPY is the careful mover who places each labeled box exactly where you point — nothing more. ADD is the mover who also opens any suitcase he finds and unpacks it, and who'll drive across town to fetch a package from a URL you hand him. Sometimes you want that extra service. Most days you just want your boxes placed untouched, because surprise unpacking breaks the careful arrangement you planned.

Every Dockerfile tutorial whispers the same advice: prefer COPY over ADD. Then every team inherits a Dockerfile with ADD everywhere and nobody remembers why the rule exists.

The confusion is fair — both instructions put files into images, and for plain local files they behave identically. The difference hides in the extras: ADD auto-extracts tarballs and downloads remote URLs, COPY never does. Those extras look convenient until they invalidate your build cache or silently unpack 400 files over your config.

You'll get five rules, not one. They cover when ADD earns its keep (checksummed remote artifacts), when COPY wins (everything else), and the modern third option — bind mounts — that beats both for build-only files.

What Each Instruction Actually Does

COPY src dest takes files from the build context (or --from=stage) and duplicates them into the image. Ownership, permissions, and bytes are preserved. No network, no extraction, no surprises.

ADD src dest does the same for plain files, then adds two behaviors: local tar files (including .tar.gz) are extracted into the destination directory, and remote http(s) or Git URLs are fetched over the network into the image. Recent BuildKit adds --checksum and --keep-git-dir refinements to the remote path.

That 'same plus extras' framing explains every rule below. If you need zero extras, COPY's predictability is free. If you need an extra, you pay with cache and auditability questions — so use ADD deliberately, not habitually.

📊 Production Insight
The Friday incident was pure 'extras' surprise: plain-file mental model, tar-extraction reality. Naming the behavior out loud in review would have caught it.
🎯 Key Takeaway
COPY = verbatim copy. ADD = copy plus tar extraction plus URL fetching. Choose based on whether you want the extras.

Rule 1: Default to COPY for Local Files

For requirements.txt, source trees, and configs, COPY is strictly better: identical results to ADD with zero magic. COPY app/ /app/ places bytes; reviewers see bytes. Cache invalidation follows file checksums, nothing else.

ADD on the same inputs behaves identically today but invites tomorrow's surprise — someone swaps app.tar.gz into that line and extraction starts silently. COPY can't change meaning under you.

Make it a lint rule: hadolint DL3014/DL3020 flag ADD usage, and every ADD needs a comment justifying the extra. Defaults should be boring; exceptions should explain themselves.

Dockerfile.copy-defaultDOCKERFILE
1
2
3
4
5
6
7
8
9
# Prefer this: explicit, cache-friendly, no magic
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/

# Avoid this for plain files: identical today, surprising tomorrow
# ADD app/ ./app/
📊 Production Insight
After enforcing COPY-by-default, one team's 'mystery file' Dockerfile tickets dropped to zero — every placement became greppable and reviewable.
🎯 Key Takeaway
COPY for all local files. Require a justification comment on every ADD.

Rule 2: Use ADD Only for Checksummed Remote Artifacts

ADD earns its place downloading a pinned remote artifact in one layer with integrity verification: ADD --checksum=sha256:270d... https://example.com/tool.tar.gz /tmp/tool.tar.gz. The checksum pins the bytes; a changed upstream fails loudly instead of shipping silently.

Without --checksum, remote ADD is a reproducibility hole — rebuilds fetch whatever the URL serves today, and any byte change busts the cache for every layer below. That's how a one-line ADD triggers a 1.2 GB full rebuild.

Even then, consider RUN curl -fsSL --output with explicit retry flags and a verified digest. curl shows in the layer history exactly what ran; ADD hides the fetch behind instruction semantics. Both are defensible — unpinned remote ADD is not.

Dockerfile.add-remoteDOCKERFILE
1
2
3
4
5
6
7
8
# Acceptable ADD: pinned remote artifact with checksum
ADD --checksum=sha256:270d731bd08040c6a3228115de1f74b91cf441c584139ff8f8f6503447cebdbb \
    https://dotnetcli.azureedge.net/dotnet/Runtime/8.0.0/dotnet-runtime-8.0.0-linux-x64.tar.gz \
    /tmp/dotnet.tar.gz

# Equivalent explicit alternative (more auditable):
# RUN curl -fsSL --retry 3 -o /tmp/dotnet.tar.gz <url> \
#   && echo "270d73... /tmp/dotnet.tar.gz" | sha256sum -c -
📊 Production Insight
Unpinned remote ADD caused a 1.2 GB cache-busting rebuild when a vendor re-cut a tarball mid-release. Checksums turned the next upstream change into a loud, reviewable failure.
🎯 Key Takeaway
Remote ADD only with --checksum. Otherwise fetch explicitly with curl and verify the digest.

Rule 3: Never Rely on Silent Tar Extraction

ADD's auto-extraction merges archive contents into the destination, including nested directories that collide with your files. No manifest, no --strip-components equivalent, no dry run. The Friday deploy proved the cost.

The explicit pattern is two lines and fully reviewable: COPY bundle.tar.gz /tmp/ followed by RUN tar -xzf /tmp/bundle.tar.gz -C /app/vendor --strip-components=1 && rm /tmp/bundle.tar.gz. Reviewers see the strip level, the target, and the cleanup.

If you genuinely want ADD extraction (rare), comment the expected top-level paths in the Dockerfile so the next reader knows what should appear. Unexplained ADD of an archive is a review red flag.

📊 Production Insight
Explicit tar with --strip-components=1 plus a config-checksum CI assertion now guards every vendor bundle. Extraction surprises have no path back in.
🎯 Key Takeaway
COPY the archive, RUN tar explicitly. Silent extraction is a deploy risk, not a convenience.

Rule 4: Bind Mounts Beat Both for Build-Only Files

requirements.txt, .npmrc, and private repos needed only during RUN shouldn't persist in any layer. COPY bakes them in (leaking secrets into history); ADD can't help. Bind mounts solve it: RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt pip install -r /tmp/requirements.txt leaves no trace in the final image.

Secrets get their own mount: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci reads the credential at build time without storing it. Combined with multi-stage builds (COPY --from=builder), the final image holds only compiled output.

Adopt the mantra: COPY for files that ship, mounts for files that build. Image sizes shrink, secret scans go quiet, and .dockerignore stops being load-bearing for security.

Dockerfile.bind-mountsDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
# requirements.txt used but NOT stored in the image
RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
    pip install --no-cache-dir -r /tmp/requirements.txt
COPY app/ ./app/

# Secrets without a trace (BuildKit):
# RUN --mount=type=secret,id=pipconf,target=/etc/pip.conf \
#     pip install --no-cache-dir -r /tmp/requirements.txt
💡Ship less, mount more
If a file isn't needed at runtime, it shouldn't be COPY'd. Bind-mount it for the RUN step and keep the final image — and its vulnerability surface — smaller.
📊 Production Insight
Switching pip installs to bind mounts cut one image from 1.2 GB to 380 MB and removed a leaked internal index URL from layer history.
🎯 Key Takeaway
Build-only files get bind mounts; secrets get secret mounts. COPY only what the runtime needs.

Rule 5: Permissions, .dockerignore, and Cache Order

Three finishing details separate senior Dockerfiles. First, set ownership inline: COPY --chown=app:app --chmod=0755 entrypoint.sh /app/ avoids a follow-up RUN chown that duplicates the layer's bytes.

Second, curate .dockerignore like .gitignore's stricter sibling: exclude .git, node_modules, *.log, and test fixtures so COPY . . doesn't ship 2 GB of junk or bust cache on irrelevant edits.

Third, order layers by change frequency: stable base deps first (requirements.txt + pip install), volatile app code last. A requirements-only change rebuilds one layer; app-code-first ordering rebuilds everything below it. These three habits compound into 10x faster rebuilds.

📊 Production Insight
Reordering COPY so requirements install before app code cut median rebuilds from 6 minutes to 35 seconds — same image, smarter layer order.
🎯 Key Takeaway
--chown/--chmod inline, strict .dockerignore, stable-first layer order. Small habits, huge rebuild wins.

Decision Flowchart You'll Actually Remember

Ask one question: does this file ship in the final image? No → bind mount (or secret mount). Yes → next question: is it remote? Remote with a real need → ADD with --checksum. Remote otherwise → RUN curl explicitly.

Local file shipping in the image → COPY, always. Local tarball you want extracted → COPY plus explicit RUN tar. That's the whole flowchart, and it fits on a sticky note.

Tape it to the review checklist: every ADD must answer 'which extra do I need and where's the checksum?' If the author can't answer, it's a COPY.

📊 Production Insight
The sticky-note flowchart ended ADD debates in review — exception requests now arrive with checksums attached instead of arguments.
🎯 Key Takeaway
Ship? No = mount. Remote? ADD only with checksum. Local? COPY, plus explicit tar when needed.
● Production incidentPOST-MORTEMseverity: high

The Tarball That Ate /app/config on Deploy Friday

Symptom
After a routine Friday deploy, EU invoices showed USD amounts and tax IDs vanished. The app booted cleanly — no crash, no alert — because the vendor tarball contained its own config/ directory that overlaid /app/config with defaults. Health checks passed; only finance reconciliation caught the $40k currency mismatch 6 hours later.
Assumption
The author assumed ADD vendor-assets.tar.gz /app/ would place the archive as a single file for the app to read. Nobody on the reviewers knew ADD auto-extracts local archives — the Dockerfile comment even said 'copy vendor bundle.' Staging didn't catch it because staging mounts its config from a volume, masking the image contents.
Root cause
ADD's tar auto-extraction unpacked vendor-assets.tar.gz directly into /app/, and its internal config/ subtree merged over /app/config/. Docker's build log mentioned extraction, but the CI log viewer truncated that line. The image grew from 380 MB to 1.2 GB in the same build, a signal nobody graphed.
Fix
Replaced ADD with COPY plus an explicit extraction step: COPY vendor-assets.tar.gz /tmp/ then RUN tar -xzf with --strip-components into /app/vendor/. Added a CI check asserting /app/config/checksum matches the committed config, and an image-size budget that fails builds growing >10%. Staging volumes were changed to mirror prod so masking can't recur.
Key lesson
  • Implicit behavior is a deploy risk: prefer explicit COPY + RUN tar so every file placement is visible and reviewable.
  • Assert image contents and size in CI — a 3x size jump or a config checksum mismatch should fail the build, not page finance.
Production debug guideFive Dockerfile file-handling failures and the precise fix for each.5 entries
Symptom · 01
Files appear extracted/unpacked when you expected a single archive
Fix
You used ADD on a local tarball — it auto-extracts. Switch to COPY for the archive, then add an explicit RUN tar step with --strip-components so the layout is visible in review.
Symptom · 02
Every build re-downloads a remote file and invalidates all later layers
Fix
ADD of a URL busts cache whenever the remote changes (or headers shift). Pin with ADD --checksum=sha256:<digest> <url>, or better: RUN curl -fsSL with a pinned digest in a versioned base layer.
Symptom · 03
COPY fails with 'not found' but the file exists on your laptop
Fix
The file is outside the build context or excluded by .dockerignore. Check docker build's context path, grep .dockerignore for the pattern, and keep needed files under the context root.
Symptom · 04
Secrets or huge files end up baked into the image
Fix
COPY of .env or credentials persists them in a layer forever. Use RUN --mount=type=secret for secrets and --mount=type=bind for build-only files like requirements.txt — neither persists in the final image.
Symptom · 05
Permissions wrong on copied configs (root-owned, unreadable)
Fix
Add --chown=app:app --chmod=0644 to COPY/ADD (BuildKit). Verify with docker run --rm img stat -c '%U %a' /app/config. Don't fix with a follow-up RUN chown — it doubles the layer size.
COPY vs ADD vs Mounts at a Glance
CapabilityCOPYADDBind mount
Local files verbatimYesYesNot persisted
Tar auto-extractionNoYes (silent)N/A (use RUN tar)
Remote URL fetchNoYesNo (use RUN curl)
Checksum pinningN/AYes (--checksum)N/A
Secrets-safeNo (persists)No (persists)Yes (no trace)
Cache behaviorFile checksumURL/bytes change bustsNo layer added
Default choiceYes for shipped filesException onlyYes for build-only
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
Dockerfile.copy-defaultFROM python:3.12-slim AS baseRule 1
Dockerfile.add-remoteADD --checksum=sha256:270d731bd08040c6a3228115de1f74b91cf441c584139ff8f8f6503447...Rule 2
Dockerfile.bind-mountsFROM python:3.12-slimRule 4

Key takeaways

1
COPY is the default for local files; ADD is the exception needing justification.
2
Remote ADD requires --checksum; otherwise fetch explicitly with curl + digest check.
3
Never depend on silent tar extraction
COPY the archive and RUN tar explicitly.
4
Bind-mount build-only files and secrets so they leave no layer trace.
5
Set --chown/--chmod inline, keep .dockerignore strict, order stable layers first.

Common mistakes to avoid

4 patterns
×

Using ADD for plain local files out of habit

Symptom
Works today, then silently extracts a tarball tomorrow and overlays configs.
Fix
Default to COPY; lint-flag every ADD and require a justification comment.
×

ADD of an unpinned remote URL

Symptom
Rebuilds fetch new bytes, bust cache for all lower layers, ship unreviewed upstream changes.
Fix
Add --checksum, or fetch with curl + digest verification in a versioned layer.
×

COPY . . without a strict .dockerignore

Symptom
2 GB contexts, leaked .env files, cache busted by every log edit.
Fix
Exclude .git, node_modules, *.log, fixtures; COPY specific paths instead of the whole context.
×

Fixing ownership with a follow-up RUN chown

Symptom
Image doubles the file bytes across two layers; larger pushes, slower pulls.
Fix
Use COPY --chown=app:app --chmod=0644 inline (BuildKit) so ownership is set in one layer.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the functional difference between COPY and ADD?
Q02SENIOR
When is ADD the right choice over COPY + curl?
Q03SENIOR
How do bind mounts change the COPY vs ADD debate?
Q01 of 03JUNIOR

What's the functional difference between COPY and ADD?

ANSWER
COPY copies local/staged files verbatim. ADD adds tar auto-extraction for local archives and remote URL fetching with optional --checksum. For plain local files they're identical — which is why COPY is the safe default.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Should I always use COPY instead of ADD?
02
Does ADD extract zip files too?
03
Why did my remote ADD rebuild every layer?
04
How do I keep secrets out of image layers?
05
COPY failed with 'not found' but the file exists — why?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
🔥

That's Docker. Mark it forged?

3 min read · try the examples if you haven't

Previous
Docker Host Access from Containers
20 / 20 · Docker