Monorepos com Turborepo: Estrutura, Cache e Deploy

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que monorepo resolve (e o que ele cria)
Dois repositórios compartilham tipos TypeScript. Alguém atualiza a interface User no repo A e esquece de propagar para o repo B. O deploy do repo B quebra às 17h de sexta. Esse cenário se repete com validações Zod, componentes de UI, configurações de ESLint e funções utilitárias. Monorepo elimina essa classe inteira de bugs: código compartilhado vive num lugar só, e qualquer mudança é visível para todos os consumidores no mesmo commit.
O custo? Builds ficam lentos, CI fica caro e deploys precisam de orquestração. Turborepo existe para atacar exatamente esses três problemas.
Estrutura de pastas que escala
A convenção do Turborepo separa apps/ (coisas que fazem deploy) de packages/ (coisas que são importadas). Parece simples, mas a decisão de o que vira pacote e o que fica dentro de uma app define a manutenibilidade do repositório inteiro.
# Estrutura base de um monorepo com Turborepo
monorepo/
├── apps/
│ ├── web/ # Next.js, faz deploy na Vercel
│ │ ├── package.json
│ │ └── next.config.js
│ └── api/ # Express/Fastify, faz deploy no Fly.io
│ ├── package.json
│ └── src/
├── packages/
│ ├── ui/ # Componentes React compartilhados
│ │ ├── package.json
│ │ └── src/
│ ├── shared-types/ # Tipos TypeScript, zero runtime
│ │ ├── package.json
│ │ └── src/
│ ├── validation/ # Schemas Zod usados por web e api
│ │ ├── package.json
│ │ └── src/
│ └── eslint-config/ # Configuração ESLint centralizada
│ └── package.json
├── turbo.json
├── package.json # Workspace root
└── pnpm-workspace.yamlO pnpm-workspace.yaml declara onde estão os pacotes:
# pnpm-workspace.yaml
# Turborepo funciona com npm, yarn e pnpm.
# pnpm é a escolha mais comum porque workspaces
# são cidadãos de primeira classe e o hoisting
# é mais previsível com node_modules isolados.
packages:
- "apps/*"
- "packages/*"O package.json raiz não tem dependências de produção. Ele serve como ponto de entrada para scripts e define o gerenciador de pacotes:
{
"name": "monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean"
},
"devDependencies": {
"turbo": "^2.5.0"
},
"packageManager": "[email protected]"
}Configuração do pipeline no turbo.json
O turbo.json é onde você declara o grafo de dependências entre tasks. Turborepo lê isso para saber o que pode rodar em paralelo e o que precisa esperar.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**", ".next/**"],
"env": ["NODE_ENV", "DATABASE_URL"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["src/**", ".eslintrc.*", "tsconfig.json"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tests/**", "vitest.config.*"]
},
"clean": {
"cache": false
}
}
}Três detalhes que passam despercebidos:
-
O
^buildnodependsOnsignifica "build dos pacotes que eu importo, não o meu próprio build". Sem o^, Turborepo tentaria rodar o build do próprio pacote como dependência de si mesmo. -
inputsrestringe quais arquivos invalidam o cache. Se você não declarainputs, qualquer mudança em qualquer arquivo do pacote invalida tudo. A taxa de acerto depende dos arquivos alterados, das variáveis de ambiente e das tarefas executadas. Compare uma execução fria e outra sem alterações, registrando hits e misses; não há percentual de ganho garantido. -
envlista variáveis de ambiente que afetam o output. SeDATABASE_URLmuda, o build precisa rodar de novo. Esquecer uma variável aqui causa bugs silenciosos: o cache serve um build antigo com a URL antiga.
Como o cache funciona por baixo
Turborepo calcula um hash para cada task baseado em: conteúdo dos arquivos listados em inputs, variáveis de ambiente listadas em env, hashes das tasks dependentes (o ^build dos pacotes importados) e a própria definição da task no turbo.json.
Se o hash bate com uma execução anterior, Turborepo pula a task e restaura os outputs do cache. Em builds locais, o cache fica em .turbo/cache na raiz do repositório (um .tar.zst por hash, mais -meta.json e -manifest.json). Em CI, você precisa de cache remoto.
Cache remoto com Vercel
A forma mais direta de habilitar cache remoto:
# Autentica com a conta Vercel e linka o repositório
npx turbo login
npx turbo linkDepois disso, qualquer turbo run build envia e busca artefatos no cache remoto da Vercel. O CI do colega que roda o mesmo commit recebe cache hit instantâneo.
Cache remoto self-hosted com Ducktape ou Turborepo Remote Cache
Se você não quer depender da Vercel (ou precisa manter artefatos em infra própria), existem implementações open source do protocolo de cache remoto. A variável TURBO_TOKEN e TURBO_TEAM configuram o endpoint:
# Variáveis de ambiente para cache remoto customizado
export TURBO_API="https://cache.sua-infra.com"
export TURBO_TOKEN="seu-token-aqui"
export TURBO_TEAM="seu-time"
# Agora turbo run build usa seu servidor como backend de cache
turbo run build| Critério | Vercel Remote Cache | Self-hosted (ducktape/custom) |
|---|---|---|
| Setup | 2 comandos | Deploy de servidor + storage (S3/GCS) |
| Custo | Gratuito até limite do plano Vercel | Custo de infra (storage + compute) |
| Latência | Depende da região Vercel | Controle total, CDN própria |
| Compliance | Dados na Vercel | Dados na sua infra |
| Manutenção | Zero | Você mantém o servidor |
Para times com menos de 20 devs e sem restrição de compliance, cache da Vercel é a escolha pragmática. Acima disso, ou com dados sensíveis nos artefatos, self-hosted compensa.
Vendo o cache acontecer: hit, miss e por que o hash mudou
Tudo abaixo roda num monorepo mínimo que você monta em um minuto, com Turborepo 2.10.12 e npm workspaces. Um pacote hello com um script de build que escreve dist/out.txt, e um turbo.json que declara inputs, uma variável de ambiente e outputs:
// turbo.json
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"build": {
"inputs": ["src/**"],
"env": ["APP_ENV"],
"outputs": ["dist/**"]
}
}
}// package.json (raiz)
{
"name": "turbo-mini",
"private": true,
"workspaces": ["packages/*"],
"packageManager": "[email protected]",
"devDependencies": { "turbo": "2.10.12" }
}O pacote packages/hello tem src/index.js, um README.md e o script "build": "node build.js". Seis execuções de npx turbo run build, com o que mudou entre cada uma e a saída real:
# 1. primeira execução
hello:build: cache miss, executing 37f35b2b3cc6c962
Time: 383ms
# 2. nada mudou
hello:build: cache hit, replaying logs 37f35b2b3cc6c962
Time: 21ms >>> FULL TURBO
# 3. README.md editado (fora de inputs): continua hit
hello:build: cache hit, replaying logs 37f35b2b3cc6c962
Time: 23ms >>> FULL TURBO
# 4. src/index.js editado: miss, hash novo
hello:build: cache miss, executing 03985f0db7d065bb
Time: 436ms
# 5. APP_ENV (declarada em env) mudou de valor: miss
hello:build: cache miss, executing 9b13bc36281dce7d
Time: 411ms
# 6. OUTRA_VAR (não declarada) definida: hit, o hash ignora
hello:build: cache hit, replaying logs 9b13bc36281dce7d
Time: 22ms >>> FULL TURBOTrês coisas para reter dessa sequência. O inputs é um contrato: o README mudou e o build não rodou, porque só src/** entra no hash. Uma variável listada em env faz parte do hash como se fosse arquivo (execução 5). Uma variável que não está em env não invalida nada (execução 6), o que é ótimo para CI=true e péssimo para uma API_URL esquecida: o build antigo volta do cache com a URL antiga embutida.
--dry-run=json: o que entrou no hash
Quando um hit ou miss surpreende, não adivinhe. O dry-run lista, por task, os arquivos e variáveis que compuseram o hash:
npx turbo run build --dry-run=json{
"taskId": "hello#build",
"hash": "03985f0db7d065bb",
"cache": "HIT",
"inputs": ["package.json", "src/index.js"],
"env": ["APP_ENV"],
"configured": []
}Repare que package.json do pacote aparece mesmo sem estar em inputs: o Turborepo sempre inclui o manifesto do pacote, o lockfile relevante e a definição da própria task.
--summarize: o registro de uma execução real
npx turbo run build --summarize grava um JSON em .turbo/runs/<id>.json com cada task, seu hash, se foi hit ou miss, os inputs expandidos com hash individual, a duração e o log. É o arquivo que você anexa a um issue quando o CI diz "cache hit" para um build que deveria ter rodado. Na execução acima, o resumo confirma hash 03985f0db7d065bb, cache HIT e expandedInputs com package.json e src/index.js.
--env-mode: strict é o padrão, e ele esconde variável
Desde o Turborepo 2, o modo padrão é strict: a task só enxerga as variáveis declaradas em env (mais as que o Turborepo libera por padrão, como PATH e HOME). Com APP_ENV=producao e OUTRA_VAR=segredo exportadas no shell, o mesmo build.js imprimindo as duas:
$ npx turbo run build --env-mode=strict --force
hello:build: build executado; APP_ENV=producao OUTRA_VAR=undefined
$ npx turbo run build --env-mode=loose --force
hello:build: build executado; APP_ENV=producao OUTRA_VAR=segredoÉ o comportamento que explica o clássico "funciona na minha máquina, no CI a variável vem undefined": a variável existe no runner, mas não foi declarada em env, então o modo strict a corta antes de o script rodar. Declare o que o build usa; usar loose para "resolver" devolve o problema da execução 6, com cache envenenado por variável fora do hash.
Pacote compartilhado: exemplo com validação Zod
O pacote packages/validation é consumido tanto pela app Next.js quanto pela API. Isso garante que frontend e backend validam payloads com as mesmas regras:
// packages/validation/src/user.ts
import { z } from "zod";
// Schema único: frontend usa para validar form,
// backend usa para validar request body.
// Mudar aqui propaga para os dois automaticamente.
export const createUserSchema = z.object({
email: z.string().email("Email inválido"),
name: z
.string()
.min(2, "Nome precisa ter pelo menos 2 caracteres")
.max(100),
role: z.enum(["admin", "member", "viewer"]),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
// Schema de resposta separado do de input
// porque a resposta inclui campos gerados pelo banco
export const userResponseSchema = createUserSchema.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
export type UserResponse = z.infer<typeof userResponseSchema>;O package.json desse pacote precisa de um detalhe que muita gente erra:
{
"name": "@monorepo/validation",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc --build",
"lint": "eslint src/"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}O "main": "./src/index.ts" aponta para TypeScript puro, não para JavaScript compilado. Isso funciona porque Next.js e a maioria dos bundlers modernos transpilam pacotes internos do workspace. Se sua app usa um bundler que não faz isso (Express puro sem build step), você precisa compilar o pacote antes e apontar main para ./dist/index.js.
Anti-patterns: o que NÃO fazer
Erro 1: dependências duplicadas entre pacotes
// ERRADO: cada pacote declara sua própria versão do React
// packages/ui/package.json
{
"dependencies": {
"react": "^18.3.0"
}
}
// apps/web/package.json
{
"dependencies": {
"react": "^19.0.0"
}
}Isso causa duas cópias do React no bundle, hooks quebram com o erro "Invalid hook call" porque existem duas instâncias do React no runtime. A correção:
// CORRETO: pacote UI declara React como peerDependency
// packages/ui/package.json
{
"peerDependencies": {
"react": "^18.3.0 || ^19.0.0"
},
"devDependencies": {
"react": "^19.0.0"
}
}O peerDependencies diz "eu preciso do React, mas quem me consome é que fornece". O devDependencies garante que o pacote tem React disponível para testes e type-checking local.
Erro 2: não declarar outputs no turbo.json
// ERRADO: sem outputs, o cache não restaura nada
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}Turborepo faz cache do hash, mas quando restaura, não tem nenhum artefato para copiar de volta. O build roda, o cache registra sucesso, mas no próximo hit o diretório dist/ continua vazio. Sempre declare:
// CORRETO: outputs explícitos
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
}
}
}Erro 3: colocar tudo num pacote "shared" gigante
Um pacote packages/shared com utils, tipos, validações, constantes e helpers cresce até ter 200 arquivos. Qualquer mudança em qualquer arquivo invalida o cache de todos os consumidores. Divida por domínio: packages/validation, packages/shared-types, packages/ui. A granularidade ideal é: se dois pacotes mudam em frequências diferentes, eles devem ser pacotes separados.
Deploy independente por app
Monorepo não significa monodeploy. Cada app em apps/ faz deploy separado. Turborepo ajuda com o filtro --filter:
# Builda apenas a app web e seus pacotes dependentes
turbo run build --filter=web
# Builda apenas a API
turbo run build --filter=api
# Builda tudo que mudou desde o último commit na main
turbo run build --filter=...[main]O --filter=...[main] é o mais útil em CI: ele compara o commit atual com a branch main e roda tasks apenas nos pacotes que tiveram arquivos alterados (ou que dependem de pacotes alterados). Isso reduz o tempo de CI de "build tudo" para "build o que mudou".
GitHub Actions com cache e filtro
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# fetch-depth 0 necessário para que o --filter=...[main]
# consiga comparar commits e detectar o que mudou
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint test build --filter=...[origin/main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Se a app web é Next.js na Vercel, a Vercel já detecta monorepos com Turborepo e configura o "Root Directory" automaticamente. Para a api no Fly.io ou Railway, você aponta o Dockerfile para apps/api e copia apenas os pacotes necessários.
Quando Turborepo NÃO é a resposta
Turborepo orquestra tasks e faz cache. Ele não resolve:
- Versionamento de pacotes publicados no npm: para isso, use Changesets junto com Turborepo.
- Monorepos com linguagens diferentes (Go + TypeScript + Python): Turborepo é JavaScript-first. Para polyglot, Bazel ou Nx com plugins são opções mais adequadas.
- Repositórios com menos de 3 pacotes: o overhead de configuração não compensa. Dois repos com tipos compartilhados via npm pack local ou git submodule resolvem mais rápido.
| Cenário | Turborepo | Nx | Bazel |
|---|---|---|---|
| Monorepo JS/TS puro | Escolha direta, setup mínimo | Funciona, mais features e mais config | Overkill |
| Monorepo polyglot | Limitado | Plugins para Go, Python, etc. | Feito para isso |
| Time < 5 devs | Ideal, baixa fricção | Pode ser demais | Definitivamente demais |
| Time > 30 devs | Funciona com cache remoto | Funciona, mais controle granular | Escala comprovada (Google, Stripe) |
| Publicação no npm | Precisa de Changesets | Built-in com nx release | Manual |
Para a maioria dos times JavaScript entre 3 e 30 devs, Turborepo é a ferramenta certa. A curva de aprendizado é de horas, não semanas. A documentação oficial do Turborepo cobre cada flag e opção com exemplos.
Integrando com testes e linting
Se você usa Vitest para testes, cada pacote pode ter seu próprio vitest.config.ts. Turborepo roda os testes em paralelo respeitando as dependências:
// packages/validation/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// globals true evita importar describe/it/expect em cada arquivo
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
},
});Para ESLint centralizado, o pacote packages/eslint-config exporta a configuração base e cada app/pacote estende:
// packages/eslint-config/base.js
import tseslint from "typescript-eslint";
export default tseslint.config({
extends: [tseslint.configs.recommended],
rules: {
// Proíbe any explícito: força tipos reais
// em pacotes compartilhados isso é crítico
// porque any num pacote propaga para todos os consumidores
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
},
});Componentes compartilhados no packages/ui podem seguir a mesma estrutura do Design System com Radix e Tailwind, com a vantagem de que mudanças no componente Button propagam para todas as apps no mesmo PR.
Para APIs que precisam de validação de segurança, o schema Zod compartilhado garante que o payload é validado antes de chegar no handler, tanto no frontend (UX) quanto no backend (segurança).
FAQ
Turborepo substitui pnpm workspaces? Não. Turborepo roda em cima de workspaces (pnpm, npm ou yarn). O workspace gerencia instalação de dependências e resolução de pacotes. Turborepo gerencia execução de tasks, paralelismo e cache. São camadas complementares.
Preciso de Docker para deploy de monorepo?
Depende do target. Next.js na Vercel não precisa: a Vercel entende monorepos nativamente. Para APIs em Fly.io, Railway ou AWS ECS, você precisa de um Dockerfile que copie apenas os pacotes relevantes. O comando turbo prune --scope=api --docker gera um diretório com apenas os arquivos necessários para a app api, ideal para builds Docker enxutos.
O cache remoto é seguro? Artefatos de build podem conter secrets?
Artefatos de build (JavaScript compilado, assets) normalmente não contêm secrets se você usa variáveis de ambiente em runtime. Se seu build step embute secrets no código (por exemplo, NEXT_PUBLIC_ no Next.js), esses valores estarão nos artefatos cacheados. Nesse caso, trate o cache remoto com o mesmo nível de segurança do seu CI.
Posso usar Turborepo com um monorepo que já existe?
Sim. Instale turbo como devDependency na raiz, crie o turbo.json com as tasks que você já tem nos package.json dos pacotes, e rode turbo run build. O Turborepo infere o grafo de dependências a partir dos package.json dos workspaces. Migração incremental funciona: você não precisa converter tudo de uma vez.
Turborepo funciona com Bun? A partir da versão 2.x, Turborepo suporta Bun como package manager. O suporte é funcional, mas menos testado em produção que pnpm. Se seu time já usa Bun, funciona. Se está escolhendo agora, pnpm tem ecossistema mais maduro para workspaces.
Posição editorial
Monorepo com Turborepo é a configuração padrão que eu recomendaria para qualquer projeto JavaScript com mais de uma app e pacotes compartilhados. A alternativa (múltiplos repos com pacotes publicados no npm) adiciona fricção desproporcional: versionamento, publicação, sincronização de tipos, CI separado por repo. Turborepo elimina essa fricção com uma ferramenta que tem exatamente a quantidade certa de opinião: suficiente para funcionar sem configuração excessiva, flexível o bastante para não atrapalhar quando você precisa de algo específico. O cache remoto é o recurso que paga o investimento de setup: a primeira vez que um build de 4 minutos vira 8 segundos de cache hit, a decisão se justifica sozinha.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


