Cache de dependências no GitHub Actions

Um pipeline de CI que roda npm install do zero a cada commit desperdiça 2-3 minutos por run. Com cache de node_modules, Docker layers e artefatos de build, o mesmo pipeline roda em menos de 1 minuto, e dentro dos 2.000 minutos gratuitos do GitHub Actions.

Cache automático do setup-node

A forma mais simples de fazer cache do npm/yarn/pnpm:

yaml
# .github/workflows/ci.yml
# O actions/setup-node tem cache nativo:
- name: Configurar Node.js com cache
  uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: 'npm'        # 'npm', 'yarn' ou 'pnpm'
    # cache-dependency-path: '**/package-lock.json'  # para monorepo

# Com cache ativo:
# - Primeiro run: npm ci completo (~2 min)
# - Runs seguintes: cache hit → npm ci em ~10s

# O setup-node usa o package-lock.json como chave de cache:
# Se o lock não mudar → cache hit
# Se qualquer dependência mudar → cache miss → npm ci completo

# Para pnpm (mais rápido que npm para CI):
- uses: pnpm/action-setup@v3
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: 'pnpm'
- run: pnpm install --frozen-lockfile

Cache manual com actions/cache

Cache com controle total sobre a chave e os caminhos:

yaml
# .github/workflows/ci.yml — cache manual:
- name: Cache node_modules
  uses: actions/cache@v4
  id: cache-node
  with:
    path: |
      ~/.npm
      node_modules
      # Para monorepo:
      packages/*/node_modules
    key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      node-${{ runner.os }}-

- name: Instalar dependências
  if: steps.cache-node.outputs.cache-hit != 'true'
  run: npm ci

# Cache do build TypeScript (tsc incremental):
- name: Cache build TypeScript
  uses: actions/cache@v4
  with:
    path: |
      dist/
      .tsbuildinfo
    key: tsc-${{ runner.os }}-${{ hashFiles('src/**/*.ts', 'tsconfig.json') }}

- name: Build TypeScript
  run: npx tsc --incremental

# Cache para Vitest (resultados de testes não mudados):
- name: Cache Vitest
  uses: actions/cache@v4
  with:
    path: .vitest/cache
    key: vitest-${{ runner.os }}-${{ hashFiles('src/**') }}

Cache de layers Docker

Acelerar builds Docker com cache de layers:

yaml
# .github/workflows/docker.yml — cache Docker com GitHub Actions:
- name: Configurar Docker Buildx
  uses: docker/setup-buildx-action@v3

# Opção 1: cache local no runner (mais simples):
- name: Cache Docker layers (local)
  uses: actions/cache@v4
  with:
    path: /tmp/.buildx-cache
    key: buildx-${{ runner.os }}-${{ github.sha }}
    restore-keys: buildx-${{ runner.os }}-

- name: Build e push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/org/app:latest
    cache-from: type=local,src=/tmp/.buildx-cache
    cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max

- name: Rotacionar cache (evitar crescimento)
  run: |
    rm -rf /tmp/.buildx-cache
    mv /tmp/.buildx-cache-new /tmp/.buildx-cache

# Opção 2: cache no GHCR (persiste entre runners, mais confiável):
- name: Build e push com cache no registry
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/org/app:latest
    cache-from: type=registry,ref=ghcr.io/org/app:buildcache
    cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max

Paralelizar jobs para reduzir tempo total

Executar testes, lint e build em paralelo:

yaml
# .github/workflows/ci.yml — jobs paralelos:
jobs:
  # Instalar dependências uma vez e compartilhar via artifact:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - uses: actions/upload-artifact@v4
        with:
          name: node_modules
          path: node_modules
          retention-days: 1

  # Lint, type check e testes rodando em paralelo:
  lint:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: node_modules
          path: node_modules
      - run: npm run lint

  typecheck:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: node_modules
          path: node_modules
      - run: npx tsc --noEmit

  test:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: node_modules
          path: node_modules
      - run: npm test

  # Deploy só após todos passarem:
  deploy:
    needs: [lint, typecheck, test]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploy!"

Medir e otimizar o tempo de cada job

Identificar gargalos no pipeline com timing explícito:

bash
# .github/workflows/ci.yml — adicionar timing:
- name: Medir tempo de npm ci
  run: |
    START=$(date +%s)
    npm ci
    END=$(date +%s)
    echo "npm ci levou $((END-START))s"
    echo "cache-status=${{ steps.cache-node.outputs.cache-hit }}" >> $GITHUB_STEP_SUMMARY

# Relatório de sumário no PR:
- name: Resumo do CI
  if: always()
  run: |
    echo "## Resultado do CI" >> $GITHUB_STEP_SUMMARY
    echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY
    echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
    echo "| TypeScript | ✅ |" >> $GITHUB_STEP_SUMMARY
    echo "| Testes | ✅ |" >> $GITHUB_STEP_SUMMARY
    echo "| Cache hit | ${{ steps.cache-node.outputs.cache-hit }} |" >> $GITHUB_STEP_SUMMARY

# Benchmarks típicos com cache bem configurado:
# npm ci sem cache:     90-180s
# npm ci com cache:      5-15s
# Docker build sem cache:  120-300s
# Docker build com cache:   15-45s
# TypeScript check:         10-30s
# Vitest (unitários):        5-30s
# Total do pipeline:
#   Sem cache: 8-12 min → com cache: 1-3 min

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

Sim, o cache é compartilhado entre branches do mesmo repositório, com algumas restrições: PRs de forks não têm acesso ao cache do repositório pai (segurança). Branches de feature têm acesso ao cache da branch base (main). O cache expira após 7 dias sem uso. Limite de armazenamento: 10 GB por repositório.

Causas comuns: (1) A chave inclui algo que muda em cada run (ex: data, SHA do commit), use hashFiles() para criar chaves baseadas em conteúdo. (2) O path não está correto, verifique se os arquivos existem no path antes do upload. (3) O cache foi criado em outro sistema operacional. (4) Para node_modules: usar cache: "npm" no setup-node é mais confiável que cache manual porque o GitHub gerencia a chave automaticamente.

Cache do .npm global (cache do npm): mais portável, funciona mesmo se a estrutura de node_modules mudar. Cache do node_modules: mais rápido (npm install lê do cache local), mas pode causar problemas com pacotes binários ou se a versão do Node.js mudar. Recomendação: use cache: "npm" no setup-node (faz cache do ~/.npm) e deixe o npm ci reconstruir node_modules, é mais seguro.

Cada instrução RUN/COPY no Dockerfile gera uma layer. Se a layer não mudou (mesmo conteúdo, mesma instrução), o BuildKit reutiliza a layer do cache. Com Dockerfile bem ordenado (dependências antes do código): mudar apenas o código-fonte invalida apenas as últimas layers. Sempre coloque COPY package*.json && npm ci ANTES de COPY . . para maximizar o cache.

Parcialmente, o runner self-hosted persiste node_modules entre runs (sem limpar o workspace por padrão), então npm ci fica rápido. Mas Docker layer cache e artefatos ainda se beneficiam de configuração explícita. Vantagem extra do self-hosted: sem cold start (runner sempre ativo), sem limite de minutos gratuitos, acesso a rede privada da VPS.