RUN, COPY, and ADD

RUN, COPY, and ADD are the Dockerfile instructions that most often change the filesystem of an image. They matter because each one can create a layer, affect build speed, and decide exactly which files and tools end up inside the final image.

Use RUN to execute commands during the build, COPY to bring files from the build context into the image, and ADD only when you need its extra behavior. Good Dockerfiles are mostly about putting these three instructions in the right order.

Overview: How RUN, COPY, and ADD Work

When Docker builds an image, it starts from the image named by FROM and processes each later instruction from top to bottom. Image layers are read-only filesystem snapshots. A RUN instruction starts a temporary build container from the current image state, runs the command, captures the changed files, and stores those changes as a new layer. A COPY or ADD instruction copies files into the image and also records the result as a layer.

The source files for COPY and most ADD instructions come from the build context: the directory you pass at the end of docker build, usually .. Docker does not let a Dockerfile copy arbitrary files from your whole computer with COPY ../../secret.txt /app/; the files must be inside the context unless you use special build features outside this basic lesson. This boundary is intentional because the Docker client sends the context to the builder, which might be local, inside Docker Desktop’s Linux VM, or remote.

The cache is the other major idea. For each instruction, BuildKit checks whether it can reuse a previous result. For RUN, the command text and previous image state matter. For COPY and ADD, Docker also considers the copied file metadata and contents. If one instruction changes, Docker must rebuild that instruction and every instruction after it. That is why a Dockerfile that runs COPY . . before installing dependencies is slow: every source-code edit can invalidate the dependency install layer.

COPY is the plain, predictable file-copy instruction. ADD does everything COPY does, plus two special behaviors: it can automatically extract a local tar archive, and it can fetch a remote URL. Those extras are convenient but easy to misuse, so Docker’s common best practice is simple: prefer COPY unless you specifically need ADD‘s archive extraction or remote-source behavior.

Syntax

The common forms are:

RUN command
COPY source destination
COPY ["source with spaces", "destination/"]
ADD source destination
ADD https://example.com/file.txt /path/file.txt
Instruction Use it for Important details
RUN command Installing packages, creating users, compiling code, generating files during the build. Runs at build time, not when the container starts. Usually creates a new filesystem layer.
COPY source destination Copying application files from the build context into the image. Predictable. Does not download URLs or auto-extract archives.
COPY ["source", "destination"] Copying paths that contain spaces or need JSON-array clarity. JSON form requires double quotes.
ADD source destination Copying like COPY, extracting local tar archives, or fetching remote URLs. Local tar archives are extracted automatically. Prefer COPY for normal local files.

Some useful options are supported by modern Docker builders. COPY --chown=user:group sets ownership while copying. COPY --chmod=755 sets permissions. These are cleaner than copying a file and then using a separate RUN chown or RUN chmod layer.

Examples

Example 1: RUN installs packages in one layer

This Dockerfile installs curl into a small Alpine-based image and removes the package index cache in the same RUN instruction:

FROM alpine:3.20
RUN apk add --no-cache curl
CMD ["curl", "--version"]

Build and run it:

docker build -t curl-tool:1.0 .
docker run --rm curl-tool:1.0

Output:

[+] Building 3.1s (6/6) FINISHED
 => [internal] load build definition from Dockerfile
 => [internal] load metadata for docker.io/library/alpine:3.20
 => [1/2] FROM docker.io/library/alpine:3.20
 => [2/2] RUN apk add --no-cache curl
 => exporting to image
curl 8.x.x

RUN executed while the image was being built. The final container does not run apk add; it starts from an image that already contains curl. Combining related package-manager work in one RUN instruction avoids extra layers and prevents temporary files from being saved in an earlier layer.

Example 2: COPY dependency files before source files

For a Node.js app, this layout keeps dependency installation cached when only server.js changes:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Build it like this:

docker build -t cache-friendly-api:1.0 .

Output on a later rebuild after editing only server.js may look like this:

[+] Building 1.0s (9/9) FINISHED
 => CACHED [2/5] WORKDIR /app
 => CACHED [3/5] COPY package*.json ./
 => CACHED [4/5] RUN npm ci --omit=dev
 => [5/5] COPY server.js ./
 => exporting to image

The package files did not change, so Docker reused the npm ci layer. This is one of the most important Dockerfile performance habits: copy slow-changing files first, run expensive setup, then copy fast-changing application code.

Example 3: ADD extracts a local tar archive

If the build context contains public.tar.gz, ADD can unpack it directly into the image:

FROM nginx:1.27-alpine
ADD public.tar.gz /usr/share/nginx/html/
EXPOSE 80

Build it:

docker build -t archived-site:1.0 .

Output:

[+] Building 2.2s (7/7) FINISHED
 => [internal] load build definition from Dockerfile
 => [internal] load metadata for docker.io/library/nginx:1.27-alpine
 => [1/2] FROM docker.io/library/nginx:1.27-alpine
 => [2/2] ADD public.tar.gz /usr/share/nginx/html/
 => exporting to image

This is a legitimate use of ADD: local tar extraction. If you wanted the archive file itself to appear in the image without extraction, use COPY public.tar.gz /some/path/ instead.

How It Works Step By Step

  1. The Docker client sends the Dockerfile and build context to the builder, excluding paths matched by .dockerignore.
  2. The builder resolves the base image named in FROM and pulls any missing read-only layers.
  3. For COPY package*.json ./, Docker hashes the matching files and checks whether an identical copy step already exists in the cache.
  4. For RUN npm ci --omit=dev, Docker starts a temporary build container from the previous image state, runs the command, and records filesystem changes such as node_modules.
  5. For COPY server.js ./, Docker stores the application file in a later layer. Changing this file invalidates this layer and following layers, but not the earlier dependency layer.
  6. When a container is created from the final image, Docker mounts the image layers read-only and adds a thin writable container layer. Build-time RUN commands are already finished; only CMD or ENTRYPOINT starts at runtime.

Common Mistakes

Using ADD when COPY is enough

This works, but it hides intent:

FROM nginx:1.27-alpine
ADD index.html /usr/share/nginx/html/index.html

The file is just a local HTML file, so COPY is clearer:

FROM nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.html

Invalidating the cache with COPY . . too early

This Dockerfile is common but slow for real projects:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]

Any changed source file can force npm ci to run again. Copy the package files first, install dependencies, then copy the source files. Also add a .dockerignore so node_modules, logs, and local build output do not enter the context.

Baking secrets into layers

Never do this with a real token:

FROM alpine:3.20
RUN echo "<YOUR_TOKEN>" > /tmp/token.txt
RUN rm /tmp/token.txt

Even though the later layer removes the file from the final visible filesystem, the earlier layer can still contain it. Secrets belong in runtime secret stores, mounted files, CI build secrets, or orchestrator-managed configuration, not in ordinary Dockerfile instructions.

Best Practices

  • Prefer COPY for normal local files because it has fewer hidden behaviors.
  • Use ADD only for local tar extraction or when you deliberately need a remote source.
  • Order instructions from least-changing to most-changing so Docker can reuse expensive layers.
  • Keep package-manager update, install, and cleanup work in the same RUN instruction when cleanup is needed.
  • Use pinned base image tags such as node:20-alpine or nginx:1.27-alpine. Avoid latest for reproducible builds.
  • Use .dockerignore to keep the build context small and to avoid copying private files by accident.
  • Use COPY --chown and COPY --chmod when ownership or permissions are known at copy time.
  • Do not store secrets with RUN, COPY, ADD, or ENV; image layers and history are not a secret store.
  • Remember that EXPOSE is only image metadata. Publish ports with docker run -p or Compose ports:.

Practice Exercises

  1. Write a Dockerfile for a small Node.js service that copies dependency manifests first, runs npm ci --omit=dev, then copies only server.js. The expected result is that editing server.js does not rerun dependency installation.
  2. Create a Dockerfile that serves a prebuilt static site from nginx:1.27-alpine. Use COPY for loose files, then try ADD with a local public.tar.gz archive and observe the difference.
  3. Audit one of your own Dockerfiles. Find every RUN, COPY, and ADD, then decide whether each one is ordered well for caching and whether ADD is truly needed.

Summary

  • RUN executes commands at build time and saves filesystem changes into an image layer.
  • COPY moves files from the build context into the image and is the default choice for local files.
  • ADD can copy files, extract local tar archives, and fetch remote URLs, so use it deliberately.
  • Changing one layer invalidates that layer and every later layer, making instruction order critical.
  • Copy dependency manifests before source files to preserve expensive dependency-install caches.
  • Secrets copied or written during a build can remain in image layers even if a later instruction deletes them.