Turbo Remote Cache: como o CI do monorepo caiu de 18min pra 1min
Esse post documenta a migração de CI do Tessel — um monorepo pnpm com 24 pacotes (apps Next.js, workers Cloudflare, libraries internas) — pra um modelo com turbo run --affected + Turbo Remote Cache. O resultado mensurado: 18min37s → 1min11s em runs warm, com um setup que demorou cerca de duas horas e tem alguns caveats que vale registrar.
A TL;DR honesta: o ganho não veio do --affected sozinho, e na verdade quase não veio do remote cache também — até a gente perceber que o cache estava silenciosamente desligado por uma variável vazia. A história dessa armadilha é metade do post.
O ponto de partida
O CI rodava em uma única job sequencial: install → lint → build all → type-check all → test all. Tempo médio: 18 minutos. Pra cada PR, mesmo que tocasse um único pacote, todos os 24 entravam na esteira.
A solução tradicional pra isso é matrix + filters — rodar só os pacotes afetados. O Turborepo 2.x já oferece --affected baseado em git diff e dependency graph. A sequência foi:
- Migrar deploy de N workflows individuais (
deploy-admin.yml,deploy-bot.yml, …) pra um único orchestrator (deploy-affected.yml) que chamaturbo run deploy --affected --dry-run=jsone gera uma matrix de pacotes pra deploy. - Adicionar Turbo Remote Cache via Vercel pra reusar artefatos entre runs.
A primeira parte foi mecânica e funcionou de imediato. A segunda parece simples na documentação (TURBO_TOKEN + TURBO_TEAM no env) e foi onde a gente quase saiu errado.
O orchestrator único
Antes:
.github/workflows/
├── deploy-admin.yml
├── deploy-bot.yml
├── deploy-workers.yml
├── deploy-upload-service.yml
├── ... (mais 13)
Cada workflow tinha um paths: filter próprio. Pushar um PR que mudava o pnpm-lock.yaml disparava 17 deploys em paralelo — caro, ruidoso, e cada um repetia install/build do zero.
O substituto é um workflow só, que detecta o que mudou e gera matrix:
jobs:
detect:
outputs:
packages: ${{ steps.affected.outputs.packages }}
has-affected: ${{ steps.affected.outputs.has-affected }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect affected packages
id: affected
run: |
AFFECTED_JSON=$(pnpm exec turbo run deploy --affected --dry-run=json 2>/dev/null || echo '{"tasks":[]}')
PACKAGES=$(echo "$AFFECTED_JSON" | jq -c '[.tasks[] |
select(.taskId | endswith("#deploy")) |
select(.command != "<NONEXISTENT>") |
.package] | unique')
echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
deploy:
needs: detect
if: needs.detect.outputs.has-affected == 'true'
strategy:
matrix:
package: ${{ fromJson(needs.detect.outputs.packages) }}
steps:
- run: pnpm exec turbo run build --filter=${{ matrix.package }}^...
- run: pnpm --filter=${{ matrix.package }} run --if-present type-check
- run: pnpm --filter=${{ matrix.package }} run --if-present test
- run: pnpm --filter=${{ matrix.package }} run deployDois detalhes que custam tempo se você descobrir tarde:
1. pnpm --filter X deploy ≠ pnpm --filter X run deploy. O primeiro é interpretado como o subcomando builtin pnpm deploy (que extrai um pacote pra um diretório standalone) e dá ERR_PNPM_INVALID_DEPLOY_TARGET. O segundo invoca o npm script. Tive que substituir em 13 workflows depois do primeiro deploy falhar.
2. select(.command != "<NONEXISTENT>") é necessário. O turbo --dry-run lista todos os pacotes que teriam uma task deploy, inclusive os que herdaram a task da config global mas não definem o script. Sem o filtro, a matrix tenta deployar pacotes que não têm script de deploy.
A armadilha do cache silenciosamente desligado
Com o orchestrator funcionando, adicionei o remote cache:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Configurei TURBO_TOKEN como secret. Subi o PR. Os runs começaram a vir mais rápidos — 18min → 6min. Vitória declarada. Postei no Slack: “remote cache ativo”.
Não estava ativo.
A pista veio quando alguém perguntou “quanto otimizamos com o cache?”. Pra responder com número, fui ler o log e procurei cache hit:
test-and-lint Build all packages cache miss, executing 5352a88e5a20c90e
test-and-lint Build all packages cache miss, executing c9aa709f399a688b
test-and-lint Build all packages cache miss, executing a5c98db22de0510d
... (24 cache miss, 0 cache hit)
Todos os 24 builds eram cache miss. Olhei o env do step:
TURBO_TOKEN: ***
TURBO_TEAM:
TURBO_TEAM vazio. O Turbo silenciosamente pula o remote cache quando uma das duas variáveis falta — sem warning, sem erro, sem nada nos logs além de “cache miss”. Eu tinha criado TURBO_TOKEN como secret e esquecido de criar TURBO_TEAM como variable.
E os 6 minutos? Vinham de outra coisa: as mudanças recentes só tocavam workflow files, e o passo “Detect changes” do CI categorizava como global=false, então pulava Build all packages (global change) e ia direto pro step final unconditional Build all packages — que rodava com cache local da Turbo (dentro do mesmo runner) e dedupava parte do trabalho. O speedup era artefato de escopo de mudança, não de cache cross-run.
A lição operacional: sempre meça o que você acha que otimizou. Se eu tivesse rodado dois pushes idênticos seguidos (mesma SHA da fonte, build determinístico), o segundo deveria ter sido instantâneo. Não foi. Era óbvio em retrospecto.
O setup correto
Pra ativar o remote cache via Vercel:
- Pegar o team slug do Vercel (não o ID — o slug é o que vai em
TURBO_TEAM):
# via Vercel MCP ou REST API:
curl https://api.vercel.com/v2/teams \
-H "Authorization: Bearer $VERCEL_TOKEN" | jq '.teams[] | {name, slug}'
# → "journeystudios"-
Criar um token no Vercel com scope no team correto.
-
Adicionar como GitHub repo configs:
gh secret set TURBO_TOKEN --body "$VERCEL_TOKEN"
gh variable set TURBO_TEAM --body "journeystudios"A distinção secret/variable importa: o token vai em secrets (sensível), o slug vai em variables (público, e expor em logs ajuda debug).
- Garantir que
TURBO_TOKENeTURBO_TEAMestão no env de todos os jobs que executam tasks turbo, não só no step específico:
jobs:
test-and-lint:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Job-level, não step-level. A Turbo lê na inicialização do CLI; configurar só no step de build deixa os steps anteriores (que também invocam turbo) sem cache.
A medição real
Com TURBO_TEAM=journeystudios configurado, fiz dois runs idênticos via workflow_dispatch:
Cold run (cache vazio, populando):
Total: 03:46:54 → 03:53:06 (6m12s)
Build all packages: ~5min, 24/24 "cache miss, executing"
Warm run (mesma SHA, sem mudanças):
Total: 03:53:52 → 03:55:03 (1m11s)
Build all packages:
cache hit, replaying logs 5352a88e5a20c90e
cache hit, replaying logs c9aa709f399a688b
... (24/24 cache hit)
Tasks: 24 successful, 24 total
Time: 3.299s >>> FULL TURBO
3.3 segundos pra “buildar” 24 pacotes. O >>> FULL TURBO é literal — Turbo imprime quando 100% das tasks foram cacheadas e não houve execução real, só replay de logs salvos.
Comparativo:
| Run | Estado | Tempo total | Build step |
|---|---|---|---|
| Pré-cache (PR 46) | sem remote cache | ~18m37s | ~14min |
| Cold (populando) | TURBO_TEAM ok | 6m12s | ~5min |
| Warm (FULL TURBO) | reuso completo | 1m11s | 3.3s |
Redução do build step: 14min → 3s (~99.6%). Redução do CI total: 18min → 1min (~94%).
Os outros ~67s do warm run são install do pnpm (cache de tarball já vinha do actions/cache), restore do node_modules, e setup do runner — overhead que o Turbo não consegue eliminar.
Onde o cache não ajuda
Pra calibrar expectativas:
Mudanças globais resetam tudo. turbo.json, package.json da raiz, pnpm-lock.yaml, tsconfig.base.json, biome.json — qualquer um desses muda o hash de input de praticamente todas as tasks. Cold de novo.
Cache miss em pacotes intermediários cascateia. Se você muda código em @tessel/utils, todos os pacotes que dependem dele (~15 dos 24) viram cache miss. O cache é por hash de input, e mudança transitiva muda input.
Tasks com side effect não devem ser cacheadas. deploy é o exemplo claro — não dá pra “replay” o output de wrangler deploy. No turbo.json:
"tasks": {
"deploy": {
"cache": false,
"dependsOn": ["build", "type-check"]
}
}A inferência aqui é que cacheamos o que é determinístico (build, lint, type-check, test) e deixamos que deploy sempre execute. O ganho é o dependsOn — se o build veio do cache, a parte cara já passou.
Logs de tasks cacheadas vêm do replay, não do run atual. Isso confunde debugging. Se você está caçando um bug de build, force --force ou apague o cache local antes de assumir que o output que apareceu é o atual.
Custo
Vercel Remote Cache na conta hobby/pro: gratuito pra free, com limites generosos (200GB/mês de transferência no Pro). O Tessel está bem dentro do free tier — os artefatos cacheados de 24 builds + tests + type-checks somam alguns MB por hash, e a TTL padrão de 7 dias mantém o working set pequeno.
GitHub Actions: a fatura caiu junto. Antes, cada PR queimava ~18min de runner (concurrent jobs incluídos). Agora a maioria dos PRs warm queima ~1min. Pra um time de 3 devs com ~50 PRs/mês, é diferença real no minuto-runner.
O que mudou em DX
O efeito mais visível não é o “1 minuto” no badge do CI — é a previsibilidade. Quando o engenheiro abre PR e vê o CI pendente, antes era “vou fazer outra coisa por 20 minutos”. Agora é “vou esperar”. A janela de feedback caiu pra dentro do contexto da revisão, e a tentação de mergear sem CI verde sumiu.
O segundo efeito é a confiança em rebasear. Antes, rebase = nova rodada de 18min. Agora, se o rebase não introduz mudanças semanticamente diferentes, o CI volta em 1-2min. Reduz a fricção de manter PRs em sync com main.
Lições gerais
-
Mensure antes de declarar vitória. “O CI ficou mais rápido” sem
cache hitno log não é evidência de cache funcionando. É evidência de que algo mudou — pode ser o cache, pode ser escopo de mudança, pode ser um runner mais rápido. Confirme com o sinal direto. -
Falhas silenciosas em ferramentas de build são caras. Turbo poderia logar “WARN: TURBO_TEAM not set, remote cache disabled”. Não loga. Outros build tools fazem o mesmo. A defesa é configurar um teste de smoke: rodar o build duas vezes em sequência e exigir que a segunda mostre
>>> FULL TURBOoucache hit. -
Variáveis vs secrets em GitHub Actions importam. Variáveis aparecem nos logs (
TURBO_TEAM: journeystudios), secrets viram***. Pra debug de “está configurado?”, coloque tudo que não é sensível em variables — você vai querer ler o valor um dia. -
Matrix + filter + remote cache se compõem. Cada otimização sozinha tem ganho marginal. Combinadas, multiplicam: matrix paraleliza,
--affectedreduz o conjunto, remote cache reusa o que já foi feito. O passo certo é aplicar nessa ordem (paralelizar > filtrar > cachear), porque matrix sem filtro é caro e cache sem paralelo perde os ganhos triviais. -
Cache key é tudo. Se a hash do Turbo inclui um arquivo que muda toda hora (timestamp gerado, version.generated.ts não-determinístico, env var de CI no input), o cache é inútil. Vale auditar
turbo.jsonregularmente pra garantir queinputseenvestão restritos ao que realmente afeta o output.
Fechamento
A migração demorou cerca de duas horas de trabalho real e uma hora extra debugando a variável esquecida. O retorno é diluído ao longo de cada PR daqui pra frente — provavelmente ~15min economizados por desenvolvedor por dia, em média. Pra um setup de monorepo grande, é dos investimentos com melhor ROI que existem em DevOps de pacote.
Se você está construindo um monorepo Turbo + GitHub Actions em 2026: comece com matrix --affected, ative remote cache no primeiro dia, e desde o início rode dois workflow_dispatch consecutivos pra confirmar que o segundo é FULL TURBO. É o smoke test que evita declarar vitória cedo demais.