Platform Automation
How to implement GitOps, CI/CD, and Policy as Code for landing zones.
Platform Automation Architecture
Repository Structure
Recommended Layout
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
| Component | Purpose |
|---|---|
| GitOps | IaC as source of truth |
| What-If | Preview changes before deploy |
| Environments | Approval gates in pipeline |
| OIDC | Secure authentication |
| Drift Detection | Detect manual changes |
| PSRule | Azure best practice validation |
Next Steps
Continue to Multi-Region Design to learn about global deployment patterns.