Applied Module 12 · AI-Accelerated Government Development

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.

💬This pipeline is real infrastructure

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 Government

What the AI generates

# Stage 1: Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 2: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: Runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy the standalone output
COPY --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 assets
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 8080
CMD ["node", "server.js"]
UID 1001 is deliberate

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.

Port 8080, not 3000

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 ID

The 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'
💡Parallel jobs save time

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 vs. Dependabot

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'
.azurecr.us for Azure Government

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 ID

The 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

Claude Code — AI-assisted
/home/user $ Generate the complete azure-pipelines.yml for our DS platform. Include lint, test, security scan, Docker build, and deploy stages for dev, staging, and production on Azure Government.
I'll generate the full pipeline configuration. Created: - azure-pipelines.yml (278 lines, 7 stages) - Dockerfile (multi-stage, Node 22 Alpine, non-root nextjs:1001) - .dockerignore Pipeline stages: 1. Lint — ESLint with --max-warnings 0, JUnit output 2. Test — Vitest (unit) and Playwright (E2E) in parallel 3. Security — npm audit + Microsoft Security DevOps SAST 4. Build — Next.js standalone + Docker image → ACR (.azurecr.us) 5. Deploy-Dev — auto on dev push, health check 6. Deploy-Staging — manual approval, smoke tests 7. Deploy-Prod — 2 approvers, smoke tests, git tag Key gov-specific settings: - ACR endpoint: .azurecr.us (not .azurecr.io) - App Service URL: .azurewebsites.us - az cloud set --name AzureUSGovernment in every deploy step - Container port: 8080 You'll need to create three variable groups in Azure DevOps: - ds-platform-dev - ds-platform-staging - ds-platform-prod Each with: AZURE_SUBSCRIPTION, RESOURCE_GROUP, APP_NAME, ACR_NAME
/home/user $ The Docker build stage is failing with 'COPY failed: file not found in build context: .next/standalone'. The npm run build succeeds but standalone output isn't being created.
The Docker build runs inside the Docker context, which starts from the repo root — it does not use the Node build from the previous step. The issue: the Dockerfile's builder stage runs npm run build inside the container, but it needs output: 'standalone' in next.config.ts. Let me check... Found it: next.config.ts has output: 'standalone' but the Dockerfile's builder stage is running npm ci (installing all deps) and then npm run build. The problem is that the builder stage's COPY . . is not copying node_modules from the deps stage. Fixed the Dockerfile: ```dockerfile FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build ``` The deps stage installs dependencies, the builder stage copies them in, then builds. The standalone output appears at /app/.next/standalone/ inside the container.
/home/user $ The security stage is failing on npm audit. There's a high-severity vulnerability in a transitive dependency we can't update without breaking MUI 7.
This is a common issue — transitive dependencies with known vulnerabilities where the fix requires a major version bump of a direct dependency. Your options: 1. **npm audit fix --force** — risky, may break MUI 7 compatibility 2. **Override the specific package** in package.json: ```json "overrides": { "vulnerable-package": "^2.1.0" } ``` 3. **Allowlist the specific advisory** so npm audit passes: ```bash npm audit --audit-level=high --omit=dev 2>&1 | grep -v 'GHSA-xxxx-xxxx' ``` 4. **Document and accept the risk** — if the vulnerability is in a dev dependency or requires user interaction to exploit, file a security exception with your risk number. Recommendation: use overrides if the patched version is compatible, or file an exception if it's in a dev-only dependency. Do not use --force — it can break your lockfile in ways that are hard to diagnose. I'll update the pipeline to use --omit=dev so dev-only vulnerabilities don't block production deployments.
/home/user $

Pipeline variable groups

Each environment needs a variable group in Azure DevOps with these values:

VariableDevStagingProd
AZURE_SUBSCRIPTIONDS-Platform-Dev-SCDS-Platform-Staging-SCDS-Platform-Prod-SC
RESOURCE_GROUPDS-Platform-Dev-RGDS-Platform-Staging-RGDS-Platform-Prod-RG
APP_NAMEds-platform-devds-platform-stagingds-platform-prod
ACR_NAMEdsplatformacrdsplatformacrdsplatformacr

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.

💡Variable groups vs. pipeline variables

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:

StagePrevents
LintInconsistent code style, unused imports, type-unsafe patterns
Unit testsBroken business logic, regression in helpers and utilities
E2E testsBroken user flows, RBAC enforcement gaps, mutation feedback missing
Security scanKnown vulnerabilities deployed to production, CJIS audit findings
Docker buildMissing dependencies in production, wrong Node version, root user
Health checkDeploying a container that starts but cannot serve requests
Approval gateUntested 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 serverExternalPackages so 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.
💬One pipeline file, everything connected

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.


KNOWLEDGE CHECK

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:1001 on 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=high fails 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.