Drift Detection Automation #

Infrastructure managed by Terraform can change without going through Terraform — someone might modify a security group directly in the console to debug, or the cloud provider automatically changes some resource attributes. Drift is the condition where the infrastructure reality no longer matches the Terraform state. If left alone, drift accumulates and eventually makes Terraform untrustworthy as a source of truth. Scheduled drift detection provides visibility into this condition before it becomes a big problem.

flowchart LR
    A["Code\n(source of truth)"] -.->|"expected"| B["State"]
    B -.->|"expected"| C["Cloud Resources"]
    C -->|"actual"| D{"Drift\ndetection"}
    D -->|"match"| E["OK ✅"]
    D -->|"drift"| F["Alert 🚨"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style C fill:#f59e0b,stroke:#d97706,color:#fff
    style D fill:#f97316,stroke:#ea580c,color:#fff
    style E fill:#10b981,stroke:#059669,color:#fff
    style F fill:#ef4444,stroke:#dc2626,color:#fff

What Is Drift and Why It’s Dangerous #

REAL DRIFT EXAMPLES:

  Terraform state says:
    aws_security_group.web:
      ingress: [port 443, 0.0.0.0/0]

  Reality on AWS:
    aws_security_group.web:
      ingress: [port 443, 0.0.0.0/0]
              [port 22, 0.0.0.0/0]   ← Added manually for debugging
              [port 8080, 10.0.0.0/8] ← Added for testing

  Consequences:
  - An unnoticed security hole (port 22 open to the public)
  - A terraform plan run WITHOUT a drift check will remove these rules
    → Surprises at apply time because of "unexpected changes"
  - Or the opposite: an apply isn't done because the plan looks clean
    but the infrastructure reality is different

TYPES OF DRIFT:
  1. Configuration changed outside Terraform (manually in the console)
  2. The cloud provider changes attributes (maintenance, auto-upgrade)
  3. Third-party integrations modifying resources
  4. Tags changed by billing tools or security scanners

Scheduled Plans for Drift Detection #

The most straightforward way to detect drift is running terraform plan on a schedule. If the plan shows changes even though nobody changed the configuration, that’s drift.

# .github/workflows/drift-detection.yml

name: Drift Detection

on:
  schedule:
    - cron: '0 8 * * 1-5'  # Every weekday at 8am
  workflow_dispatch:          # Can be triggered manually if needed

jobs:
  detect-drift:
    name: Detect Drift — ${{ matrix.environment }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        environment: [production, staging]
      fail-fast: false  # Continue even if one environment fails

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/TerraformReadRole
          aws-region: ap-southeast-1

      - name: Terraform Init
        run: terraform init
        working-directory: infrastructure/environments/${{ matrix.environment }}

      - name: Terraform Plan (Drift Check)
        id: plan
        run: |
          # -detailed-exitcode: exit 0 = no changes, exit 2 = changes exist
          terraform plan \
            -detailed-exitcode \
            -no-color \
            -refresh=true \
            2>&1 | tee plan_output.txt
          echo "exit_code=$?" >> $GITHUB_OUTPUT          
        working-directory: infrastructure/environments/${{ matrix.environment }}
        continue-on-error: true

      - name: Analyze Drift
        id: drift
        run: |
          EXIT_CODE="${{ steps.plan.outputs.exit_code }}"
          case $EXIT_CODE in
            0) echo "status=no_drift"   >> $GITHUB_OUTPUT
               echo "✅ No drift detected in ${{ matrix.environment }}" ;;
            1) echo "status=plan_error" >> $GITHUB_OUTPUT
               echo "❌ Terraform plan failed — check the logs" ;;
            2) echo "status=drift_detected" >> $GITHUB_OUTPUT
               echo "⚠️ Drift detected in ${{ matrix.environment }}!" ;;
          esac          

      - name: Extract Drift Summary
        if: steps.drift.outputs.status == 'drift_detected'
        run: |
          # Grab only the relevant parts of the plan output
          grep -E "^  [+~-]|will be|must be replaced|Plan:" plan_output.txt \
            | head -50 > drift_summary.txt
          cat drift_summary.txt          
        working-directory: infrastructure/environments/${{ matrix.environment }}

      - name: Notify Slack — Drift Detected
        if: steps.drift.outputs.status == 'drift_detected'
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "⚠️ Infrastructure drift detected",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*⚠️ Drift Detected: `${{ matrix.environment }}`*\n\nThe infrastructure doesn't match the Terraform state.\nSee details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                  }
                }
              ]
            }            
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_INFRA_WEBHOOK }}

Terraform Refresh: Updating the State #

When drift is detected, there are two possible responses: bring the infrastructure back to the Terraform configuration (apply), or update the state to reflect the actual condition (refresh).

# OPTION 1: Bring the infrastructure back to the Terraform configuration
# This is the default response and the most recommended
terraform apply

# OPTION 2: Update the state to reflect the actual condition
# Use this if the manual change was intentional and you want to keep it
# WARNING: this doesn't change the .tf configuration — only the state
terraform apply -refresh-only

# terraform refresh (deprecated — use apply -refresh-only)
# terraform refresh

# After a refresh-only apply, you need to update the .tf configuration
# to match the actual condition, then commit it to the repository
# Example: After drift is found on a security group
# Actual condition: extra rules that are truly wanted

# Choice A: Remove the manual rules (revert to Terraform)
# → terraform apply (without .tf configuration changes)

# Choice B: Add the rules to the configuration (accept the manual changes)
resource "aws_security_group_rule" "debug_access" {
  type              = "ingress"
  from_port         = 8080
  to_port           = 8080
  protocol          = "tcp"
  cidr_blocks       = ["10.0.0.0/8"]
  security_group_id = aws_security_group.web.id
  description       = "Internal debug access — added after drift review 2024-01-15"
}
# Commit this to the repository → drift resolved

Managing Noise: Ignorable Drift #

Not all drift is a problem. Some attributes legitimately change in normal operation and shouldn’t be worried about.

# Add ignore_changes for attributes that change outside Terraform
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type

  lifecycle {
    ignore_changes = [
      # The AMI changes when there's an automatic patch
      ami,
      # Tags added by billing tools or security scanners
      tags["LastScannedBy"],
      tags["CostCenter"],
      # User data may be changed by external automation
      user_data_base64,
    ]
  }
}
# Filter the drift report — only show what isn't from ignore_changes
# (Terraform already automatically doesn't report attributes in ignore_changes)

# For custom filtering: parse the JSON plan output
terraform show -json tfplan | jq '
  .resource_changes[]
  | select(.change.actions != ["no-op"])
  | {
      resource: .address,
      actions: .change.actions,
      changes: (.change.before // {} | keys)
    }
'

flowchart TD
    A["Cron schedule"] --> B["terraform plan"]
    B --> C{"Drift?"}
    C -->|"no changes"| D["Log OK"]
    C -->|"changes"| E["Alert team"]
    E --> F["Review"]
    F --> G{"Decide"}
    G -->|"accept"| H["terraform apply"]
    G -->|"ignore"| I["Update config"]
    I --> B

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#3b82f6,stroke:#1e40af,color:#fff
    style C fill:#f97316,stroke:#ea580c,color:#fff
    style D fill:#10b981,stroke:#059669,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style F fill:#f59e0b,stroke:#d97706,color:#fff
    style G fill:#f97316,stroke:#ea580c,color:#fff
    style H fill:#10b981,stroke:#059669,color:#fff
    style I fill:#8b5cf6,stroke:#6d28d9,color:#fff

Drift Remediation Workflow #

DRIFT DETECTED → Notification → Review → Auto-fix or Manual Fix

Auto-fix flow:
1. Drift detection cron runs
2. Drift detected
3. Create a ticket/issue
4. Auto-apply if minor (tags, metadata)
5. Manual approval if major (instance type, security)

Manual fix flow:
1. Drift detected
2. Notify the team
3. An engineer reviews
4. Decide: fix the infrastructure or update the code
5. Apply the change
6. Close the ticket
# Drift remediation policy
# Minor drift: auto-remediate
# Major drift: require manual approval

# Sentinel policy
import "tfplan/v2" as tfplan
import "strings"

minor_drift_types = ["tags", "description", "metadata"]

is_minor_drift = rule {
  all tfplan.resource_changes as _, rc {
    rc.change.actions contains "update" implies
    all rc.change.before_unknown as field {
      strings.has_prefix(field, "tags") or field == "description"
    }
  }
}

main = rule {
  is_minor_drift or
  tfrun.workspace.name is not "production"
}

Drift Detection Metrics #

DRIFT METRICS:

Track drift occurrence over time:
├── Total resources with drift
├── Drift by resource type
├── Drift by environment
├── Time to remediation
└── Drift recurrence rate

Alerting thresholds:
- 0 drifts: healthy
- 1-5 drifts: warning
- 6+ drifts: critical
- Any security drift: critical

Drift Notification Setup #

# CloudWatch Event Rule for drift detection
resource "aws_cloudwatch_event_rule" "drift" {
  name        = "terraform-drift-detected"
  description = "Trigger when Terraform detects drift"
  event_pattern = jsonencode({
    source      = ["custom.terraform"]
    detail-type = ["Drift Detected"]
  })
}

resource "aws_cloudwatch_event_target" "notify" {
  rule      = aws_cloudwatch_event_rule.drift.name
  target_id = "SendToSNS"
  arn       = aws_sns_topic.alerts.arn
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "email"
  endpoint  = "[email protected]"
}

Summary #

  • Drift happens when infrastructure is changed outside Terraform — the console, ad-hoc CLI, or external automation. If undetected, drift accumulates and makes Terraform untrustworthy.
  • Scheduled plans (-detailed-exitcode) are the most straightforward way to detect drift — run daily, alert if there are changes.
  • Exit code 2 from terraform plan -detailed-exitcode means there are changes (drift); exit code 0 means clean.
  • Two responses when drift is found: terraform apply (revert to the configuration) or terraform apply -refresh-only then update the configuration (accept the manual changes).
  • ignore_changes in the lifecycle block prevents false drift — use it for attributes that genuinely change outside Terraform (tags from billing tools, automatic AMI patches).
  • Alert to Slack or the team’s communication channel when drift is found — the team needs to know and respond before drift becomes a bigger problem.

← Previous: Policy as Code   Next: Terraform Cloud →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact