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.
- Tag everything Terraform creates, for example
managed-by = "terraform"throughdefault_tagsin the AWS provider ordefault_labelsin the Google provider. - 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.
- For each hit, decide: import it with an
importblock, 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
importblock.
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-onlyto see all drift on managed resources. - Run refresh-only and normal plans with
-detailed-exitcodeon 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.
