You fix one line of your app, rebuild your Docker image, and wait while it downloads every library again. Your friend's build, for the same change, finishes in seconds. The difference is usually not the computer. It is the order of the lines in the Dockerfile.
A Docker image is a frozen package: your app, its libraries and the files it needs, ready to run. A container is a running copy made from an image. You can start many containers from one image, the way you can print many pages from one file.
A Dockerfile is the recipe for building an image. It is a list of steps, run from top to bottom, for example "start from Node", "copy my files in", "install the libraries". When Docker builds, it remembers the result of each step. That memory is the build cache, and each remembered result is a layer stacked on the one before it.
Before running a step, Docker asks: "Have I already run exactly this step on top of exactly the same layer below it?" If yes, it reuses the saved result (a cache hit). If not, it runs the step (a rebuild).
What counts as "exactly this step" depends on the instruction. For RUN, Docker compares only the command text. It does not look inside the container to see whether the internet changed. For COPY, it compares a fingerprint of the files being copied; the file's modified-time stamp is ignored.
The key part: once one step is a rebuild, every step after it is a rebuild too, because the layer below it is now new. A change high in the file spoils the cache for everything beneath it.
Below are two versions of a Dockerfile for a small Node.js app. package.json lists the libraries; app.js is your code. Pick a version, press Build, then edit a file and build again. The number is a pretend time: installing libraries takes 40 seconds, other steps 1 second.
In Version A, COPY . . comes first. Any edit to any file changes that step, so npm install runs again even though you never touched package.json. In Version B, only package.json is copied before the install. Editing app.js leaves that fingerprint alone, so the install is reused, and only the last copy step runs. Edit package.json in Version B and the install does run again, which is correct: the library list really changed.
This is the whole idea as code. Each step gets a key made from the key of the step below it, its own text, and the files it reads. A key we have seen before is a hit. I ran this script; the output below is its real output.
import hashlib
def h(*parts):
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:6]
def build(steps, files, cache):
parent = "start"
for text, reads in steps:
key = h(parent, text, *[files[f] for f in reads])
status = "cached" if key in cache else "REBUILT"
cache.add(key)
print(f" {status:8} {text}")
parent = key
Running Version B twice, with app.js changed in between, prints this on the second build:
after editing app.js
cached FROM node:22-slim
cached COPY package.json .
cached RUN npm install
REBUILT COPY . .
REBUILT CMD node app.js
Notice that parent is part of every key. That single detail is why one rebuild forces all the rest.
Put the things that change rarely at the top and the things that change often at the bottom. For most apps that means: base image, then the files that list your libraries, then the install, then your own code. Your code changes every few minutes; your library list changes a few times a month.
The same idea works in other languages. In Python you would copy requirements.txt and run pip install before copying the rest. If a build ever feels slow, read its output from the top and find the first step that was rebuilt. Everything above it was fine; the cause is that step or the files it reads.
One honest limit: the cache only knows what it can see. A RUN command like apt-get update is matched by its text, so Docker may keep serving an old saved result even though the internet has moved on. When you need a fresh one, you can ask Docker to ignore the cache for a build with the --no-cache option.