skip to content

What do the `--chown` and `--link` flags on a Dockerfile COPY instruction do, and what problem does each one solve that a plain COPY followed by a RUN chown does not?

level: seniorimportance: nice to knowfreq 32%

answer

  1. --chown avoids a full-size RUN chown layer
  2. Numeric UID:GID — names need /etc/passwd
  3. --chmod for exec bits without a layer
  4. --link = independent, rebasable layer
  5. --link copies into an empty dest — no merge

basics

~20 s

--chown sets ownership as the files are written, avoiding a second full-size layer that a later RUN chown would create. --link writes the copied content as a self-contained layer that does not depend on earlier layers, so it stays cached and reusable when the base changes.

solid answer

~50 s

**`--chown=uid:gid`** applies ownership while the files are written. Without it, copied files land as root and you need `RUN chown -R`, which rewrites every file into a *new* layer — doubling the size contribution of a large directory. Prefer numeric IDs: names must exist in the image's `/etc/passwd`, and Kubernetes `runAsUser` compares numbers anyway. `--chmod` behaves the same way for permission bits. **`--link`** (BuildKit, Dockerfile syntax 1.4+) writes the copied content as an **independent** layer, created without needing the previous layers to be present. Two payoffs: - **Cache survives base changes.** Change the base image or an earlier instruction and a `--link` COPY layer can be rebased instead of recomputed, so it stays cached. - **Faster builds**, since the layer can be produced without unpacking the parent chain. The catch: `--link` copies into a fresh empty filesystem at the destination path, so it does not see or merge with existing content there, and it ignores files created by earlier instructions at that path.

code

dockerfile · 14 lines
dockerfile
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /src
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs22
WORKDIR /app
COPY --link --from=build --chown=1000:1000 /src/node_modules ./node_modules
COPY --link --from=build --chown=1000:1000 /src/dist ./dist
USER 1000
CMD ["dist/server.js"]

go deeper

for a junior

Know that copied files are root-owned by default and that COPY --chown=uid:gid sets ownership directly instead of a separate chown step.

for a middle

Explain the extra-layer size cost of RUN chown -R, why numeric IDs are safer than names, and what --chmod covers.

for a senior

Explain --link's rebasable-layer semantics, the base-bump cache win, the empty-destination caveat, and the BuildKit/syntax requirements.

for a principal

Treat it as build-cost policy across a fleet: standard non-root UIDs, --link on artifact copies so security base bumps are cheap, and lint rules against RUN chown -R on large trees.

## --chown (and --chmod) By default `COPY` and `ADD` write files owned by UID 0 / GID 0 regardless of the ownership on the host. Since a well-built image runs as a non-root user, you need the runtime user to own (or at least be able to read) the application files. The naive approach costs a full extra layer: ``` COPY . /app RUN chown -R 1000:1000 /app # rewrites every file: a second full-size layer ``` Changing a file's owner changes its inode metadata, so the union filesystem must copy every touched file up into the new layer. For a 200 MB tree, the image grows by roughly 200 MB. The flag avoids that entirely: ``` COPY --chown=1000:1000 . /app ``` Ownership is applied while the layer is being written, so there is exactly one layer. Practical notes: - **Use numeric IDs.** `--chown=appuser:appgroup` requires those names to already exist in the image's `/etc/passwd` and `/etc/group`, so it fails on distroless or scratch bases and breaks if a base image renumbers its users. Numeric IDs always work and match what an orchestrator's `runAsUser` enforces. - **`--chmod`** sets mode bits the same way, useful for making a copied entrypoint script executable without a `RUN chmod +x` layer, and for stripping group/other write bits. - Both flags work with `--from`, which is where they are most needed: a builder stage typically ran as root, and the runtime stage does not. - On the destination side, ownership of *parent directories* Docker creates is a separate matter — create them explicitly if their ownership matters. ## --link Normally, building a layer requires materialising the parent chain: the builder unpacks the previous layers so the COPY can be applied on top of that state. That creates a dependency — the resulting layer's identity is tied to what came before it. `COPY --link` changes this. The copied content is written into an independent snapshot rooted at the destination path, and the resulting layer is then *appended* to whatever parent chain exists. Because the layer's content does not depend on the parent's state, BuildKit can **rebase** it: if the base image is updated or an earlier instruction changes, the `--link` layer is reused as-is rather than recomputed. Why that is valuable: - **Base image bumps stop invalidating everything.** In a classic Dockerfile, changing `FROM` invalidates every subsequent instruction. With `--link` on the expensive copies, those layers survive the bump, so a security-patch rebase of the base becomes a near-instant build. - **Better parallelism and less I/O**, since the layer can be produced without unpacking the parent chain first. - **Cross-image layer reuse.** Identical `--link` layers are content-identical across images, improving registry deduplication and pull behaviour. The behavioural caveat that trips people up: the copy happens into an *empty* filesystem at the destination, so it cannot see or merge with what is already there. - If an earlier instruction created `/app/config` and you then `COPY --link ./app /app`, the pre-existing content is not merged the way an ordinary COPY would layer over it. - Symlinks in the destination path are not followed — writing through a symlinked directory created earlier does not work as expected. - Ownership of implicitly created parent directories differs from the non-link case, so pair it with explicit `--chown` when a non-root user must own the tree. The rule of thumb: use `--link` for copies into a clean destination path — build artifacts from a builder stage, `node_modules`, a compiled binary. Avoid it when you are deliberately overlaying files onto content an earlier instruction produced at the same path. ## Requirements and how to enable Both flags need BuildKit, which is the default builder in current Docker versions. `--link` additionally requires Dockerfile frontend syntax 1.4 or newer; if you are pinning an older frontend, declare it at the top: ``` # syntax=docker/dockerfile:1 ``` On a legacy builder (`DOCKER_BUILDKIT=0`), `--link` is not recognised and the build fails to parse, which is worth knowing if some CI runner is still on the classic builder. ## Putting them together ``` FROM gcr.io/distroless/nodejs22 WORKDIR /app COPY --link --from=build --chown=1000:1000 /src/node_modules ./node_modules COPY --link --from=build --chown=1000:1000 /src/dist ./dist USER 1000 ``` This is one layer per copy, correctly owned for a non-root runtime, and both layers survive a base-image bump without rebuilding. The interview-worthy insight is that these flags are not cosmetic: `--chown` is a size fix and `--link` is a cache-topology fix, and each addresses a cost that the obvious `COPY` + `RUN chown` spelling silently incurs.

  • Why does `RUN chown -R` after a large COPY roughly double that directory's contribution to image size?
    Ownership lives in file metadata, so changing it modifies each file. In a union filesystem, modifying a file from a lower layer copies it up into the new layer, so the chown layer ends up containing a full second copy of every file in the tree. The earlier layer still ships too. `COPY --chown` sets ownership as the single layer is written, avoiding the duplicate entirely.
  • When would you deliberately not use `--link` on a COPY?
    When the destination path already holds content produced by earlier instructions that you intend to overlay onto, or when the path traverses a symlink created earlier — `--link` writes into a fresh filesystem at that path and does not follow or merge with existing state. It also requires BuildKit with Dockerfile syntax 1.4+, so it is unusable on a legacy builder.

saying these in an interview costs you the question

  • Using --chown with user names on a distroless or scratch base where /etc/passwd has no such user
  • Assuming RUN chown -R is free because 'it only changes metadata'
  • Expecting COPY --link to merge with files an earlier instruction placed at the same path
  • Believing --link works on the legacy (non-BuildKit) builder
  • Thinking --chown affects the container's runtime user; that is USER's job

context