Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Docker Layer Caching for Faster Builds

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.

Docker Layer Stack and Cache EvaluationLayer 4: COPY app codeLayer 3: COPY composer filesCACHE HIT — skippedLayer 2: RUN composer installCACHE HIT — skippedLayer 1: FROM php:8.3-fpmCode change invalidatesLayer 4 onlycomposer.json changeinvalidates Layers 2–4
Docker layer caching for faster builds — only layers after the first changed instruction are rebuilt

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 COPY or ADD source file changes checksum.
  • A parent layer above was rebuilt, which changes the starting filesystem state.
  • A RUN step uses a network resource that the builder treats as non-deterministic in certain modes.
  • You pass a different --build-arg value 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.

Dockerfile Order for Maximum Cache Hits1. FROM base image (php:8.3-fpm, node:26-alpine)2. RUN system packages and extensions3. COPY lock files only — composer.lock, package-lock.json4. RUN dependency install — composer install, npm ci5. COPY app source + build assets last
Correct Dockerfile layer order keeps dependency installs cached across routine code commits

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.

FeatureClassic layer cacheBuildKit + cache mounts
GranularityPer instruction layerPer instruction plus persistent mount paths
Invalidation scopeCurrent layer and all belowLayer invalidates; mount data survives
Composer/npm benefitFull reinstall on lock changePartial reuse of package tarballs
CI persistenceRegistry layer push/pullRegistry cache + type=cache export
Multi-stage buildsSupportedSupported with improved parallel stages
Default in Docker 2026Legacy builder deprecatedDefault 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.

CI Pipeline Cache FlowGit Pushtriggers CI jobPull Cachefrom registryBuildKitcache hitsPush Image+ cache layersCache miss triggersFresh runner with no --cache-fromLock file change without mode=max exportCOPY . . placed before dependencies
Registry-backed Docker layer caching in CI — pull cache before build, export after push

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.

  1. Copying everything first. COPY . . on line three destroys cache for all dependency steps below it. Split copies by change frequency.
  2. Running package managers without lock files. composer install without a committed composer.lock produces non-deterministic layers. The cache hits, but the output drifts between builds.
  3. Pinning floating tags. FROM php:8.3 resolves to a new digest periodically. That invalidates all downstream layers even when your code is unchanged. Pin by digest or rebuild on a schedule.
  4. Ignoring .dockerignore. Copying node_modules/, vendor/, or .git/ into the build context changes checksums unpredictably. It also slows context upload to the daemon.
  5. Using docker compose build --no-cache in CI. Some teams add this flag while debugging and forget to remove it. Every deploy becomes a cold build.
  6. 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.

Build Time: Before vs After Cache OptimisationBefore — poor layer orderapt + composer: 3m 20snpm ci: 1m 45sasset build: 55sTotal: ~6 min every pushAfter — cache optimisedapt + composer: CACHEDnpm ci: CACHEDasset build: 55sTotal: ~1 min code-onlyfixTypical Laravel + Vite project on GitLab CI with registry cache
Docker layer caching for faster builds can reduce routine CI rebuilds from six minutes to about one minute

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=cache for Composer, npm, and apt directories to survive lock-file changes.
  • Export CI cache to a registry with mode=max so ephemeral runners reuse intermediate layers across jobs.
  • Maintain a strict .dockerignore to keep build context checksums stable and uploads fast.
  • Debug with --progress=plain and 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

Docker layer caching reuses unchanged image layers when a Dockerfile instruction and its input files match a previous build, skipping expensive steps like dependency installs.

Every Dockerfile instruction produces a read-only layer stacked on the previous one. Docker hashes each layer from the instruction string plus the content of any copied files. If the hash matches a layer in local cache or a remote registry, the builder skips execution and reuses it. The builder evaluates top to bottom, one instruction at a time, with no look-ahead. A cache miss on any layer invalidates that layer and every layer below it in build order. That cascade is why volatile steps like COPY . . belong at the bottom and stable dependency installs belong higher.

A cache miss triggers when the instruction text changes, including whitespace or argument order in some builders. COPY or ADD source file checksum changes also invalidate. If a parent layer above was rebuilt, the starting filesystem state differs. RUN steps using network resources can be treated as non-deterministic in certain modes. Different --build-arg values referenced in that layer cause misses too. ARG declarations before the first FROM can invalidate the entire build when they change, so scope build args to the stages that actually need them, especially in multi-stage pipelines.

Install dependencies before copying application code. On a Laravel 13 app with PHP 8.3, copy composer.json and composer.lock first, run composer install, then COPY . . for application source. Expensive composer install runs only when lock files change; routine commits touching app/ or resources/ reuse earlier layers. Frontend projects using Vite 8.x follow the same pattern: copy package.json and package-lock.json, run npm ci, copy source, run npm run build last. Base images, OS packages, and lock files change rarely; application source changes every commit. Put volatile steps last.

Classic layer caching stores the entire filesystem state after each instruction. BuildKit adds cache mounts that persist directories like /root/.composer/cache or /root/.npm across builds even when the RUN layer itself invalidates. With classic caching, a composer.lock change forces a full reinstall. With cache mounts, only packages that actually changed download again. BuildKit also improves multi-stage parallel stages. BuildKit is default when enabled; the legacy builder is deprecated. For PHP projects, apt caches during base image construction speed rebuilds when you bump the PHP patch version.

On Ubuntu 22.04 or 24.04, export DOCKER_BUILDKIT=1 before docker build, or set it permanently in /etc/docker/daemon.json with "features": { "buildkit": true }. GitLab CI pipelines set DOCKER_BUILDKIT: "1" as a variable alongside docker:27 and docker:27-dind service images. BuildKit must be active for RUN --mount=type=cache syntax in Dockerfiles. Without it, you get only classic per-instruction layer caching, which is far less effective when lock files change frequently on Laravel or Node.js projects.

Ephemeral CI runners on GitLab CI, GitHub Actions, and Jenkins need external cache backends. Registry-backed inline cache pushes the built image with embedded cache metadata; the next build pulls it as a cache source using docker buildx build with --cache-from and --cache-to type=registry and mode=max. Local cache export writes to a persistent directory on runners that keep disk between jobs. GitLab CI also supports the cache: keyword for dependency folders. On sister sites sharing a Deployer 7 pipeline, registry cache cut average build time from four minutes to under forty seconds.

Copying everything first with COPY . . on an early line destroys cache for all dependency steps below. Running composer install without a committed composer.lock produces non-deterministic layers. Pinning floating tags like FROM php:8.3 resolves to new digests periodically and invalidates downstream layers. Ignoring .dockerignore copies node_modules/, vendor/, or .git/ and changes checksums unpredictably. Some teams leave docker compose build --no-cache in CI after debugging, forcing cold builds every deploy. Mixing dev dependencies in production layers adds files and invalidation triggers; use --no-dev or separate stages instead.

A practical .dockerignore for Laravel 13 excludes .git, .github, node_modules, vendor, storage/logs, storage/framework/cache, storage/framework/sessions, storage/framework/views, .env, .env.*, tests, phpunit.xml, markdown files, and docker-compose yml files. Without it, copying node_modules/, vendor/, or .git/ into the build context changes checksums unpredictably and slows context upload to the daemon. Every unnecessary file in context can trigger cache misses on COPY instructions. Treat .dockerignore as part of cache strategy, not an optional cleanup step, especially on projects where routine commits should not invalidate dependency layers.

Cache mounts persist directories like /root/.composer/cache or /root/.npm across builds even when the RUN layer invalidates. In a Laravel Dockerfile, RUN --mount=type=cache,target=/root/.composer/cache before composer install reuses downloaded package tarballs when composer.lock changes. Downloads drop from hundreds of megabytes to only packages that actually changed. The same pattern applies to npm ci with an npm cache mount. For PHP base images, mounting /var/cache/apt and /var/lib/apt with sharing=locked speeds apt-get installs when you bump the PHP patch version and rebuild OS package layers.

Each stage in a multi-stage Dockerfile maintains its own cache chain. A builder stage compiling frontend assets can cache independently from the runtime stage copying only compiled output. On the Adventure Third Pole Trek booking platform built with Laravel and Livewire, a builder stage for frontend assets and a slim PHP-FPM runtime stage meant cache hits on the builder stage alone saved roughly three minutes per deploy when only Blade templates changed. Pair multi-stage builds with correct layer ordering inside each stage. Cross-platform Buildx builds add complexity, but cache mounts still apply per architecture.

Run builds with plain progress output to see which steps hit cache: docker build --progress=plain --no-cache=false and grep for CACHED or DONE markers. BuildKit supports a detailed frontend log showing CACHED 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. Common causes are an early COPY . ., missing .dockerignore entries, changed lock files, or a rebuilt parent layer above. Fix ordering before chasing CI cache backend configuration.

Docker evaluates instructions top to bottom and hashes COPY layers from instruction text plus copied file checksums. Application source changes on every commit, so COPY . . on an early line produces a new hash every push. That cache miss invalidates the current layer and every layer below it, forcing Composer, npm, and apt steps to rerun even when lock files are unchanged. The builder does not look ahead. Stable inputs like composer.lock and package-lock.json belong higher; volatile application code belongs lower. Reordering alone often cuts routine CI rebuilds from six minutes to about one minute.

Registry-backed inline cache embeds cache metadata in a pushed image so the next build pulls it as a cache source before building. Use docker buildx build with --cache-from type=registry,ref=registry.example.com/myapp:buildcache and --cache-to with mode=max, which exports all intermediate layers, not just the final image. Storage cost rises but cache hit rates improve dramatically. This works well with a self-hosted Docker registry on the same network as your runners. GitLab CI pipelines reference $CI_REGISTRY_IMAGE:cache the same way. Watch registry storage if mode=max exports grow unbounded over months.

Fast builds shorten the feedback loop between commit and staging; a thirty-second build after a one-line fix keeps momentum high where slow pipelines encourage batching changes that are harder to debug. Cached builds produce images that deploy quicker through Deployer 7 symlink swaps, so PHP-FPM reloads happen sooner and rollbacks via dep rollback pull smaller registry layers. Faster deploys mean migrations and smoke tests run more often, catching schema drift before production MySQL 9.7 or PostgreSQL 18 instances. Build speed does not replace runtime tuning; pair cached builds with container resource limits for predictable production load.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: