DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Monorepos NX en CodeBuild con matrices, builds paralelos y caché distribuida

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Y configuré el plugin en nx.json:

{
  "s3": {
    "bucket": "ittal-nx-cache",
    "region": "us-east-1",
    "prefix": "monorepo-banca"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/**/*'
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 0
NX + Nx Cloud 5 min Remoto 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);
});
Enter fullscreen mode Exit fullscreen mode

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)