Every manual deployment step is a mistake waiting to happen at 2 AM on a Friday. Every CI check that doesn’t run is a bug that reaches production. This post builds the automation layer that ensures TechShop’s code reaches production reliably, repeatably, and without surprises.
Part 10 of the Angular Ecommerce Playbook. We build the GitHub Actions CI/CD pipeline for Angular 21 + .NET 10, including the API codegen drift check, Docker containerization, and Azure Container Apps deployment.
📊 Download: CI/CD Pipeline Diagram (draw.io)
Pipeline Overview
On Pull Request:
┌─────────────────────────────────────────────────────┐
│ 1. Angular: ESLint + TypeScript check │
│ 2. Angular: Vitest unit tests (affected libraries) │
│ 3. .NET: build + xUnit tests │
│ 4. API codegen drift check │
│ 5. Playwright E2E (smoke tests only on PR) │
└─────────────────────────────────────────────────────┘
On Merge to main:
┌─────────────────────────────────────────────────────┐
│ 1. All PR checks pass │
│ 2. Angular SSR Docker build │
│ 3. .NET Docker build │
│ 4. Push to Azure Container Registry │
│ 5. Deploy to Azure Container Apps (staging) │
│ 6. Playwright E2E (full suite on staging) │
│ 7. Manual approval gate │
│ 8. Deploy to production │
└─────────────────────────────────────────────────────┘
The PR Workflow
# .github/workflows/pr.yml
name: Pull Request Checks
on:
pull_request:
branches: [main, develop]
env:
NODE_VERSION: '22'
DOTNET_VERSION: '10.0.x'
jobs:
angular-checks:
name: 'Angular: Lint + Test'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for Nx affected calculation
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v4
with: { version: 9 }
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set Nx SHAs for affected calculation
uses: nrwl/nx-set-shas@v4
- name: TypeScript type check (affected)
run: pnpm nx affected --target=type-check --base=$NX_BASE --head=$NX_HEAD
- name: ESLint (affected)
run: pnpm nx affected --target=lint --base=$NX_BASE --head=$NX_HEAD
- name: Vitest unit tests (affected, with coverage)
run: |
pnpm nx affected --target=test \
--base=$NX_BASE --head=$NX_HEAD \
--parallel=4 \
--coverage \
--coverageThreshold='{"statements":80,"branches":80}'
dotnet-checks:
name: '.NET: Build + Test'
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: techshop_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres_test
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 10s
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore packages
run: dotnet restore
working-directory: ./api
- name: Build
run: dotnet build --no-restore --configuration Release
working-directory: ./api
- name: Unit Tests
run: dotnet test --no-build --configuration Release --filter "Category=Unit"
working-directory: ./api
- name: Integration Tests
run: |
dotnet test --no-build --configuration Release \
--filter "Category=Integration" \
--collect:"XPlat Code Coverage"
working-directory: ./api
env:
ConnectionStrings__DefaultConnection: "Host=localhost;Database=techshop_test;Username=postgres;Password=postgres_test"
api-codegen-check:
name: 'API Codegen: Check for Drift'
runs-on: ubuntu-latest
needs: dotnet-checks
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with: { dotnet-version: '${{ env.DOTNET_VERSION }}' }
- name: Setup Node.js + Kiota
uses: actions/setup-node@v4
with: { node-version: '${{ env.NODE_VERSION }}' }
- run: pnpm add -g @microsoft/kiota@latest
- name: Start .NET API (background)
run: |
dotnet run --project api/src/TechShop.Api \
--launch-profile Development &
npx wait-on http://localhost:5000/openapi/v1.json --timeout 60000
- name: Regenerate Kiota client
run: |
kiota generate \
--openapi http://localhost:5000/openapi/v1.json \
--language typescript \
--class-name TechShopApiClient \
--output libs/shared/api-client/src/lib/generated \
--clean-output
- name: Check for changes (fail if drift detected)
run: |
if ! git diff --exit-code libs/shared/api-client/src/lib/generated; then
echo "❌ API client is out of date. Run 'kiota generate...' and commit the result."
exit 1
fi
echo "✅ API client is up to date."
The Merge-to-Main Deployment Workflow
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
env:
REGISTRY: techshopacr.azurecr.io
ACR_NAME: techshopacr
RESOURCE_GROUP: techshop-prod
jobs:
build-and-push:
name: 'Docker: Build & Push'
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Azure login
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Login to ACR
run: az acr login --name ${{ env.ACR_NAME }}
- name: Generate Docker image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/techshop-angular
tags: |
type=sha,prefix=sha-
type=ref,event=branch
# Build Angular SSR app
- name: Build Angular SSR Docker image
run: |
docker build \
-f apps/shell/Dockerfile \
-t ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }} \
.
docker push ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }}
# Build .NET API
- name: Build .NET API Docker image
run: |
docker build \
-f api/src/TechShop.Api/Dockerfile \
-t ${{ env.REGISTRY }}/techshop-api:${{ github.sha }} \
./api
docker push ${{ env.REGISTRY }}/techshop-api:${{ github.sha }}
deploy-staging:
name: 'Deploy: Staging'
runs-on: ubuntu-latest
needs: build-and-push
environment: staging
steps:
- uses: azure/login@v2
with: { creds: '${{ secrets.AZURE_CREDENTIALS }}' }
- name: Deploy Angular to Azure Container Apps (staging)
run: |
az containerapp update \
--name techshop-angular-staging \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }}
- name: Deploy .NET API to Azure Container Apps (staging)
run: |
az containerapp update \
--name techshop-api-staging \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/techshop-api:${{ github.sha }}
e2e-staging:
name: 'E2E: Full Suite on Staging'
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install chromium
- name: Run Playwright E2E against staging
run: pnpm playwright test
env:
E2E_BASE_URL: https://staging.techshop.com
E2E_TEST_USER: ${{ secrets.E2E_TEST_USER }}
E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14
deploy-production:
name: 'Deploy: Production (Manual Approval)'
runs-on: ubuntu-latest
needs: e2e-staging
environment: production # Requires manual reviewer approval in GitHub
steps:
- uses: azure/login@v2
with: { creds: '${{ secrets.AZURE_CREDENTIALS }}' }
- name: Deploy Angular to Production
run: |
az containerapp update \
--name techshop-angular-prod \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/techshop-angular:${{ github.sha }}
- name: Deploy .NET API to Production
run: |
az containerapp update \
--name techshop-api-prod \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.REGISTRY }}/techshop-api:${{ github.sha }}
Dockerfiles
Angular SSR Dockerfile
# apps/shell/Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm nx build shell --configuration=production
# Production image — minimal Node runtime
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Only copy the built output
COPY --from=builder /app/dist/apps/shell /app/dist
EXPOSE 4000
CMD ["node", "dist/server/server.mjs"]
.NET 10 API Dockerfile
# api/src/TechShop.Api/Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/TechShop.Api/TechShop.Api.csproj", "src/TechShop.Api/"]
COPY ["src/TechShop.Application/TechShop.Application.csproj", "src/TechShop.Application/"]
COPY ["src/TechShop.Domain/TechShop.Domain.csproj", "src/TechShop.Domain/"]
COPY ["src/TechShop.Infrastructure/TechShop.Infrastructure.csproj", "src/TechShop.Infrastructure/"]
RUN dotnet restore "src/TechShop.Api/TechShop.Api.csproj"
COPY . .
RUN dotnet build "src/TechShop.Api/TechShop.Api.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "src/TechShop.Api/TechShop.Api.csproj" \
-c Release -o /app/publish \
--no-restore \
/p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "TechShop.Api.dll"]
Dependency Automation
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule: { interval: weekly, day: monday }
groups:
angular:
patterns: ['@angular/*', '@angular-eslint/*']
nx:
patterns: ['@nx/*', 'nx']
dev-tooling:
patterns: ['vitest', 'playwright', 'typescript', 'eslint']
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major'] # Manual for majors
- package-ecosystem: nuget
directory: /api
schedule: { interval: weekly, day: monday }
- package-ecosystem: docker
directory: /
schedule: { interval: monthly }
directories:
- /apps/shell
- /api/src/TechShop.Api
References
- GitHub Actions — Documentation
- Azure Container Apps — Deploy
- Azure Container Registry
- Nx Affected Commands
- Nrwl/nx-set-shas Action
- Docker multi-stage builds
- .NET 10 Docker Images — Microsoft
- Playwright CI Setup
- GitHub Dependabot
- Draw.io Diagram: CI/CD Pipeline
This is Part 10 of 11 in the Angular Ecommerce Playbook. ← Part 9: Testing Strategy | Part 11: Tech Lead Playbook →