Skip to content

Docker Build Too Slow? 10 Ways to Speed It Up

Cut Docker build time with 10 tested fixes: .dockerignore, layer order, cache mounts, parallel stages and CI cache. Timed results included.

Dasun Kalupahana Updated 8 min read 28 views 0 comments
On this page
  1. Step 0: how I tested, and what the numbers mean
  2. 1. Find out which step is slow before changing anything
  3. 2. Add a .dockerignore file
  4. 3. Order layers from least to most frequently changed
  5. 4. Use cache mounts for package downloads
  6. 5. Split independent work into stages: BuildKit builds them in parallel
  7. 6. Create and delete in the same layer
  8. 7. Choose a smaller, pinned base image
  9. 8. Share the build cache in CI
  10. 9. Stop accidentally invalidating the cache
  11. 10. Watch the builder's cache, and prune on purpose
  12. Putting it together: a Laravel Dockerfile
  13. Common mistakes
  14. Related Dev Talk articles and tools

A slow Docker build usually has one of a few causes: you are sending far more files to the builder than you need, a cheap change at the top of the Dockerfile throws away an expensive step below it, or every build re-downloads packages it fetched an hour ago. All of these are fixable without changing your application. This article goes through ten fixes in the order I would try them. Most of them I ran and timed on a real Docker Engine, and I show the output, so you can compare it with what you see on your machine.

Step 0: how I tested, and what the numbers mean

Docker Engine 29.8.2, Buildx 0.37.1, BuildKit 0.33.1, on Linux with the containerd image store. The environment could not pull images from Docker Hub, so I built a tiny local base image (a shell plus a few core tools) and used sleep 4 as a stand-in for a slow dependency install. That makes the cache behaviour real, because the cache does not care what a step does, but the absolute timings are not what npm install or composer install will take on your project. Every timing is a single run. Look at the ratios, then measure your own project.

1. Find out which step is slow before changing anything

Run the build with plain progress output. It prints each step and how long it took, and marks steps served from cache:

docker build --progress=plain -t myapp .
#6 [2/5] WORKDIR /app
#6 CACHED
#8 [4/5] RUN sleep 4 && mkdir -p node_modules
#8 CACHED
#9 [5/5] COPY src ./src
#9 DONE 0.0s

Look for three things: transferring context with a large size (see technique 2), steps near the top that are not CACHED on a rebuild (techniques 3 and 9), and a single step that dominates the total (techniques 4 and 5). The default fancy progress view hides most of this, so use --progress=plain while you investigate.

2. Add a .dockerignore file

When your Dockerfile has COPY . ., Docker has to send everything in the build directory to the builder: .git, node_modules, logs, local build output. I made a project with 450 MB of that kind of material and one small source file, then built with COPY . .:

without .dockerignore:  transferring context: 472.0MB   total 33.9 s
with .dockerignore:     transferring context: 254B       total  1.0 s

A starting point for a Laravel project:

.git
node_modules
vendor
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
.env
.env.*
*.log
.idea
.vscode

Excluding .env matters for more than speed: otherwise COPY . . bakes your local secrets into an image layer. Only exclude vendor if the image installs its own dependencies, as in the example near the end. One detail from my testing: BuildKit transfers only the files a COPY actually references. With COPY src ./src the same 450 MB project transferred 79 bytes, so the huge-context problem shows up with COPY . ., which is what most Dockerfiles use.

3. Order layers from least to most frequently changed

Docker reuses a cached layer only if the instruction and everything before it are unchanged. If COPY . . comes first, any source edit invalidates the dependency install after it. Here is the same 4-second "install" step in both orders, timed after changing one line of source:

# Slow: source copied first
FROM mini:1
WORKDIR /app
COPY . .
RUN sleep 4 && mkdir -p node_modules

# Fast: manifest first, install, then source
FROM mini:1
WORKDIR /app
COPY package.json .
RUN sleep 4 && mkdir -p node_modules
COPY src ./src
slow order, after a source edit: 4.6 s
fast order, after a source edit: 0.3 s

For real projects this means copying package.json and package-lock.json, or composer.json and composer.lock, before the rest of the code, and running the install in between.

4. Use cache mounts for package downloads

Layer caching helps until the manifest changes. Add one dependency and the whole install reruns, re-downloading everything. A BuildKit cache mount keeps the package manager's download cache between builds, outside the image:

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

I tested the mechanism with a step that "downloads" for 4 seconds only when its cache directory is empty, and changed package.json between builds so the layer was invalidated each time:

without cache mount: first 4.7 s | after package.json change 4.5 s
with cache mount:    first 4.7 s | after package.json change 0.5 s
after docker builder prune -af:   4.7 s again

The Docker documentation lists cache targets for common tools: /root/.npm for npm, /root/.cache/pip for pip, /root/.gem for Bundler, /go/pkg/mod and /root/.cache/go-build for Go, /tmp/cache for Composer, and for apt /var/cache/apt and /var/lib/apt with sharing=locked. For Composer I would set the directory explicitly rather than rely on a default, using Composer's documented COMPOSER_CACHE_DIR variable (I confirmed that the variable changes composer config cache-dir):

ENV COMPOSER_CACHE_DIR=/tmp/cache
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist

Two cautions. The cache lives in the builder's storage, not in the image, so a fresh CI runner starts with it empty (see technique 8). And as the test shows, pruning the builder cache wipes it.

5. Split independent work into stages: BuildKit builds them in parallel

A multi-stage build keeps compilers and dev tools out of the final image, and BuildKit also runs stages that do not depend on each other at the same time. A single-stage Dockerfile runs its steps one after another. I used two independent 3-second steps:

FROM mini:1 AS front
RUN sleep 3 && echo front > /front.txt

FROM mini:1 AS back
RUN sleep 3 && echo back > /back.txt

FROM mini:1
COPY --from=front /front.txt /
COPY --from=back /back.txt /
both steps in one stage (serial):   6.9 s
two independent stages (parallel):  3.7 s

The real-world version is a Laravel image where Composer dependencies and frontend assets are separate stages. A fuller example is near the end.

6. Create and delete in the same layer

Deleting a file in a later RUN does not shrink the image; the earlier layer still holds it. Bigger images mean slower pushes, pulls and CI cache uploads. I created a 100 MB temporary file and removed it, once in two steps and once in one:

two RUN steps:
 8.19kB   RUN rm /tmp/build.tmp
 105MB    RUN dd if=/dev/urandom of=/tmp/build.tmp ...

one RUN step (create and rm joined with &&):
 16.4kB   RUN dd if=/dev/urandom of=/tmp/build.tmp ... && rm /tmp/build.tmp

That is output from docker history. Use it rather than the total size from docker image inspect for this comparison: on this containerd-store install the inspect size was larger than the sum of the layers, so I did not trust it. Typical real cases are archive downloads you extract and delete, and build tools installed for one compile step.

7. Choose a smaller, pinned base image

This one I could not measure, because I could not pull images here. The reasoning is straightforward: the base image is pulled on every fresh machine, so a smaller one means faster first builds and pulls. Pin an explicit version tag (for example a specific PHP or Node version) instead of latest, so a base image update does not silently invalidate your whole cache or change behavior. Slim and Alpine variants are smaller, but they differ from the full images (Alpine uses musl instead of glibc, which can break some native extensions), so test your app before switching. Also keep your language version supported: see the PHP 8.2 end-of-life and Node.js 20 end-of-life articles.

8. Share the build cache in CI

CI runners are usually fresh machines, so every build starts with an empty cache and rebuilds everything. Buildx can export the cache and import it on the next run. The local form is easy to try:

docker buildx build --cache-to type=local,dest=./cache,mode=max -t myapp .

# later, on another machine or after a prune
docker buildx build --cache-from type=local,src=./cache -t myapp .

I ran this, pruned the whole builder cache in between, and the second build finished in 0.4 s with every step marked CACHED. In CI you would store that directory with your CI's cache feature, or use a registry or the GitHub Actions backend. The Docker documentation's GitHub Actions example is:

- name: Build and push
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: "<registry>/<image>:latest"
    cache-from: type=gha
    cache-to: type=gha,mode=max

I did not run this on GitHub Actions. The same documentation page states that the gha backend works with the default docker driver only when the containerd image store is enabled; otherwise use a different driver. mode=max exports the layers of intermediate stages too, which matters for multi-stage builds, at the cost of a larger cache. If you build several images in one workflow, give each a separate scope, otherwise each build overwrites the previous cache.

9. Stop accidentally invalidating the cache

These are the usual culprits:

  • A changing ARG near the top. A build timestamp or commit SHA passed as an ARG invalidates every step after it. In my test, the same build with the same argument value was fully cached (0.3 s), and changing only the value rerun the expensive step (3.5 s). Declare such arguments at the end, just before the instruction that needs them.
  • COPY . . before the install, as in technique 3.
  • Copying files that change on every build, such as logs, caches or a generated file, which also belong in .dockerignore.
  • Splitting apt-get update from apt-get install in different RUN steps, which can leave a stale package list cached. Keep them in one step.

Docker can also lint the Dockerfile for common mistakes:

docker build --check .
Check complete, 2 warnings have been found!
WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
WARNING: FromAsCasing - https://docs.docker.com/go/dockerfile/rule/from-as-casing/

Those two rules are style and signal-handling warnings rather than speed problems, but the check is quick and catches real mistakes.

10. Watch the builder's cache, and prune on purpose

docker buildx du        # what the build cache holds
docker builder prune    # remove unused build cache
docker builder prune -af   # remove all of it, no prompt

A full prune explains "why is my build suddenly slow again" when it follows a disk cleanup, since it also empties your cache mounts, as the technique 4 test showed. If disk space is the reason you prune, consider removing only old cache instead of everything.

Putting it together: a Laravel Dockerfile

This combines techniques 3, 4, 5 and 9 for a Laravel app. I did not build this exact file, because the official PHP, Node and Composer images could not be pulled here; each technique in it was tested separately above. Check the tags and the files your frontend build reads (the config files, resources) against your project.

FROM composer:2 AS vendor
WORKDIR /app
ENV COMPOSER_CACHE_DIR=/tmp/cache
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev

FROM node:24 AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY vite.config.js ./
COPY resources ./resources
RUN npm run build

FROM php:8.4-fpm AS app
WORKDIR /var/www/html
RUN docker-php-ext-install pdo_mysql
COPY --from=vendor /app ./
COPY --from=assets /app/public/build ./public/build

The vendor and assets stages do not depend on each other, so BuildKit can run them at the same time. The slow system-level step (docker-php-ext-install) comes before any application code, so it stays cached across code changes. After the build, the image contains neither Composer's cache nor node_modules.

Common mistakes

  • Optimizing without reading the timings. Use --progress=plain first.
  • Copying the whole project before installing dependencies. It is the most common reason the cache never hits.
  • Using --no-cache as a habit. It is a debugging tool. It rebuilds everything every time.
  • Expecting cache mounts to survive on a fresh CI runner. They live in the builder's storage.
  • Leaving .env out of .dockerignore. Secrets end up in image layers.
  • Using latest as the base tag. A base image update can change behavior and bust the cache without any change in your repo.

Tools: docker build --check and docker buildx du (both used above), plus the Dockerfile linter Hadolint and the image layer explorer Dive. I did not run the last two for this article.

Free resources for this topic

Code snippets and fixes for this topic

Get one practical tip every week

New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.

Free download: Docker cheat sheet (PDF)

Have a question about this post?

Ask the community: other developers and the author can answer.

Ask the community

Get notified about new articles

One click, no email needed. Turn it off anytime with the bell at the top.

Get new articles on your iPhone

Add DEV Talk to your Home Screen, open it, then tap the bell.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.