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:
- CI wrapper pipeline
Detects what apps were changed in a commit and sets pipeline variables. - Reusable template
Executesnpm install,nx lint,nx build, andnx testfor 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-dashboardis modified, the variableApp1_Dashboard=trueis set.- The variable
NPM_Install=trueensuresnpm installruns 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 ciinstead ofnpm installfor reproducible builds, since it respectspackage-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_nameparameter.
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:
- Copy build artifacts into a staging folder.
- Tag the build in Azure DevOps with the app name (not a git tag).
- 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
affectedcommands 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, leveragingnx 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