On this page
- Step 0: how I tested, and what the numbers mean
- 1. Find out which step is slow before changing anything
- 2. Add a .dockerignore file
- 3. Order layers from least to most frequently changed
- 4. Use cache mounts for package downloads
- 5. Split independent work into stages: BuildKit builds them in parallel
- 6. Create and delete in the same layer
- 7. Choose a smaller, pinned base image
- 8. Share the build cache in CI
- 9. Stop accidentally invalidating the cache
- 10. Watch the builder's cache, and prune on purpose
- Putting it together: a Laravel Dockerfile
- Common mistakes
- 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.0sLook 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 sA 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
.vscodeExcluding .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 ./srcslow order, after a source edit: 4.6 s
fast order, after a source edit: 0.3 sFor 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 ciI 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 againThe 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-distTwo 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 sThe 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.tmpThat 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=maxI 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
ARGnear the top. A build timestamp or commit SHA passed as anARGinvalidates 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 updatefromapt-get installin differentRUNsteps, 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 promptA 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/buildThe 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=plainfirst. - Copying the whole project before installing dependencies. It is the most common reason the cache never hits.
- Using
--no-cacheas 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
.envout of.dockerignore. Secrets end up in image layers. - Using
latestas the base tag. A base image update can change behavior and bust the cache without any change in your repo.
Related Dev Talk articles and tools
- Docker vs Kubernetes: what's the difference?
- Upgrade Laravel 12 to 13, step by step
- PHP 8.2 end of life and Node.js 20 end of life
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.
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.
Enjoying the content?
If this article helped you, you can support the author with a coffee. It keeps new posts coming.
Comments 0
No comments yet
Be the first to share your thoughts.