Happy Dussehra! May good always win. Happy Diwali! Wishing you a bright year ahead. Merry Christmas! Warm wishes from Vizag. Happy New Year! Here’s to a great year ahead.

Archive · DevOps

Jenkins: Build an Angular UI and Deploy It to S3 in Every Region

One build, then a parallel deploy to regional S3 buckets and CloudFront with region-specific API URLs, approvals, smoke tests and rollback.

4 min read

  • Jenkins 2.492 LTS
  • Angular 19
  • Node.js 22 LTS
  • Amazon S3
  • CloudFront

This post turns the previous one into a pipeline. Jenkins builds the Angular application once, then deploys the same artefact to a bucket in every region, writing a region-specific config.json for each.

Versions in use on this date. Jenkins 2.492.1 became the current LTS on 5 February 2025 (replacing the 2.479 line), and it needs Java 17 or 21. Angular 19 builds with Node.js 22 LTS, and the Pipeline: AWS Steps plugin provides withAWS.

Architecture

flowchart TB
  GIT[Git repository] -->|webhook| JC[Jenkins controller<br/>private subnet]
  JC --> AG[Build agent<br/>Node 22, AWS CLI]
  AG -->|npm ci and ng build| ART[Archived artefact<br/>dist browser folder]
  ART --> APPR{Approval<br/>for production}
  APPR --> D1[Deploy ap-south-1]
  APPR --> D2[Deploy eu-west-1]
  APPR --> D3[Deploy us-east-1]
  D1 --> S1[S3 bucket + CloudFront]
  D2 --> S2[S3 bucket + CloudFront]
  D3 --> S3[S3 bucket + CloudFront]
  S1 --> T[Smoke test config.json]
  S2 --> T
  S3 --> T
Piece Choice
Controller Jenkins LTS on a private instance, behind an HTTPS load balancer, with no builds running on the controller
Agents Ephemeral agents with Node 22 (EC2 or Kubernetes agents); a label such as node22 selects them
AWS access The agent’s instance role assumes a deploy role in the target account through withAWS, so no access keys are stored in Jenkins
Artefact Built once, archived, and reused by every region, which is what makes rollback possible

The template for per-region configuration

config.json.template:

{ "apiUrl": "${API_URL}", "region": "${REGION}" }

Jenkinsfile

Angular 19 writes the build to dist/<project-name>/browser. The pipeline below uses a variable for the project name so the path is not guessed:

pipeline {
  agent { label 'node22' }
  options { timestamps(); disableConcurrentBuilds(); buildDiscarder(logRotator(numToKeepStr: '20')) }
  environment {
    APP = 'acme-ui'
    OUT = "dist/${APP}/browser"
  }
  stages {
    stage('Build') {
      steps {
        sh 'node --version && npm ci && npx ng test --watch=false --browsers=ChromeHeadless && npx ng build --configuration production'
        archiveArtifacts artifacts: "${OUT}/**", fingerprint: true
        stash name: 'dist', includes: "${OUT}/**"
      }
    }
    stage('Approve production') {
      when { branch 'main' }
      steps { input message: 'Deploy to all regions?', submitter: 'release-managers' }
    }
    stage('Deploy') {
      when { branch 'main' }
      matrix {
        axes { axis { name 'REGION'; values 'ap-south-1', 'eu-west-1', 'us-east-1' } }
        stages {
          stage('Publish') {
            steps {
              unstash 'dist'
              script {
                def cfg = [
                  'ap-south-1': [api: 'https://api-in.example.com', dist: 'E1INDIA000000'],
                  'eu-west-1' : [api: 'https://api-eu.example.com', dist: 'E1EUROPE00000'],
                  'us-east-1' : [api: 'https://api-us.example.com', dist: 'E1USA00000000'],
                ][REGION]
                // withEnv is scoped to this matrix cell; assigning env.X would be shared by the parallel cells
                withEnv(["API_URL=${cfg.api}", "DIST_ID=${cfg.dist}"]) {
                  withAWS(role: 'ui-deployer', roleAccount: '111122223333', region: "${REGION}") {
                    sh '''
                      envsubst < config.json.template > "$OUT/config.json"
                      aws s3 sync "$OUT" "s3://acme-ui-$REGION" --delete --exclude index.html --exclude config.json \
                        --cache-control "public,max-age=31536000,immutable"
                      aws s3 cp "$OUT/index.html"  "s3://acme-ui-$REGION/index.html"  --cache-control no-cache
                      aws s3 cp "$OUT/config.json" "s3://acme-ui-$REGION/config.json" --cache-control no-cache
                      aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/index.html" "/config.json"
                    '''
                  }
                }
              }
            }
          }
          stage('Smoke test') {
            steps {
              sh '''
                curl -fsS "https://ui-$REGION.example.com/config.json" | jq -e --arg r "$REGION" '.region == $r and (.apiUrl | startswith("https://"))'
                curl -fsS -o /dev/null "https://ui-$REGION.example.com/"
              '''
            }
          }
        }
      }
    }
  }
  post { failure { echo 'Notify the on-call channel here' } }
}

Notes on what this does and why:

  • Cache headers. Hashed bundles get a long cache lifetime; index.html and config.json are no-cache, matching the hosting rules from the previous post. The --delete flag removes old bundles from the bucket, so run it only after the new files are in place (keep the previous release’s bundles for a short time if users may hold old index.html copies).
  • Approvals. The input step pauses before production. For larger setups, deploy one canary region first, then the remaining regions.
  • Parallel matrix. The three regions deploy at the same time. Set failFast if one failing region should stop the others.
  • Least privilege. The ui-deployer role allows s3:PutObject, s3:DeleteObject, s3:ListBucket on the acme-ui-* buckets and cloudfront:CreateInvalidation on the three distributions, and nothing else.

Rollback

Enable S3 versioning on each bucket, and keep archived artefacts in Jenkins. To roll back, rebuild the pipeline from the previous successful build’s artefact (using the Copy Artifact plugin) and run the Deploy stage again; never rebuild old source, because dependencies may have moved. Test the rollback once before you need it.

Housekeeping

Pin the Jenkins plugins and update them with the LTS line, keep agents on a patched image, rotate the deploy role’s trust policy when the controller changes, and alert on pipeline failures and on the synthetic config.json check from the previous post.