Contents

Docker Build Cache Tutorial: Optimize Build Time and Image Size

With the same Dockerfile, build time can range from less than a second to several minutes depending on how you write it, and image size can vary significantly too. This section uses experiments to explain cache rules and ways to reduce image size.

How the cache works

docker build processes each instruction in order. For each line, Docker checks: Is this instruction, along with all the layers before it, the same as in the previous build?

  • Same: Docker uses the cache directly, and the output shows CACHED.
  • Different: Docker runs this instruction again, and every subsequent instruction also stops using the cache.
InstructionWhat Docker checks
RUNWhether the instruction string is identical (it does not check whether packages online have been updated)
COPY / ADDWhether the contents of the copied files are identical
OtherWhether the instruction and its arguments are identical
Rebuilding after changing code: installing packages first lets npm ci use the cache, while copying source first causes npm ci to run again
When a layer changes, every layer after it must be rebuilt: put steps that change less often first

Experiment: How instruction order matters

Use a small project that uses Express (the package.json file includes the express package) to compare two Dockerfiles.

Incorrect order (Dockerfile.bad):

FROM node:24-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

Correct order (Dockerfile.good):

FROM node:24-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD ["node", "server.js"]

Incorrect order: change one line of code

Build once, change one word in server.js, then build again:

$ docker build -f Dockerfile.bad -t cache-bad .

[+] Building 6.9s (9/9) FINISHED                                                     docker:default
 => [internal] load build definition from Dockerfile.bad                                       0.0s
 => => transferring dockerfile: 125B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              0.2s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 65B                                                               0.0s
 => [1/4] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 280B                                                              0.0s
 => CACHED [2/4] WORKDIR /app                                                                  0.0s
 => [3/4] COPY . .                                                                             0.1s
 => [4/4] RUN npm install                                                                      5.4s
 => exporting to image                                                                         1.0s
 => => exporting layers                                                                        0.5s
 => => exporting manifest sha256:d27475f082a53018e303541bc1c0691ac25526e87761300e2aa8ec76d73e  0.0s
 => => exporting config sha256:19f2024df4329a5f2af23e50d8c18826f073d425e18133e9571c393999d913  0.0s
 => => exporting attestation manifest sha256:58b4730e9566f2ec7e8222cd41c14ea971d87272b6b0c190  0.0s
 => => exporting manifest list sha256:58b8fc79124f2fe21270801d5e40f09839c06ba5203304423125270  0.0s
 => => naming to docker.io/library/cache-bad:latest                                            0.0s
 => => unpacking to docker.io/library/cache-bad:latest                                         0.4s

[3/4] COPY . . runs again because server.js changed. The subsequent [4/4] RUN npm install runs again too, taking 5.4 seconds to download and install all packages again.

Correct order: change one line of code

First build (npm install has to run):

$ docker build -f Dockerfile.good -t cache-good .

[+] Building 5.7s (10/10) FINISHED                                                   docker:default
 => [internal] load build definition from Dockerfile.good                                      0.0s
 => => transferring dockerfile: 147B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              0.1s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 65B                                                               0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 95B                                                               0.0s
 => CACHED [2/5] WORKDIR /app                                                                  0.0s
 => [3/5] COPY package.json ./                                                                 0.0s
 => [4/5] RUN npm install                                                                      4.4s
 => [5/5] COPY . .                                                                             0.1s
 => exporting to image                                                                         0.9s
 => => exporting layers                                                                        0.5s
 => => exporting manifest sha256:4b36d92a774b02cc1a188193e178a445b90f330b19f2dcc741d631e246e8  0.0s
 => => exporting config sha256:4a597aa667d9ba52eab36c4483f7507bbcae158b1b22af5ca6e4ac5b7020fa  0.0s
 => => exporting attestation manifest sha256:efea2ef65e2672718391b6419b9f5f2be50396163032a29e  0.0s
 => => exporting manifest list sha256:236918100fd2092d0388da5c03b32dfa3abaa2e62304abb3e3dd335  0.0s
 => => naming to docker.io/library/cache-good:latest                                           0.0s
 => => unpacking to docker.io/library/cache-good:latest                                        0.4s

Build again after changing server.js:

$ docker build -f Dockerfile.good -t cache-good .

[+] Building 0.4s (10/10) FINISHED                                                   docker:default
 => [internal] load build definition from Dockerfile.good                                      0.0s
 => => transferring dockerfile: 147B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              0.1s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 65B                                                               0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 280B                                                              0.0s
 => CACHED [2/5] WORKDIR /app                                                                  0.0s
 => CACHED [3/5] COPY package.json ./                                                          0.0s
 => CACHED [4/5] RUN npm install                                                               0.0s
 => [5/5] COPY . .                                                                             0.0s
 => exporting to image                                                                         0.1s
 => => exporting layers                                                                        0.0s
 => => exporting manifest sha256:6cd8c2671a295aab85724aefe72f8dc749f4d332224f5d534fff5e4e57fa  0.0s
 => => exporting config sha256:7df3d574ae91f98ea3634c4dcede63e64ce8018c902643c2db08c2b4dee4c3  0.0s
 => => exporting attestation manifest sha256:fc9ff7e57f4187c04b74446f5dd96e46ef9c7f0ea88372a6  0.0s
 => => exporting manifest list sha256:79e8a6d76ff2382f02bcadc183a44670959d1d0a30467a27b9d619e  0.0s
 => => naming to docker.io/library/cache-good:latest                                           0.0s
 => => unpacking to docker.io/library/cache-good:latest                                        0.0s

The whole build took just 0.4 seconds: COPY package.json and RUN npm install were both CACHED; only the final COPY . . ran again. This project has only one package. Real projects often have hundreds, and npm install can take several minutes, making the difference even greater.

The same principle applies to every language:

LanguageCopy firstInstall or download next
Node.jspackage.json, package-lock.jsonnpm ci
Pythonrequirements.txt or pyproject.tomlpip install
Gogo.mod, go.sumgo mod download
Java (Maven)pom.xmlmvn dependency:go-offline
PHPcomposer.json, composer.lockcomposer install

Experiment: Clean up temporary files in the same layer

Each layer builds on the previous one. Deleting files in a later RUN does not reduce the size of the earlier layer. Compare two ways to install curl:

Dockerfile.separate:

FROM debian:trixie-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl
RUN rm -rf /var/lib/apt/lists/*

Dockerfile.same:

FROM debian:trixie-slim
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

Compare the sizes after building:

$ docker images --filter reference=apt-separate --filter reference=apt-same --filter reference=debian:trixie-slim
IMAGE                 ID             DISK USAGE   CONTENT SIZE   EXTRA
apt-same:latest       5357e9775c1e        136MB         34.9MB
apt-separate:latest   ad6c0a37d9ea        174MB         51.7MB

The contents are identical (both have curl installed and the package lists removed), but apt-separate is 38 MB larger. Use docker history to see why:

$ docker history apt-separate
IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
ad6c0a37d9ea   11 seconds ago   RUN /bin/sh -c rm -rf /var/lib/apt/lists/* #…   20.5kB    buildkit.dockerfile.v0
<missing>      11 seconds ago   RUN /bin/sh -c apt-get install -y --no-insta…   13.4MB    buildkit.dockerfile.v0
<missing>      16 seconds ago   RUN /bin/sh -c apt-get update # buildkit        21.6MB    buildkit.dockerfile.v0
<missing>      8 days ago       # debian.sh --arch 'amd64' out/ 'trixie' '@1…   87.7MB    debuerreotype 0.17

$ docker history apt-same
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
5357e9775c1e   1 second ago   RUN /bin/sh -c apt-get update     && apt-get…   13.4MB    buildkit.dockerfile.v0
<missing>      8 days ago     # debian.sh --arch 'amd64' out/ 'trixie' '@1…   87.7MB    debuerreotype 0.17

The apt-get update layer in apt-separate is 21.6 MB (the downloaded package lists). The later rm -rf only marks them as deleted in a new layer; the original 21.6 MB remains in the image. When these commands are in the same RUN, the package lists are deleted before the layer is complete and are never saved in any layer.

Common ways to avoid keeping temporary files in different languages:

RUN pip install --no-cache-dir -r requirements.txt
RUN npm ci && npm cache clean --force
RUN apk add --no-cache curl

.dockerignore

At the start of a build, Docker sends the entire build context (usually the current directory) to the build engine. Without excluding node_modules, the example in Dockerfile basics sends 30 MB; excluding it reduces that to 159 B. Example .dockerignore:

.git
node_modules
dist
*.log
.env
.env.*
Dockerfile*
.dockerignore

This speeds up builds, prevents changes to these files from invalidating the COPY . . cache, and keeps .env files and private keys out of the image.

Choose a smaller base image

Measured sizes for the Python 3.14 images in the Images section:

Base imageDisk space
python:3.141.63 GB
python:3.14-slim180 MB
python:3.14-alpine82.6 MB

Multi-stage builds

Build tools and development packages are needed only during the build and should not remain in the final image. Multi-stage builds are the most effective way to reduce image size.

Cache mounts: speed up package downloads

BuildKit supports cache mounts, which preserve package manager download caches across builds. Even if the cache for the RUN npm ci layer is invalidated, you do not have to download all the packages from the network again:

# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Cache mount contents are not included in the image, so they do not increase its size.

Force a build without the cache

When you need the latest package versions or suspect a problem with the cache:

$ docker build --no-cache -f Dockerfile.good -t cache-good .

[+] Building 6.6s (10/10) FINISHED                                                   docker:default
 => [internal] load build definition from Dockerfile.good                                      0.0s
 => => transferring dockerfile: 147B                                                           0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                              0.3s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 65B                                                               0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401  0.0s
 => [internal] load build context                                                              0.0s
 => => transferring context: 280B                                                              0.0s
 => CACHED [2/5] WORKDIR /app                                                                  0.0s
 => [3/5] COPY package.json ./                                                                 0.0s
 => [4/5] RUN npm install                                                                      5.1s
 => [5/5] COPY . .                                                                             0.1s
 => exporting to image                                                                         1.0s
 => => exporting layers                                                                        0.4s
 => => exporting manifest sha256:30688d5c22069d97e9db0bd32d5939e8e99fda0c6a0c4af1f32fb5baacc9  0.0s
 => => exporting config sha256:e8e75108925e8f145e7367b8e36350a8175c3776154f560fb47a9fb287fe21  0.0s
 => => exporting attestation manifest sha256:adbbb2fc6683c9a35c67bb9237cf3afcfd24c39dd9aa4b55  0.0s
 => => exporting manifest list sha256:6b92b5704d4fa7a3f11cfda5ffde00e6516bdbf358e23dbece712a2  0.0s
 => => naming to docker.io/library/cache-good:latest                                           0.0s
 => => unpacking to docker.io/library/cache-good:latest                                        0.5s

--no-cache reruns every step (none show CACHED). Add --pull to check for a newer base image too.

View and clear the build cache

The build cache accumulates over time:

$ docker builder du
ID                           RECLAIMABLE   SIZE       LAST ACCESSED
mr6xd3ylof28pr8obxiet3fo8    true          403B       3 minutes ago
l34l5occgcgh8bscivg2iusrn    true          626B       3 minutes ago
... (output trimmed)
Shared:		488.2MB
Private:	118.5MB
Reclaimable:	606.7MB
Total:		606.7MB
$ docker builder prune -f
ID						RECLAIMABLE	SIZE		LAST ACCESSED
xqb9jtg0kmq0mdzb0vrmod68j               	true 		20.67kB   	3 minutes ago
u1pbndrekyh1q91qbb4n9e3fy*              	true 	8.192kB   	5 minutes ago
... (output trimmed)
Total:	118.5MB

docker builder prune deletes only the build cache; it does not affect images or containers. See Freeing disk space for how to clean up all Docker data.

Recap

  • When a layer changes, all layers after it must be rebuilt.
  • Put steps that change less often (installing packages) first, and frequently changing steps (copying code) later.
  • Download, install, and clean up temporary files in the same RUN so they are not left in the image.
  • RUN --mount=type=cache lets multiple builds share package downloads.

Exercises

Exercise 1: Which of these Dockerfile snippets produces a smaller image, and why?

# A
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# B
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
Answer

B is smaller. In A, rm runs in a new layer, so the package lists remain in the earlier layer. B downloads and deletes them in the same layer, so the lists are never saved.

Exercise 2: Build the same project once with docker build --no-cache and once without the option. Compare the total time shown by [+] Building.

Hint

Without the option, most steps show CACHED, and the build usually takes a fraction of a second. --no-cache reruns every step, and the time depends on how quickly packages install.

FAQ

RUN apt-get update is cached, so how do I avoid installing outdated packages?

The cache only compares the instruction string; it does not check whether packages online have been updated. Put apt-get update and apt-get install in the same RUN, and use docker build --no-cache to force a rebuild when needed.

Does the build cache take up a lot of disk space?

Use docker builder du to inspect it and docker builder prune to clear it. See [Freeing disk space](/docker/cleanup/) for measured results.

Is it slow when CI builds from scratch every time?

Use an external cache, such as cache-from: type=gha in GitHub Actions. See [Automated builds with GitHub Actions](/docker/ci-cd/).

Updated October 14, 2026