Skip to main content

Platform Automation

How to implement GitOps, CI/CD, and Policy as Code for landing zones.

Platform Automation Architecture

Repository Structure

azure-landing-zone/
├── .github/
│ └── workflows/
│ ├── platform-deploy.yml
│ ├── landing-zone-deploy.yml
│ └── policy-deploy.yml
├── platform/
│ ├── management-groups/
│ │ ├── main.bicep
│ │ └── parameters/
│ │ └── prod.bicepparam
│ ├── logging/
│ │ ├── main.bicep
│ │ └── parameters/
│ ├── hub-networking/
│ │ ├── main.bicep
│ │ └── parameters/
│ └── policy/
│ ├── definitions/
│ ├── initiatives/
│ └── assignments/
├── landing-zones/
│ ├── corp/
│ │ ├── finance-prod/
│ │ ├── hr-prod/
│ │ └── sap-prod/
│ └── online/
│ └── web-prod/
├── modules/
│ ├── management-group/
│ ├── policy-definition/
│ ├── spoke-network/
│ └── subscription-vending/
├── scripts/
│ ├── deploy.ps1
│ └── validate.ps1
└── docs/
└── runbooks/

GitOps Workflow

Branch Strategy

GitOps Flow

CI/CD Pipeline Examples

GitHub Actions: Platform Deployment

# .github/workflows/platform-deploy.yml
name: Platform Deployment

on:
push:
branches: [main]
paths:
- 'platform/**'
- 'modules/**'
pull_request:
branches: [main]
paths:
- 'platform/**'
- 'modules/**'

permissions:
id-token: write
contents: read
pull-requests: write

env:
LOCATION: 'eastus'

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

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

- name: Lint Bicep
run: |
az bicep build --file platform/management-groups/main.bicep
az bicep build --file platform/logging/main.bicep
az bicep build --file platform/hub-networking/main.bicep

- name: What-If Management Groups
id: whatif-mg
run: |
result=$(az deployment tenant what-if \
--location ${{ env.LOCATION }} \
--template-file platform/management-groups/main.bicep \
--parameters platform/management-groups/parameters/prod.bicepparam \
--no-pretty-print)
echo "result<<EOF" >> $GITHUB_OUTPUT
echo "$result" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

- name: Post What-If to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const output = `#### Bicep What-If Results
<details><summary>Show Changes</summary>

\`\`\`
${{ steps.whatif-mg.outputs.result }}
\`\`\`

</details>`;

github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});

deploy:
needs: validate
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4

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

- name: Deploy Management Groups
run: |
az deployment tenant create \
--name "mg-$(date +%Y%m%d%H%M%S)" \
--location ${{ env.LOCATION }} \
--template-file platform/management-groups/main.bicep \
--parameters platform/management-groups/parameters/prod.bicepparam

- name: Deploy Logging
run: |
az deployment mg create \
--name "logging-$(date +%Y%m%d%H%M%S)" \
--location ${{ env.LOCATION }} \
--management-group-id alz-platform-management \
--template-file platform/logging/main.bicep \
--parameters platform/logging/parameters/prod.bicepparam

- name: Deploy Hub Networking
run: |
az deployment mg create \
--name "hub-$(date +%Y%m%d%H%M%S)" \
--location ${{ env.LOCATION }} \
--management-group-id alz-platform-connectivity \
--template-file platform/hub-networking/main.bicep \
--parameters platform/hub-networking/parameters/prod.bicepparam

Azure DevOps: Multi-Stage Pipeline

# azure-pipelines.yml
trigger:
branches:
include:
- main
paths:
include:
- platform/**
- modules/**

pool:
vmImage: 'ubuntu-latest'

variables:
- group: alz-variables
- name: location
value: 'eastus'

stages:
- stage: Validate
displayName: 'Validate'
jobs:
- job: ValidateBicep
displayName: 'Validate Bicep Templates'
steps:
- task: AzureCLI@2
displayName: 'Lint Bicep'
inputs:
azureSubscription: 'azure-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az bicep build --file platform/management-groups/main.bicep
az bicep build --file platform/logging/main.bicep
az bicep build --file platform/hub-networking/main.bicep

- task: AzureCLI@2
displayName: 'What-If Analysis'
inputs:
azureSubscription: 'azure-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az deployment tenant what-if \
--location $(location) \
--template-file platform/management-groups/main.bicep \
--parameters platform/management-groups/parameters/prod.bicepparam

- stage: DeployPlatform
displayName: 'Deploy Platform'
dependsOn: Validate
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployManagementGroups
displayName: 'Deploy Management Groups'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- checkout: self

- task: AzureCLI@2
displayName: 'Deploy'
inputs:
azureSubscription: 'azure-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az deployment tenant create \
--name "mg-$(Build.BuildNumber)" \
--location $(location) \
--template-file platform/management-groups/main.bicep \
--parameters platform/management-groups/parameters/prod.bicepparam

- deployment: DeployLogging
displayName: 'Deploy Logging'
dependsOn: DeployManagementGroups
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- checkout: self

- task: AzureCLI@2
displayName: 'Deploy'
inputs:
azureSubscription: 'azure-service-connection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: |
az deployment mg create \
--name "logging-$(Build.BuildNumber)" \
--location $(location) \
--management-group-id alz-platform-management \
--template-file platform/logging/main.bicep

Policy as Code

Policy Definition Workflow

Policy Deployment Pipeline

# .github/workflows/policy-deploy.yml
name: Policy Deployment

on:
push:
branches: [main]
paths:
- 'platform/policy/**'
pull_request:
branches: [main]
paths:
- 'platform/policy/**'

jobs:
deploy-definitions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

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

- name: Deploy Policy Definitions
run: |
for file in platform/policy/definitions/*.bicep; do
az deployment mg create \
--name "policy-def-$(basename $file .bicep)-$(date +%Y%m%d%H%M%S)" \
--location eastus \
--management-group-id alz \
--template-file "$file"
done

- name: Deploy Policy Initiatives
run: |
for file in platform/policy/initiatives/*.bicep; do
az deployment mg create \
--name "policy-init-$(basename $file .bicep)-$(date +%Y%m%d%H%M%S)" \
--location eastus \
--management-group-id alz \
--template-file "$file"
done

- name: Deploy Policy Assignments
run: |
for file in platform/policy/assignments/*.bicep; do
az deployment mg create \
--name "policy-assign-$(basename $file .bicep)-$(date +%Y%m%d%H%M%S)" \
--location eastus \
--management-group-id alz \
--template-file "$file"
done

Drift Detection

Scheduled Drift Check

# .github/workflows/drift-detection.yml
name: Drift Detection

on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch:

jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

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

- name: Check Management Group Drift
id: mg-drift
run: |
result=$(az deployment tenant what-if \
--location eastus \
--template-file platform/management-groups/main.bicep \
--parameters platform/management-groups/parameters/prod.bicepparam \
--no-pretty-print)

if echo "$result" | grep -q "no changes"; then
echo "drift=false" >> $GITHUB_OUTPUT
else
echo "drift=true" >> $GITHUB_OUTPUT
echo "details<<EOF" >> $GITHUB_OUTPUT
echo "$result" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi

- name: Create Issue if Drift Detected
if: steps.mg-drift.outputs.drift == 'true'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: '🚨 Configuration Drift Detected',
body: `Configuration drift detected in Azure Landing Zone.

## Details
\`\`\`
${{ steps.mg-drift.outputs.details }}
\`\`\`

Please review and either:
1. Update the code to match Azure state
2. Re-deploy to fix the drift`,
labels: ['drift', 'platform']
});

Testing

Bicep Linting with PSRule

# .github/workflows/test.yml
name: Test

on:
pull_request:
paths:
- '**.bicep'

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install PSRule
shell: pwsh
run: |
Install-Module -Name PSRule.Rules.Azure -Force -Scope CurrentUser

- name: Run PSRule Analysis
shell: pwsh
run: |
Assert-PSRule -InputPath . -Module PSRule.Rules.Azure -OutputFormat NUnit3 -OutputPath reports/psrule.xml

- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: reports/*.xml

Automation Checklist

✅ Repository Setup

  • Repository structure defined
  • Branch protection enabled
  • PR templates created
  • CODEOWNERS configured

✅ CI/CD Pipeline

  • Validation stage implemented
  • What-If analysis enabled
  • Manual approval gates
  • Deployment stages configured
  • Environment protection rules

✅ Security

  • Workload identity federation (OIDC)
  • Least privilege permissions
  • Secret scanning enabled
  • Dependency scanning enabled

✅ Operations

  • Drift detection scheduled
  • Alert on failures
  • Deployment notifications
  • Runbooks documented

Quick Reference Card

ComponentPurpose
GitOpsIaC as source of truth
What-IfPreview changes before deploy
EnvironmentsApproval gates in pipeline
OIDCSecure authentication
Drift DetectionDetect manual changes
PSRuleAzure best practice validation

Next Steps

Continue to Multi-Region Design to learn about global deployment patterns.