Composer · npm · Docker Build · Cache-Strategie · CI/CD
Composer und npm Caches
in Docker-Builds strategisch nutzen

Composer und npm neu von null zu starten kostet in jedem Build Minuten. Die gute Nachricht: beide Package-Manager haben robuste lokale Caches, die zwischen Docker-Builds persistent gehalten werden können, ohne die Image-Größe zu erhöhen. Der Schlüssel liegt in der richtigen Trennung von Lock-File-Layer, Vendor-Layer und Cache-Mount — und in dem Verständnis, wann welcher Cache-Typ der richtige ist.

13 Min. Lesezeit Composer · npm · yarn · BuildKit Mount · Lock-File · Vendor-Layer PHP 8.4 · Node 22 · Docker BuildKit · GitHub Actions

1. Das Grundproblem: Paket-Download in jedem Build

Das typische Dockerfile für ein PHP-Projekt enthält irgendwo RUN composer install und RUN npm install. Bei jedem Build, der diesen Layer invalidiert, werden alle Pakete neu heruntergeladen. Für ein Magento 2-Projekt mit 300+ Composer-Abhängigkeiten bedeutet das zwei bis vier Minuten reine Download-Zeit. Für ein Frontend mit 500 npm-Paketen nochmals zwei Minuten. Das Frustrating: Die überwiegende Mehrheit dieser Pakete hat sich nicht geändert. Nur weil ein Source-Code-File vor dem COPY-Befehl angefasst wurde, muss der gesamte Composer-Cache neu aufgebaut werden.

Das Problem liegt in der Kopplung von zwei verschiedenen Cache-Schichten, die logisch unabhängig voneinander sein sollten: der Layer-Cache für Image-Inhalte und der Package-Manager-Cache für heruntergeladene Tarballs. Der Layer-Cache arbeitet auf Hash-Basis des gesamten Layer-Inhalts: Ändert sich irgendetwas, verfällt der Layer. Der Composer-Cache und der npm-Cache arbeiten auf Paket-Versions-Basis: Ein Paket mit unveränderter Version und Hash muss nie wieder heruntergeladen werden. Diese beiden Cache-Philosophien kollidieren im klassischen Dockerfile – und BuildKit Cache Mounts sind der Mechanismus, der die Kollision auflöst.

Ein zweites, weniger offensichtliches Problem: Wenn Composer und npm ihre Caches im Image-Layer speichern, wird das Image aufgebläht. Composer-Cache unter ~/.composer/cache kann bei großen Projekten 500 MB und mehr umfassen. Das traditionelle Muster, diesen Cache nach der Installation zu löschen (rm -rf ~/.composer/cache), löst das Image-Größenproblem, macht aber jeden Build unabhängig vom vorherigen – keine Wiederverwendung, kein Zeitgewinn. BuildKit Cache Mounts lösen beide Probleme gleichzeitig: der Cache bleibt zwischen Builds erhalten, aber nie im Image.

2. Die Lock-File-Strategie: Trennung als Fundament

Bevor über Cache-Mount-Typen diskutiert wird, muss die Layer-Struktur im Dockerfile richtig sein. Die Lock-File-Strategie ist der einfachste und wichtigste Optimierungsschritt: composer.json und composer.lock werden in einem separaten COPY-Befehl vor dem Applikations-Quellcode kopiert, und composer install wird unmittelbar danach ausgeführt. Der Composer-Layer ist damit nur dann ein Cache-Miss, wenn sich die Dependency-Definitionen geändert haben – nicht bei jeder Source-Code-Änderung.

Dasselbe Muster gilt für npm: package.json und package-lock.json werden vor dem Source-Code kopiert, npm ci wird in einem separaten Layer ausgeführt. Diese Trennung ist eine Grundlage, über die alle weiteren Cache-Optimierungen aufgebaut werden. Ohne sie bringen Cache Mounts wenig: Wenn der Layer bei jedem Commit invalide ist, wird der Cache Mount zwar für die Paket-Downloads genutzt, aber der Layer selbst wird neu ausgeführt und das Ergebnis neu geschrieben. Die Lock-File-Strategie minimiert die Häufigkeit, mit der der Dependency-Layer überhaupt ausgeführt wird.


# 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: was genau gespeichert wird

Der Composer-Cache unter ~/.composer/cache enthält zwei Hauptbereiche: Unter files/ liegen die heruntergeladenen Paket-Tarballs mit Versionshash als Dateiname. Unter repo/ liegen gecachte Metadaten von Packagist und anderen Repositories – Package-Listen, Versionsinformationen und Provider-Hashes. Der erste Bereich ist für die Download-Zeit entscheidend: Ist ein Tarball im Cache vorhanden, wird er direkt entpackt ohne Netzwerkzugriff. Der zweite Bereich beschleunigt composer update und den initialen Resolve-Vorgang.

Composer bietet die Umgebungsvariable COMPOSER_CACHE_DIR zum Konfigurieren des Cache-Verzeichnisses. Das ist nützlich im Docker-Build, um den Cache-Pfad explizit auf den Mount-Pfad zu setzen: ENV COMPOSER_CACHE_DIR=/composer-cache kombiniert mit --mount=type=cache,id=composer,target=/composer-cache. Damit ist der Cache-Pfad unabhängig vom HOME-Verzeichnis des Build-Users und konsistent, auch wenn der Build mit einem anderen User durchgeführt wird. Die Umgebungsvariable COMPOSER_HOME konfiguriert dagegen den Pfad für die Konfigurationsdatei und die Auth-Tokens – diese sollten via Secret Mount (--mount=type=secret) eingebunden werden, nicht als Build-Argument.

4. Composer in Docker: Mount-Typen und Varianten

Es gibt drei BuildKit-Mount-Typen, die für Composer-Builds relevant sind: type=cache für den Package-Download-Cache, type=bind für das temporäre Einlesen von Dateien ohne Layer-Eintrag und type=secret für Auth-Tokens für private Repositories. Ein Bind Mount eignet sich, um composer.json und composer.lock direkt in den Build-Schritt einzuhängen ohne sie vorher zu kopieren – das ist sinnvoll, wenn man nur den Vendor-Ordner als Artefakt braucht und die Lock-Dateien nicht im Image-Layer landen sollen.

Der --no-scripts-Flag bei composer install im Build ist wichtig: Post-Install-Scripts laufen im Build-Kontext ohne vollständige Applikationsstruktur und scheitern häufig oder haben unerwünschte Nebeneffekte. Mit --no-scripts werden nur die Pakete installiert; die Scripts laufen dann in einem späteren Build-Step, wenn der vollständige Quellcode verfügbar ist. Das Composer-Lock-File sollte immer in der Versionskontrolle eingecheckt sein und niemals durch den Container-Build geändert werden – composer install (nicht update) im Dockerfile stellt das sicher.

5. npm-Cache: Unterschied zwischen Cache und node_modules

Ein häufiges Missverständnis bei npm in Docker-Builds: Es gibt einen Unterschied zwischen dem npm-Cache unter ~/.npm und dem node_modules-Verzeichnis. Der npm-Cache ist ein Content-Addressable Storage für Paket-Tarballs und Metadaten. node_modules ist das entpackte, verlinkte Verzeichnis für das konkrete Projekt. Wenn als Cache-Strategie das node_modules-Verzeichnis im Build-Artefakt gespeichert wird, ist das Image massiv aufgebläht. Wenn nur der npm-Cache als Cache Mount eingehängt wird, bleibt node_modules ein neu erstellter Layer bei jedem Build, aber der Download entfällt.

Für npm ci ist der Unterschied besonders klar: npm ci löscht node_modules immer und installiert sauber aus dem Lock-File. Es nutzt aber den ~/.npm-Cache für Tarballs. Das bedeutet: npm ci mit Cache Mount auf ~/.npm ist die korrekte Kombination für reproduzierbare, deterministische Builds mit maximalem Geschwindigkeitsvorteil. npm install mit node_modules als Bind Mount wäre sneller bei unverändertem Lock-File, aber weniger reproduzierbar und schwerer zu debuggen. Für CI-Umgebungen ist npm ci immer vorzuziehen.


# 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-Modus und Cache-Strategie

Der --prefer-offline-Flag bei npm ci kombiniert mit dem Cache Mount auf ~/.npm ergibt ein Build-Verhalten, das dem eines lokalen Entwickler-Builds sehr ähnelt: npm schaut zuerst im Cache, bevor es das Netzwerk konsultiert. Wenn alle Pakete im Cache vorhanden sind – was bei unverändertem package-lock.json nach dem ersten Build der Fall ist – verursacht das komplette npm ci keinen Netzwerkzugriff mehr. Das macht den Build unabhängig von externer Registry-Verfügbarkeit und drastisch schneller.

Der --no-audit-Flag überspringt den Security-Audit-Netzwerkaufruf am Build-Ende. In CI-Umgebungen sollte der Audit als separater Job laufen, nicht als Teil des Image-Builds – er verlangsamt den Build und blockiert bei Netzwerkproblemen. Der --no-fund-Flag verhindert die Finanzierungs-Benachrichtigungen, die im CI-Log als Rauschen erscheinen. Diese drei Flags zusammen – npm ci --prefer-offline --no-audit --no-fund – sind das optimale Muster für npm in Docker-Builds.

7. Vendor-Isolation: Dependencies als eigene Build-Stage

Die Vendor-Isolation ist die nächste Ebene nach der Lock-File-Strategie: Die gesamte Dependency-Installation wird in eine eigene Multi-Stage-Build-Stage ausgelagert. Diese Stage hat als einzigen Output den vendor/-Ordner (Composer) oder das node_modules/-Verzeichnis (npm). Die finale Image-Stage kopiert mit COPY --from=vendor /app/vendor/ ./vendor/ nur den Vendor-Ordner, ohne Build-Tools, Composer-Binary oder System-Pakete, die nur für die Installation benötigt wurden.

Dieses Muster hat mehrere Vorteile: Das finale Image ist kleiner, weil keine Build-Dependencies enthalten sind. Die Vendor-Stage kann mit einem anderen Base-Image gebaut werden (z.B. einem Image mit Compiler-Tools für native PHP-Extensions) als das finale Runtime-Image. Und bei Monorepos können mehrere Applikationen dieselbe Vendor-Stage als Quelle nutzen, wenn sie dieselbe composer.lock haben. Die Vendor-Isolation kombiniert gut mit dem Composer-Cache-Mount: Der Cache-Mount in der Vendor-Stage beschleunigt die Installation, und das Ergebnis wird sauber in die Runtime-Stage übertragen.


# 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-Konfiguration: Caches über Job-Grenzen hinweg

In GitHub Actions steht der integrierte Cache-Service zur Verfügung, der mit dem BuildKit-Typ type=gha genutzt werden kann. Dieser Cache wird per Branch-Key und Commit-Hash gespeichert und für bis zu sieben Tage vorgehalten. Für den Composer-Cache und npm-Cache empfiehlt sich ein Cache-Key, der den Hash des Lock-Files enthält: Ändert sich die composer.lock, wird ein neuer Cache-Eintrag angelegt; beim nächsten identischen Lock-File wird der Cache-Eintrag wiederverwendet. Mit Fallback auf den Branch-Cache und dem Main-Branch-Cache als letzter Fallback findet fast jeder Build einen nützlichen Cache-Ausgangszustand.

In GitLab CI ist der Ansatz ähnlich, aber die Konfiguration unterschiedlich: Der cache-Block in der .gitlab-ci.yml konfiguriert, welche Verzeichnisse zwischen Jobs und Pipelines gecacht werden. Alternativ bietet sich der Registry-Cache-Export an, der keinen Pipeline-Cache benötigt. Beide Ansätze können kombiniert werden: Registry-Cache für Layer-Cache, separater Composer/npm-Cache für den Package-Manager-Cache-Mount. Die konkrete Wahl hängt davon ab, ob persistente Runner verfügbar sind und wie viel Registry-Speicherplatz für Cache-Manifeste zur Verfügung steht.

9. Cache-Ansätze im direkten Vergleich

Für Composer- und npm-Caches in Docker-Builds gibt es verschiedene Ansätze mit unterschiedlichen Trade-offs bei Implementierungsaufwand, Portabilität und Effektivität.

Ansatz Persistent auf ephem. Runner Image-Größe Implementierungsaufwand
Kein Caching Nein Normal Kein Aufwand
Layer-Cache (Lock-File zuerst) Nein Normal Minimal
BuildKit Cache Mount Nur Self-Hosted-Runner Normal (nicht im Image) Gering
Cache Mount + Registry-Cache Ja Normal Mittel
Cache im Image-Layer (rm danach) Nein Minimal erhöht Mittel (extra Cleanup-Layer)

Die Empfehlung für die meisten Projekte: Lock-File-Strategie als Basis, BuildKit Cache Mounts für Package-Manager, Registry-Cache für ephemere CI-Runner. Diese Kombination deckt alle Fälle ab und ist in 30 Minuten implementiert. Für Projekte auf Self-Hosted-Runnern mit persistentem Disk reicht die Kombination aus Lock-File-Strategie und Cache Mounts – ohne Registry-Cache-Overhead. Der wichtigste erste Schritt ist immer die Lock-File-Strategie: Sie kostet nichts und bringt sofort messbar weniger Cache-Invalidierungen.

Mironsoft

Docker-Build-Optimierung, CI/CD-Pipeline-Design und Package-Cache-Strategie

Composer- und npm-Builds in Minuten statt Minuten?

Wir analysieren eure Dockerfiles und CI-Pipeline, implementieren Lock-File-Strategie, BuildKit Cache Mounts und Registry-Cache für kurze Build-Zeiten bei Composer- und npm-intensiven PHP- und Node-Projekten.

Dockerfile-Review

Layer-Reihenfolge und Cache-Invalidierungspunkte analysieren und optimieren

Cache-Implementierung

BuildKit Cache Mounts für Composer, npm und yarn konfigurieren

CI-Integration

Registry-Cache für GitHub Actions und GitLab CI auf ephemeren Runnern einrichten

10. Zusammenfassung

Die strategische Nutzung von Composer- und npm-Caches in Docker-Builds ist ein dreistufiger Prozess. Erstens: Lock-File-Strategie – composer.lock und package-lock.json werden vor dem Source-Code in eigene Layer kopiert, damit Dependency-Installationen nur bei Änderungen der Abhängigkeiten neu laufen. Zweitens: BuildKit Cache Mounts – RUN --mount=type=cache hält den Composer-Cache und den npm-Cache zwischen Builds persistent, ohne die Image-Größe zu erhöhen. Drittens: Registry-Cache für ephemere CI-Runner – --cache-to type=registry persistiert den Layer-Cache über Job-Grenzen hinweg.

Jede dieser drei Maßnahmen ist einzeln implementierbar und bringt messbare Verbesserungen. In der Kombination reduzieren sie die Build-Zeit für typische PHP/Node-Projekte von fünf bis acht Minuten auf unter zwei Minuten für normale Feature-Branches mit unverändertem Lock-File. Das ist keine Infrastruktur-Investition, sondern eine Dockerfile-Optimierung, die einmal implementiert jeden folgenden Build beschleunigt. Der erste Schritt ist die Lock-File-Strategie – sie kostet 15 Minuten Implementierung und zahlt sich ab dem ersten Build aus.

Composer und npm Caches in Docker — Das Wichtigste auf einen Blick

Lock-File-Strategie

composer.lock und package-lock.json vor Source-Code kopieren. Dependency-Layer verfällt nur bei Dependency-Änderungen — nicht bei jedem Commit.

BuildKit Cache Mount

RUN --mount=type=cache,id=composer,target=/root/.composer/cache — Package-Downloads persistent ohne Image-Bloat. Analog für npm unter ~/.npm.

npm ci vs. npm install

npm ci für CI-Builds — löscht node_modules, nutzt aber ~/.npm-Cache. Reproduzierbar und mit --prefer-offline nach erstem Build netzwerkfrei.

Vendor-Isolation

Dependencies in eigener Build-Stage isolieren. Finale Stage nur COPY --from=vendor/vendor/. Kleinere Images, sauberere Trennung von Build und Runtime.

11. FAQ: Composer und npm Caches in Docker-Builds

1Was ist die Lock-File-Strategie?
composer.lock und package-lock.json vor Source-Code kopieren. Dependency-Layer verfällt nur bei Dependency-Änderungen — nicht bei jedem Code-Commit.
2Unterschied Composer-Cache vs. vendor/?
Cache (~/.composer/cache): heruntergeladene Tarballs für künftige Installationen. vendor/: entpackte Pakete für die aktuelle Installation.
3Warum npm ci statt npm install?
npm ci: reproduzierbar, löscht node_modules, ändert Lock-File nicht. npm install: kann Lock-File aktualisieren, nicht für CI geeignet.
4Erhöhen Cache Mounts die Image-Größe?
Nein — Cache-Mount-Inhalt ist explizit vom Image-Layer getrennt und erscheint nicht im finalen Image.
5Was macht --no-scripts bei composer install?
Verhindert Post-Install-Scripts im Build-Kontext ohne vollständigen Quellcode. Scripts in separatem RUN nach COPY Source ausführen.
6Composer-Cache-Pfad im Docker-Build konfigurieren?
ENV COMPOSER_CACHE_DIR=/composer-cache + --mount=type=cache,target=/composer-cache. Unabhängig vom Home-Verzeichnis des Build-Users.
7Was ist Vendor-Isolation?
Dependency-Installation in eigener Build-Stage. Finale Stage kopiert nur vendor/ via COPY --from=stage. Build-Tools landen nicht im Runtime-Image.
8npm --prefer-offline im Docker-Build?
Ja, mit Cache Mount auf ~/.npm. Bei unverändertem Lock-File kein Netzwerkzugriff mehr — alle Pakete aus lokalem Cache.
9Yarn Berry mit BuildKit Cache Mounts?
Ja — /root/.yarn/berry/cache als Cache-Mount-Target. yarn install --immutable ist das Äquivalent zu npm ci.
10Soll composer.lock in Git eingecheckt sein?
Ja, immer. composer install im Dockerfile stellt sicher, dass der Lock-File durch den Build nicht verändert wird.