Dockerfile COPY vs ADD
When to use COPY and when ADD is right, --chown, --chmod, --from, checksums, .dockerignore, and the build-context errors that stop a build dead.
COPY and ADD both bring files from the build context into the image. The short answer is use COPY, but the long answer is worth knowing, because ADD has picked up genuinely useful features recently and the old advice to never touch it is now slightly out of date.
The build context decides what is copyable
Before either instruction runs, Docker packages a directory and sends it to the daemon. That directory is the build context, the . at the end of docker build -t myapp ., and it is the only thing COPY can read from.
docker build -t myapp .
[+] Building 0.4s (8/8) FINISHED
=> [internal] load build context
=> => transferring context: 2.41MB
That transferring context line is worth watching. If it reports hundreds of megabytes, you are shipping node_modules and .git to the daemon on every single build before any instruction executes.
This is also why the most confusing COPY error exists:
COPY ../shared/config.json /app/
failed to solve: failed to compute cache key: "/../shared/config.json":
not found
Nothing outside the context is reachable, no matter how valid the path looks on your machine. The fix is to build from a directory that contains everything you need and point at the Dockerfile explicitly:
docker build -f services/api/Dockerfile -t api .
COPY: the default choice
# Single file
COPY server.js /app/server.js
# Into a directory. The trailing slash matters
COPY server.js /app/
# Multiple sources always need a directory destination
COPY package.json package-lock.json /app/
# Wildcard
COPY *.json /app/
# Entire directory
COPY ./src /app/src
Source paths are relative to the build context. Destination paths are absolute, or relative to WORKDIR.
The trailing-slash trap
COPY server.js /app
If /app does not already exist, this creates a file named /app containing your server code. The next instruction that expects /app to be a directory then fails with something that looks unrelated:
/bin/sh: cd: line 0: can't cd to /app: Not a directory
Two safe habits: always write a trailing slash on directory destinations, or set WORKDIR /app first and copy to ..
Directory copy semantics
COPY copies the contents of a source directory, not the directory itself:
COPY ./src /app/ # src/index.js lands at /app/index.js
COPY ./src /app/src/ # src/index.js lands at /app/src/index.js
Getting this backwards produces a /app/src/src nesting that is easy to miss until something 404s at runtime.
COPY --chown
Copied files are owned by root by default. If your container runs as a non-root user, and it should, that user may not be able to write to its own application directory:
Error: EACCES: permission denied, open '/app/logs/app.log'
--chown sets ownership as part of the same layer, which costs nothing. A separate RUN chown -R duplicates every file into a new layer and can double your image size:
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]
Numeric IDs are more portable, since a named user has to exist in the image already:
COPY --chown=1000:1000 . .
Referencing a user that does not exist yet fails the build:
failed to solve: unable to convert uid/gid chown string to host mapping:
can't find uid for user appuser: no such user
Create the user with RUN adduser before the COPY that references it.
COPY --chmod
COPY --chmod=755 entrypoint.sh /entrypoint.sh
COPY --chmod=644 config.yaml /app/config.yaml
This saves a RUN chmod layer and, more usefully, works regardless of the permissions the file had on the machine that built it, which is what makes a Windows checkout produce a working entrypoint script.
--chmod needs BuildKit, the default builder in current Docker. On an older engine, add the syntax directive at the very top of the Dockerfile:
# syntax=docker/dockerfile:1
COPY --from
--from reads from another build stage, or from an entirely separate image, instead of the build context:
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Pulling a single binary out of a published image is the other common use, and it beats a curl and checksum dance:
COPY --from=hashicorp/terraform:1.9 /bin/terraform /usr/local/bin/terraform
The dependency-manifest pattern
The highest-value COPY pattern in any Dockerfile. Copy the manifest, install, then copy source:
COPY package*.json ./
RUN npm ci
COPY . .
Reverse those and every one-character source edit reinstalls every dependency, because the COPY . . layer is invalidated and everything after it rebuilds. The same shape applies in every ecosystem:
# Python
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Go
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Ruby
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
COPY with Ownership
You are writing a Dockerfile based on node:20-alpine with WORKDIR /app. You want to copy package.json into the image owned by the "node" user, install dependencies, and then copy the rest of the source - also owned by "node". Which snippet is correct?
ADD: what it actually does
ADD is COPY plus three behaviours.
Auto-extracts local tar archives. This is the one legitimate reason to reach for it:
ADD rootfs.tar.gz /
It is also the behaviour that bites people. A .tar.gz you meant to ship verbatim gets unpacked instead, and nothing warns you. Note the asymmetry. A local archive is extracted, a remote one is not.
Fetches URLs, now with checksum verification. The long-standing objection to ADD <url> was that you could not verify what you downloaded. Modern Dockerfile syntax fixes that:
# syntax=docker/dockerfile:1
ADD --checksum=sha256:24454f830c... https://example.com/tool.tar.gz /tmp/
Clones a Git repository:
ADD --keep-git-dir=true https://github.com/moby/buildkit.git#v0.13.0 /buildkit
When to prefer RUN curl anyway
ADD <url> still cannot clean up after itself. The downloaded file is in that layer permanently. When you need to download, use, and discard something, one RUN does all three in a single layer:
RUN curl -fsSL -o /tmp/tool.tar.gz https://example.com/tool.tar.gz \
&& echo "24454f830c... /tmp/tool.tar.gz" | sha256sum -c \
&& tar xzf /tmp/tool.tar.gz -C /usr/local/bin \
&& rm /tmp/tool.tar.gz
The rule: COPY by default. ADD for local tar extraction, or for a URL when the file is genuinely meant to stay in the image and you pin a --checksum.
.dockerignore
COPY . . takes everything in the context unless you exclude it. The syntax mirrors .gitignore:
# Dependencies. Reinstalled inside the image anyway
node_modules/
vendor/
__pycache__/
.venv/
# Version control
.git/
.gitignore
# Secrets and local config
.env
.env.*
*.pem
*.key
# Build artifacts
dist/
build/
target/
# IDE and OS noise
.vscode/
.idea/
.DS_Store
# Tests and docs
tests/
coverage/
*.md
Three things it buys you: local secrets never get baked into a layer, the context shrinks so builds start faster, and irrelevant file changes stop invalidating your cache.
Negation works, which is how you ship one file out of an excluded directory:
dist/
!dist/manifest.json
The classic .dockerignore failure is over-excluding. A blanket * or an overly broad pattern silently hides a file COPY needs, and the error names the file rather than the ignore rule:
failed to compute cache key: "/package.json": not found
If a COPY cannot find a file that plainly exists, check .dockerignore before anything else.
Common mistakes
| Mistake | Consequence |
|---|---|
COPY . . before dependency install | Every source edit reinstalls everything |
No .dockerignore | Slow builds, secrets baked into layers |
COPY server.js /app with no /app | Creates a file named /app |
RUN chown -R after COPY | Duplicates every file into a new layer |
--chown naming a user created later | Build fails with "no such user" |
ADD for an ordinary local file | Silent tar extraction |
ADD <url> with no --checksum | Unverified content in the image |
COPY ../file | Outside the build context; unreachable |
Quick reference
| Task | Instruction |
|---|---|
| Copy a file | COPY src.js /app/ |
| Copy and set owner | COPY --chown=node:node . . |
| Copy and set mode | COPY --chmod=755 entry.sh / |
| Copy from a stage | COPY --from=build /app/dist ./dist |
| Copy from an image | COPY --from=alpine:3.20 /etc/ssl /etc/ssl |
| Extract a local tarball | ADD rootfs.tar.gz / |
| Download and verify | ADD --checksum=sha256:… <url> /tmp/ |
| Inspect context size | Read transferring context in build output |
Key takeaways
- Only the build context is copyable.
COPY ../xcan never work; change what you build from instead. - Use
COPY. Reach forADDonly for local tar extraction or a checksummed download. - A trailing slash on the destination is the difference between a directory and a file named
/app. --chownin theCOPYcosts nothing; a laterRUN chown -Rduplicates every file into a new layer.- Manifest first, source last: the single biggest build-speed win available.
- Write
.dockerignorebefore your firstCOPY . ., and suspect it whenever a file "is not found". --fromreads other stages and other images, which often replaces a download entirely.
Check your understanding
A path that exists but cannot be copied
A Dockerfile at services/api/Dockerfile has COPY ../../shared/config.json /app/. The file definitely exists on disk, but the build fails with "not found". Why?
Frequently Asked Questions
What is the difference between COPY and ADD in a Dockerfile?
COPY copies files and directories from the build context into the image and does nothing else. ADD does the same, and additionally extracts local tar archives automatically, downloads remote URLs, and clones Git repositories. The extras are why the default advice is COPY. An archive you meant to ship intact gets silently unpacked. Use ADD deliberately, for local tar extraction or a URL download you pin with --checksum, and COPY for everything else.
Why does my COPY fail with "forbidden path outside the build context"?
Because the source path climbs above the directory you passed to docker build. Docker packages that directory and sends it to the daemon before any instruction runs, so anything outside it simply does not exist as far as COPY is concerned. Regardless of how valid the path looks in your shell. Build from a directory that contains everything you need and point at the Dockerfile with -f, for example docker build -f services/api/Dockerfile -t api . from the repository root.
How do I fix "permission denied" after adding USER to my Dockerfile?
The copied files are still owned by root, and your non-root user cannot write to them. Add --chown to the COPY instructions, COPY --chown=node:node . ., which sets ownership as part of that layer at no cost. Avoid fixing it with a separate RUN chown -R, because that rewrites every file into a brand new layer and can roughly double the image size. If the user does not exist in the base image, create it with RUN adduser before the COPY that references it.
Why is COPY saying a file is not found when it clearly exists?
Check .dockerignore first. An over-broad exclusion pattern removes the file from the build context, and the resulting error names the file rather than the rule that hid it, typically failed to compute cache key: "/package.json": not found. The other cause is the source path pointing outside the build context. Both mean the same thing: the daemon never received that file.
Do I need a .dockerignore file?
Effectively yes, before your first COPY . .. Without one the entire directory is sent to the daemon on every build, so node_modules and .git slow the build before an instruction runs, local .env files and private keys get baked into layers where anyone with the image can read them, and unrelated file changes invalidate your layer cache. At minimum exclude node_modules/, .git/, .env* and your build output directory.
What does COPY --from do?
It reads from somewhere other than the build context. Either an earlier stage in the same Dockerfile (COPY --from=build /app/dist ./dist) or a completely separate image (COPY --from=hashicorp/terraform:1.9 /bin/terraform /usr/local/bin/). The first is what makes multi-stage builds work, letting compilers and dev dependencies stay behind in the build stage. The second is often a cleaner way to obtain a binary than downloading and verifying it yourself, since the image is already versioned and pinnable.