Azure Pipelines DevSecOps Capstone
What you'll learn
~40 min- Build a multi-stage Azure Pipelines YAML pipeline with lint, test, build, Docker, and deploy stages
- Configure a production Dockerfile with Node 22 Alpine, non-root user, and port 8080
- Integrate SAST scanning and dependency vulnerability checks into the pipeline
The capstone: everything in one pipeline
This is the lesson where the entire module comes together. The CLAUDE.md from Lesson 1 that constrains the AI. The auth layer from Lesson 2. The RBAC from Lesson 4. The tests from Lessons 12-13. The telemetry from Lesson 16. The deployment process from Lesson 18. All of it runs in a single pipeline that takes code from a PR merge on dev to a running application on Azure Government.
The pipeline has five stages: lint (ESLint), test (Vitest + Playwright), build (Next.js standalone + Docker image), security scan (SAST + dependency check), and deploy (to Azure Gov App Service). Each stage gates the next — a linting failure stops the pipeline before tests even run.
What you build in this lesson is not a learning exercise that gets deleted. This is the pipeline that deploys your platform. Every change your team makes flows through this file. Treat azure-pipelines.yml with the same care you treat production code — because it is.
The Dockerfile
Before the pipeline, you need the Dockerfile. Azure App Service runs containers, and the container needs to be minimal, secure, and configured for the gov cloud environment.
The prompt
Create a production Dockerfile for our Next.js 15.5 platform.
Requirements:1. Multi-stage build: - Stage 1 (deps): Node 22 Alpine, install production dependencies only - Stage 2 (builder): Copy source, run npm run build - Stage 3 (runner): Minimal Node 22 Alpine, copy standalone output only
2. Runner stage specifics: - Create a non-root user: addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs - Copy .next/standalone as nextjs user - Copy .next/static into .next/standalone/.next/static - Copy public into .next/standalone/public - EXPOSE 8080 - ENV PORT=8080 HOSTNAME="0.0.0.0" - CMD ["node", "server.js"] - USER nextjs
3. Security: - No dev dependencies in the final image - No .env files, no .git directory, no test files - Add a .dockerignore that excludes node_modules, .next, .git, *.md, tests/, playwright-report/, coverage/
4. Labels: - org.opencontainers.image.title=ds-platform - org.opencontainers.image.description=DS Platform - State GovernmentWhat the AI generates
# Stage 1: DependenciesFROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=dev
# Stage 2: BuildFROM node:22-alpine AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build
# Stage 3: RunnerFROM node:22-alpine AS runnerWORKDIR /app
ENV NODE_ENV=productionENV PORT=8080ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs
# Copy the standalone outputCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./# Copy static files (the critical step)COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static# Copy public assetsCOPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 8080
CMD ["node", "server.js"]Azure App Service for Linux runs containers as non-root by default. If your Dockerfile does not specify a non-root user, App Service assigns one — but the UID may not have read access to your application files. Explicitly creating nextjs:1001 and setting --chown=nextjs:nodejs on the COPY commands ensures the application user owns all files.
Next.js defaults to port 3000. Azure App Service expects 8080. The ENV PORT=8080 in the Dockerfile and the WEBSITES_PORT=8080 App Service setting must match. If they disagree, the App Service health check fails and the container restarts indefinitely.
The pipeline
The prompt
Generate an azure-pipelines.yml for our Next.js 15.5 platform with these stages:
TRIGGER:- Trigger on push to dev branch- Trigger on PR to dev or main- Do NOT trigger on changes to *.md, docs/*, or .vscode/*
VARIABLES:- Variable groups: ds-platform-dev, ds-platform-staging, ds-platform-prod (each contains AZURE_SUBSCRIPTION, RESOURCE_GROUP, APP_NAME, ACR_NAME)- Pipeline variables: nodeVersion=22, npmVersion=10
STAGES:
1. Lint - Node 22 on ubuntu-latest - npm ci - npx eslint src/ --max-warnings 0 - If lint fails, stop the pipeline
2. Test - Depends on Lint - Two parallel jobs: a. Unit tests: npx vitest run --coverage b. E2E tests: npx playwright install --with-deps && npx playwright test - Publish test results (JUnit format) and coverage report - If any test fails, stop the pipeline
3. Security - Depends on Test - npm audit --audit-level=high (fail on high/critical vulnerabilities) - Run Microsoft Security DevOps task (SAST scanning) - Publish security scan results as pipeline artifact
4. Build - Depends on Security - npm run build - Docker build with the Dockerfile above - Tag image: $(ACR_NAME).azurecr.us/ds-platform:$(Build.BuildId) - Push to Azure Container Registry (note: .azurecr.us for gov) - Publish the standalone zip as a pipeline artifact
5. Deploy-Dev - Depends on Build - Condition: branch is dev, not a PR - Use variable group ds-platform-dev - az webapp config container set with the Docker image - Run health check against dev URL - No approval gate
6. Deploy-Staging - Depends on Deploy-Dev - Use variable group ds-platform-staging - Manual approval gate (environment: staging) - Deploy same image to staging App Service - Run health check + smoke tests (curl key endpoints)
7. Deploy-Prod - Depends on Deploy-Staging - Use variable group ds-platform-prod - Manual approval gate (environment: production, require 2 approvers) - Deploy same image to production App Service - Run health check + smoke tests - Tag the git commit with the build IDThe pipeline YAML
The AI produces a comprehensive azure-pipelines.yml. Here are the key sections:
Trigger and variables
trigger: branches: include: - dev paths: exclude: - '*.md' - 'docs/*' - '.vscode/*'
pr: branches: include: - dev - main paths: exclude: - '*.md' - 'docs/*'
variables: nodeVersion: '22' npmVersion: '10' dockerfilePath: 'Dockerfile' imageRepository: 'ds-platform'Lint stage
stages: - stage: Lint displayName: 'Lint' jobs: - job: ESLint pool: vmImage: 'ubuntu-latest' steps: - task: UseNode@1 inputs: version: $(nodeVersion) - script: npm ci displayName: 'Install dependencies' - script: npx eslint src/ --max-warnings 0 --format junit --output-file eslint-results.xml displayName: 'Run ESLint' - task: PublishTestResults@2 condition: always() inputs: testResultsFormat: 'JUnit' testResultsFiles: 'eslint-results.xml' testRunTitle: 'ESLint Results'Test stage (parallel unit + E2E)
- stage: Test displayName: 'Test' dependsOn: Lint jobs: - job: UnitTests displayName: 'Vitest Unit Tests' pool: vmImage: 'ubuntu-latest' steps: - task: UseNode@1 inputs: version: $(nodeVersion) - script: npm ci displayName: 'Install dependencies' - script: npx vitest run --coverage --reporter=junit --outputFile=test-results.xml displayName: 'Run Vitest' - task: PublishTestResults@2 condition: always() inputs: testResultsFormat: 'JUnit' testResultsFiles: 'test-results.xml' testRunTitle: 'Vitest Unit Tests' - task: PublishCodeCoverageResults@2 inputs: summaryFileLocation: 'coverage/cobertura-coverage.xml'
- job: E2ETests displayName: 'Playwright E2E Tests' pool: vmImage: 'ubuntu-latest' steps: - task: UseNode@1 inputs: version: $(nodeVersion) - script: npm ci displayName: 'Install dependencies' - script: npx playwright install --with-deps displayName: 'Install Playwright browsers' - script: npx playwright test --reporter=junit displayName: 'Run Playwright' env: PLAYWRIGHT_JUNIT_OUTPUT_NAME: playwright-results.xml - task: PublishTestResults@2 condition: always() inputs: testResultsFormat: 'JUnit' testResultsFiles: 'playwright-results.xml' testRunTitle: 'Playwright E2E Tests'Unit tests and E2E tests run in parallel because they are independent. A 3-minute Vitest run and a 6-minute Playwright run complete in 6 minutes total, not 9. On a team that pushes 10 PRs a day, that is 30 minutes saved.
Security stage
- stage: Security displayName: 'Security Scan' dependsOn: Test jobs: - job: DependencyAudit displayName: 'Dependency Vulnerability Check' pool: vmImage: 'ubuntu-latest' steps: - task: UseNode@1 inputs: version: $(nodeVersion) - script: npm ci displayName: 'Install dependencies' - script: npm audit --audit-level=high displayName: 'npm audit (fail on high/critical)' - task: MicrosoftSecurityDevOps@1 displayName: 'Microsoft Security DevOps (SAST)' inputs: categories: 'code' - task: PublishBuildArtifacts@1 inputs: pathToPublish: '$(System.DefaultWorkingDirectory)/.gdn' artifactName: 'SecurityScanResults' condition: always()npm audit catches known vulnerabilities at build time. It is a gate — the pipeline stops if a high-severity vulnerability exists in your dependency tree. This is separate from Dependabot (GitHub) or similar tools that create PRs for dependency updates. Both serve a purpose: audit blocks bad builds now, Dependabot fixes the root cause asynchronously.
Build stage
- stage: Build displayName: 'Build & Package' dependsOn: Security jobs: - job: BuildAndPush displayName: 'Build Docker Image' pool: vmImage: 'ubuntu-latest' steps: - task: UseNode@1 inputs: version: $(nodeVersion) - script: npm ci && npm run build displayName: 'Build Next.js' - script: | cp -r .next/static .next/standalone/.next/static cp -r public .next/standalone/public cd .next/standalone zip -r ../../deploy.zip . -x "*.map" displayName: 'Package standalone + static files' - task: Docker@2 displayName: 'Build & Push Docker image' inputs: containerRegistry: 'AzureGovACR' repository: $(imageRepository) command: 'buildAndPush' Dockerfile: $(dockerfilePath) tags: | $(Build.BuildId) latest - task: PublishBuildArtifacts@1 inputs: pathToPublish: 'deploy.zip' artifactName: 'standalone-package'The container registry endpoint in Azure Gov is $(ACR_NAME).azurecr.us, not .azurecr.io. If the service connection in Azure DevOps points to the wrong registry URL, the Docker push fails with an authentication error that looks like a credential problem but is actually an endpoint problem.
Deploy stages
- stage: DeployDev displayName: 'Deploy to Dev' dependsOn: Build condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/dev'), ne(variables['Build.Reason'], 'PullRequest')) variables: - group: ds-platform-dev jobs: - deployment: DeployDev displayName: 'Deploy to Dev App Service' environment: 'dev' strategy: runOnce: deploy: steps: - task: AzureCLI@2 displayName: 'Deploy container to App Service' inputs: azureSubscription: '$(AZURE_SUBSCRIPTION)' scriptType: 'bash' scriptLocation: 'inlineScript' inlineScript: | az cloud set --name AzureUSGovernment az webapp config container set \ --resource-group $(RESOURCE_GROUP) \ --name $(APP_NAME) \ --container-image-name $(ACR_NAME).azurecr.us/$(imageRepository):$(Build.BuildId) \ --container-registry-url https://$(ACR_NAME).azurecr.us - script: | for i in $(seq 1 12); do STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://$(APP_NAME).azurewebsites.us/api/health" || echo "000") if [ "$STATUS" = "200" ]; then echo "Health check passed (attempt $i)" exit 0 fi echo "Waiting... (attempt $i/12, status: $STATUS)" sleep 5 done echo "Health check failed after 60 seconds" exit 1 displayName: 'Health check'
- stage: DeployStaging displayName: 'Deploy to Staging' dependsOn: DeployDev variables: - group: ds-platform-staging jobs: - deployment: DeployStaging displayName: 'Deploy to Staging' environment: 'staging' # Requires manual approval strategy: runOnce: deploy: steps: # Same container deploy + health check as dev # Plus smoke tests against staging endpoints
- stage: DeployProd displayName: 'Deploy to Production' dependsOn: DeployStaging variables: - group: ds-platform-prod jobs: - deployment: DeployProd displayName: 'Deploy to Production' environment: 'production' # Requires 2 approvers strategy: runOnce: deploy: steps: # Same container deploy + health check # Tag the commit with the build IDThe staging and production stages use Azure DevOps environments with approval gates. You configure the approvers in Azure DevOps project settings (Pipelines > Environments > staging/production > Approvals and checks).
How the stages connect
PR to dev → Lint ──→ Test (Unit ∥ E2E) ──→ Security ──→ Build │ (PR stops here)
Push to dev → Lint ──→ Test (Unit ∥ E2E) ──→ Security ──→ Build ──→ Deploy Dev │ (auto, no gate) │ Deploy Staging │ (manual approval) │ Deploy Prod │ (2 approvers required)PRs run lint, test, security, and build — proving the code is safe to merge. Only pushes to dev (i.e., completed PR merges) trigger deployment. This means every deployment has passed the full pipeline at least twice: once on the PR, once on the merge commit.
🔍Why deploy on push to dev, not on PR complete?
Azure DevOps triggers can fire on PR completion, but there is a subtle issue: the PR trigger runs against the source branch, while the push trigger runs against the merge result. If two PRs merge in quick succession, the push trigger catches merge conflicts that the individual PR triggers missed. Triggering on push to dev ensures the pipeline runs against exactly the code that will be deployed.
See it in action
Pipeline variable groups
Each environment needs a variable group in Azure DevOps with these values:
| Variable | Dev | Staging | Prod |
|---|---|---|---|
AZURE_SUBSCRIPTION | DS-Platform-Dev-SC | DS-Platform-Staging-SC | DS-Platform-Prod-SC |
RESOURCE_GROUP | DS-Platform-Dev-RG | DS-Platform-Staging-RG | DS-Platform-Prod-RG |
APP_NAME | ds-platform-dev | ds-platform-staging | ds-platform-prod |
ACR_NAME | dsplatformacr | dsplatformacr | dsplatformacr |
The ACR name is shared across environments — you push one image and deploy it to all three. This ensures dev, staging, and production run the exact same container image, which eliminates the “works on dev but not prod” category of bugs.
Use variable groups for values that differ per environment (resource names, subscription IDs). Use pipeline-level variables for values that are the same everywhere (Node version, image repository name). This separation makes it clear which values change per environment and which are constant.
What this pipeline prevents
Every stage in this pipeline prevents a specific class of failure:
| Stage | Prevents |
|---|---|
| Lint | Inconsistent code style, unused imports, type-unsafe patterns |
| Unit tests | Broken business logic, regression in helpers and utilities |
| E2E tests | Broken user flows, RBAC enforcement gaps, mutation feedback missing |
| Security scan | Known vulnerabilities deployed to production, CJIS audit findings |
| Docker build | Missing dependencies in production, wrong Node version, root user |
| Health check | Deploying a container that starts but cannot serve requests |
| Approval gate | Untested changes reaching production, unauthorized deployments |
Without the pipeline, each of these is a manual check that someone forgets on a Friday afternoon deploy. With it, the pipeline forgets nothing.
Tying it all together
This capstone connects every lesson in Module 14:
- Lesson 1 (Platform Map): The CLAUDE.md you created constrains the AI when generating any file in this pipeline.
- Lesson 2 (Government Auth): The MSAL configuration and JWT strategy are what the E2E tests verify.
- Lesson 4 (RBAC): The
withPermission()enforcement is covered by Playwright tests in the Test stage. - Lesson 8 (Azure SQL): The Managed Identity connection is tested by Vitest integration tests.
- Lessons 12-13 (Testing): The Vitest and Playwright configurations you built are the exact tests that run in the pipeline.
- Lesson 14 (Accessibility): Your accessibility tests run as part of the Playwright suite.
- Lesson 16 (Telemetry): The App Insights SDK is included in
serverExternalPackagesso the Docker build does not fail. - Lesson 17 (ADO Workflow): The branching strategy determines which pushes trigger which stages.
- Lesson 18 (Deployment): The standalone build and static file copy are the Build stage’s core steps.
azure-pipelines.yml is one file. But it represents every decision your team has made about code quality, security, testing, and deployment. When someone new joins the team, this file tells them exactly how code gets from a developer’s branch to production. That documentation-through-automation is worth more than any wiki page.
Your azure-pipelines.yml builds a Docker image with the tag ds-platform:$(Build.BuildId) and pushes it to Azure Container Registry. The same image is deployed to dev, staging, and production. Why use one image across all environments instead of building separate images per environment?
Key takeaways
- The pipeline has five stages: Lint, Test, Security, Build, Deploy. Each gates the next. A failure at any stage stops the pipeline before the problem reaches production.
- The Dockerfile uses multi-stage builds with Node 22 Alpine. The final image runs as non-root user
nextjs:1001on port 8080. The static file copy happens inside the Dockerfile, not as a manual step. - Azure Gov container registry uses
.azurecr.us, not.azurecr.io. This affects the Docker push command, the App Service container configuration, and the service connection in Azure DevOps. - One image, all environments. Build the Docker image once, push it to ACR, deploy the same image to dev, staging, and production. Environment-specific behavior comes from runtime environment variables, not build-time configuration.
- Approval gates on staging and production prevent unauthorized deployments. Staging requires 1 approver. Production requires 2. This is configured in Azure DevOps Environments, not in the YAML.
- SAST and dependency scanning run on every PR.
npm audit --audit-level=highfails the pipeline on known vulnerabilities. Microsoft Security DevOps provides static analysis. These are not optional add-ons — they are compliance requirements for state platforms handling CJIS or HIPAA data. - This pipeline is the module capstone because it connects every lesson: auth, RBAC, testing, telemetry, branching, and deployment. The pipeline is the proof that all of those pieces work together.
What’s next
You have completed Module 14. You now have a complete workflow for AI-accelerated government development: a platform map that constrains the AI, an auth and RBAC layer, a module development pattern, a testing strategy, and a deployment pipeline.
The AI CLI tool is not replacing your judgment about security, compliance, or architecture. It is eliminating the toil — the boilerplate, the copy-paste, the things you know how to do but take 30 minutes of typing. The judgment stays with you. The typing does not have to.