Problem Context
You deployed your infrastructure with Terraform three weeks ago. Since then, someone manually adjusted a network security group rule to debug an incident, a colleague added a storage container through the portal, and auto-scaling silently changed a VM SKU. Your Terraform state no longer reflects reality. The next terraform apply may overwrite changes you want to keep β or miss security regressions you need to catch.
Infrastructure driftis the divergence between declared state (your IaC code) and actual state (what's running in the cloud). Drift detection is the practice of continuously measuring that gap and acting on it before it causes an incident.
- Someone fixed a firewall rule manually and βforgotβ to update Terraform
- Your compliance scanner flags a resource that your IaC says is compliant
- A plan shows unexpected deletions even though you didn't change anything
- You don't know if your prod environment matches your last approved deployment
Drift detection closes the loop β infrastructure as code only works if the code stays authoritative.
How Drift Happens
Drift sources fall into three categories:
- Manual changes: Portal edits, CLI one-liners during incidents, direct API calls.
- Automated platform changes: Auto-scaling events, Azure Policy remediations, platform-managed rotations, AKS node pool updates.
- IaC gaps: Resources created outside IaC (the βjust this onceβ resource), properties not managed by the IaC tool (Azure adds new properties your older provider doesn't track).
Terraform: Built-In Drift Detection
Terraform's terraform plan is drift detection β it reads live state, compares it to your configuration, and produces a diff. The key is running it regularly, not just before applies.
Scheduled plan-only runs
# Run plan and capture exit code
# Exit 0 = no changes, Exit 2 = changes detected, Exit 1 = error
terraform plan -detailed-exitcode -out=tfplan
if [ $? -eq 2 ]; then
echo "DRIFT DETECTED" | notify-slack
fiGitHub Actions drift detection workflow
name: Drift Detection
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
workflow_dispatch:
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
env:
ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}
- name: Check for Drift
id: plan
run: terraform plan -detailed-exitcode -no-color 2>&1
continue-on-error: true
- name: Alert on Drift
if: steps.plan.outputs.exitcode == 2
uses: slackapi/slack-github-action@v1
with:
payload: |
{ "text": "β οΈ Infrastructure drift detected in production. Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" }terraform refresh and State Reconciliation
terraform refresh updates the state file to match the real infrastructure withoutmodifying the infrastructure. It's useful when platform-managed changes (like auto-scaling) cause spurious drift noise. However, it also silently accepts manual security changes into state β use it carefully.
# Refresh state from live infrastructure
terraform refresh
# Or during plan (refresh is implicit but can be disabled)
terraform plan -refresh=false # Skip refresh, use cached state
terraform plan -refresh=true # Default: refresh before planningAzure Policy for Continuous Compliance
For Azure-specific deployments, Azure Policy provides a complementary layer. While Terraform detects drift in what IaC manages, Azure Policy detects compliance violations across all resources, including those outside IaC scope.
# Check compliance for a policy assignment
az policy state list \
--resource-group rg-production \
--filter "complianceState eq 'NonCompliant'" \
--query "[].{resource: resourceId, policy: policyDefinitionId}" \
--output table
# Trigger an on-demand compliance evaluation
az policy state trigger-scan --resource-group rg-productionDrift in Bicep / ARM Deployments
ARM deployments in Incremental mode will not detect or revert manual changes to properties not in your template. Complete mode deletes unlisted resources, effectively enforcing desired state. For continuous drift correction:
# What-if to see drift
az deployment group what-if \
--mode Complete \
--resource-group rg-production \
--template-file main.bicep \
--parameters @main.prod.bicepparam
# Complete mode deployment to correct drift
az deployment group create \
--mode Complete \
--resource-group rg-production \
--template-file main.bicep \
--parameters @main.prod.bicepparamDrift vs Desired State Enforcement
Detection tells you drift exists. Enforcement corrects it. These are two distinct operations with different risk profiles:
- Detection only (safe): Schedule
terraform plan/what-ifon a cron, alert the team. Humans decide whether to revert or adopt the change. - Auto-remediation (higher risk): Pipeline automatically runs
terraform applyor complete-mode ARM deploy on drift detection. Can overwrite legitimate emergency changes. Use only when you have strong change controls and rollback mechanisms.
For most production environments, detection + human approval is the right balance.
Production Checklist
- Run
terraform plan -detailed-exitcodeon a schedule (not just before applies) - Alert the on-call team immediately on drift detection
- Document which resources are intentionally not managed by IaC (e.g., manually scaled resources)
- Use
lifecycle { ignore_changes }for properties that legitimately drift (auto-scaling) - Separate drift detection from remediation β require human approval before auto-apply
- Combine Terraform drift detection with Azure Policy compliance scans for full coverage

