CI/CD Pipeline with GitHub Actions and Azure
Build an enterprise-grade CI/CD pipeline using GitHub Actions with secure deployment to Azure. This project implements security scanning, automated testing, and OIDC-based authentication for zero-secret deployments.
Project Overview
What We're Building
┌─────────────────────────────────────────────────────────────────────┐
│ CI/CD Pipeline Architecture │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Developer │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ GitHub │ │
│ │ Repository │ │
│ └──────┬───────┘ │
│ │ Push/PR │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ GitHub Actions │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Build │─▶│ Test │─▶│Security │─▶│ Deploy │ │ │
│ │ │ │ │ │ │ Scan │ │ │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └────┬────┘ │ │
│ └──────────────────────────────────────────────┼───────────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐│
│ │ ▼ ││
│ │ ┌─────────────────────┐ ││
│ │ │ Azure (OIDC) │ ││
│ │ │ No Secrets! │ ││
│ │ └──────────┬──────────┘ ││
│ │ │ ││
│ │ ┌──────────┼──────────┐ ││
│ │ ▼ ▼ ▼ ││
│ │ ┌─────┐ ┌─────┐ ┌─────────┐ ││
│ │ │ ACR │ │ AKS │ │App Svc │ ││
│ │ └─────┘ └─────┘ └─────────┘ ││
│ └────────────────────────────────────┘│
│ │
└─────────────────────────────────────────────────────────────────────┘Prerequisites
- GitHub repository with application code
- Azure subscription
- Azure CLI installed locally
- Docker installed locally
- Basic understanding of GitHub Actions
Part 1: Azure Infrastructure Setup
Step 1: Create Azure Resources
# Variables
RESOURCE_GROUP="rg-webapp-prod"
LOCATION="eastus"
ACR_NAME="acrwebappprod"
APP_SERVICE_PLAN="asp-webapp-prod"
WEB_APP_NAME="webapp-prod-$(openssl rand -hex 4)"
# Create resource group
az group create --name $RESOURCE_GROUP --location $LOCATION
# Create Azure Container Registry
az acr create \
--resource-group $RESOURCE_GROUP \
--name $ACR_NAME \
--sku Standard \
--admin-enabled false
# Create App Service Plan
az appservice plan create \
--resource-group $RESOURCE_GROUP \
--name $APP_SERVICE_PLAN \
--is-linux \
--sku P1v3
# Create Web App
az webapp create \
--resource-group $RESOURCE_GROUP \
--plan $APP_SERVICE_PLAN \
--name $WEB_APP_NAME \
--deployment-container-image-name mcr.microsoft.com/appsvc/staticsite:latest
# Configure Web App to use ACR
az webapp config container set \
--resource-group $RESOURCE_GROUP \
--name $WEB_APP_NAME \
--docker-custom-image-name "$ACR_NAME.azurecr.io/webapp:latest" \
--docker-registry-server-url "https://$ACR_NAME.azurecr.io"Step 2: Configure OIDC Authentication
Create Azure AD Application:
# Create app registration
APP_ID=$(az ad app create \
--display-name "GitHub-Actions-OIDC" \
--query appId -o tsv)
# Create service principal
az ad sp create --id $APP_ID
# Get subscription and tenant IDs
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
# Configure federated credential for GitHub
GITHUB_ORG="your-github-org"
GITHUB_REPO="your-repo-name"
az ad app federated-credential create \
--id $APP_ID \
--parameters "{
\"name\": \"github-main-branch\",
\"issuer\": \"https://token.actions.githubusercontent.com\",
\"subject\": \"repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/main\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
# Add credential for pull requests (optional)
az ad app federated-credential create \
--id $APP_ID \
--parameters "{
\"name\": \"github-pull-request\",
\"issuer\": \"https://token.actions.githubusercontent.com\",
\"subject\": \"repo:${GITHUB_ORG}/${GITHUB_REPO}:pull_request\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
# Grant permissions to resource group
az role assignment create \
--assignee $APP_ID \
--role Contributor \
--scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP"
# Grant ACR push permission
ACR_ID=$(az acr show --name $ACR_NAME --query id -o tsv)
az role assignment create \
--assignee $APP_ID \
--role AcrPush \
--scope $ACR_ID
# Output values for GitHub secrets
echo "AZURE_CLIENT_ID: $APP_ID"
echo "AZURE_TENANT_ID: $TENANT_ID"
echo "AZURE_SUBSCRIPTION_ID: $SUBSCRIPTION_ID"Step 3: Configure GitHub Secrets
Navigate to: Repository → Settings → Secrets and variables → Actions
Add these secrets:
| Secret Name | Value |
|---|---|
AZURE_CLIENT_ID | App registration Application ID |
AZURE_TENANT_ID | Azure AD Tenant ID |
AZURE_SUBSCRIPTION_ID | Azure Subscription ID |
ACR_NAME | Azure Container Registry name |
WEBAPP_NAME | Azure Web App name |
Part 2: GitHub Actions Workflow
Step 4: Create Main CI/CD Workflow
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
ACR_NAME: ${{ secrets.ACR_NAME }}
WEBAPP_NAME: ${{ secrets.WEBAPP_NAME }}
IMAGE_NAME: webapp
permissions:
id-token: write
contents: read
security-events: write
pull-requests: write
jobs:
# ===================
# BUILD JOB
# ===================
build:
name: Build Application
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
# ===================
# TEST JOB
# ===================
test:
name: Run Tests
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --coverage
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# ===================
# SECURITY SCANNING
# ===================
security-scan:
name: Security Scanning
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
# SAST - Static Application Security Testing
- name: Run CodeQL Analysis
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
# Dependency Scanning
- name: Run npm audit
run: npm audit --audit-level=high
continue-on-error: true
- name: Run Snyk security scan
uses: snyk/actions/node@master
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
# Secret Scanning
- name: Scan for secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
# ===================
# DOCKER BUILD
# ===================
docker-build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [test, security-scan]
if: github.event_name == 'push'
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
# Login to Azure
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Login to ACR
- name: Login to ACR
run: az acr login --name ${{ env.ACR_NAME }}
# Generate Docker metadata
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
# Build and push
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Scan container image
- name: Scan container image
uses: azure/container-scan@v0
with:
image-name: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
severity-threshold: HIGH
# ===================
# DEPLOY TO STAGING
# ===================
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: docker-build
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://${{ secrets.STAGING_WEBAPP_NAME }}.azurewebsites.net
steps:
- name: Azure Login (OIDC)
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 Azure Web App
uses: azure/webapps-deploy@v3
with:
app-name: ${{ secrets.STAGING_WEBAPP_NAME }}
images: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Health check
run: |
sleep 30
curl -f https://${{ secrets.STAGING_WEBAPP_NAME }}.azurewebsites.net/health || exit 1
# ===================
# DEPLOY TO PRODUCTION
# ===================
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: docker-build
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://${{ secrets.WEBAPP_NAME }}.azurewebsites.net
steps:
- name: Azure Login (OIDC)
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 Azure Web App
uses: azure/webapps-deploy@v3
with:
app-name: ${{ secrets.WEBAPP_NAME }}
images: ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Health check
run: |
sleep 30
curl -f https://${{ secrets.WEBAPP_NAME }}.azurewebsites.net/health || exit 1
- name: Create deployment summary
run: |
echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Environment:** Production" >> $GITHUB_STEP_SUMMARY
echo "- **Image:** ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
echo "- **URL:** https://${{ secrets.WEBAPP_NAME }}.azurewebsites.net" >> $GITHUB_STEP_SUMMARYStep 5: Create Dockerfile
# Dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy built assets
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]Part 3: Security Scanning Deep Dive
Step 6: Configure SAST (Static Analysis)
# .github/workflows/security.yml
name: Security Scanning
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly on Monday
permissions:
security-events: write
contents: read
jobs:
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ['javascript']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0Step 7: Configure DAST (Dynamic Analysis)
# .github/workflows/dast.yml
name: DAST Scanning
on:
workflow_run:
workflows: ["CI/CD Pipeline"]
types:
- completed
branches: [develop]
jobs:
dast-scan:
name: OWASP ZAP Scan
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Run OWASP ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: 'https://${{ secrets.STAGING_WEBAPP_NAME }}.azurewebsites.net'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a'
- name: Upload ZAP Report
uses: actions/upload-artifact@v4
with:
name: zap-report
path: report_html.htmlPart 4: Infrastructure as Code
Step 8: Terraform for Azure Infrastructure
# terraform/main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stterraformstate"
container_name = "tfstate"
key = "webapp.tfstate"
}
}
provider "azurerm" {
features {}
}
variable "environment" {
type = string
default = "prod"
}
variable "location" {
type = string
default = "eastus"
}
resource "azurerm_resource_group" "main" {
name = "rg-webapp-${var.environment}"
location = var.location
}
resource "azurerm_container_registry" "main" {
name = "acrwebapp${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
sku = "Standard"
admin_enabled = false
}
resource "azurerm_service_plan" "main" {
name = "asp-webapp-${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
os_type = "Linux"
sku_name = "P1v3"
}
resource "azurerm_linux_web_app" "main" {
name = "webapp-${var.environment}-${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
service_plan_id = azurerm_service_plan.main.id
site_config {
always_on = true
application_stack {
docker_image_name = "${azurerm_container_registry.main.login_server}/webapp:latest"
docker_registry_url = "https://${azurerm_container_registry.main.login_server}"
}
}
identity {
type = "SystemAssigned"
}
}
resource "random_id" "suffix" {
byte_length = 4
}
output "webapp_url" {
value = "https://${azurerm_linux_web_app.main.default_hostname}"
}Step 9: Terraform Workflow
# .github/workflows/terraform.yml
name: Terraform Infrastructure
on:
push:
branches: [main]
paths:
- 'terraform/**'
pull_request:
branches: [main]
paths:
- 'terraform/**'
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform:
name: Terraform
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Init
run: terraform init
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
continue-on-error: true
- name: Comment PR with Plan
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const output = `#### Terraform Plan 📖
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\`
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve tfplanVerification Checklist
Azure Setup:
- Resource group created
- ACR created and configured
- App Service Plan created
- Web App configured
- OIDC authentication working
GitHub Configuration:
- Secrets configured
- Workflow files created
- Branch protection enabled
- Environment approvals configured
Pipeline Testing:
- Build job succeeds
- Tests pass
- Security scans complete
- Docker image pushed
- Deployment succeeds
Resources
Questions? Reach out in our community Discord!