Back to Blog
AngularFebruary 5, 202611 min read

CI/CD with Azure Pipelines for Angular: A Practical Setup

DR

Doug Ribeiro

11 min read

Most Angular projects I have joined at an enterprise level have had some version of CI/CD. The gap between a pipeline that technically runs and one that actually catches real problems before production is enormous. After years in financial services environments, here is an illustrative setup that earns its keep.

Why Azure Pipelines

At BMO, Azure Pipelines and SonarQube were part of the team's setup. In organizations on Azure DevOps, Pipelines integrates directly with the repos, work items, and artifact feeds already in use, and the decision is usually made before a front-end developer joins the project. The principles here translate to GitHub Actions or GitLab CI with minimal changes, so the structural decisions are worth understanding regardless of tooling.

The Four Stages That Actually Matter

  • Lint: catch style and static analysis issues before tests run. Fast feedback, cheap to fix. Fails fast if someone pushed without running locally.
  • Test: unit tests with coverage thresholds enforced. No threshold configured means no gate. Threshold without enforcement is theater.
  • Build: environment-specific builds using Angular's fileReplacements. Not runtime environment detection, not injected globals. Build-time substitution, resolved before the bundle is written.
  • Deploy: artifact upload to Azure Blob or App Service, gated by a manual approval on the production environment. At least one approver from outside the team that wrote the change.
Install (npm ci) Lint Test + Coverage SonarQube Build Deploy
yaml
# Illustrative azure-pipelines.yml: a generic starting point, not any employer's pipeline
trigger:
  branches:
    include:
      - main
      - develop

pool:
  vmImage: 'ubuntu-latest'

variables:
  nodeVersion: '20.x'
  npmCacheDir: $(Pipeline.Workspace)/.npm
  buildEnvironment: 'production'  # or 'staging'; must match a configuration in angular.json
  sonarConnection: 'sonarqube-service-connection'
  azureConnection: 'azure-service-connection'
  webAppName: 'my-angular-app-prod'

stages:
  - stage: Lint
    jobs:
      - job: LintJob
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - task: Cache@2
            inputs:
              key: 'npm | "$(Agent.OS)" | package-lock.json'
              restoreKeys: 'npm | "$(Agent.OS)"'
              path: $(npmCacheDir)
          - script: npm ci --cache $(npmCacheDir)
            displayName: 'Install dependencies'
          - script: npm run lint
            displayName: 'Run ESLint'

  - stage: Test
    dependsOn: Lint
    jobs:
      - job: TestJob
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - task: Cache@2
            inputs:
              key: 'npm | "$(Agent.OS)" | package-lock.json'
              restoreKeys: 'npm | "$(Agent.OS)"'
              path: $(npmCacheDir)
          - script: npm ci --cache $(npmCacheDir)
          # Task major versions track the SonarQube extension installed in your org. Check its
          # marketplace page before copying the @N suffixes.
          - task: SonarQubePrepare@6
            displayName: 'Prepare SonarQube analysis'
            inputs:
              SonarQube: $(sonarConnection)
              scannerMode: 'CLI'
              configMode: 'manual'
              cliProjectKey: 'my-angular-app'
              cliSources: 'src'
              extraProperties: |
                sonar.javascript.lcov.reportPaths=coverage/lcov.info
                sonar.qualitygate.wait=true
          - script: npm test -- --ci --coverage
            displayName: 'Run unit tests'
          # Jest writes these files only if 'cobertura' and 'lcov' are in its coverageReporters
          - task: PublishCodeCoverageResults@2
            inputs:
              summaryFileLocation: '$(System.DefaultWorkingDirectory)/coverage/**/cobertura-coverage.xml'
          - task: SonarQubeAnalyze@6
            displayName: 'Run SonarQube analysis'
          # This is the step that turns a red quality gate into a failed pipeline on Azure DevOps
          - task: SonarQubePublish@6
            displayName: 'Publish Quality Gate Result'
            inputs:
              pollingTimeoutSec: '300'

  - stage: Build
    dependsOn: Test
    jobs:
      - job: BuildJob
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: $(nodeVersion)
          - task: Cache@2
            inputs:
              key: 'npm | "$(Agent.OS)" | package-lock.json'
              restoreKeys: 'npm | "$(Agent.OS)"'
              path: $(npmCacheDir)
          - script: npm ci --cache $(npmCacheDir)
          - script: npm run build -- --configuration=$(buildEnvironment)
            displayName: 'Build Angular app'
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: dist
              artifact: angular-dist

  - stage: Deploy
    dependsOn: Build
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      # A deployment job, not a plain job: the environment is what carries the approval
      - deployment: DeployProduction
        # Approvals and checks are configured on the environment in Azure DevOps, not in this file
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - download: current
                  artifact: angular-dist
                - task: AzureWebApp@1
                  displayName: 'Deploy to App Service'
                  inputs:
                    azureSubscription: $(azureConnection)
                    appType: 'webAppLinux'
                    appName: $(webAppName)
                    # The application builder writes dist/<project>/browser
                    package: '$(Pipeline.Workspace)/angular-dist/my-angular-app/browser'

Caching npm Properly

The Cache@2 task with package-lock.json as the key skips most of the dependency download on every run, and on a large Angular project that is minutes per run. Multiply that across every PR in a week and you are recovering meaningful engineer time. The cache is only invalidated when dependencies actually change, which is exactly what you want.

Use npm ci with the --cache flag, not npm install. npm ci guarantees a clean install from the lockfile. npm install can silently resolve differently and introduce drift between local and CI builds. I have chased bugs that originated from this drift, which is why I treat npm ci as non-negotiable in CI.

Environment-Specific Builds via fileReplacements

Angular's fileReplacements in angular.json is the correct mechanism for environment configuration. Not runtime window.location checks. Not injected globals. Build-time substitution means the wrong environment config cannot reach production because it was compiled out at build time.

json
{
  "configurations": {
    "staging": {
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.staging.ts"
        }
      ],
      "optimization": true,
      "sourceMap": false
    },
    "production": {
      "fileReplacements": [
        {
          "replace": "src/environments/environment.ts",
          "with": "src/environments/environment.prod.ts"
        }
      ],
      "optimization": true,
      "sourceMap": false,
      "budgets": [
        {
          "type": "initial",
          "maximumWarning": "500kb",
          "maximumError": "1mb"
        }
      ]
    }
  }
}

SonarQube as a Blocking Gate

In a regulated environment, SonarQube or an equivalent static analysis gate is not optional. The mistake I see most often is integrating SonarQube as a report step that nobody reviews. A quality gate that does not block the pipeline is not a gate. It is a suggestion.

The SonarQubePrepare, SonarQubeAnalyze and SonarQubePublish tasks sit in the Test stage above. Publishing the result does not stop anything on its own: set sonar.qualitygate.wait=true in the prepare step's extra properties so the pipeline fails on a red gate. On Azure DevOps that failure surfaces through the Publish Quality Gate Result task, which waits for the gate and then fails the build, not through the analysis step. Then configure the quality gate in SonarQube to fail if coverage drops below threshold or if new critical security issues appear. The first time it blocks a merge with a real security finding, the team stops treating it as noise.

A quality gate that does not block the pipeline is not a gate. It is a suggestion. Treat it as blocking from day one or do not bother setting it up.

Approval Gates for Production

The Deploy stage needs a manual approval requirement for the production environment. In Azure Pipelines that is an Environment resource approval, configured on the environment rather than in the YAML, which is why the deployment job above only names it. My baseline is at least one approver from outside the team that wrote the change. How many approvers and which roles sign off is an org policy call, not something the pipeline author decides. The approval also creates an audit trail that compliance teams require, and having it from the start means you are not retrofitting governance onto a pipeline that already has risky deployment habits.

The setup described here is illustrative, not a copy of any employer's pipeline. Your specifics will differ, but the structure does not change: lint fast, test with enforced thresholds, build against explicit environment configs, deploy with human gates. That sequence catches the problems that matter and does not slow the team down on the problems that do not.

References

AngularAzure PipelinesCI/CDDevOps