Your first real deployment. In this lesson you'll create an Azure App Service, wire up a CD pipeline, and see your code go live — with staging → production promotion and a smoke test gate.

What We're Building

CI lint + test (auto) Deploy Staging App Service slot (auto) Smoke Test curl /health (auto) 👤 Deploy Production Slot swap (approval) Push to main → CI → Staging (auto) → Smoke test → Approval → Production Total: ~3 minutes from push to live (excluding approval wait)

🏋️ Step 1: Create the App

Create a simple Express API to deploy:

# In your cicd-mastery repo
npm install express

cat > src/server.js << 'EOF'
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({
    service: 'cicd-mastery-api',
    version: process.env.npm_package_version || '1.0.0',
    environment: process.env.NODE_ENV || 'development',
    deployed: new Date().toISOString()
  });
});

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy' });
});

app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});
EOF

Add "start": "node src/server.js" to your package.json scripts. Test locally:

npm start
# Visit http://localhost:3000 and http://localhost:3000/health

🏋️ Step 2: Create Azure Resources

# Variables (customize APP_NAME — must be globally unique)
RESOURCE_GROUP="rg-cicd-mastery"
APP_NAME="app-cicd-mastery-$(openssl rand -hex 3)"
PLAN_NAME="plan-cicd-mastery"

# Create App Service Plan (Free tier for learning)
az appservice plan create \
  --name $PLAN_NAME \
  --resource-group $RESOURCE_GROUP \
  --sku F1 \
  --is-linux

# Create the Web App
az webapp create \
  --name $APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --plan $PLAN_NAME \
  --runtime "NODE:20-lts"

# Create a staging deployment slot (requires Standard+ tier)
# For Free tier, we'll simulate with a separate app:
az webapp create \
  --name "${APP_NAME}-staging" \
  --resource-group $RESOURCE_GROUP \
  --plan $PLAN_NAME \
  --runtime "NODE:20-lts"

echo "Production: https://${APP_NAME}.azurewebsites.net"
echo "Staging: https://${APP_NAME}-staging.azurewebsites.net"

# Add APP_NAME as a GitHub variable
# Settings → Variables → AZURE_APP_NAME = your app name

🏋️ Step 3: The CD Workflow

# .github/workflows/cd.yml
name: CD

on:
  push:
    branches: [main]
    paths-ignore: ['**.md']
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

concurrency:
  group: cd-${{ github.ref }}
  cancel-in-progress: false  # Don't cancel deployments!

jobs:
  # ─── CI Gate ───
  ci:
    name: 🧪 CI Gate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm test

  # ─── Deploy to Staging ───
  deploy-staging:
    name: 🚀 Staging
    needs: ci
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://${{ vars.AZURE_APP_NAME }}-staging.azurewebsites.net
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }

      - run: npm ci --omit=dev  # Production deps only

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to Staging
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ vars.AZURE_APP_NAME }}-staging
          package: .

  # ─── Smoke Test ───
  smoke-test:
    name: 🔍 Smoke Test
    needs: deploy-staging
    runs-on: ubuntu-latest
    steps:
      - name: Wait for deployment to stabilize
        run: sleep 30

      - name: Health check
        run: |
          URL="https://${{ vars.AZURE_APP_NAME }}-staging.azurewebsites.net"
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL/health")
          if [ "$STATUS" != "200" ]; then
              echo "::error::Smoke test FAILED! Got HTTP $STATUS"
              exit 1
          fi
          echo "✅ Staging healthy (HTTP $STATUS)"
          
          # Verify response content
          BODY=$(curl -s "$URL/")
          echo "$BODY" | jq .
          echo "## 🔍 Smoke Test Passed" >> $GITHUB_STEP_SUMMARY
          echo '```json' >> $GITHUB_STEP_SUMMARY
          echo "$BODY" | jq . >> $GITHUB_STEP_SUMMARY
          echo '```' >> $GITHUB_STEP_SUMMARY

  # ─── Deploy to Production (requires approval) ───
  deploy-production:
    name: 🚀 Production
    needs: smoke-test
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://${{ vars.AZURE_APP_NAME }}.azurewebsites.net
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci --omit=dev

      - name: Azure Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to Production
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ vars.AZURE_APP_NAME }}
          package: .

      - name: Verify production
        run: |
          sleep 30
          curl -f "https://${{ vars.AZURE_APP_NAME }}.azurewebsites.net/health"
          echo "## 🎉 Production Deployed!" >> $GITHUB_STEP_SUMMARY

What's Happening at Each Stage

CI Gate • npm test • Catch bugs HERE • Fail fast, no deploy Deploy Staging • OIDC login to Azure • Push code to App Service • Real deployment Smoke Test • HTTP health check • Verify app responds • Block if unhealthy Production • Approval required • Same deploy process • Verify again 🏗️ Pattern: Gate → Deploy → Verify → Promote This pattern appears in EVERY production pipeline you'll ever build. The tools change; the shape doesn't.

Why Smoke Tests Matter

Deploy without verification:

App crashes on startup due to missing env var. Nobody notices for 20 minutes. Users see 500 errors. You deploy to production anyway because "staging succeeded" (it didn't — you just didn't check).

Deploy with smoke test:

App crashes on startup. Smoke test gets HTTP 502. Pipeline STOPS. Production never touched. You get a clear error: "Smoke test FAILED! Got HTTP 502." Fix and re-push.

A deployment without verification is not a deployment — it's a hope. Always verify after deploy, even if it's just a health check. In later lessons we'll add integration tests and progressive traffic shifting as verification.

🧠 Recall Check

  1. What action handles deploying to Azure App Service?
  2. Why do we use npm ci --omit=dev for deployment instead of npm ci?
  3. What's the purpose of the 30-second sleep before the smoke test?
  4. Why is cancel-in-progress: false important for deployment workflows?
Reveal answers
  1. azure/webapps-deploy@v3 — it packages your app and pushes it to App Service.
  2. --omit=dev skips devDependencies (jest, eslint, etc.) since they're not needed at runtime. This makes the deployment package smaller and faster.
  3. App Service takes time to restart and stabilize after a new deployment. The sleep ensures the new version is actually serving before we test it.
  4. You never want to cancel a deployment mid-way — that could leave your app in a broken half-deployed state. Queue deployments, don't cancel them.

Next lesson: Container Pipelines — building Docker images and pushing to Azure Container Registry. This is the path toward Kubernetes deployment.