GitHub Actions · Lesson 4 of 5
Deployment Pipelines to Azure with GitHub Actions
Complete CD Workflow
# .github/workflows/cd.yml
# Triggers on merge to main — build, test, deploy to staging, then await approval for production
name: CD
on:
push:
branches: [main]
permissions:
id-token: write # OIDC
contents: read
env:
DOTNET_VERSION: '8.x'
APP_NAME: 'clinical-platform'
RESOURCE_GROUP: 'clinical-rg'
jobs:
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Build and publish
run: |
dotnet publish src/Host/SystemForge.Api/SystemForge.Api.csproj \
--configuration Release \
--output ./publish
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app-publish
path: ./publishStaging Deployment
deploy-staging:
runs-on: ubuntu-latest
needs: build
environment: staging
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: app-publish
path: ./publish
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to staging slot
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.APP_NAME }}
slot-name: staging
package: ./publish
- name: Run smoke tests against staging
run: |
# Wait for App Service to become healthy
for i in $(seq 1 12); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/live")
if [ "$STATUS" = "200" ]; then
echo "Staging is healthy"
break
fi
echo "Waiting... HTTP $STATUS"
sleep 10
done
# Run a functional smoke test
curl -f \
"https://${{ env.APP_NAME }}-staging.azurewebsites.net/api/health" \
-H "Accept: application/json"Production Deployment with Approval Gate
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment: production # requires reviewer approval in GitHub Environments
steps:
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Swap staging → production
uses: azure/cli@v2
with:
inlineScript: |
az webapp deployment slot swap \
--resource-group ${{ env.RESOURCE_GROUP }} \
--name ${{ env.APP_NAME }} \
--slot staging \
--target-slot production
- name: Verify production health
run: |
for i in $(seq 1 10); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"https://${{ env.APP_NAME }}.azurewebsites.net/health/live")
if [ "$STATUS" = "200" ]; then
echo "Production is healthy — deployment successful"
exit 0
fi
echo "Attempt $i: HTTP $STATUS"
sleep 15
done
echo "Production health check failed after deployment"
exit 1Rollback on Failure
rollback-on-failure:
runs-on: ubuntu-latest
needs: deploy-production
if: failure() # only runs if deploy-production fails
steps:
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Swap back (rollback)
uses: azure/cli@v2
with:
inlineScript: |
# Swap production back to staging (reverses the swap)
az webapp deployment slot swap \
--resource-group ${{ env.RESOURCE_GROUP }} \
--name ${{ env.APP_NAME }} \
--slot production \
--target-slot staging
echo "Rollback complete — previous version restored to production"
- name: Notify team of rollback
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Production deployment ROLLED BACK for ${{ github.repository }}. Commit: ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Database Migration as a Deployment Step
# Run EF Core migrations BEFORE swapping to production
# Migrations must be backward-compatible with the current production code
run-migrations:
runs-on: ubuntu-latest
needs: build
environment: production # uses production connection string
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install EF Core tools
run: dotnet tool install --global dotnet-ef
- name: Run migrations
run: |
dotnet ef database update \
--project src/Modules/Prescriptions/Prescriptions.Infrastructure \
--startup-project src/Host/SystemForge.Api \
--context PrescriptionsDbContext
env:
ConnectionStrings__Clinical: ${{ secrets.PRODUCTION_CONNECTION_STRING }}
# Run migrations before the slot swap — new schema is in place before new code goes live
# If migration fails, abort before the swap (production code still runs on old schema)Deployment Notifications
- name: Notify Slack on success
if: success()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Deployed to production: ${{ github.repository }}@${{ github.sha }}",
"attachments": [{
"color": "good",
"text": "Commit: ${{ github.event.head_commit.message }}\nAuthor: ${{ github.actor }}"
}]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Production issue I've seen: A team's CD pipeline deployed new code to production before running database migrations. For 3 minutes, the new application code was talking to the old schema. A query that referenced a new column (
warfarin_enhanced_check_required) failed for every prescription that was accessed during those 3 minutes — nurses received 500 errors. The migration then ran and everything worked. The fix: always run migrations first, then deploy the application code. For zero-downtime migrations: make the migration backward-compatible so the old code still works with the new schema (add nullable columns, don't remove or rename).
Key Takeaway
Structure the CD pipeline as: build → deploy staging → smoke test → await approval → swap to production → verify health → rollback on failure. Use OIDC for Azure authentication — no stored credentials. Protect the production environment in GitHub with required reviewers. Run database migrations before the slot swap, not after. Always include a rollback step triggered on failure. Notify the team on both success and failure via Slack or Teams integration.