ML
Docker

Docker Layers, the COW Filesystem, and Why Your Dockerfile Order Quietly Decides Your Build Time

Every Dockerfile line is a layer; every layer is a decision. Here's what's actually happening on disk — and why moving COPY around can take your build from 2 minutes to 12 seconds.

February 04, 20269 min readDockerBuildInternals

The thing nobody tells you about Docker is that the build is a filesystem, not a script. Every RUN, COPY, ADD creates a new layer on top of the last one. Layers are immutable tarballs of file diffs. The image you ship is just a stack of those tarballs plus a small JSON manifest that lists them.

Once that mental model lands, every confusing thing about Docker — slow builds, mysterious cache misses, 800 MB images for a 12 MB app — stops being confusing. It just becomes filesystem math.

1. What a layer actually is

Pull any image and look at it raw:

$ docker save node:20-alpine -o node.tar
$ tar -tf node.tar | head
manifest.json
6c8...d3.json
e9b...fa/layer.tar
2a1...77/layer.tar
…

Every layer.tar is a normal tarball of the files added at that step. Not the whole filesystem — just the diff. When the image runs, the runtime stacks them via a copy-on-write (COW) overlay driver (overlay2 on Linux). Reads merge; writes go to a top scratch layer.

2. Layer caching: the rule that runs your CI bill

Docker keys every layer by:

  • The exact instruction (e.g. RUN apt-get install -y curl).
  • The hash of any files referenced (for COPY / ADD).
  • The hash of the parent layer.

If all three match a previous build, the layer is reused — instantly, no execution. If any of them changes, that layer and every layer after it are rebuilt. This is why ordering matters more than any other Dockerfile decision.

3. The single most common mistake

This Dockerfile rebuilds the whole world on every commit:

FROM node:20-alpine
WORKDIR /app
COPY . .                # <-- changes on every commit
RUN npm install         # <-- always runs
RUN npm run build
CMD ["npm", "start"]

Reorder it like this and your CI bill drops in half:

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # only changes when deps change
RUN npm ci                                # cached unless deps changed
COPY . .                                  # source, fast tar
RUN npm run build
CMD ["npm", "start"]

The trick: copy what changes least, first. package.json changes ~weekly, source changes ~hourly. Putting deps before source means a typical commit reuses the install layer and only re-executes the cheap build step.

4. Why your image is 1.2 GB

Three culprits, in order of frequency:

  • Build artifacts left in the final layer. apt-get install dumps /var/lib/apt/lists into the layer forever. Even if you delete it in the next RUN, the previous layer still has it. You cannot un-add a file from a layer.
  • Wrong base image. node:20 is 1.1 GB. node:20-alpine is 130 MB. node:20-slim is 200 MB. Pick the smallest one your code actually runs on (alpine uses musl, which breaks some binaries).
  • Source repo copied with node_modules or .git. Always have a .dockerignore. Mine starts: .git, node_modules, *.log, .env*, dist, .next.

The fix for the apt example:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

All three commands in one RUN means they end up in one layer, and the cleanup actually frees the bytes.

5. The layer ceiling

Docker has a soft cap of 127 layers (overlay2 limit). I've never hit it in production, but I've seen Dockerfiles that come close — usually because someone wrote forty RUN echo "step"-style debug lines and forgot to consolidate. Treat layers as a budget. Every layer is a tax on disk, push time, and pull time.

Rules of thumb

  • Order from least-changing to most-changing. Cache hits compound.
  • One RUN per logical operation. Don't shard apt installs across five lines.
  • Always --no-install-recommends on apt, always --no-cache-dir on pip, always npm ci not npm install.
  • .dockerignore is mandatory, not optional.
  • If your image is over 500 MB and your code is JavaScript, you have a bug, not a constraint.

Once layers click, Docker stops being a black box and becomes a fairly predictable filesystem-with-CI-on-top. Everything else — multi-stage builds, BuildKit, distroless — is just leverage on the same primitive.

SharePostLinkedIn

Reader Discussion

6 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Highlighted by author
    Arnab Mitra· DevOps · FintechFrom experience

    the COPY-package.json-first trick is one of those things you only learn after you've burned 90 minutes of CI per PR for a month. our build dropped from 4.5min to 38s with that one reorder.

    Feb 06, 2026·2 days later
  2. Quân Đỗ🇻🇳 HCMC· Backend LeadAgrees

    Distroless thay cho alpine cho Java app là 1 quyết định mà mình tiếc đã không làm sớm. CVE alert giảm 80%, kích thước tương đương alpine, không có shell tức là không có exec attack surface. Highly recommend.

    Feb 08, 2026·4 days later
  3. Fenna Vermeer· Senior SWEStory

    the jlink section made me laugh out loud — we did the EXACT same thing, shaved 18MB, then shipped a heap dump tool that needed jdk.management and woke ourselves up at 3am. distroless really is the sweet spot.

    Feb 09, 2026·5 days later
  4. Ravi Subramanian· Platform EngAsks

    Q — anyone moved to BuildKit cache mounts (RUN --mount=type=cache,target=/root/.m2)? we tested it and it's a step beyond multi-stage for repeatable builds, but the syntax is intimidating to onboard new devs.

    Feb 10, 2026·6 days later
  5. Mei Lin· Full StackFrom experience

    .dockerignore is mandatory not optional — copying that to the wall. our junior shipped an image with .git inside and accidentally exposed our deploy keys. one-line fix, six-month security review.

    Feb 07, 2026·3 days later
  6. Isabella Costa· Junior EngineerKind words

    saved this. sharing at standup tomorrow — we've had exactly this problem for 2 sprints and nobody on the team had framed it this way 🙏

    Feb 06, 2026·2 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email