Using Composer and npm Caches Strategically in Docker Builds
AI generated
Composer · npm · Docker Build · Cache Strategy · CI/CD
Using Composer and npm Caches
Strategically in Docker Builds

Starting Composer and npm from scratch costs minutes on every single build. The good news is that both package managers ship with robust local caches that can persist across Docker builds without inflating the image size. The key lies in correctly separating the lock file layer, the vendor layer and the cache mount, and in understanding which cache type is the right one for each situation.

13 min read Composer · npm · yarn · BuildKit mount · lock file · vendor layer PHP 8.4 · Node 22 · Docker BuildKit · GitHub Actions

1. The core problem: downloading packages on every build

A typical Dockerfile for a PHP project contains a RUN composer install and a RUN npm install somewhere. Every build that invalidates this layer causes all packages to be downloaded again. For a Magento 2 project with 300+ Composer dependencies, that means two to four minutes of pure download time. A frontend with 500 npm packages adds another two minutes. The frustrating part is that the vast majority of these packages have not changed at all. Just because a source code file was touched before the COPY instruction, the entire Composer cache has to be rebuilt from zero.

The problem lies in coupling two different cache layers that should logically be independent of each other: the layer cache for image contents and the package manager cache for downloaded tarballs. The layer cache works on a hash of the entire layer content, so if anything changes, the layer is invalidated. The Composer cache and the npm cache work on a package version basis instead, meaning a package with an unchanged version and hash never has to be downloaded again. These two cache philosophies collide in a classic Dockerfile, and BuildKit cache mounts are the mechanism that resolves that collision.

A second, less obvious problem is that when Composer and npm store their caches inside the image layer, the image bloats up. A Composer cache under ~/.composer/cache can easily reach 500 MB or more on larger projects. The traditional pattern of deleting this cache after installation (rm -rf ~/.composer/cache) solves the image size problem but makes every build independent of the previous one, so there is no reuse and no time saved. BuildKit cache mounts solve both problems at once: the cache survives between builds, but it never ends up in the image.

2. The lock file strategy: separation as the foundation

Before discussing cache mount types, the layer structure in the Dockerfile needs to be right. The lock file strategy is the simplest and most important optimization step: composer.json and composer.lock are copied in their own COPY instruction before the application source code, and composer install is run right after that. This way the Composer layer only becomes a cache miss when the dependency definitions actually change, not on every source code change.

The same pattern applies to npm: package.json and package-lock.json are copied before the source code, and npm ci runs in its own separate layer. This separation is the foundation on which every further cache optimization is built. Without it, cache mounts do not help much: if the layer is invalidated on every commit, the cache mount is still used for the package downloads, but the layer itself is re-executed and the result is rewritten anyway. The lock file strategy minimizes how often the dependency layer even runs in the first place.


# Dockerfile: lock file strategy, dependency layer isolated from source
# syntax=docker/dockerfile:1.6
FROM php:8.4-fpm-alpine AS base

WORKDIR /var/www/html

# Stage 1: Install system dependencies with APT cache mount
RUN --mount=type=cache,id=apk-cache,target=/var/cache/apk \
    apk add --no-cache $PHPIZE_DEPS libzip-dev icu-dev \
    && docker-php-ext-install zip intl pdo_mysql opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# Stage 2: Dependency layer, ONLY lock files, not application source
# This layer only rebuilds when composer.json or composer.lock changes
COPY composer.json composer.lock ./

RUN --mount=type=cache,id=composer,target=/root/.composer/cache \
    composer install \
      --no-dev \
      --no-interaction \
      --prefer-dist \
      --no-scripts \
      --optimize-autoloader

# Stage 3: Source layer, separate from deps, rebuilt on every source change
COPY app/ ./app/
COPY bootstrap/ ./bootstrap/
COPY config/ ./config/
COPY public/ ./public/

# Run post-install scripts only now when full source is available
RUN composer run-script post-install-cmd --no-interaction

3. Composer cache: what exactly gets stored

The Composer cache under ~/.composer/cache contains two main areas: under files/ sit the downloaded package tarballs, named by version hash. Under repo/ sits cached metadata from Packagist and other repositories, including package lists, version information and provider hashes. The first area is decisive for download time: if a tarball already exists in the cache, it is extracted directly without any network access. The second area speeds up composer update and the initial resolve step.

Composer offers the environment variable COMPOSER_CACHE_DIR to configure the cache directory. This is useful in a Docker build to set the cache path explicitly to the mount path: ENV COMPOSER_CACHE_DIR=/composer-cache combined with --mount=type=cache,id=composer,target=/composer-cache. That way the cache path is independent of the build user's HOME directory and stays consistent even if the build runs under a different user. The environment variable COMPOSER_HOME, on the other hand, configures the path for the configuration file and auth tokens. Those should be mounted via a secret mount (--mount=type=secret), not passed as a build argument.

4. Composer in Docker: mount types and variants

There are three BuildKit mount types relevant to Composer builds: type=cache for the package download cache, type=bind for temporarily reading files without adding a layer entry, and type=secret for auth tokens for private repositories. A bind mount is useful for mounting composer.json and composer.lock directly into the build step without copying them beforehand, which makes sense when you only need the vendor folder as the artifact and don't want the lock files to end up in an image layer.

The --no-scripts flag on composer install during the build matters: post-install scripts run in a build context without a complete application structure and often fail or trigger unwanted side effects. With --no-scripts, only the packages are installed; the scripts then run in a later build step once the full source code is available. The Composer lock file should always be checked into version control and never modified by the container build; using composer install (not update) in the Dockerfile guarantees that.

5. npm cache: the difference between cache and node_modules

A common misunderstanding about npm in Docker builds: there is a difference between the npm cache under ~/.npm and the node_modules directory. The npm cache is a content-addressable store for package tarballs and metadata. node_modules is the extracted, linked directory for the specific project. If the cache strategy stores the node_modules directory in the build artifact, the image ends up massively bloated. If only the npm cache is mounted as a cache mount, node_modules remains a freshly created layer on every build, but the download itself is skipped.

For npm ci the difference is especially clear: npm ci always deletes node_modules and installs cleanly from the lock file, but it does use the ~/.npm cache for tarballs. That means npm ci combined with a cache mount on ~/.npm is the correct combination for reproducible, deterministic builds with the maximum speed benefit. npm install with node_modules as a bind mount would be faster when the lock file is unchanged, but less reproducible and harder to debug. For CI environments, npm ci is always preferable.


# Dockerfile: npm build with cache mount and separate frontend stage
# syntax=docker/dockerfile:1.6
FROM node:22-alpine AS frontend-deps

WORKDIR /build

# Dependency layer: only manifest files, rebuilt only when deps change
COPY package.json package-lock.json ./

# npm cache mount: ~/.npm stores tarballs, npm ci uses them without re-downloading
RUN --mount=type=cache,id=npm,target=/root/.npm \
    npm ci --prefer-offline --no-audit --no-fund

# Build stage: source separate from deps
FROM frontend-deps AS frontend-build

# Copy only the files needed for the frontend build
COPY web/tailwind/ ./web/tailwind/
COPY web/src/ ./web/src/

# Vite build cache can also be mounted to speed up incremental CSS/JS builds
RUN --mount=type=cache,id=vite,target=/build/.vite \
    npm run build -- --logLevel info

# ---- yarn Berry (v3+) equivalent ----
# FROM node:22-alpine AS yarn-deps
# WORKDIR /build
# COPY package.json yarn.lock .yarnrc.yml ./
# RUN --mount=type=cache,id=yarn-berry,target=/root/.yarn/berry/cache \
#     yarn install --immutable

# Result artifact: only built assets, no source or node_modules
FROM scratch AS frontend-dist
COPY --from=frontend-build /build/dist/ /dist/

6. npm in Docker: npm ci, offline mode and cache strategy

The --prefer-offline flag on npm ci combined with a cache mount on ~/.npm produces build behavior that closely resembles a local developer build: npm checks the cache first before consulting the network. If all packages are already present in the cache, which is the case with an unchanged package-lock.json after the first build, the entire npm ci run causes no network access at all. That makes the build independent of external registry availability and dramatically faster.

The --no-audit flag skips the security audit network call at the end of the build. In CI environments the audit should run as a separate job, not as part of the image build, since it slows down the build and can block on network issues. The --no-fund flag suppresses the funding notices that would otherwise show up as noise in the CI log. These three flags together, npm ci --prefer-offline --no-audit --no-fund, form the optimal pattern for npm in Docker builds.

7. Vendor isolation: dependencies as their own build stage

Vendor isolation is the next level after the lock file strategy: the entire dependency installation is moved into its own multi-stage build stage. This stage has a single output, either the vendor/ folder (Composer) or the node_modules/ directory (npm). The final image stage then copies only the vendor folder with COPY --from=vendor /app/vendor/ ./vendor/, without any build tools, the Composer binary, or system packages that were only needed for the installation.

This pattern has several benefits: the final image is smaller because it contains no build dependencies. The vendor stage can be built with a different base image (for example one with compiler tools for native PHP extensions) than the final runtime image. And in monorepos, multiple applications can reuse the same vendor stage as a source if they share the same composer.lock. Vendor isolation combines well with the Composer cache mount: the cache mount in the vendor stage speeds up the installation, and the result is cleanly transferred into the runtime stage.


# docker-compose.yml: multi-service project with shared composer vendor stage
# Each service references the same vendor image as build context
services:
  api:
    build:
      context: .
      dockerfile: docker/api/Dockerfile
      target: runtime
      cache_from:
        - type=registry,ref=registry.example.com/cache/api:buildcache
      cache_to:
        - type=registry,ref=registry.example.com/cache/api:buildcache,mode=max
    image: registry.example.com/api:${APP_VERSION:-latest}

  worker:
    build:
      context: .
      dockerfile: docker/worker/Dockerfile
      target: runtime
      # Share vendor cache between api and worker builds (same composer.lock)
      cache_from:
        - type=registry,ref=registry.example.com/cache/api:buildcache
    image: registry.example.com/worker:${APP_VERSION:-latest}

# Bake file for parallel builds: docker buildx bake
# docker-bake.hcl
# group "default" { targets = ["api", "worker", "frontend"] }
# target "api" {
#   context = "."
#   dockerfile = "docker/api/Dockerfile"
#   cache-from = ["type=registry,ref=registry.example.com/cache:buildcache"]
#   cache-to   = ["type=registry,ref=registry.example.com/cache:buildcache,mode=max"]
# }

8. CI configuration: caches across job boundaries

GitHub Actions offers a built-in cache service that can be used with the BuildKit type type=gha. This cache is stored per branch key and commit hash and is retained for up to seven days. For the Composer cache and the npm cache, a good cache key includes the hash of the lock file: if composer.lock changes, a new cache entry is created; the next identical lock file reuses that cache entry. With a fallback to the branch cache, and the main branch cache as a last resort, almost every build finds a useful starting cache state.

In GitLab CI the approach is similar but the configuration differs: the cache block in .gitlab-ci.yml configures which directories get cached between jobs and pipelines. Alternatively, registry cache export is an option that needs no pipeline cache at all. Both approaches can be combined: registry cache for the layer cache, a separate Composer or npm cache for the package manager cache mount. The right choice depends on whether persistent runners are available and how much registry storage is available for cache manifests.

9. Cache approaches compared side by side

For Composer and npm caches in Docker builds there are several approaches with different trade-offs in implementation effort, portability and effectiveness.

Approach Persists on ephemeral runners Image size Implementation effort
No caching No Normal None
Layer cache (lock file first) No Normal Minimal
BuildKit cache mount Self-hosted runners only Normal (not in image) Low
Cache mount plus registry cache Yes Normal Medium
Cache in image layer (rm afterward) No Slightly increased Medium (extra cleanup layer)

The recommendation for most projects: lock file strategy as the base, BuildKit cache mounts for package managers, and registry cache for ephemeral CI runners. This combination covers every case and can be implemented in about 30 minutes. For projects on self-hosted runners with persistent disk, the combination of lock file strategy and cache mounts alone is enough, without the registry cache overhead. The most important first step is always the lock file strategy: it costs nothing and immediately delivers measurably fewer cache invalidations.

Mironsoft

Docker build optimization, CI/CD pipeline design and package cache strategy

Composer and npm builds in minutes, not minutes wasted?

We analyze your Dockerfiles and CI pipeline, and implement lock file strategy, BuildKit cache mounts and registry cache for short build times on Composer and npm heavy PHP and Node projects.

Dockerfile review

Analyze and optimize layer order and cache invalidation points

Cache implementation

Configure BuildKit cache mounts for Composer, npm and yarn

CI integration

Set up registry cache for GitHub Actions and GitLab CI on ephemeral runners

10. Summary

Using Composer and npm caches strategically in Docker builds is a three-step process. First, the lock file strategy: composer.lock and package-lock.json are copied into their own layer before the source code, so dependency installations only rerun when the dependencies themselves change. Second, BuildKit cache mounts: RUN --mount=type=cache keeps the Composer cache and the npm cache persistent between builds without increasing the image size. Third, registry cache for ephemeral CI runners: --cache-to type=registry persists the layer cache across job boundaries.

Each of these three measures can be implemented individually and delivers measurable improvements on its own. Combined, they reduce the build time for typical PHP or Node projects from five to eight minutes down to under two minutes for a normal feature branch with an unchanged lock file. This is not an infrastructure investment but a Dockerfile optimization that, once implemented, speeds up every following build. The first step is the lock file strategy: it costs 15 minutes to implement and pays off starting with the very first build.

Composer and npm caches in Docker: the essentials at a glance

Lock file strategy

Copy composer.lock and package-lock.json before the source code. The dependency layer only invalidates on dependency changes, not on every commit.

BuildKit cache mount

RUN --mount=type=cache,id=composer,target=/root/.composer/cache keeps package downloads persistent without image bloat. The same applies to npm under ~/.npm.

npm ci vs. npm install

npm ci for CI builds deletes node_modules but still uses the ~/.npm cache. Reproducible, and network free after the first build with --prefer-offline.

Vendor isolation

Isolate dependencies in their own build stage. The final stage only runs COPY --from=vendor/vendor/. Smaller images, cleaner separation of build and runtime.

11. FAQ: Composer and npm caches in Docker builds

1What is the lock file strategy?
Copy composer.lock and package-lock.json before the source code. The dependency layer only invalidates on dependency changes, not on every code commit.
2Difference between the Composer cache and vendor/?
Cache (~/.composer/cache): downloaded tarballs for future installations. vendor/: extracted packages for the current installation.
3Why npm ci instead of npm install?
npm ci: reproducible, deletes node_modules, never changes the lock file. npm install: can update the lock file, not suited for CI.
4Do cache mounts increase the image size?
No. Cache mount content is explicitly separated from the image layer and never appears in the final image.
5What does --no-scripts do for composer install?
Prevents post-install scripts from running in a build context without the full source code. Run scripts in a separate RUN after copying the source.
6How to configure the Composer cache path in a Docker build?
ENV COMPOSER_CACHE_DIR=/composer-cache plus --mount=type=cache,target=/composer-cache. Independent of the build user's home directory.
7What is vendor isolation?
Running the dependency installation in its own build stage. The final stage only copies vendor/ via COPY --from=stage. Build tools never end up in the runtime image.
8npm --prefer-offline in a Docker build?
Yes, with a cache mount on ~/.npm. With an unchanged lock file there is no network access at all, everything comes from the local cache.
9Yarn Berry with BuildKit cache mounts?
Yes, use /root/.yarn/berry/cache as the cache mount target. yarn install --immutable is the equivalent of npm ci.
10Should composer.lock be checked into Git?
Yes, always. Using composer install in the Dockerfile ensures the lock file is never changed by the build.