| |

Docker 15 🐳 Managing Files inside Dockerfiles: COPY vs ADD (Auto-extraction & Remote URLs)

COPY and ADD are the two Dockerfile instructions that bring files from the build context into the image. They look interchangeable, and for the simple case of copying a local file they behave identically. The difference is that ADD does two extra things: it auto-extracts local tar archives and it can fetch remote URLs. Those extra behaviors are the reason the official guidance is to use COPY by default. COPY does one predictable thing, and a reviewer reading the Dockerfile can see exactly what will happen without knowing the file extension of the source.

The trap with ADD is the implicit behavior. ADD app.tar.gz /opt/ leaves the contents of the archive at /opt/, while COPY app.tar.gz /opt/ leaves the archive itself at /opt/app.tar.gz. If a build suddenly changes behavior because someone renamed an artifact, this is why. Understanding when each instruction is appropriate is a matter of knowing what you want the build to do and making that intent explicit.

This chapter covers the build context, COPY and its flags, ADD and its two extra behaviors, the auto-extraction rules, remote URL handling, and the patterns that keep file management predictable and secure.

Key point: Use COPY for nearly everything. Use ADD only when you deliberately want local tar auto-extraction (the classic case is ADD rootfs.tar.xz / when building a base image) or a verified remote download. Avoid ADD <url> for unverified downloads; curl or wget in a RUN instruction gives you checksum verification and cleanup in the same layer.


Why COPY and ADD both exist

The predictability problem. COPY was introduced as an additional instruction because ADD‘s auto-extraction behavior was causing surprises. A user who wanted to copy a compressed file into the image without extracting it would find that ADD had already unpacked it. COPY was introduced to provide a straightforward, predictable copy that does exactly what it says.

The build-context problem. Both instructions copy from the build context, which is the set of files accessible to the Docker daemon during the build. The build context is sent to the daemon when docker build runs, and COPY and ADD operate within that scope. Neither can reach outside the context, which is a deliberate security boundary.

The remote-artifact problem. Some builds need to download a file from the internet. ADD supports this directly. But the download is cached based on the URL, not the content, and ADD does not support authentication on older syntax. For anything more than a simple, versioned, checksum-verified download, RUN curl or RUN wget is a better tool.

The layer-hygiene problem. A remote download via ADD creates a layer that contains the downloaded file. If the file is later deleted, the layer still contains it, and the image size includes it. A RUN curl ... && rm ... does the download, verification, extraction, and cleanup in a single layer, so nothing unnecessary remains in the image.

The cache-precision problem. Modern BuildKit supports ADD --checksum=sha256:... for remote URLs, which binds the download to a specific checksum and improves cache precision. This closes part of the verification gap, though the layer-hygiene argument for RUN curl still stands when cleanup is needed.


a. The build context

Before comparing the instructions, the build context must be understood. When docker build is run, Docker sends the contents of the specified directory (and its subdirectories) to the Docker daemon. That set of files is the build context, and it is the only place COPY and ADD can read from.

The .dockerignore file controls what is excluded from the context. Files like .git, node_modules, and local .env files should be listed there so they never enter the context at all. This reduces the context size and prevents accidental inclusion of files that should not be in the image.

# .dockerignore
.git
node_modules
.env
Dockerfile

b. COPY: predictable file transfer

The COPY instruction copies files and directories from the build context into the image. It has no other behavior.

COPY ./app /usr/src/app
COPY requirements.txt /usr/src/app/

COPY does not extract archives. If the source is a .tar.gz, the archive is copied as-is. COPY does not fetch remote URLs. If the source is a URL, the build fails. This is the point: the instruction does one thing, and the result is predictable from reading the Dockerfile.

COPY supports flags that are important for efficient builds:

FlagPurpose
--from=<stage>Copy from a previous build stage or image
--chown=user:groupSet ownership at copy time
--chmod=<perms>Set permissions at copy time
--linkCreate a layer that can be reused with --cache-from
--parentsPreserve parent directory structure

The --from flag is the backbone of multi-stage builds. A compiled binary is built in a builder stage and copied into a minimal final stage, so the build tools never reach the final image.

FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app

FROM alpine:3.19
COPY --from=builder /app /app
CMD ["/app"]

The --link flag improves cache reuse. When the destination directory is created with --link, the copied files are placed in an empty directory that becomes a layer, and that layer can be reused in subsequent builds even if previous layers have changed. This is useful for rebasing images and for multi-stage builds where a COPY --from would otherwise be invalidated.

The --parents flag preserves the parent directory structure of the source. COPY --parents ./x/a.txt ./y/a.txt /parents/ produces /parents/x/a.txt and /parents/y/a.txt rather than /parents/a.txt twice.


c. ADD: auto-extraction and remote URLs

ADD does everything COPY does, plus two behaviors that COPY does not have.

Auto-extraction of local tar archives. When the source is a local tar archive, ADD extracts it into the destination directory. The recognized formats are .tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz, and .txz. A .zip file or a plain .gz file is not extracted; only tar formats are recognized.

ADD app.tar.gz /app/
# The contents of app.tar.gz are extracted into /app/

The classic use case is building a base image from a root filesystem tarball:

FROM scratch
ADD ubuntu-noble-core-cloudimg-amd64-root.tar.gz /

The --unpack flag controls the behavior explicitly. ADD --unpack=false app.tar.gz /app/ copies the archive without extracting it, which is useful when the implicit behavior is not wanted.

Remote URL download. When the source is a URL, ADD downloads the file to the destination. The file is not extracted, even if it is a tar archive. Remote archives are downloaded without unpacking unless --unpack=true is specified.

ADD https://example.com/tool.tar.gz /tmp/tool.tar.gz
# Downloads the file; does not extract

The download is cached based on the URL. If the URL does not change, Docker may use the cached layer even if the content at the URL has changed. For reproducible builds, pin the URL to a versioned artifact and use --checksum.

ADD --checksum=sha256:270d731bd08040c6a3228115de1f74b91cf441c584139ff8f8f6503447cebdbb \
    https://example.com/app.tar.gz /tmp/app.tar.gz

ADD does not support authentication. If the URL requires credentials, RUN curl or RUN wget must be used, or the BuildKit secrets HTTP_AUTH_HEADER_<host> and HTTP_AUTH_TOKEN_<host> can be configured.


d. Why ADD for remote URLs is usually the wrong tool

The Docker documentation recommends using RUN curl or RUN wget instead of ADD for remote downloads, and the reasons are specific.

Layer bloat. The downloaded file lands in its own layer. If it is later deleted, the layer still contains it, so the image size includes the file even though it is not present in the final filesystem. A RUN curl ... && rm ... does the download and cleanup in one layer, so nothing unnecessary remains.

Verification gap. Classic ADD <url> syntax does not support checksum verification. A RUN curl can verify a SHA256 checksum in the same instruction:

RUN curl -fsSL https://example.com/tool.tar.gz -o /tmp/tool.tar.gz \
 && echo "9f2c8b1d...  /tmp/tool.tar.gz" | sha256sum -c - \
 && tar -xzf /tmp/tool.tar.gz -C /usr/local && rm /tmp/tool.tar.gz

Modern BuildKit adds ADD --checksum=sha256:..., which closes the verification gap, but the layer-hygiene argument still applies when cleanup is needed.

Cache unpredictability. The cache is keyed on the URL, not the content. If the remote file is updated but the URL stays the same, Docker may keep using the old cached version. A versioned URL and --checksum improve this, but RUN curl with an explicit checksum gives full control.

Authentication. ADD does not support HTTP authentication. Any download behind a login or a private registry requires RUN curl or RUN wget.


e. Choosing between COPY and ADD

The decision is straightforward once the behaviors are separated.

BehaviorCOPYADD
Copy local files and directoriesYesYes
Auto-extract local tar archivesNoYes
Fetch remote URLNoYes
Support authenticationNoNo
Support --checksumNoYes (BuildKit)
Predictable for a reviewerYesDepends on file extension

Use COPY for nearly everything. Use ADD only when you deliberately want one of its two extra behaviors.

SituationRecommendation
Copying application sourceCOPY
Copying configuration filesCOPY
Building a base image from a rootfs tarballADD with the tarball
Extracting a local application bundleADD if extraction is intended
Downloading a versioned, checksum-verified artifactADD --checksum=sha256:...
Downloading with authenticationRUN curl or RUN wget
Downloading and cleaning up in one layerRUN curl or RUN wget
Copying from a previous build stageCOPY --from=

The default should be COPY. When ADD is used, the reason should be obvious from the instruction, and a comment should explain it when the reason is not.


f. Flags shared by both

Both COPY and ADD support flags that reduce layers and improve cache behavior.

--chown=user:group sets ownership at copy time. Without it, a separate RUN chown -R would be needed, which duplicates the entire directory into a new layer.

COPY --chown=node:node . /app

--chmod=<perms> sets permissions at copy time. This is a BuildKit feature.

COPY --chmod=755 script.sh /usr/local/bin/script.sh

--from=<stage> copies from a previous build stage or another image. This is only meaningful for COPY in practice, though ADD technically supports it.

--link creates a layer that can be reused independently of previous layers. This is useful for rebasing and for improving cache reuse in multi-stage builds.


Complete Example Session

# ============================================
# PART 1: BASIC COPY
# ============================================
FROM alpine:3.19
WORKDIR /app
COPY app.sh /app/app.sh
CMD ["/app/app.sh"]
# ============================================
# PART 2: COPY WITH TRAILING SLASH
# ============================================
FROM alpine:3.19
COPY config.json /app/
# /app/config.json
# ============================================
# PART 3: ADD AUTO-EXTRACTION
# ============================================
FROM scratch
ADD rootfs.tar.gz /
# Contents of the archive are extracted to the root
# ============================================
# PART 4: ADD WITHOUT EXTRACTION
# ============================================
# syntax=docker/dockerfile:1
FROM alpine:3.19
ADD --unpack=false app.tar.gz /app/app.tar.gz
# Archive is copied, not extracted
# ============================================
# PART 5: ADD REMOTE URL
# ============================================
FROM alpine:3.19
ADD https://example.com/tool.tar.gz /tmp/tool.tar.gz
# Downloads the file; does not extract
# ============================================
# PART 6: ADD REMOTE URL WITH CHECKSUM
# ============================================
# syntax=docker/dockerfile:1
FROM alpine:3.19
ADD --checksum=sha256:270d731bd08040c6a3228115de1f74b91cf441c584139ff8f8f6503447cebdbb \
    https://example.com/app.tar.gz /tmp/app.tar.gz
# ============================================
# PART 7: RUN CURL WITH VERIFICATION AND CLEANUP
# ============================================
FROM alpine:3.19
RUN apk add --no-cache curl && \
    curl -fsSL https://example.com/tool.tar.gz -o /tmp/tool.tar.gz && \
    echo "9f2c8b1d...  /tmp/tool.tar.gz" | sha256sum -c - && \
    tar -xzf /tmp/tool.tar.gz -C /usr/local && \
    rm /tmp/tool.tar.gz
# ============================================
# PART 8: MULTI-STAGE WITH COPY --from
# ============================================
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app

FROM alpine:3.19
COPY --from=builder /app /app
CMD ["/app"]
# ============================================
# PART 9: COPY WITH --chown
# ============================================
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
CMD ["node", "server.js"]
# ============================================
# PART 10: .dockerignore
# ============================================
# .dockerignore
.git
node_modules
.env
*.log

These ten parts cover basic COPY, COPY with a trailing slash, ADD auto-extraction, ADD without extraction, ADD with a remote URL, ADD with a checksum, RUN curl with verification and cleanup, multi-stage COPY --from, COPY --chown, and .dockerignore.


Quick Reference

COPY vs ADD

FeatureCOPYADD
Copy local filesYesYes
Auto-extract local tarNoYes
Fetch remote URLNoYes
Support authenticationNoNo
Support --checksumNoYes (BuildKit)
PredictableYesDepends on file extension

Common Flags

FlagPurpose
--from=<stage>Copy from a build stage or image
--chown=user:groupSet ownership at copy time
--chmod=<perms>Set permissions at copy time
--linkReusable layer for cache
--parentsPreserve parent directory structure
--unpack=<bool>Control tar extraction (ADD)
--checksum=sha256:...Verify remote download (ADD)

When to Use Each

SituationInstruction
Copy source codeCOPY
Copy config filesCOPY
Build base image from rootfs tarballADD
Extract a local bundleADD (if intended)
Download versioned artifactADD --checksum
Download with authenticationRUN curl
Download and clean up in one layerRUN curl
Copy from a build stageCOPY --from

Trailing Slash Behavior

InstructionResult
COPY file /dest/dest is a file
COPY file /dest//dest/file is created

Best Practices

✅ Do This:

# Use COPY for local files
COPY . /app

# Use COPY --from for multi-stage builds
COPY --from=builder /app /app

# Use ADD only for tar extraction
ADD rootfs.tar.gz /

# Use RUN curl for remote downloads with verification
RUN curl -fsSL https://example.com/tool.tar.gz -o /tmp/t.tgz \
 && echo "9f2c8b1d...  /tmp/t.tgz" | sha256sum -c - \
 && tar -xzf /tmp/t.tgz -C /usr/local && rm /tmp/t.tgz

# Use --chown to set ownership at copy time
COPY --chown=node:node . /app

❌ Don’t Do This:

# Use ADD for plain file copies
ADD package.json /app/  # ❌ use COPY

# Use ADD <url> without checksum
ADD https://example.com/tool.tar.gz /tmp/  # ❌ unverified

# Use ADD <url> when authentication is needed
ADD https://private.example.com/tool.tar.gz /tmp/  # ❌ unsupported

# Rely on ADD's implicit extraction
ADD app.tar.gz /app/  # ❌ if you wanted the archive, not its contents

Common Pitfalls

PitfallWhy It HappensFix
Archive unexpectedly extractedUsed ADD with a tar fileUse COPY or --unpack=false
Remote download not verifiedUsed ADD <url>Use --checksum or RUN curl
Layer bloat from downloadFile remains in layer after deletionUse RUN curl && rm
Authentication failsADD does not support authUse RUN curl
Cache uses stale remote fileURL unchanged, content changedUse versioned URL and --checksum
Copy fails from outside contextBuild context boundaryMove file into context or use --from
Trailing slash confusionDestination treated as file vs directoryAlways use trailing slash for directories

Real-World Examples

1. Copy Source Code

COPY . /app

2. Copy Single File

COPY package.json /app/package.json

3. Copy from Builder Stage

COPY --from=builder /app /app

4. Extract Rootfs Tarball

FROM scratch
ADD rootfs.tar.gz /

5. Extract Application Bundle

ADD dist.tar.gz /app/

6. Download with Checksum

ADD --checksum=sha256:... https://example.com/app.tar.gz /tmp/app.tar.gz

7. Download with RUN and Cleanup

RUN curl -fsSL https://example.com/app.tar.gz | tar -xz -C /app

8. Set Ownership at Copy

COPY --chown=node:node . /app

9. Preserve Parent Directories

COPY --parents ./src/a.txt ./src/b.txt /app/

10. Reusable Layer with –link

COPY --link /app /app

Visual

COPY vs ADD Behavior

┌──────────────────────────────────────────────────────────────┐
│  COPY app.tar.gz /opt/                                       │
│  └── /opt/app.tar.gz (archive as-is)                         │
│                                                              │
│  ADD app.tar.gz /opt/                                        │
│  └── /opt/ (contents extracted)                              │
│                                                              │
│  COPY config.json /opt/                                      │
│  └── /opt/config.json (file)                                 │
│                                                              │
│  ADD https://example.com/tool.tar.gz /opt/                   │
│  └── /opt/tool.tar.gz (downloaded, not extracted)            │
└──────────────────────────────────────────────────────────────┘

Decision Flow

┌──────────────────────────────────────────────────────────────┐
│  What are you copying?                                       │
│       │                                                      │
│       ├── Local files/directories ──▶ COPY                   │
│       │                                                      │
│       ├── Local tar archive + want extraction ──▶ ADD        │
│       │                                                      │
│       ├── Remote URL + want verification ──▶ ADD --checksum  │
│       │                                                      │
│       ├── Remote URL + need auth ──▶ RUN curl                │
│       │                                                      │
│       └── Remote URL + want cleanup ──▶ RUN curl && rm       │
└──────────────────────────────────────────────────────────────┘

Layer Hygiene with Remote Download

┌──────────────────────────────────────────────────────────────┐
│  ADD https://example.com/tool.tar.gz /tmp/tool.tar.gz        │
│  └── Layer contains the archive                              │
│  └── Deleting it later does not remove it from the layer     │
│                                                              │
│  RUN curl -fsSL https://example.com/tool.tar.gz -o /tmp/t.tgz \
│   && tar -xzf /tmp/t.tgz -C /usr/local && rm /tmp/t.tgz      │
│  └── One layer: download, extract, cleanup                   │
│  └── No archive remains in the image                         │
└──────────────────────────────────────────────────────────────┘

Multi-Stage with COPY –from

┌──────────────────────────────────────────────────────────────┐
│  STAGE 1: builder                                            │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  FROM golang:1.22                                      │  │
│  │  COPY . .                                              │  │
│  │  RUN go build -o /app                                  │  │
│  └────────────────────────────────────────────────────────┘  │
│                          │                                   │
│                          │ COPY --from=builder /app /app     │
│                          ▼                                   │
│  STAGE 2: final                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  FROM alpine:3.19                                      │  │
│  │  COPY --from=builder /app /app                         │  │
│  │  CMD ["/app"]                                          │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Build tools never reach the final image.                    │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
COPYCopies files and directories from build context
ADDCOPY plus local tar extraction and remote URLs
Auto-extractionADD only; tar formats only
Remote URLADD only; no auth, no extraction by default
--checksumADD with BuildKit; verifies remote download
--unpackADD; controls tar extraction
--fromCopy from a previous build stage
--chownSet ownership at copy time
--linkReusable layer for cache
Default recommendationUse COPY for nearly everything

Key takeaways:

  • COPY does one predictable thing: it copies files and directories. It does not extract archives, does not fetch URLs, and its behavior is obvious from the Dockerfile.
  • ADD does everything COPY does, plus two extra behaviors. It auto-extracts local tar archives and can fetch remote URLs. The auto-extraction is implicit and depends on the file extension, which is why it surprises people.
  • Use COPY for nearly everything. The default should be COPY. Use ADD only when you deliberately want tar extraction or a verified remote download.
  • Avoid ADD <url> for unverified downloads. The download lands in its own layer and remains in the image even if deleted. Use RUN curl or RUN wget for authentication, checksum verification, and cleanup in one layer.
  • Modern BuildKit adds ADD --checksum=sha256:.... This closes the verification gap for remote URLs, but the layer-hygiene argument still applies when cleanup is needed.
  • COPY --from is the backbone of multi-stage builds. It copies artifacts from a builder stage into a minimal final stage, so build tools never reach the final image.
  • Use --chown to set ownership at copy time. Without it, a separate RUN chown duplicates the directory into a new layer.
  • The build context is a security boundary. COPY and ADD cannot reach outside the context. Use .dockerignore to exclude files that should not enter the context at all.

Remember: COPY and ADD both bring files into the image, but ADD has two extra behaviors that make it less predictable. The official guidance is to use COPY by default because it does exactly what it says. Use ADD only when you want local tar auto-extraction or a checksum-verified remote download, and use RUN curl or RUN wget when you need authentication, verification, or cleanup in a single layer. The build context is the scope for both instructions, and .dockerignore controls what is available. Understanding these differences is what keeps Dockerfiles predictable, secure, and small.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!