Configuring CI/CD with Azure Pipelines and Nx Monorepos — hero banner

December 14, 2021·3 min read

When managing 15+ Angular applications in one product, the overhead of separate package.json files, builds, and pipelines quickly becomes painful. To address this, we turned to Nx, which provides tooling for managing monorepos with shared node_modules, optimized dependency graphs, and powerful affected commands.

This post details how I set up CI/CD pipelines in Azure DevOps for our Nx monorepo — including how to detect changed apps, run only the relevant builds/tests, and trigger releases per micro-application.


Why Nx?

Nx lets you manage a large Angular monorepo with:

  • A single package.json for dependencies.
  • Dependency-aware commands like nx affected:build, which only build/test/lint the projects impacted by a given change.
  • Consistency across builds: every app is built with the same toolchain and caching strategy.

The challenge was integrating this into Azure Pipelines for both CI (build/test) and CD (deployments).


Two Pipeline YAMLs: CI and Reusable Template

We ended up with two main YAML files:

  1. CI wrapper pipeline
    Detects what apps were changed in a commit and sets pipeline variables.
  2. Reusable template
    Executes npm install, nx lint, nx build, and nx test for each app in a consistent, parameterized way.

Step 1 — Detect Changed Applications

The first job, Get_Affected_App, scans the changeset paths and sets pipeline variables accordingly.

foreach ($path in $changesFolder) {
  if ($path -match '/apps/app1-dashboard') {
    echo "##vso[task.setvariable variable=App1_Dashboard;isOutput=true]$True"
    echo "##vso[task.setvariable variable=NPM_Install;isOutput=true]$True"
    break
  }
}

Explanation:

  • If any file under apps/app1-dashboard is modified, the variable App1_Dashboard=true is set.
  • The variable NPM_Install=true ensures npm install runs for the entire repo.
  • This is repeated for each app in the monorepo (app2-dashboard, app3-dashboard, etc.).

This mechanism ensures that we only queue CI jobs for the applications that actually changed.


Step 2 — Run NPM Install Once

Because Nx shares a single node_modules, we only need to run npm install once per pipeline run, not per app.

- job: NPM_Install_For_All
  pool:
    vmImage: 'ubuntu-latest'
  condition: eq(dependencies.Get_Affected_App.outputs['NX_PROJECT_VARIABLE.NPM_Install'], 'true')
  steps:
    - checkout: self
    - task: NodeTool@0
      inputs:
        versionSpec: '18.x'
    - script: npm ci
      displayName: 'Install Node modules'

Tip: Use npm ci instead of npm install for reproducible builds, since it respects package-lock.json.


Step 3 — Build & Test Affected Applications

Now we spin up one job per app, gated by the variables set earlier.

- job: App1_Dashboard_CI
  pool:
    name: Agent-Pool
    demands: Agent.OS -equals Windows_NT
  dependsOn: NPM_Install_For_All
  condition: eq(dependencies.Get_Affected_App.outputs['NX_PROJECT_VARIABLE.App1_Dashboard'], 'true')
  steps:
    - checkout: self
      persistCredentials: true
    - template: setup-and-build.yml
      parameters:
        app_name: 'app1-dashboard'

This job:

  • Runs only if App1_Dashboard=true.
  • Calls the reusable template with the app_name parameter.

Step 4 — Reusable Build Template

setup-and-build.yml is where the Nx magic lives:

parameters:
  app_name: ""

steps:
  - script: |
      echo "Linting ${{ parameters.app_name }}"
      npx nx lint ${{ parameters.app_name }}
    displayName: "Lint"

  - script: |
      echo "Building ${{ parameters.app_name }}"
      npx nx build ${{ parameters.app_name }} --configuration=production
    displayName: "Build"

  - script: |
      echo "Testing ${{ parameters.app_name }}"
      npx nx test ${{ parameters.app_name }} --ci --code-coverage
    displayName: "Test"

Explanation:

  • Each app is linted, built, and tested consistently.
  • Using a template reduces duplication across 15+ apps.
  • Adding new apps is as simple as copying a job block in the CI YAML and pointing it at this template.

Step 5 — Continuous Delivery (CD)

After a successful CI build, the last few steps handle deployment prep:

  1. Copy build artifacts into a staging folder.
  2. Tag the build in Azure DevOps with the app name (not a git tag).
  3. Trigger the release pipeline for that app.

Example tagging step:

- task: PowerShell@2
  displayName: "Tag Build"
  inputs:
    targetType: 'inline'
    script: |
      Write-Host "##vso[build.addbuildtag]${{ parameters.app_name }}"

The release pipeline listens for builds tagged with app1-dashboard, app2-dashboard, etc., and deploys only that micro-application.


Step 6 — Why This Works for Nx Monorepos

  • Scalable: Adding new apps is trivial — copy a job, change the app_name.
  • Efficient: Only changed apps are built/tested, saving agent time and compute cost.
  • Consistent: Every build runs the same lint/build/test flow via the shared template.
  • Releasable: Build tags cleanly map to release triggers per micro-app.

Lessons Learned

  • Nx’s affected commands are powerful, but Azure DevOps variable passing required a little brute force scripting.
  • CI/CD pipelines become far easier to maintain once you standardize on templates.
  • Using build tags instead of git tags gave us precise control over release triggers.
  • There’s still room to improve: caching node_modules, leveraging nx cloud, and consolidating release templates.

Conclusion

Migrating to Nx with Azure Pipelines gave us a repeatable, optimized, and modular CI/CD pipeline for a monorepo with over 15 Angular applications.

While this setup may look verbose at first, it has paid off in speed, consistency, and maintainability.
Future improvements will likely include Nx Cloud caching and deployment previews per pull request.


Originally written Dec 14, 2021 — updated with more detail and commentary.

Enjoyed this post? Give it a clap!

Comments