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.

Build Caching: Speed Up CI Builds

By Kokil Thapa | Last reviewed: September 2026

Build Caching: Speed Up CI Builds is the difference between a pipeline that finishes in three minutes and one that burns twelve on every push. Your CI runner re-downloads Composer packages, npm modules, and Docker base layers on each job unless you tell it what to reuse. On real client projects I maintain with Composer and npm CI caching, cache hits cut install time by 60–80%. The rest of this guide walks through cache keys, platform config, and the stale-cache traps that waste more time than they save.

What is build caching in CI and why does it matter?

Build caching stores filesystem paths or image layers from a previous CI run and restores them before expensive steps run again. Without it, every job starts cold. That means another composer install, another npm ci, and another Docker pull.

The cost adds up fast. A Laravel 13 project on PHP 8.3 with Vite 8.x frontend assets might spend four minutes on dependencies alone. Multiply that by ten pushes per day across three developers and you lose two hours daily to redundant work.

Caching is not free complexity. You trade a bit of pipeline YAML and occasional cache invalidation for predictable speed. For teams using GitLab CI with Laravel or Jenkins agents, the ROI shows up within the first week.

Build Caching: Speed Up CI BuildsWithout CacheWith CacheCheckout30 secInstall Deps6 minBuild + Test4 minTotal: ~10 minCheckout30 secRestore Cache45 secBuild + Test3 minTotal: ~4 min
Build caching speeds up CI builds by skipping repeated dependency installs on every pipeline run

Think of CI cache as a shared scratch disk keyed to your lock files. Application runtime caching with Redis in Laravel solves a different problem. Build cache lives on the runner or object storage and dies when keys change.

What should you cache first?

Prioritize the slowest, most deterministic steps:

  • Composer vendor/ — keyed on composer.lock
  • npm node_modules/ — keyed on package-lock.json
  • Docker layers — keyed on Dockerfile and context hashes
  • Build artefacts — Vite public/build/ when tests need compiled assets
  • Test caches — PHPUnit result cache, ESLint cache, static analysis output

Do not cache secrets, .env files, or database dumps. Those belong in masked CI variables or encrypted storage.

How do you configure cache keys for Composer, npm, and Docker?

A cache key tells the runner which saved folder matches the current job. Change the lock file and the key changes. The runner misses cache, installs fresh, then writes a new entry.

A common mistake is keying only on branch name. Two developers on feature/checkout with different lock files would share a broken vendor tree. Always include a hash of the lock file.

GitLab CI cache example for Laravel

This pattern works on the sister-site pipelines I run with Deployer 7 and GitLab CI. It caches Composer 2.10 and npm 12 installs separately:

# .gitlab-ci.yml
variables:
  COMPOSER_CACHE_DIR: .composer-cache

cache:
  key:
    files:
      - composer.lock
      - package-lock.json
  paths:
    - vendor/
    - node_modules/
    - .composer-cache/
  policy: pull-push

stages:
  - test

php-test:
  stage: test
  image: php:8.3-cli
  before_script:
    - apt-get update && apt-get install -y git unzip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --no-interaction --prefer-dist --no-progress
    - corepack enable && npm ci
  script:
    - cp .env.testing .env
    - php artisan key:generate
    - php artisan test

GitLab restores cache before before_script runs. If vendor/ is intact, Composer skips most downloads. See the official GitLab CI caching documentation for fallback_keys and per-job cache overrides.

GitHub Actions cache example

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: composer:2.10

      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: |
            composer-

      - name: Install PHP deps
        run: composer install --no-interaction --prefer-dist

      - name: Cache npm
        uses: actions/cache@v4
        with:
          path: node_modules
          key: npm-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            npm-

      - name: Install JS deps
        run: npm ci

      - name: Run tests
        run: php artisan test

GitHub Actions uses restore-keys as prefix fallbacks. A partial hit beats a full miss when you bump one lock file but not the other. The GitHub Actions dependency caching guide covers size limits and eviction rules.

Cache Key ResolutionRead Lock FilesHash + Branch SlugCache HitRestore pathsCache MissFresh installSave After Success
Cache keys derived from lock file hashes decide whether CI restores saved vendor and node_modules folders

Docker layer caching

Container builds benefit from layer reuse even when application code changes. Put rarely changing instructions first in your Dockerfile:

# Dockerfile — cache-friendly order
FROM php:8.3-fpm

RUN apt-get update && apt-get install -y \
    git unzip libzip-dev \
    && docker-php-ext-install zip pdo_mysql

COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist

COPY . .
RUN npm ci && npm run build

CMD ["php-fpm"]

Use docker buildx build --cache-to type=registry --cache-from type=registry in CI for shared layer storage across runners. The Docker layer caching guide covers BuildKit backends in more depth. Docker's own build cache documentation explains inline and registry cache exporters.

Which CI platforms support build caching in 2026?

Every major CI vendor offers caching, but semantics differ. Pick the model that matches your runner topology and budget.

PlatformCache storageKey modelBest for
GitLab CIRunner local or S3-compatibleFile hash + branchLaravel teams already on GitLab
GitHub ActionsGitHub-managed blob per repoExplicit key + restore-keysOpen-source and SaaS repos
Azure PipelinesAzure Blob via Cache@2 taskKey + restore keys.NET + mixed stacks
CircleCIDedicated cache serviceTemplate keys per branchHigh-volume parallel jobs
JenkinsPlugin-dependent (S3, NFS)Manual job configSelf-hosted bare metal

GitLab shared runners in Nepal often have limited disk. I set cache:policy: pull on feature branches and pull-push only on main. That stops every branch from writing multi-gigabyte vendor trees.

For Jenkins setups, read the Jenkins CI/CD practical tutorial and add the Pipeline Utility Steps plugin for stash and unstash. It is less elegant than native cache but works on agents without shared storage.

Azure and Bitbucket teams should cross-read the Azure Pipelines guide and Bitbucket Pipelines guide for platform-specific YAML.

How do you avoid stale cache bugs in CI pipelines?

Stale cache is the main reason teams disable caching after one bad deploy. A restored vendor/ folder from an old lock file passes CI but fails in production with a class-not-found error.

These guardrails keep cache safe without throwing away speed gains.

Validate after restore

Never assume restore equals correct. Run install commands anyway. Composer and npm are idempotent when lock files match:

composer install --no-interaction --prefer-dist
npm ci

npm ci deletes and reinstalls from lock file if node_modules drifts. Composer verifies checksums. The cache still saves download time even when validation runs.

Bust cache on demand

Add a cache version variable you bump when debugging weird failures:

cache:
  key: "v2-composer-${CI_COMMIT_REF_SLUG}-${CI_COMMIT_SHA:0:8}"

Or use a CACHE_BUST CI variable set manually from the GitLab UI. One click invalidates without editing lock files.

Separate pull and push policies

On merge-request pipelines, pull only. Push cache updates from main after tests pass. That reduces race conditions when five developers push within the same hour.

Cache Policy by BranchFeature Branchpolicy: pullFast, no write racesMain Branchpolicy: pull-pushUpdates golden cacheStale Cache GotchaLock file changed but key did notFix: hash lock files + run npm ci
Pull-only cache on feature branches and pull-push on main reduces stale vendor folder conflicts

I've seen PHPUnit pass against stale autoload maps while production crashed. Running composer dump-autoload -o after restore closes that gap. Pair it with code coverage gates in CI so test gaps surface before merge.

What are the best practices for Laravel and PHP CI caching?

Laravel 13 on PHP 8.3 adds a few PHP-specific wrinkles. Opcache and path repositories behave differently on ephemeral runners than on your laptop.

Cache the right paths

  1. vendor/ keyed on composer.lock
  2. node_modules/ keyed on package-lock.json
  3. .composer-cache/ for Composer 2.10 download cache
  4. public/build/ only if downstream jobs skip npm run build
  5. Never cache bootstrap/cache/ or storage/ between unrelated jobs

For Vite 8.x frontends, decide whether CI builds assets or commits them. Several projects I deploy commit compiled assets because production servers have no Node. In that case cache JS deps only for lint jobs. See Vite vs Webpack for frontend builds for the trade-off.

Parallel jobs and cache contention

Split lint, unit tests, and browser tests into parallel jobs. Each job pulls the same cache key. Only one job should push updates per pipeline to avoid last-write-wins corruption:

composer-cache:
  stage: prepare
  cache:
    key:
      files:
        - composer.lock
    paths:
      - vendor/
    policy: pull-push
  script:
    - composer install --no-interaction --prefer-dist

phpunit:
  stage: test
  needs: [composer-cache]
  cache:
    key:
      files:
        - composer.lock
    paths:
      - vendor/
    policy: pull
  script:
    - php artisan test

This prepare-job pattern mirrors what I use on legal-tech portals like the Adventure Third Pole Trek booking platform. One install, many consumers.

Frontend and asset pipeline caching

When CI runs npm run build, cache node_modules/.cache if Vite writes there. ESLint and Prettier benefit from --cache flags stored under .eslintcache. The npm scripts for build automation article lists script patterns that play well with cached runners.

WordPress and WooCommerce 11.1 projects on shared hosting follow a different path. CI might only run PHPCS and PHPUnit without a full asset pipeline. The WordPress development service page covers hosting constraints that affect what you cache locally versus in CI.

Laravel Parallel CI CachePrepare Jobcomposer install + pushPHPUnitpull vendorPHPStanpull vendorESLintpull node_modulesDeployer 7 Releaseneeds all green tests
A prepare job that caches vendor once feeds parallel Laravel test and lint jobs before Deployer release

Measure before and after

Log install duration in every pipeline. GitLab exposes job duration in the UI. Compare median times over two weeks before declaring victory.

A rough budget for a mid-size Laravel app on shared runners:

  • Uncached pipeline: 8–14 minutes
  • Cached pipeline (hit): 3–6 minutes
  • Cache miss after lock bump: 7–10 minutes once, then back to baseline

That time savings matters for small teams in Kathmandu billing hourly. Faster feedback also means fewer context switches. Use the JSON formatter tool to inspect CI API responses when you automate metrics collection.

For broader pipeline design, read build pipeline automation best practices and build automation complete guide. Teams needing hands-on help can review testing and optimization services or custom software development for audit work.

Self-hosted runners on Ubuntu benefit from the same disk hygiene as production servers. The Linux system administration service covers disk quotas and cleanup cron jobs that keep cache directories from filling root partitions.

If you maintain long-lived Jenkins agents, schedule weekly cache pruning. Orphaned vendor trees from deleted branches consume tens of gigabytes silently.

Key Takeaways

  • Key caches on lock file hashes, not branch names alone, to prevent stale vendor restores.
  • Run composer install and npm ci after every cache restore for safe validation.
  • Use pull-only cache on feature branches and pull-push on main to cut write contention.
  • Split a dedicated prepare job that installs deps once for parallel PHPUnit, static analysis, and lint jobs.
  • Cache Docker layers by ordering Dockerfile instructions from least to most frequently changed.
  • Track median pipeline duration weekly so cache regressions show up before developers complain.

People Also Ask

Does CI caching work with monorepos?

Yes, but keys must include path-specific lock files. Use separate cache entries per package with keys like hashFiles('apps/api/composer.lock'). GitLab supports multiple cache blocks per job. Without path scoping, unrelated package changes invalidate the entire cache.

How large can CI caches get?

GitHub Actions limits caches to 10 GB per repository with LRU eviction. GitLab defaults depend on runner disk, often 5–20 GB per machine. A typical Laravel vendor plus node_modules folder runs 200–600 MB compressed. Monitor size and exclude test fixtures or generated reports from cached paths.

Should you cache production vendor with dev dependencies?

Keep separate cache keys for --no-dev and full installs. Production deploy pipelines should key on composer.lock plus a prod suffix. Mixing dev and prod vendor trees causes subtle extension mismatches in CI.

Is build caching the same as a Composer mirror?

No. A mirror replicates Packagist to a local server and helps every install everywhere. CI cache only helps jobs on runners that share storage. Many teams use both: a mirror for reliability and CI cache for speed on ephemeral runners.

Ship faster pipelines with intentional cache design

Build Caching: Speed Up CI Builds is not a toggle you flip once. It is a discipline: correct keys, safe restore validation, and branch-aware push policies. Start with Composer and npm paths on your noisiest pipeline. Measure the delta. Then add Docker layer cache and parallel job splits.

If your team waits ten minutes per push and deploys multiple Laravel apps weekly, fixing cache pays for itself quickly. For pipeline audits, deployment hardening, or ongoing maintenance, reach out via contact us. You can also browse the portfolio for production CI examples or read more on the blog.

Frequently Asked Questions

Build caching stores filesystem paths or Docker image layers from a previous CI run and restores them before expensive steps run again, so pipelines skip re-downloading Composer packages, npm modules, and base layers on every push.

On projects with Composer and npm CI caching, cache hits typically cut install time by 60–80%. A mid-size Laravel app often drops from 8–14 minutes uncached to 3–6 minutes on a cache hit.

Hit rates above 70% typically halve total build time. Below that, review your cache keys and push policies before adding more cached paths.

Prioritize the slowest, most deterministic steps: Composer vendor/ keyed on composer.lock, npm node_modules/ keyed on package-lock.json, Docker layers keyed on Dockerfile and context hashes, Vite public/build/ when tests need compiled assets, and PHPUnit or ESLint result caches. Never cache secrets, .env files, or database dumps.

Always hash lock files, not branch names alone. Two developers on the same feature branch with different lock files would share a broken vendor tree if you key only on branch. Change composer.lock or package-lock.json and the key changes: the runner misses cache, installs fresh, then writes a new entry. GitHub Actions restore-keys provide prefix fallbacks when one lock file changes but not the other.

Define cache key files as composer.lock and package-lock.json, with paths vendor/, node_modules/, and .composer-cache/. Set COMPOSER_CACHE_DIR to .composer-cache and use policy pull-push. GitLab restores cache before before_script runs, so an intact vendor/ folder lets Composer 2.10 skip most downloads. On shared runners with limited disk, use pull-only on feature branches and pull-push only on main.

Use actions/cache@v4 with path vendor and key composer-${{ hashFiles('composer.lock') }}, plus restore-keys with a composer- prefix. Cache node_modules separately with key npm-${{ hashFiles('package-lock.json') }} and npm- restore-keys. Run composer install --no-interaction --prefer-dist and npm ci after restore. Partial hits from restore-keys beat full misses when only one lock file changes.

Put rarely changing Dockerfile instructions first, such as base image setup and composer install from copied lock files, then COPY application code and run npm ci plus npm run build. Use docker buildx build with --cache-to type=registry and --cache-from type=registry so runners share layer storage. Application code changes then reuse earlier layers instead of rebuilding everything.

Every major vendor offers caching with different semantics. GitLab CI uses runner-local or S3-compatible storage keyed on file hashes plus branch, ideal for Laravel teams already on GitLab. GitHub Actions uses repo-managed blobs with explicit keys and restore-keys. Azure Pipelines, CircleCI, and Jenkins also support caching through platform tasks, dedicated cache services, or stash and unstash plugins on self-hosted agents.

Never assume a restore equals a correct install. Run composer install and npm ci after every restore; both are idempotent when lock files match and still save download time. Add a cache version variable or CACHE_BUST CI variable to invalidate on demand. Use pull-only cache on merge-request pipelines and push updates from main after tests pass. Run composer dump-autoload -o after restore to avoid PHPUnit passing against stale autoload maps.

Cache vendor/ on composer.lock, node_modules/ on package-lock.json, and .composer-cache/ for Composer 2.10 downloads. Cache public/build/ only if downstream jobs skip npm run build. Never cache bootstrap/cache/ or storage/ between unrelated jobs. Split lint, unit tests, and browser tests into parallel jobs fed by one prepare job that installs dependencies once. Log install duration every pipeline and compare median times over two weeks before declaring victory.

Yes. Composer and npm are idempotent when lock files match: composer install verifies checksums, and npm ci deletes and reinstalls from package-lock.json if node_modules drifts. The cache still saves download time even when validation runs. Skipping install after restore is how teams get vendor folders from old lock files that pass CI but fail in production with class-not-found errors.

On feature branches and merge-request pipelines, set cache policy to pull only. Push cache updates from main after tests pass. That reduces race conditions when multiple developers push within the same hour and stops every branch from writing multi-gigabyte vendor trees on shared runners with limited disk. Pull-only on branches and pull-push on main is the pattern I use on Deployer 7 and GitLab CI sister-site pipelines.

GitHub Actions limits caches to 10 GB per repository with LRU eviction. GitLab defaults depend on runner disk, often 5–20 GB per machine. A typical Laravel vendor plus node_modules folder runs 200–600 MB compressed. Monitor size and exclude test fixtures or generated reports from cached paths. On long-lived Jenkins agents, schedule weekly cache pruning because orphaned vendor trees from deleted branches consume tens of gigabytes silently.

Yes, but cache keys must include path-specific lock files. Use separate cache entries per package with keys like hashFiles('apps/api/composer.lock'). GitLab supports multiple cache blocks per job. Without path scoping, unrelated package changes invalidate the entire cache and every job pays full install cost even when only one app changed.

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: