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.

CI CD Caching for Fast Composer and NPM Installs

By Kokil Thapa | Last reviewed: September 2026

CI CD caching for fast Composer and NPM installs is the single cheapest speed win most PHP and JavaScript pipelines still miss. Every push that runs composer install and npm ci from a cold disk burns two to eight minutes downloading packages your last green build already fetched. On a real GitLab CI pipeline for Laravel, that wasted time stacks across lint, test, and deploy jobs. This guide shows exactly what to cache, where to store it, and how to wire it in GitLab CI, GitHub Actions, and Jenkins without stale-lock surprises.

What should you cache for Composer and NPM in CI CD pipelines?

Cache the directories dependency managers read and write, not random project folders. Composer downloads into a global cache, then copies into vendor/. NPM writes tarballs into its cache before extracting into node_modules/.

Your CI job still needs a correct install step. Caching replaces repeated network fetches; it does not replace lock-file discipline.

Composer cache paths

Composer 2.10 keeps downloaded packages under a home-relative cache directory. On Linux runners that is typically ~/.composer/cache or ~/.cache/composer depending on XDG settings.

Many teams cache vendor/ directly when the job runs composer install --prefer-dist --no-progress. That works on small Laravel 13 apps. Large monorepos with shifting dev dependencies often prefer caching the Composer cache dir and rebuilding vendor each run.

# Show Composer cache location on the runner
composer config cache-dir

# Typical output on Ubuntu GitLab runner:
# /home/gitlab-runner/.cache/composer

NPM cache paths

NPM 12 stores tarballs under ~/.npm. With npm ci, the cache speeds tarball retrieval while node_modules/ is rebuilt cleanly from package-lock.json.

Do not cache node_modules/ across major Node version changes. Native modules compiled for Node.js 24 LTS will break on a 26 LTS runner.

CI CD Dependency Cache LayersLock Filescomposer.lockComposervendor + cacheNPM~/.npm cacheCache Key = hash(lock file) + runner OS + PHP/Node versionMiss = full download | Hit = secondsPipeline Jobs Reuse Cachelint → test → build → deploy
CI CD caching for fast Composer and NPM installs: lock files drive cache keys, layers feed every downstream job

What not to cache

  • .env and secrets — never belong in a cache artifact.
  • Build outputpublic/build/ from Vite 8.x should be a job artifact, not a long-lived cache.
  • Database dumps — use job artifacts with short TTL instead.
  • Platform-specific native modules — rebuild when the runner image changes.

For deeper Composer tuning beyond caching, see the guide on Composer autoloader vs classmap optimization.

How do you configure GitLab CI cache for Composer and NPM?

GitLab CI has first-class cache support in .gitlab-ci.yml. I've used this pattern on sister legal-tech sites that share a Deployer 7 GitLab CI deploy pipeline — same runner, same cache policy, predictable install times.

Single-job Laravel example

stages:
  - test

variables:
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"

cache:
  key:
    files:
      - composer.lock
      - package-lock.json
    prefix: php8.5-node26
  paths:
    - .composer-cache/
    - vendor/
    - .npm/

before_script:
  - composer install --prefer-dist --no-interaction --no-progress
  - npm ci --cache .npm --prefer-offline

phpunit:
  stage: test
  image: php:8.5-cli
  script:
    - php artisan test

The prefix includes your PHP and Node versions. Bump it when you upgrade from PHP 8.4 to 8.5 or from Node 24 LTS to 26 LTS. Old cache entries then expire naturally.

Per-job cache with fallback key

GitLab supports a fallback key when the exact lock hash misses. That helps fresh branches that have not run yet.

cache:
  - key:
      files:
        - composer.lock
    paths:
      - vendor/
      - .composer-cache/
    policy: pull-push
  - key: composer-fallback
    paths:
      - .composer-cache/
    policy: pull

Read the official GitLab CI caching documentation for cache key limits and runner storage quotas.

Separate frontend and backend jobs

Split jobs when your pipeline runs PHP tests and a Vite 8.x asset build in parallel. Each job pulls only what it needs.

  1. Backend job — cache vendor/ keyed to composer.lock.
  2. Frontend job — cache .npm/ keyed to package-lock.json.
  3. Deploy job — receive vendor/ and public/build/ as artifacts, not cache.

For a full pipeline skeleton, start with the GitLab CI YAML deep dive for PHP projects.

How do you set up GitHub Actions cache for npm and Composer?

GitHub Actions uses the actions/cache@v4 action or built-in cache hooks in setup actions. The cache key must change when lock files change.

Composer with setup-php

name: CI

on: [push, pull_request]

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

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
          tools: composer:2.10
          coverage: none

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

      - uses: actions/cache@v4
        with:
          path: |
            ${{ steps.composer-cache.outputs.dir }}
            vendor
          key: composer-${{ hashFiles('composer.lock') }}-php85
          restore-keys: |
            composer-

      - run: composer install --prefer-dist --no-interaction --no-progress
      - run: php artisan test

NPM with setup-node

      - uses: actions/setup-node@v4
        with:
          node-version: '26'
          cache: 'npm'

      - run: npm ci
      - run: npm run build

The cache: 'npm' option in setup-node handles cache paths automatically. It keys off package-lock.json in the repo root. See the GitHub Actions dependency caching guide for eviction rules — unused caches expire after seven days on free tiers.

GitHub Actions Cache FlowCheckoutRestore CacheInstall DepsRun TestsCache Miss PathFull download then save cache at job endCache Hit PathRestore vendor and .npm in under 30 seconds
GitHub Actions restores dependency cache before install, then saves it after a successful job

Choosing between platforms? The GitHub Actions vs GitLab CI comparison for 2026 covers cache limits and pricing trade-offs.

Which CI CD cache strategy works best for Laravel and Node monorepos?

One flat cache block feels simple but hurts monorepos. A Laravel 13 API plus a separate Vue admin panel share a repo but have independent lock files.

Cache key design rules

Build keys from inputs that actually change dependencies:

  • Hash of composer.lock for PHP jobs.
  • Hash of package-lock.json or pnpm-lock.yaml for JS jobs.
  • Runner OS image tag (ubuntu-24.04 vs ubuntu-22.04).
  • PHP version and Node major version in the prefix.

Do not key on branch name alone. Two branches with the same lock file should share cache entries.

Policy: pull-push vs pull-only

Use pull-push on the job that installs dependencies first — usually lint or test. Use pull on downstream jobs that only consume vendor.

On GitLab CI, the default is pull-push for all jobs. That causes race conditions when five parallel jobs all try to write cache at once. Pin push to one job.

StrategyBest forTypical install timeStale risk
Cache vendor/ directlySingle-app Laravel 12/13 repos15–45 seconds on hitMedium if lock changes mid-pipeline
Cache Composer cache dir onlyLarge apps, frequent dev-dep changes45–90 seconds on hitLow
npm ci + NPM cache dirAll production JS builds20–60 seconds on hitLow
Cache node_modules/ directlySmall frontends, pinned Node version10–30 seconds on hitHigh on Node upgrade
No cache (cold install)Security-audited one-off builds3–8 minutesNone

On a production Laravel eCommerce build, I combine Composer cache-dir caching with a committed Vite build artifact. That mirrors patterns in building fast Laravel eCommerce platforms where deploy speed affects release cadence.

Cache Hit vs Miss: Install TimeCache MissComposer: 3–5 minNPM ci: 2–4 minTotal: 5–9 minEvery cold pushCache HitComposer: 20–45 secNPM ci: 15–40 secTotal: 35–85 secSame lock filesTypical Laravel + Vite pipeline on shared runner
CI CD caching for fast Composer and NPM installs often cuts dependency stages from minutes to under ninety seconds

What are the common CI CD cache mistakes with Composer and NPM?

Caches fail quietly. The job still passes; it just runs slow. These are the patterns I fix most often on client pipelines.

Stale vendor after lock file change

Symptom: composer install reports success but tests fail with missing classes. Cause: cache key did not include composer.lock hash, so an old vendor/ restored.

Fix: always hash the lock file. Run composer validate --strict in CI to catch lock drift early.

Cached credentials or auth tokens

Never bake COMPOSER_AUTH or .npmrc tokens into cache paths. Use CI masked variables and ephemeral composer config in before_script.

For private packages, see Composer private packages via Satis and Repman.

Runner disk exhaustion

Self-hosted runners on a Linux VPS can fill disk when cache never expires. Schedule weekly gitlab-runner cache prune or cap GitHub Actions cache at 5 GB on small instances.

Skipping npm ci in favour of npm install

npm install mutates package-lock.json and hides drift. Always use npm ci in CI. Cache the NPM cache directory, not a half-updated node_modules/ tree.

PHP extension mismatch

Caching vendor/ built on PHP 8.3 and restoring on PHP 8.5 usually works for pure PHP packages. Extensions like imagick or custom ext-* deps may need a full rebuild. Include PHP version in your cache prefix.

Cache Troubleshooting TreeInstall still slow?Check cache keyincludes lock hash?Check runner logshit or miss?Add lock hash + version prefixbump prefix on upgradeTests fail after hit?purge cache, reinstallStill broken? Delete cache key manuallyre-run pipeline from clean state
Decision tree for diagnosing stale CI CD cache issues with Composer vendor and NPM installs

How do Jenkins and Bitbucket Pipelines handle dependency caching?

Not every team runs GitLab or GitHub. Jenkins and Bitbucket have workable options with slightly more wiring.

Jenkins Pipeline

Use the Job Cacher plugin or a manual tarball step keyed to lock file checksum.

stage('Install') {
  steps {
    script {
      def lockHash = sh(script: 'md5sum composer.lock | cut -d" " -f1', returnStdout: true).trim()
      cache(maxCacheSize: 500, caches: [
        [$class: 'ArbitraryFileCache', path: 'vendor', cacheName: "vendor-${lockHash}"]
      ]) {
        sh 'composer install --prefer-dist --no-interaction'
      }
    }
  }
}

See the Jenkins CI/CD practical tutorial for full pipeline context.

Bitbucket Pipelines

Bitbucket uses definitions.caches in bitbucket-pipelines.yml:

definitions:
  caches:
    composer: ~/.composer/cache
    npm: ~/.npm

pipelines:
  default:
    - step:
        caches:
          - composer
          - npm
        script:
          - composer install --prefer-dist --no-interaction
          - npm ci

The Bitbucket Pipelines CI/CD guide covers parallel steps and cache scope across branches.

Self-hosted runner tips

Self-hosted runners on Ubuntu 24 keep cache between jobs by design. That is good for speed but bad if a compromised job poisons cache. Restrict who can push to protected branches. Read self-hosted CI runner security setup before enabling shared cache on a bare VPS.

Projects like Translation Nepal and other sister sites on shared EC2 benefit from one runner pool with a consistent cache policy — less disk churn, faster deploys.

Key Takeaways

  • Key CI CD caches to composer.lock and package-lock.json hashes plus PHP and Node version prefixes.
  • Cache Composer’s cache directory or vendor/; pair npm ci with the NPM cache dir, not a stale node_modules/.
  • Let one install job push cache; make parallel test jobs pull-only to avoid write races.
  • Bump the cache prefix when upgrading PHP 8.5, Node 26 LTS, or the runner OS image.
  • Pass build output as short-lived artifacts; keep long-lived cache for dependency downloads only.
  • Monitor runner disk usage on self-hosted setups and prune caches on a weekly schedule.

People Also Ask

Does CI CD caching work with composer install and npm ci together?

Yes. Define separate cache paths and keys for each lock file in the same pipeline. A Laravel 13 job can restore vendor/ and .npm/ in one before_script block, then run composer install and npm ci sequentially or in parallel jobs.

How often should you invalidate CI dependency cache?

Invalidate automatically when lock files change — that is what hash-based keys do. Manually purge cache when upgrading PHP, Node, or the runner image even if locks are unchanged. Native modules and platform packages can otherwise slip through.

Is caching node_modules safe in production CI pipelines?

Generally no. npm ci with a warm NPM cache dir is safer. It guarantees a clean tree from package-lock.json while still skipping redundant tarball downloads. Direct node_modules/ caching risks partial corruption across Node version bumps.

Which is faster: GitLab CI cache or GitHub Actions cache?

Speed depends on runner proximity and cache size, not the platform alone. GitLab cache lives on runner or cloud storage tied to the project. GitHub Actions cache is per-repository with a 10 GB default limit. Both beat cold installs by a wide margin when keys are correct.

Ship faster pipelines with dependency caching done right

CI CD caching for fast Composer and NPM installs is low effort and high return. Add lock-file hash keys today and your next ten pushes on unchanged dependencies will prove the gain. If your pipeline still cold-installs everything, you are paying minutes per push that caching hands back for free.

Need help wiring cache policy into a Laravel deploy pipeline, tuning a slow GitLab runner, or auditing a VPS that runs out of disk? Review the testing and optimization service or ongoing support and maintenance options. For a greenfield setup, see custom software development. Validate your pipeline YAML syntax with the free JSON formatter when converting API responses or cache reports. Browse the Adventure Third Pole Trek portfolio for a live Laravel + Livewire project deployed through GitLab CI.

Contact us to audit your current CI/CD pipeline and cut install times on your next release.

Frequently Asked Questions

It stores Composer vendor/ and NPM cache directories between builds, keyed to lock files. Unchanged locks mean installs finish in seconds, not minutes.

Cache the directories dependency managers read and write, not random project folders. Composer 2.10 downloads into a global cache under ~/.composer/cache or ~/.cache/composer, then copies into vendor/. On small Laravel 13 apps, caching vendor/ directly after composer install --prefer-dist works well. Large monorepos often cache the Composer cache dir and rebuild vendor each run. NPM 12 stores tarballs under ~/.npm. Pair npm ci with a warm NPM cache dir so node_modules rebuilds cleanly from package-lock.json while skipping redundant downloads.

Two to eight minutes per cold install, often cut to under ninety seconds with correct lock-file keys.

In .gitlab-ci.yml, set cache key files to composer.lock and package-lock.json with a prefix like php8.5-node26 that you bump on runtime upgrades. Cache paths include .composer-cache/, vendor/, and .npm/. Set COMPOSER_CACHE_DIR to $CI_PROJECT_DIR/.composer-cache, then run composer install --prefer-dist --no-interaction and npm ci --cache .npm --prefer-offline in before_script. Use a fallback key for fresh branches. Split backend and frontend jobs so each pulls only what it needs, and pass public/build/ as artifacts to deploy jobs, not cache.

Use actions/checkout@v4, shivammathur/setup-php@v2 with PHP 8.5 and Composer 2.10, then actions/cache@v4 on the Composer cache-files-dir and vendor keyed to hashFiles('composer.lock') with a php85 suffix and composer- restore-keys. For NPM, actions/setup-node@v4 with node-version 26 and cache npm handles paths automatically, keyed to package-lock.json. Run composer install and npm ci after restore. GitHub restores cache before install and saves after a successful job. Unused caches expire after seven days on free tiers with a 10 GB default repository limit.

Cache the NPM cache directory with npm ci, not node_modules directly. npm ci guarantees a clean tree from package-lock.json while the warm cache skips tarball downloads, typically finishing in 20–60 seconds on a hit. Direct node_modules caching can hit 10–30 seconds on small frontends but carries high stale risk when Node major versions change. Native modules compiled for Node.js 24 LTS break on a 26 LTS runner. Rebuild cleanly whenever the runner image or Node version changes.

Never cache .env files, secrets, COMPOSER_AUTH tokens, or .npmrc credentials in cache paths — use CI masked variables and ephemeral composer config in before_script instead. Build output like public/build/ from Vite 8.x belongs in short-lived job artifacts, not long-lived cache. Database dumps need artifacts with TTL. Platform-specific native modules should rebuild when the runner image changes, because a poisoned or mismatched cache affects every downstream job that restores the same paths on shared runners.

Hash-based keys auto-invalidate when composer.lock or package-lock.json changes — that is the primary trigger. Manually purge when upgrading PHP 8.5, Node 26 LTS, or the runner OS image even if locks stay unchanged, because native modules and platform packages can slip through unchanged keys. Bump the cache prefix so old entries expire naturally. On self-hosted GitLab runners, schedule weekly gitlab-runner cache prune. Cap GitHub Actions cache at 5 GB on small VPS instances to prevent disk exhaustion.

Stale vendor after a lock change when the cache key skipped composer.lock causes missing-class test failures — always hash the lock file and run composer validate --strict. Never bake COMPOSER_AUTH or .npmrc tokens into cached paths. Using npm install instead of npm ci mutates package-lock.json and hides drift. Setting all parallel GitLab jobs to pull-push causes write races — pin push to one install job. Caching vendor built on PHP 8.3 then restoring on PHP 8.5 breaks packages depending on extensions like imagick.

Avoid one flat cache block. Build keys from composer.lock for PHP jobs and package-lock.json for JS jobs, plus runner OS image tag and PHP/Node major version in the prefix. Do not key on branch name alone — two branches with the same lock file should share entries. Use pull-push on the first install job and pull-only on downstream consumers. Backend jobs cache vendor/; frontend jobs cache .npm/; deploy jobs receive vendor/ and public/build/ as artifacts. Large apps with shifting dev dependencies often prefer Composer cache-dir-only over direct vendor/ caching.

Speed depends on runner proximity and cache size, not the platform alone. GitLab cache lives on the runner or project-linked cloud storage. GitHub Actions cache is per-repository with a 10 GB default limit. Both beat cold installs by a wide margin when lock-file keys are correct. Cold installs without cache typically take three to eight minutes. The GitHub Actions vs GitLab CI comparison for 2026 covers cache limits and pricing trade-offs if you are choosing between platforms for a new pipeline.

Jenkins uses the Job Cacher plugin or a manual tarball step keyed to a lock file checksum, wrapping composer install inside a cache block with maxCacheSize caps. Bitbucket defines named caches in bitbucket-pipelines.yml pointing to ~/.composer/cache and ~/.npm, then references them in step caches arrays before running composer install and npm ci. Neither platform provides GitLab's files: key shorthand out of the box, so both need explicit lock-file hashing. Self-hosted runners on Ubuntu 24 keep cache between jobs by design, which helps speed but requires branch protection on shared VPS pools.

The cache key did not include the composer.lock hash, so CI restored an old vendor/ directory from a previous dependency set. composer install may report success because vendor/ already looks populated, but classes from newly added packages are absent. Fix by always hashing composer.lock in the cache key and adding a php85-style prefix that changes on PHP upgrades. Run composer validate --strict early in the pipeline to catch lock drift before tests execute and waste runner minutes on false failures.

pull-push restores cache at job start and saves updated paths after a successful run. pull-only restores but never writes back. GitLab defaults to pull-push on all jobs, which causes race conditions when five parallel jobs all try to write cache simultaneously. Pin pull-push to one dependency install job — usually lint or test — and set pull on downstream jobs that only consume vendor/. For monorepos, the backend install job pushes vendor/ while parallel PHPUnit jobs pull the same keyed cache without competing writes.

Yes. Define separate cache paths and keys for each lock file in the same pipeline, restoring vendor/ and .npm/ before running both commands.

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: