Skip to main content
RunBook Academy

TerraformXVII · Drift Detection and ReconciliationProduction Terraform

Intentional Drift: When the Real World Is Right

Intermediate⏱ ~12 minbash

What you'll learn

  • Distinguish intentional drift from accidental drift using audit log evidence
  • Codify accepted reality with `lifecycle { ignore_changes = [attribute, ...] }`
  • Recognise the cost of silent `ignore_changes` against adopting the manual change into HCL
  • Choose between `ignore_changes`, codification, and `terraform apply -refresh-only` based on intent

Prerequisites

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

Not all drift is wrong. Some of it is a deliberate change the team made through a faster channel because Terraform was too slow to use. The job is to recognise it, codify it, and prevent the next terraform apply from silently undoing a decision a person already made.

This lesson covers the third of the four reconciliation shapes: drift that should stay. The other three (accidental drift to revert, drift to leave alone, drift to plan around) are covered in the lessons that follow.

What intentional drift looks like

The drift detector found a change to a security group. The attribute that differs is description. The configuration says "ALB ingress". The real world says "ALB ingress (do not touch)". A reader of the plan diff sees a single attribute with a single new string, edited by someone who knew what they were doing.

The audit log confirms it. The console history shows a change made at 22:14 by an on-call operator with an attached incident number. The operator changed the description because the runbook for that incident said “tag any change with the incident ID in the description for audit”.

This is intentional drift. The real world is right. The configuration has not caught up. If we run terraform apply without a decision, the apply will revert the description to the string in HCL, the audit trail will disappear, and the operator will rightly be angry.

The four shapes of a drift finding

Every drift finding falls into one of four shapes. The shape determines the response.

ShapeSymptomResponse
UndesiredThe real world is wrong (resource created by an incident, a stale test resource, a tag added in error).Revert the real world. Then terraform apply -refresh-only.
Unrecorded intentThe real world is right and intended (operator made a change in response to an incident, a partner team added tags, a control plane mutated the resource).Codify in HCL. Commit. Then terraform apply -refresh-only.
UninterestingThe real world is right and is going to change back next refresh anyway (provider-added default, transient tag, scheduler-managed attribute).lifecycle { ignore_changes = [...] } after documenting why.
UnknownCannot tell from the audit log who or what changed it.Treat as undesired. Investigate before deciding.

The detection job hands you a list of findings. The investigation job classifies them. The decision belongs to a person with the relevant context.

Codifying into HCL

The cleanest response to intentional drift is to bring the configuration in line with the real world. A short PR with a single line change is the audit trail you want.

resource "aws_security_group" "alb_sg" {
  name        = "alb-sg"
  description = "ALB ingress (do not touch)"  # <-- the new intent
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

The next apply sees no drift. The description is preserved. The operator’s intent is now machine-managed.

This is the default. Codify whenever the drift represents an ongoing intent. The configuration becomes the source of truth once more.

lifecycle { ignore_changes } and its cost

For attributes that the human intentionally does not want to manage through Terraform, the configuration can declare an exception:

resource "aws_instance" "app" {
  ami           = var.ami
  instance_type = "t3.medium"

  lifecycle {
    ignore_changes = [
      # We let the autoscaler tag instances with the launch template
      # version. The version is meaningful in the console but not in
      # Terraform. Codifying it produces a noisy apply on every
      # scale event.
      tags["LaunchTemplateVersion"],
    ]
  }
}

The meaning: “if the real-world value of tags["LaunchTemplateVersion"] differs from the HCL value, do not propose a change”. The attribute is rewritten by Terraform on the next refresh; the configuration is not.

The cost of ignore_changes:

  • It silences future drift. A change to the attribute that matters is no longer surfaced by terraform plan. The only place that change becomes visible is the audit log of the cloud provider.
  • It ages out. The reason for ignoring an attribute is true today. The infrastructure evolves. A tags[...] ignore that made sense when the autoscaler rewrote it is still applied when an attacker rewrites it for the same reason the attribute is silenced.
  • It blocks refactors. Renaming an ignored attribute is a tedious mv operation. Documentation rots.
  • It accumulates. One ignore becomes two becomes a list. The configuration stops expressing the world accurately. The drift detector still finds unmanaged drift; the configuration just lies about it.

The right way to use ignore_changes:

  • One or two attributes per resource, with a comment explaining the reason.
  • Quarterly review. Read the list, ask “do these still hold?”. Throw away the entries that have aged out.
  • Never ignore a security-relevant attribute. A silenced ingress block, a silenced iam_role, or a silenced tags["Owner"] is an open door.

Apply -refresh-only

When the configuration already matches the real world but the state file has not caught up, the right command is terraform apply -refresh-only. It runs the refresh, writes the refreshed state, and proposes zero changes. The drift entry in the next plan is empty.

# READ-ONLY against the API; writes the refreshed state.
terraform apply -refresh-only -auto-approve

This is the right command for the case where codification is not needed (the change is one-off, transient, or will not return).

Practical decision tree

   Detector found drift.
        |
        v
   Audit log: who did it, when, why?
        |
   +----+----+----+----+
   |    |    |    |    |
   v    v    v    v    v
 Uknown Undesired Unrecorded Uninteresting Intentional
   |        |       intent      |
   |        |        |          |
   v        v        v          v
 Treat   Revert   Codify     ignore_changes
 as       the     into HCL   (or apply
 undesired  world then         -refresh-only)
            refresh-only

A team that has this loop in writing will respond to drift findings the same way every time. The variance lives in the classification, not in the response.

Verification

# 1. Confirm the audit log can answer "who changed this?".
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceName,AttributeValue=alb-sg \
  --max-results 5

# 2. Confirm the codification produces a clean plan.
terraform plan -input=false
# Expected: "No changes. Your infrastructure matches the configuration."

# 3. Confirm ignore_changes block has a comment and a narrow scope.
grep -B 1 -A 5 'ignore_changes' main.tf

# 4. Confirm apply -refresh-only produces no change in the next plan.
terraform apply -refresh-only -auto-approve
terraform plan -input=false
# Expected: "No changes."

To confirm the lesson:

  • You can name the four shapes of a drift finding.
  • You can pick between codification, ignore_changes, and apply -refresh-only based on intent and audit evidence.
  • You can list the operational cost of ignore_changes beyond the immediate suppression.

Knowledge check · 7 questions

  1. Q1. What is intentional drift?

  2. Q2. What is the right response to a drift finding classified as 'unrecorded intent'?

  3. Q3. `lifecycle { ignore_changes = [...] }` carries a real operational cost: a later meaningful change to the same attribute becomes invisible to the team.

  4. Q4. Which of these are valid reasons to use `lifecycle { ignore_changes }`? (Select all that apply.)

  5. Q5. When is `terraform apply -refresh-only` the right answer?

  6. Q6. The detector finds that the `iam-instance-profile` attribute of an EC2 instance has been changed via the console by an operator during an incident response. The audit log shows the change was authorised by the on-call SRE. The configuration still has the old profile ARN. What is the right response?

  7. Q7. What is the right cadence for reviewing an `ignore_changes` block?

Passing score: 75%. Answers are checked in this browser.