Turbo Remote Cache: como o CI do monorepo caiu de 18min pra 1min
Este post documenta la migración de CI de Tessel — un monorepo pnpm con 24 paquetes (apps Next.js, workers Cloudflare, bibliotecas internas) — a un modelo con turbo run --affected + Turbo Remote Cache. El resultado medido: 18min37s → 1min11s en runs warm, con un setup que tomó alrededor de dos horas y tiene algunos caveats que vale la pena registrar.
La TL;DR honesta: la ganancia no vino del --affected solo, y en realidad casi no vino del remote cache tampoco — hasta que nos dimos cuenta de que el cache estaba silenciosamente desactivado por una variable vacía. La historia de esta trampa es la mitad del post.
El punto de partida
El CI se ejecutaba en un solo job secuencial: install → lint → build all → type-check all → test all. Tiempo promedio: 18 minutos. Para cada PR, aunque tocara un solo paquete, todos los 24 entraban en la línea de producción.
La solución tradicional para esto es matrix + filters — ejecutar solo los paquetes afectados. El Turborepo 2.x ya ofrece --affected basado en git diff y dependency graph. La secuencia fue:
- Migrar el despliegue de N workflows individuales (
deploy-admin.yml,deploy-bot.yml, …) a un único orquestador (deploy-affected.yml) que llama aturbo run deploy --affected --dry-run=jsony genera una matriz de paquetes para el despliegue. - Añadir Turbo Remote Cache vía Vercel para reutilizar artefactos entre ejecuciones.
La primera parte fue mecánica y funcionó de inmediato. La segunda parece simple en la documentación (TURBO_TOKEN + TURBO_TEAM en el entorno) y fue donde casi salimos mal.
O orchestrador ú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.
La trampa del caché silenciosamente desactivado
Con el orchestrator funcionando, añadí el caché remoto:
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Configuré TURBO_TOKEN como secreto. Subí el PR. Los runs comenzaron a venir más rápidos — 18min → 6min. Victoria declarada. Publicé en Slack: “caché remoto activo”.
No estaba activo.
La pista llegó cuando alguien preguntó “¿cuánto optimizamos con el caché?”. Para responder con un número, fui a leer el log y busqué 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 los 24 builds eran cache miss. Miré el env del paso:
TURBO_TOKEN: ***
TURBO_TEAM:
TURBO_TEAM vacío. Turbo silenciosamente salta el caché remoto cuando una de las dos variables falta — sin advertencia, sin error, sin nada en los logs más allá de “cache miss”. Yo había creado TURBO_TOKEN como secreto y olvidado de crear TURBO_TEAM como variable.
Y los 6 minutos? Venían de otra cosa: los cambios recientes solo tocaban archivos de workflow, y el paso “Detect changes” del CI categorizaba como global=false, entonces saltaba Build all packages (global change) e iba directo al paso final incondicional Build all packages — que corría con caché local de Turbo (dentro del mismo runner) y dedupaba parte del trabajo. El speedup era un artefacto del alcance del cambio, no del caché cross-run.
La lección operacional: siempre mida lo que cree que ha optimizado. Si hubiera hecho dos pushes idénticos seguidos (misma SHA de la fuente, build determinístico), el segundo debería haber sido instantáneo. No lo fue. Era obvio en retrospectiva.
Configuración correcta
Para activar el remote cache via Vercel:
- Obtener el team slug de Vercel (no el ID — el slug es lo que va en
TURBO_TEAM):
# via Vercel MCP o REST API:
curl https://api.vercel.com/v2/teams \
-H "Authorization: Bearer $VERCEL_TOKEN" | jq '.teams[] | {name, slug}'
# → "journeystudios"-
Crear un token en Vercel con scope en el team correcto.
-
Agregar como GitHub repo configs:
gh secret set TURBO_TOKEN --body "$VERCEL_TOKEN"
gh variable set TURBO_TEAM --body "journeystudios"La distinción secret/variable importa: el token va en secrets (sensible), el slug va en variables (público, y exponer en logs ayuda a debug).
- Asegurarse de que
TURBO_TOKENyTURBO_TEAMestán en el env de todos los jobs que ejecutan tareas turbo, no solo en el step específico:
jobs:
test-and-lint:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Nivel de job, no nivel de step. Turbo lee en la inicialización del CLI; configurar solo en el step de build deja los steps anteriores (que también invocan turbo) sin cache.
La medición real
Con TURBO_TEAM=journeystudios configurado, hice dos runs idénticos vía workflow_dispatch:
Cold run (cache vacío, poblándose):
Total: 03:46:54 → 03:53:06 (6m12s)
Build all packages: ~5min, 24/24 "cache miss, executing"
Warm run (misma SHA, sin cambios):
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 para “buildar” 24 paquetes. El >>> FULL TURBO es literal — Turbo imprime cuando el 100% de las tareas fueron cacheadas y no hubo ejecución real, solo replay de logs salvados.
Comparativo:
| Run | Estado | Tiempo total | Build step |
|---|---|---|---|
| Pre-cache (PR 46) | sin remote cache | ~18m37s | ~14min |
| Cold (poblándose) | TURBO_TEAM ok | 6m12s | ~5min |
| Warm (FULL TURBO) | reuso completo | 1m11s | 3.3s |
Reducción del build step: 14min → 3s (~99.6%). Reducción del CI total: 18min → 1min (~94%).
Los otros ~67s del warm run son install del pnpm (cache de tarball ya venía del actions/cache), restore del node_modules, y setup del runner — overhead que el Turbo no puede eliminar.
Onde o cache não ajuda
Para 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 en la cuenta hobby/pro: gratuito para free, con límites generosos (200GB/mes de transferencia en Pro). El Tessel está bien dentro del free tier — los artefactos cacheados de 24 builds + tests + type-checks suman algunos MB por hash, y la TTL estándar de 7 días mantiene el working set pequeño.
GitHub Actions: la factura bajó junto. Antes, cada PR quemaba ~18min de runner (jobs concurrentes incluidos). Ahora la mayoría de los PRs warm queman ~1min. Para un equipo de 3 devs con ~50 PRs/mes, es una diferencia real en el minuto-runner.
Qué ha cambiado en DX
El efecto más visible no es el “1 minuto” en el badge del CI — es la previsibilidad. Cuando el ingeniero abre un PR y ve el CI pendiente, antes era “voy a hacer otra cosa durante 20 minutos”. Ahora es “voy a esperar”. La ventana de feedback ha caído dentro del contexto de la revisión, y la tentación de hacer merge sin CI verde ha desaparecido.
El segundo efecto es la confianza en rebasear. Antes, rebase = nueva ronda de 18min. Ahora, si el rebase no introduce cambios semánticamente diferentes, el CI vuelve en 1-2min. Reduce la fricción de mantener los PRs en sincronía con main.
Lecciones generales
-
Mida antes de declarar victoria. “El CI se volvió más rápido” sin
cache hiten el log no es evidencia de que el cache funcione. Es evidencia de que algo cambió — puede ser el cache, puede ser el alcance del cambio, puede ser un runner más rápido. Confirme con la señal directa. -
Fallos silenciosos en herramientas de compilación son caros. Turbo podría registrar “WARN: TURBO_TEAM no está configurado, caché remota deshabilitada”. No lo registra. Otras herramientas de compilación hacen lo mismo. La defensa es configurar una prueba de humo: ejecutar la compilación dos veces seguidas y exigir que la segunda muestre
>>> FULL TURBOocache hit. -
Variables vs secretos en GitHub Actions importan. Las variables aparecen en los logs (
TURBO_TEAM: journeystudios), los secretos se convierten en***. Para el depurado de “¿está configurado?”, coloque todo lo que no es sensible en variables — algún día querrá leer el valor. -
Matriz + filtro + caché remota se combinan. Cada optimización por sí sola tiene un beneficio marginal. Combinadas, multiplican: la matriz paraleliza,
--affectedreduce el conjunto, la caché remota reutiliza lo que ya se hizo. El paso correcto es aplicarlas en este orden (paralelizar > filtrar > cachear), porque la matriz sin filtro es cara y la caché sin paralelo pierde las ganancias triviales. -
La clave del caché es todo. Si el hash del Turbo incluye un archivo que cambia todo el tiempo (timestamp generado, version.generated.ts no determinístico, var de entorno de CI en la entrada), el caché es inútil. Vale la pena auditar
turbo.jsonregularmente para garantizar queinputsyenvestán restringidos a lo que realmente afecta la salida.
Fechamiento
La migración demoró aproximadamente dos horas de trabajo real y una hora extra depurando la variable olvidada. El retorno se diluye a lo largo de cada PR a partir de ahora — probablemente ~15min ahorrados por desarrollador por día, en promedio. Para un setup de monorepo grande, es una de las inversiones con mejor ROI que existen en DevOps de paquetes.
Si estás construyendo un monorepo Turbo + GitHub Actions en 2026: comienza con matrix --affected, activa el caché remoto desde el primer día, y desde el principio ejecuta dos workflow_dispatch consecutivos para confirmar que el segundo es FULL TURBO. Es la prueba de humo que evita declarar la victoria demasiado pronto.