Aller au contenu
English
CI/CD & Automatisation medium

Cache Node.js/npm dans GitHub Actions

Read this page in English

30 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.
  • Concurrency : Annuler les exécutions obsolètes pour ne pas payer deux fois la restauration du même cache npm.
  • Runners GitHub Actions : Comprendre où le cache est restauré, et pourquoi un changement de runner fausse vos mesures.
  • GitHub CLI (gh) : Lister et purger les caches d'un dépôt sans passer par l'interface web.

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