
September 10, 2026
14 min read
By Kokil Thapa | Last reviewed: September 2026
Docker layer caching for faster builds is the difference between a CI job that finishes in thirty seconds and one that burns five minutes reinstalling the same Composer and npm packages on every push. Each instruction in a Dockerfile creates an immutable layer. When the instruction text and its input files are unchanged, Docker reuses the cached layer instead of running the step again. That behaviour is predictable, but only if you understand how the builder evaluates each line. This guide covers cache mechanics, Dockerfile ordering, BuildKit cache mounts, and CI patterns I use on production Laravel and PHP deployments.
The same idea applies whether you run builds on a GitLab runner, a Jenkins agent, or a local Ubuntu workstation. Pair it with multi-stage Docker builds for smaller images when you want lean production images. Caching is what makes those multi-stage pipelines tolerable during daily development.
How does Docker layer caching work?
Every Dockerfile instruction produces a read-only layer stacked on top of the previous one. Docker hashes each layer from the instruction string plus the content of any copied files. If the hash matches a layer already in the local cache or a remote registry, the builder skips execution and reuses that layer.
A cache miss invalidates the current layer and every layer below it in the build order. That cascade is why a poorly placed COPY . . early in the file forces Composer, npm, and apt to rerun on every code change. The builder does not look ahead. It evaluates top to bottom, one instruction at a time.
Think of the Dockerfile as a dependency graph written in linear form. Stable inputs belong higher in the file. Volatile inputs belong lower. Base images, OS packages, and lock files change rarely. Application source changes on every commit. Put the volatile steps last.
What invalidates a cached layer?
These events trigger a cache miss for that instruction and all subsequent instructions:
- The instruction text changes, including whitespace or argument order in some builders.
- A
COPYorADDsource file changes checksum. - A parent layer above was rebuilt, which changes the starting filesystem state.
- A
RUNstep uses a network resource that the builder treats as non-deterministic in certain modes. - You pass a different
--build-argvalue referenced in that layer.
Arguments declared with ARG before the first FROM can invalidate the entire build when they change. Keep build args scoped to the stages that actually need them. This matters in multi-stage build pipelines that reduce image size.
How should you order Dockerfile instructions for maximum cache hits?
The golden rule is simple. Install dependencies before copying application code. On a production Laravel 13 application running PHP 8.3, a well-ordered Dockerfile looks like this:
# syntax=docker/dockerfile:1
FROM php:8.3-fpm AS base
RUN apt-get update && apt-get install -y \
git unzip libzip-dev \
&& docker-php-ext-install zip pdo_mysql \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2.10 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Dependency layer — cached until lock files change
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction
# Application layer — rebuilt on every code push
COPY . .
RUN composer dump-autoload --optimize
CMD ["php-fpm"] Notice what happens here. The expensive composer install runs only when composer.json or composer.lock changes. A typical feature commit touches PHP files under app/ or resources/. Docker reuses layers one through three and rebuilds only from COPY . . downward.
Frontend assets and Node.js
Laravel projects using Vite 8.x follow the same pattern. Copy package.json and package-lock.json first. Run npm ci. Copy source files. Run npm run build last. See Vite vs Webpack for frontend build tooling for asset pipeline choices. The cache order stays identical regardless of bundler.
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build On real client projects I commit compiled assets and skip Node inside the production image entirely. That removes an entire cache-sensitive stage. It is a valid trade-off when the deploy server has no Node.js 26 LTS runtime and build time matters more than image purity.
What is the difference between classic Docker caching and BuildKit cache mounts?
Docker Layer Caching for Faster Builds improved significantly when BuildKit became the default builder. Classic layer caching stores the entire filesystem state after each instruction. BuildKit adds cache mounts, which persist directories like /root/.composer/cache or /root/.npm across builds even when the RUN layer itself invalidates.
Enable BuildKit on Ubuntu 22.04 or 24.04 servers with:
export DOCKER_BUILDKIT=1
docker build -t myapp:latest . Or set it permanently in /etc/docker/daemon.json after you install Docker on Ubuntu:
{
"features": { "buildkit": true }
} Cache mounts look like this in a Laravel Dockerfile:
RUN --mount=type=cache,target=/root/.composer/cache \
composer install --no-dev --prefer-dist --no-interaction Even when composer.lock changes and the layer invalidates, Composer reuses downloaded packages from the mount. Downloads drop from hundreds of megabytes to only the packages that actually changed. The official Docker build cache documentation covers mount types, sharing modes, and garbage collection.
| Feature | Classic layer cache | BuildKit + cache mounts |
|---|---|---|
| Granularity | Per instruction layer | Per instruction plus persistent mount paths |
| Invalidation scope | Current layer and all below | Layer invalidates; mount data survives |
| Composer/npm benefit | Full reinstall on lock change | Partial reuse of package tarballs |
| CI persistence | Registry layer push/pull | Registry cache + type=cache export |
| Multi-stage builds | Supported | Supported with improved parallel stages |
| Default in Docker 2026 | Legacy builder deprecated | Default when BuildKit enabled |
For PHP projects I also mount apt caches during image construction. It speeds up base image rebuilds when you bump the PHP patch version:
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y git unzip How do you persist Docker build cache in CI pipelines?
Local caching is useless if your CI runner starts fresh on every job. Ephemeral runners are the norm on GitLab CI, GitHub Actions, and Jenkins agents. You need an external cache backend. I use three patterns depending on infrastructure budget and security requirements.
Registry-backed inline cache
Push the built image to a registry with cache metadata embedded. The next build pulls that image as a cache source before building. This works well with a self-hosted Docker registry on the same network as your runners.
docker buildx build \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
--tag registry.example.com/myapp:latest \
--push . The mode=max option exports all intermediate layers, not just the final image. Storage cost rises, but cache hit rates improve dramatically. On sister sites sharing a Deployer 7 pipeline, registry cache cut average build time from four minutes to under forty seconds.
Local cache export on persistent runners
When the CI agent keeps the same disk between jobs, export cache to a directory on the runner:
docker buildx build \
--cache-from type=local,src=/tmp/docker-cache \
--cache-to type=local,dest=/tmp/docker-cache,mode=max \
-t myapp:latest . This is the cheapest option. It requires a Jenkins agent with a persistent workspace or a GitLab runner with a cached volume mount. Watch disk usage. Unbounded cache directories have filled production disks on projects I maintain.
GitLab CI cache keyword for Dockerfile contexts
GitLab CI also supports the cache: keyword for directories. Use it for dependency folders when you build outside Docker or use BuildKit mount IDs:
build:
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_BUILDKIT: "1"
script:
- docker buildx build --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --push .
rules:
- if: $CI_COMMIT_BRANCH Align this with broader build pipeline automation practices so cache strategy, test stages, and deploy gates stay consistent across environments.
What are the most common Docker cache mistakes in production projects?
I've debugged slow builds on Laravel apps, WooCommerce 11.1 containers, and custom API services. The same mistakes appear repeatedly. Fixing them rarely requires architectural rewrites. You reorder the Dockerfile and enable BuildKit.
- Copying everything first.
COPY . .on line three destroys cache for all dependency steps below it. Split copies by change frequency. - Running package managers without lock files.
composer installwithout a committedcomposer.lockproduces non-deterministic layers. The cache hits, but the output drifts between builds. - Pinning floating tags.
FROM php:8.3resolves to a new digest periodically. That invalidates all downstream layers even when your code is unchanged. Pin by digest or rebuild on a schedule. - Ignoring
.dockerignore. Copyingnode_modules/,vendor/, or.git/into the build context changes checksums unpredictably. It also slows context upload to the daemon. - Using
docker compose build --no-cachein CI. Some teams add this flag while debugging and forget to remove it. Every deploy becomes a cold build. - Mixing dev dependencies in production layers. Install dev packages in a separate stage or use
--no-dev. Fewer files mean smaller context and fewer invalidation triggers.
A practical .dockerignore for a Laravel 13 project:
.git
.github
node_modules
vendor
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
.env
.env.*
tests
phpunit.xml
*.md
docker-compose*.yml Validate JSON configs in your pipeline with a JSON formatter tool before they reach the build stage. Malformed CI YAML wastes time on failed jobs that never reach the cache layer.
Multi-stage builds and cache scope
Each stage in a multi-stage Dockerfile maintains its own cache chain. A builder stage that compiles assets can cache independently from the runtime stage that copies only the compiled output. Read Docker Buildx multi-platform image builds when you also target arm64 runners. Cross-platform builds add complexity, but cache mounts still apply per architecture.
On the Adventure Third Pole Trek booking platform I shipped with Laravel and Livewire, we used a builder stage for frontend assets and a slim PHP-FPM runtime stage. Cache hits on the builder stage alone saved roughly three minutes per deploy when only Blade templates changed. See the Adventure Third Pole Trek portfolio case study for context on that stack.
Debugging cache behaviour
Run builds with plain progress output to see which steps hit cache:
docker build --progress=plain --no-cache=false -t myapp:test . 2>&1 | grep -E "CACHED|DONE" BuildKit also supports a detailed frontend log. Look for CACHED markers next to individual steps. If a step you expect to cache shows DONE with a long duration, trace backward to the first non-cached layer. That layer is your invalidation source.
The BuildKit documentation explains advanced features like RUN --mount=type=bind and secret mounts. Secret mounts keep credentials out of layers while still allowing authenticated package downloads during build.
How does Docker layer caching fit into a broader deployment workflow?
Fast builds matter because they shorten the feedback loop between commit and staging. Slow pipelines encourage developers to batch changes. Batches make debugging harder. A thirty-second build after a one-line fix keeps momentum high.
Caching connects directly to server-side concerns. Smaller, faster-built images deploy quicker through Deployer 7 symlink swaps. PHP-FPM reloads happen sooner. Rollbacks via dep rollback pull smaller layers from the registry. Teams that treat Linux system administration and container hosting as part of the development workflow tend to adopt cache export early.
Resource limits still apply after the container runs. Build speed does not replace runtime tuning. Pair cached builds with Docker container resource limits so production pods stay predictable under load.
For WordPress 7.1 or WooCommerce 11.1 containerisation, the same layer-order rules apply. Copy composer.json or plugin lock files before the full WordPress tree. Use Docker Compose profiles and overrides to separate dev tooling from production images. Dev containers can mount source code as a volume and skip rebuilds entirely during theme edits.
Database-heavy applications benefit indirectly. Faster deploys mean you run migrations and smoke tests more often. That catches schema drift before it reaches production MySQL 9.7 or PostgreSQL 18 instances. See running PostgreSQL in Docker for development for local parity patterns.
When builds remain slow after cache tuning, audit the pipeline holistically. Frontend tooling changes documented in npm scripts for build automation sometimes add redundant test stages before the Docker build step. Move static analysis outside the image build when possible. Let the Dockerfile focus on producing the artefact.
Production support contracts often include pipeline maintenance. If your team lacks time to tune cache export and registry storage, ongoing support and maintenance services cover CI optimisation alongside application updates. A one-time Dockerfile reorder pays for itself within the first week of faster deploys.
Key Takeaways
- Order Dockerfile instructions from stable to volatile — lock files and dependency installs before
COPY . .. - Enable BuildKit and use
--mount=type=cachefor Composer, npm, and apt directories to survive lock-file changes. - Export CI cache to a registry with
mode=maxso ephemeral runners reuse intermediate layers across jobs. - Maintain a strict
.dockerignoreto keep build context checksums stable and uploads fast. - Debug with
--progress=plainand trace cache misses to the first changed instruction, not the slowest step. - Pin base images by digest when you need reproducible layers across scheduled rebuilds.
People Also Ask
Does Docker cache layers between different projects?
No. Each image name and build context maintains its own cache chain. Shared base images like php:8.3-fpm are cached globally on the daemon, so the initial FROM pull may hit cache even on a new project. Application layers do not cross projects unless you explicitly use --cache-from pointing at another image.
Why does my Docker build ignore cache in CI but not locally?
Ephemeral CI runners start with an empty local cache. Without --cache-from pointing at a registry or persistent directory, every instruction runs from scratch. Locally your daemon accumulates layers across builds. Add registry cache export and import to close the gap.
Should I use docker compose build or docker buildx build for caching?
Both support BuildKit caching when DOCKER_BUILDKIT=1 is set. Compose v2 passes build options through the x-bake or cache_from keys in compose.yaml. For fine-grained CI control, docker buildx build with explicit --cache-to flags is clearer and easier to audit in pipeline logs.
Do multi-stage builds share cache between stages?
Each FROM starts a new stage with its own cache lineage. You can copy artefacts between stages with COPY --from=, but cache in the builder stage does not automatically propagate to the runtime stage. Both stages benefit from correct layer ordering independently.
Ship faster builds on your next deploy
Docker layer caching for faster builds is not a hidden feature. It is a discipline. Reorder your Dockerfile today, enable BuildKit, add a registry cache backend in CI, and measure the difference on the next ten commits. Most PHP and Laravel teams I work with cut routine build times by seventy percent or more without changing application code. If you want help auditing a slow pipeline or containerising an existing application, contact us about your deployment setup or explore testing and optimisation services for a full CI review.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

