Hace meses heredé un monorepo de un cliente de banca con 12 aplicaciones frontend, 8 libraries compartidas y un pipeline que tardaba 42 minutos. Cada push dolía. Lo migré a NX con matrices de CodeBuild y caché distribuida en S3. Hoy el pipeline tarda 8 minutos en el peor caso y 90 segundos cuando solo se toca una librería.
Este artículo es la guía paso a paso de esa migración.
El problema de un monorepo sin herramientas
El setup original era un npm-workspaces con un Jenkinsfile que ejecutaba builds secuenciales:
npm run build:app1
npm run build:app2
...
npm run test:all
npm run e2e:all
Cada app tardaba 3 minutos en buildear. Los tests otros 15 minutos. E2E otros 10. El cuello de botella: nadie sabía qué había cambiado realmente, así que se construía todo siempre.
flowchart TD
Push[git push] --> Jenkins
Jenkins --> Build1[Build app1<br/>3 min]
Build1 --> Build2[Build app2<br/>3 min]
Build2 --> Build12[Build app12<br/>3 min]
Build12 --> Test[Tests secuenciales<br/>15 min]
Test --> E2E[E2E todos<br/>10 min]
E2E --> Deploy
style Jenkins fill:#ff6b6b
NX resuelve esto con dos conceptos: el grafo de dependencias y el caché computacional. Sabe qué afectó cada commit y solo ejecuta tareas sobre lo afectado.
Setup de NX en el monorepo
npx nx@latest init
El comando analiza el repo y genera nx.json con detección automática de apps y librerías. Después limpié la configuración a mano:
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.test.tsx",
"!{projectRoot}/jest.config.ts",
"!{projectRoot}/.eslintrc.json"
],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"cache": true
},
"test": {
"inputs": ["default", "^production"],
"cache": true
},
"lint": {
"inputs": ["default"],
"cache": true
},
"e2e": {
"inputs": ["default", "^production"],
"cache": true
}
},
"parallel": 4,
"cacheDirectory": "node_modules/.cache/nx"
}
El namedInputs define qué archivos invalidan el caché. Para build uso production (que excluye tests y config de lint) porque cambiar un test no debería reconstruir la app. Para test uso default (incluye todo) porque cambios en la app afectan sus tests.
Caché distribuida en S3
El caché local de NX funciona en tu máquina. En CI, cada build parte de cero salvo que compartas el caché. Nx Cloud es la solución oficial pero cuesta dinero. S3 sale gratis prácticamente.
Instalé @nx/powerpack-s3-cache:
npm install -D @nx/powerpack-s3-cache
Y configuré el plugin en nx.json:
{
"s3": {
"bucket": "ittal-nx-cache",
"region": "us-east-1",
"prefix": "monorepo-banca"
}
}
El plugin usa las credenciales de AWS del ambiente. En CodeBuild ya vienen inyectadas por el IAM role del proyecto. En local uso aws-vault.
Buildspec con matriz de CodeBuild
Aquí está la pieza clave. CodeBuild soporta batch builds con matrices. Ejecuto una fase que calcula qué proyectos están afectados y genera la matriz dinámicamente:
# buildspec.yml principal
version: 0.2
batch:
fast-fail: true
build-graph:
- identifier: detect_affected
buildspec: buildspec-detect.yml
env:
compute-type: BUILD_GENERAL1_SMALL
- identifier: build_apps
depend-on:
- detect_affected
buildspec: buildspec-build.yml
env:
compute-type: BUILD_GENERAL1_MEDIUM
variables:
AFFECTED_PROJECTS: ""
- identifier: test_apps
depend-on:
- detect_affected
buildspec: buildspec-test.yml
env:
compute-type: BUILD_GENERAL1_MEDIUM
- identifier: e2e
depend-on:
- build_apps
buildspec: buildspec-e2e.yml
env:
compute-type: BUILD_GENERAL1_LARGE
- identifier: deploy
depend-on:
- test_apps
- e2e
buildspec: buildspec-deploy.yml
Fase 1: detectar qué cambió
# buildspec-detect.yml
version: 0.2
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm ci --prefer-offline
build:
commands:
- git fetch origin main
- export BASE_SHA=$(git merge-base HEAD origin/main)
- echo "Base SHA for affected calculation: $BASE_SHA"
- |
AFFECTED=$(npx nx show projects --affected --base=$BASE_SHA --type=app --json)
echo "Affected apps: $AFFECTED"
echo "$AFFECTED" > affected-apps.json
- |
AFFECTED_LIBS=$(npx nx show projects --affected --base=$BASE_SHA --type=lib --json)
echo "Affected libs: $AFFECTED_LIBS"
echo "$AFFECTED_LIBS" > affected-libs.json
artifacts:
files:
- affected-apps.json
- affected-libs.json
name: affected-manifest
git merge-base HEAD origin/main me da el commit donde mi branch se separó de main. NX calcula qué proyectos cambiaron desde ese punto. Esto es correcto para pull requests y también para builds de main (donde el base es el commit previo).
Fase 2: build paralelo de afectados
# buildspec-build.yml
version: 0.2
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm ci --prefer-offline
pre_build:
commands:
- aws s3 cp s3://ittal-nx-cache-artifacts/$CODEBUILD_BUILD_NUMBER/affected-apps.json .
- export AFFECTED_COUNT=$(jq 'length' affected-apps.json)
- echo "Building $AFFECTED_COUNT affected projects"
build:
commands:
- |
if [ "$AFFECTED_COUNT" = "0" ]; then
echo "Nothing affected, skipping build"
exit 0
fi
- npx nx affected --target=build --parallel=4 --configuration=production
- npx nx affected --target=lint --parallel=4
post_build:
commands:
- |
for app in $(jq -r '.[]' affected-apps.json); do
if [ -d "dist/apps/$app" ]; then
echo "Uploading $app artifacts"
aws s3 sync dist/apps/$app s3://ittal-build-artifacts/$CODEBUILD_BUILD_NUMBER/$app/
fi
done
cache:
paths:
- 'node_modules/**/*'
- 'node_modules/.cache/nx/**/*'
Con --parallel=4 construyo hasta 4 apps simultáneamente en una instancia MEDIUM (4 vCPU, 7GB RAM). Para el monorepo actual es suficiente. Si creciera a 30 apps, saltaría a LARGE con paralelismo 8.
Fase 3: tests con split automático
NX soporta distribución de tests entre agents. Para un solo agent es fácil:
# buildspec-test.yml
version: 0.2
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm ci --prefer-offline
build:
commands:
- npx nx affected --target=test --parallel=4 --ci --codeCoverage
- |
mkdir -p coverage-reports
find . -path '*/coverage/coverage-final.json' -exec cp --parents {} coverage-reports/ \;
post_build:
commands:
- aws s3 sync coverage-reports s3://ittal-coverage-reports/$CODEBUILD_BUILD_NUMBER/
Para repos más grandes uso la estrategia de split en el nivel de proyecto con Jest sharding:
// apps/dashboard/project.json
{
"targets": {
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/dashboard/jest.config.ts",
"shard": "1/3"
}
}
}
}
Luego en CodeBuild lanzo 3 builds paralelos con variable TEST_SHARD=1/3, 2/3, 3/3.
Benchmark real
gantt
title Pipeline viejo vs nuevo (branch con cambio en 1 app)
dateFormat HH:mm:ss
axisFormat %M:%S
section Viejo Jenkins
Install deps :a1, 00:00:00, 2m
Build 12 apps :a2, after a1, 36m
Test all :a3, after a2, 15m
E2E all :a4, after a3, 10m
Deploy :a5, after a4, 3m
section NX + CodeBuild
Detect affected :b1, 00:00:00, 45s
Build 1 app :b2, after b1, 3m
Test affected :b3, after b1, 2m
E2E affected :b4, after b2, 3m
Deploy :b5, after b4, 2m
Con cache hit (build idéntico), el pipeline completo tarda 90 segundos porque NX detecta que todo ya está cacheado y solo hace el deploy. Sin cache (primera vez construyendo esa combinación), 8 minutos para un cambio grande.
Tabla comparativa de estrategias
| Estrategia | Setup | Cache | Paralelismo | Costo mensual |
|---|---|---|---|---|
| Jenkins secuencial | Ninguno | No | No | 0 |
| NX local sin CI | Ya viene | Local | Sí | 0 |
| NX + Nx Cloud | 5 min | Remoto | Sí | Desde 16 USD |
| NX + S3 cache + CodeBuild | 1 día | Remoto | Matrix | 15-40 USD |
| GitHub Actions + NX | 1 día | Cache Actions | Matrix | Gratis hasta límite |
Para este cliente elegí CodeBuild porque ya tenían el resto de su infra en AWS y el role IAM ya existía.
Integración con distribución de tareas
NX Cloud tiene una feature llamada DTE (Distributed Task Execution) que balancea tareas entre agents. Lo repliqué manualmente con SQS:
// ci/distribute-tasks.ts
import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
import { execSync } from 'child_process';
const sqs = new SQSClient({ region: 'us-east-1' });
async function distributeTasks() {
const baseSha = execSync('git merge-base HEAD origin/main').toString().trim();
const affectedJson = execSync(
`npx nx show projects --affected --base=${baseSha} --json`
).toString();
const affected = JSON.parse(affectedJson) as string[];
const tasks = affected.flatMap((project) => [
{ project, target: 'build' },
{ project, target: 'test' },
{ project, target: 'lint' },
]);
const batches = [];
for (let i = 0; i < tasks.length; i += 10) {
batches.push(tasks.slice(i, i + 10));
}
for (const [idx, batch] of batches.entries()) {
await sqs.send(
new SendMessageBatchCommand({
QueueUrl: process.env.TASK_QUEUE_URL!,
Entries: batch.map((task, i) => ({
Id: `batch-${idx}-${i}`,
MessageBody: JSON.stringify(task),
})),
})
);
}
console.log(`Enqueued ${tasks.length} tasks for distributed execution`);
}
distributeTasks().catch((err) => {
console.error(err);
process.exit(1);
});
Los workers son otras instancias de CodeBuild polleando la queue. Lo usé cuando el monorepo creció a 30 proyectos. Para menos de 15, la matriz estática es más simple.
Lo que aprendí
1. El caché de NX se invalida por cualquier archivo dentro del proyecto.
Descubrí que tenía .DS_Store commiteados de Macs. Cada desarrollador hacía un commit y el caché se invalidaba aunque el código no cambiara. Moraleja: revisa .gitignore cuando configures NX, los archivos espurios matan el caché.
2. dependsOn: ["^build"] es la magia.
El ^ significa "build de las dependencias". Si app1 usa lib-ui, NX construye lib-ui primero automáticamente. Sin esto, los builds fallan intermitentemente por orden.
3. S3 cache hit requiere comparar hashes no archivos.
NX calcula un hash de los inputs (código, config, env vars). El hash es la key en S3. Si tu env var BUILD_ID cambia cada vez, nunca hay cache hit. Exclúyela con runtimeCacheInputs o envInputs.
4. CodeBuild batch tiene límites.
Máximo 20 builds en un batch. Si tu matriz crece, divídela en fases. También el fast-fail por defecto está en false: si una fase falla, las siguientes siguen corriendo. Habilítalo o vas a pagar por builds inútiles.
5. Los artefactos intermedios en S3 necesitan lifecycle.
Cada build subía dist/ de cada app a S3. En un mes tenía 400GB acumulados. Agregué regla de lifecycle: borrar builds antiguos a los 7 días, mover a Glacier los que están en tags de release.
Cuándo NO usar NX
Si tienes un solo proyecto, NX es overkill. El overhead de configurar nx.json, targets, inputs, etc no compensa para un repo con una app.
Si tu equipo no entiende el modelo de grafo de dependencias, NX puede ser confuso. Hay comandos que se comportan diferente según el estado del caché. Para equipos junior, un monorepo simple con npm workspaces es más predecible.
Tampoco NX si tu stack mezcla lenguajes no-JS. NX tiene plugins para Go, Python, Rust, pero son comunitarios y no están al mismo nivel. Para monorepos políglotas, Bazel sigue siendo más robusto aunque más complejo.
El próximo artículo cubre WebSockets con API Gateway para dashboards en tiempo real. Spoiler: migré un cliente de Pusher a AWS native y ahorré 800 USD al mes.
Top comments (0)