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
🏋️ 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
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.
🧠 Recall Check
- What action handles deploying to Azure App Service?
- Why do we use
npm ci --omit=devfor deployment instead ofnpm ci? - What's the purpose of the 30-second sleep before the smoke test?
- Why is
cancel-in-progress: falseimportant for deployment workflows?
Reveal answers
azure/webapps-deploy@v3— it packages your app and pushes it to App Service.--omit=devskips devDependencies (jest, eslint, etc.) since they're not needed at runtime. This makes the deployment package smaller and faster.- 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.
- 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.