Building Your First CI/CD Pipeline with GitHub Actions and Azure
A practical walkthrough of automated deployments that you can actually use in production.
CI/CD sounds intimidating until you build your first pipeline. Then it just makes sense.
I'm going to walk you through setting up automated deployments from GitHub to Azure — the same pattern I use for real projects.
What We're Building
A workflow that:
- Triggers when you push to the main branch
- Builds and tests your application
- Deploys to Azure App Service
- Notifies you of success or failure
No magic. No complex abstractions. Just working automation.
Build a GitHub Actions pipeline that triggers on push to main, builds and tests your app, and deploys to Azure App Service using federated credentials. Add environment protection for approval gates and notifications for success/failure. Start simple — get the basic flow working before adding staging environments and rollbacks.
Step 1: Connect GitHub to Azure
The secure way to connect GitHub Actions to Azure is with a service principal and federated credentials. No secrets to rotate.
# Create a service principal
az ad sp create-for-rbac --name "github-actions-sp" \
--role contributor \
--scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group} \
--sdk-authSave that JSON output — you'll need it.
In your GitHub repo, go to Settings → Secrets and variables → Actions and add:
AZURE_CREDENTIALS: The JSON output from aboveAZURE_SUBSCRIPTION_ID: Your subscription ID
Step 2: The Basic Workflow
Create .github/workflows/deploy.yml:
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
AZURE_WEBAPP_NAME: your-app-name
AZURE_WEBAPP_PACKAGE_PATH: '.'
NODE_VERSION: '20.x'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build application
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: app-build
path: ./dist
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: app-build
path: ./dist
- name: Login to Azure
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy to App Service
uses: azure/webapps-deploy@v2
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
package: ./distUnderstanding the Workflow
Let me break down what's happening:
Triggers
on:
push:
branches: [main]
pull_request:
branches: [main]This runs on every push to main AND on PRs targeting main. PRs will run tests but won't deploy (notice the if condition on the deploy job).
Build Job
The build job runs on every trigger:
- Checks out your code
- Sets up Node.js with caching (faster subsequent runs)
- Installs dependencies with
npm ci(clean install, more reliable thannpm install) - Runs tests
- Builds the application
- Uploads the build as an artifact
Deploy Job
The deploy job only runs when:
- The build job succeeds (
needs: build) - We're on the main branch (
if: github.ref == 'refs/heads/main')
This means PRs get tested but not deployed. Only merged code goes to production.
Step 3: Add Environment Protection
For production deployments, you probably want approval gates.
In GitHub: Settings → Environments → New environment
Create "production" and add:
- Required reviewers
- Wait timer (optional)
- Deployment branches (only main)
Update your workflow:
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment: production # Add this lineNow deployments will wait for approval.
Step 4: Add Notifications
Nobody wants to babysit pipelines. Add Slack or Teams notifications:
- name: Notify on success
if: success()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-type: application/json' \
-d '{"text":"✅ Deployment to production succeeded!"}'
- name: Notify on failure
if: failure()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-type: application/json' \
-d '{"text":"❌ Deployment to production failed!"}'Common Gotchas
1. Secrets Not Available in PRs from Forks
For security, GitHub doesn't expose secrets to workflows triggered by PRs from forks. Your tests should work without Azure credentials.
2. Caching Saves Time
The cache: 'npm' in the setup-node action caches node_modules. First run might take 2 minutes. Subsequent runs take 30 seconds.
3. Artifact Retention
By default, artifacts are kept for 90 days. For frequent deployments, you might want to reduce this:
- uses: actions/upload-artifact@v4
with:
name: app-build
path: ./dist
retention-days: 7Azure CLI Cheatsheet
Quick reference for the Azure CLI commands used in CI/CD pipelines.
The Bigger Picture
This workflow is a starting point. Real-world pipelines often add:
- Multiple environments (dev → staging → production)
- Database migrations
- Smoke tests after deployment
- Rollback capabilities
- Infrastructure as Code deployment
But don't overcomplicate it initially. Get the basic flow working, then iterate.
Why This Matters
Manual deployments are:
- Slow
- Error-prone
- Not auditable
- Dependent on specific people being available
Automated deployments are:
- Fast (minutes, not hours)
- Consistent (same process every time)
- Auditable (full history in GitHub)
- Available (push code, it deploys)
Once you experience automated deployments, you'll never want to go back to manual.
Start simple. Build from there.
Read Next
Azure Static Web Apps Is the Most Underrated Service for Developers
Why Azure Static Web Apps should be your default for frontend deployments — free tier, zero-config CI/CD, built-in auth, and API routes included.
Azure Deployment Stacks Change Everything About Resource Lifecycle
How Deployment Stacks solve the resource orphan problem that Bicep and ARM alone can't — with DenySettings, managed cleanup, and lifecycle awareness.