Infrastructure Drift Detection: What terraform plan Misses

Infrastructure Drift Detection: What terraform plan Misses

Reading time1 min
#devops#infrastructure#terraform#drift detection

Infrastructure Drift Detection: What terraform plan Misses

Drift is any difference between what your Terraform code describes and what actually runs. Someone opens a port in the console during an incident, a script changes a setting, another tool takes over a resource. terraform plan catches some of this, but not all of it, and only when someone runs it.

What plan actually does

A common claim is that terraform plan only compares code with the state file. That's wrong. By default plan refreshes first: it reads every resource in state from the provider API, then compares your configuration with that refreshed view and proposes changes. You can turn this off with -refresh=false, but that is not the default.

So if someone changes an attribute that your code sets, plan shows an update that reverts it. Since Terraform 1.2, the "Objects have changed outside of Terraform" section only lists outside changes that are relevant to the proposed actions. To see all drift on resources Terraform manages, use a refresh-only plan:

terraform plan -refresh-only

It shows how the state would change to match reality, and changes nothing unless you apply it.

What plan can't see

Resources that aren't in state. A VM, bucket or IAM user created in the console is invisible to Terraform. Plan reports nothing because Terraform doesn't know the object exists.

Rules and attachments managed as separate resources. If security group rules are managed with aws_vpc_security_group_ingress_rule resources, a rule added in the console is a new object outside state. Plan won't flag it. Inline rule blocks on the security group would show the extra rule as drift, which is one reason some teams keep critical rules inline.

Attributes your code doesn't set. For optional attributes that you leave unset, the provider often accepts whatever value is there. A manual change to such an attribute is not reverted and doesn't appear in a normal plan.

ignore_changes. Anything listed there is ignored on purpose. Review these lists from time to time. They tend to grow.

Stacks nobody runs. A root module that nobody plans for months is drifting with nobody watching.

Fields owned by another controller. An ECS service's desired_count changed by Application Auto Scaling, or a Deployment's replica count changed by an HPA, isn't drift. It is two systems fighting over one field. Let one owner win: ignore_changes = [desired_count] in Terraform, or remove replicas from the manifest when an HPA manages it.

Scheduled drift checks

Run a refresh-only plan for every root module on a schedule, for example nightly in CI, with read-only credentials.

#!/usr/bin/env bash
set -uo pipefail

terraform init -input=false -no-color > /dev/null
terraform plan -refresh-only -detailed-exitcode -input=false -no-color > drift.txt
rc=$?

case "$rc" in
  0) echo "no drift" ;;
  2) echo "drift detected"; cat drift.txt; exit 2 ;;
  *) echo "plan failed"; cat drift.txt; exit 1 ;;
esac

With -detailed-exitcode, exit code 0 means no changes, 1 means error and 2 means changes are present. Run a normal terraform plan -detailed-exitcode in the same job too: exit code 2 there means the code and the real infrastructure disagree, which also catches merged code that was never applied.

Send the result to the owning team's channel, not to a shared inbox nobody reads.

Continuous checks with check blocks

Terraform 1.5 added check blocks. They run assertions on every plan and apply, and failures are reported as warnings without blocking the run.

check "site_health" {
  data "http" "health" {
    url = "https://example.com/health"
  }

  assert {
    condition     = data.http.health.status_code == 200
    error_message = "example.com/health returned ${data.http.health.status_code}"
  }
}

HCP Terraform can run drift detection and these checks on a schedule (health assessments). If you don't use it, the CI job above does the same job.

Finding resources Terraform doesn't know about

For unmanaged resources you need an inventory of what exists, compared with what state knows.

  1. Tag everything Terraform creates, for example managed-by = "terraform" through default_tags in the AWS provider or default_labels in the Google provider.
  2. Query an inventory for resources without that tag: AWS Config or Resource Explorer, GCP Cloud Asset Inventory, Azure Resource Graph, or a tool like Steampipe or CloudQuery.
  3. For each hit, decide: import it with an import block, delete it, or document why it lives outside Terraform.

Static analysis tools such as Checkov or tfsec look at your code, not at the cloud. They are useful, but they don't detect drift.

Prevent drift instead of chasing it

  • Read-only console access for humans by default. Keep a break-glass role for incidents, and alert when it is used.
  • Organization-level guardrails. SCPs on AWS, organization policies on GCP, Azure Policy. For example, enable S3 Block Public Access at the account level so a console change can't make a bucket public:
resource "aws_s3_account_public_access_block" "this" {
  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true
}
  • Audit trails. CloudTrail, Cloud Audit Logs and the Azure Activity Log tell you who changed what. Link them from drift alerts.

When you find drift

Decide which side is right.

  • The code is right: run a normal plan and apply to revert the change.
  • The change was right: update the code to match, until plan shows no changes.
  • The resource should be managed: add an import block.

Avoid terraform refresh. It is deprecated and equivalent to terraform apply -refresh-only -auto-approve, which writes whatever it finds into state without review.

Checklist

  • Know that plan refreshes, and use -refresh-only to see all drift on managed resources.
  • Run refresh-only and normal plans with -detailed-exitcode on a schedule for every root module.
  • Tag managed resources and query an inventory for the rest.
  • Review ignore_changes, and give fields owned by autoscalers a single owner.
  • Use read-only console access and organization guardrails to prevent drift.
  • Fix drift by changing code or applying it, never by editing state.