Aller au contenu
CI/CD & Automatisation medium

Cache Node.js/npm dans GitHub Actions

14 min de lecture

Les projets Node.js ont souvent des centaines de dépendances. Sans cache, chaque npm ci télécharge tout depuis le registre npm, 30 à 60 secondes minimum. Avec le cache, c'est réduit à quelques secondes.

  • Activer le cache intégré de setup-node pour npm, pnpm et yarn
  • Configurer actions/cache quand vous avez besoin de contrôle
  • Cacher les builds Next.js, Turborepo et ESLint
  • Gérer un monorepo à workspaces
  • Éviter les pièges du cache de node_modules et de pnpm

L'action setup-node sait gérer le cache elle-même : une ligne suffit, elle calcule la clé à partir du lockfile et restaure le cache du gestionnaire de paquets avant l'installation. Attention à ce qui est réellement mis en cache : le magasin de téléchargement (~/.npm), pas node_modules. npm ci s'exécute donc toujours, mais sans aucun appel réseau au registre, ce qui concentre le gain.

La méthode la plus simple :

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'npm' # Détecte automatiquement package-lock.json
- run: npm ci

setup-node détecte le gestionnaire de paquets selon les fichiers présents, mais la valeur de cache: reste à votre charge : elle doit correspondre au lockfile versionné dans le dépôt. Une valeur npm sur un projet qui n'a qu'un pnpm-lock.yaml fait échouer le step, faute de fichier à hacher.

Fichier présentGestionnaire détecté
package-lock.jsonnpm
pnpm-lock.yamlpnpm
yarn.lockyarn
# Pour pnpm
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install
# Pour yarn
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'yarn'
- run: yarn install --frozen-lockfile

Pour plus de contrôle ou des cas spécifiques, actions/cache reprend la main : vous choisissez le chemin à conserver, la clé exacte et les restore-keys de repli. C'est indispensable quand vous devez cacher autre chose que le magasin du gestionnaire de paquets, ou partager un cache entre plusieurs jobs qui n'appellent pas setup-node.

Le chemin du magasin npm varie selon le système et la version : le lire avec npm config get cache évite de le coder en dur et garde le workflow valable sur ubuntu, macos et windows. Les restore-keys servent de repli quand le lockfile a changé, le cache le plus proche est alors réutilisé plutôt que reparti de zéro.

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
- name: Get npm cache directory
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci

L'ordre des deux actions n'est pas interchangeable : setup-node interroge pnpm store path pour savoir quoi cacher, donc pnpm doit déjà être installé au moment où il s'exécute.

- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 9
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile

Sur Yarn Berry, --immutable remplace l'ancien --frozen-lockfile et fait échouer l'installation si le yarn.lock devait être modifié, exactement ce qu'on attend en CI.

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'yarn'
- run: yarn install --immutable

Pour des gains maximum, cachez directement node_modules : sur un cache hit, l'installation est purement et simplement sautée, ce qui supprime les quelques secondes que npm ci passe encore à écrire l'arborescence. La condition if: sur la sortie cache-hit est ce qui rend l'approche viable : sans elle, vous restaureriez le cache puis réinstalleriez par-dessus.

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
- name: Cache node_modules
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: cache-node-modules
with:
path: node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci

Au-delà des dépendances, les outils de build maintiennent leur propre cache sur disque. Le préserver d'un run à l'autre accélère franchement la compilation.

Next.js conserve dans .next/cache les modules déjà compilés et les images optimisées. La clé combine ici le lockfile et le hash des sources : elle change à chaque modification de code, et les restore-keys fournissent alors le cache précédent comme point de départ, ce qui limite la recompilation aux fichiers modifiés.

- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
nextjs-${{ runner.os }}-

Turborepo indexe le résultat de chaque tâche dans .turbo et le rejoue tel quel si les entrées n'ont pas bougé : sur un monorepo, seuls les packages modifiés sont reconstruits. Le github.sha dans la clé garantit une écriture à chaque run, les restore-keys assurant la reprise du cache le plus récent.

- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
turbo-${{ runner.os }}-

Le fichier .eslintcache mémorise l'empreinte des fichiers déjà analysés sans erreur ; le drapeau --cache est obligatoire côté commande, sinon rien n'est ni lu ni écrit. Sur un gros dépôt, le lint ne porte alors plus que sur les fichiers modifiés.

- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: eslint-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- run: npm run lint -- --cache

Ce workflow assemble les briques précédentes dans un cas réel : test d'une bibliothèque sur trois versions de Node. La matrice lance trois jobs indépendants, et comme runner.os et la version de Node entrent dans la clé calculée par setup-node, chacun dispose de son cache propre sans écraser celui des autres.

name: Node.js CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Aucun droit par défaut : le job demande le minimum
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build

Dans un monorepo, setup-node ne regarde par défaut que le lockfile à la racine : une modification dans un package ne change alors pas la clé, et le cache restauré est périmé. cache-dependency-path corrige ce comportement en faisant entrer tous les lockfiles du dépôt dans le calcul de la clé.

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: '**/package-lock.json' # Tous les lockfiles
- run: npm ci --workspaces

pnpm n'a pas besoin de ce réglage : son lockfile unique à la racine décrit déjà tous les packages du workspace. L'option -r exécute ensuite le script dans chaque package, en respectant l'ordre de leurs dépendances internes.

- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 9
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm -r build # Build tous les packages

Certains cas particuliers, modules natifs, tests end-to-end, ont leurs propres caches à connaître.

Pour les packages avec compilation native (sharp, bcrypt, etc.), le coût n'est pas le téléchargement mais la compilation. node-gyp télécharge en plus les en-têtes du noyau Node dans ~/.node-gyp : les cacher évite de les récupérer à chaque run. La version de Node entre ici dans la clé, un binaire compilé pour Node 20 étant inutilisable sur Node 22.

- name: Cache native modules
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.npm
~/.node-gyp
key: native-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}

Cypress installe son navigateur, plusieurs centaines de mégaoctets, hors de node_modules, dans ~/.cache/Cypress : ce dossier échappe donc au cache npm et se déclare séparément.

- name: Cache Cypress
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

Deux problèmes reviennent souvent avec le cache Node.js. Voici comment les reconnaître et les corriger.

Ce message ne vient pas du cache lui-même mais d'un package.json et d'un package-lock.json désynchronisés. Une clé de cache qui ignore le lockfile prolonge le problème : le workflow restaure indéfiniment un magasin obsolète et l'erreur survit aux relances.

Fenêtre de terminal
npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in sync

La clé de cache doit inclure le hash du lockfile :

# ✅ Correct
key: npm-${{ hashFiles('**/package-lock.json') }}

Le symptôme est un step setup-node qui échoue en annonçant ne pas trouver le store pnpm. La cause est toujours la même : setup-node s'exécute avant l'installation de pnpm et n'a donc aucune commande à interroger pour localiser le magasin.

# Assurez-vous d'installer pnpm avant setup-node
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
cache: 'pnpm'

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Le cache intégré de setup-node (cache: 'npm' / 'pnpm' / 'yarn') couvre la majorité des projets.
  • Pour pnpm, installez pnpm/action-setup avant setup-node, sinon le store reste introuvable.
  • Cacher node_modules directement est plus rapide mais fragile : les scripts postinstall sont sautés sur un cache hit.
  • Cachez les builds (.next/cache, .turbo, .eslintcache) en plus des dépendances pour le gain maximal.
  • En monorepo, cache-dependency-path: '**/package-lock.json' couvre tous les workspaces.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn