Terraform Lesson 34 of 89

Detecting and Reconciling Terraform Drift Without Nuking Production

In a nutshell

Imagine you drew up a precise floor plan for a room — sofa here, reading lamp there — and handed it to a decorator. That blueprint is your Terraform configuration: the way things are supposed to be. Now imagine people wander in over the week and quietly rearrange the furniture by hand. Someone drags the sofa to catch better light, a cleaner unplugs the lamp, a visitor stacks the chairs in a corner. Nothing is on fire, but the room no longer matches the plan. That mismatch — reality quietly diverging from the blueprint — is drift.

Drift detection is hanging a camera that takes a photo of the room every night and lays it next to the blueprint. Anything that moved lights up. The camera doesn’t fix anything and it doesn’t shove the furniture back — it just tells you, calmly and before you stub your toe on the relocated sofa in the dark, exactly what changed. In Terraform terms that nightly photo is terraform plan -refresh-only: it reads the live cloud, compares it against what Terraform last recorded, and reports the differences without touching a single real resource.

The reason this deserves a whole lesson is the alternative. If you never take the photo, the first time you discover the furniture moved is when you run a routine terraform apply for some unrelated change and it proposes to “put everything back” — dragging that sofa across the room in the middle of the business day. Detection turns a nasty surprise into a scheduled, deliberate decision: keep the change, revert it, or agree to stop managing that piece. This lesson walks the full loop — see the drift, read it correctly, and reconcile it on purpose, never by accident.

Level: Advanced · Time: ~31 min

Drift detection + reconciliation loop: real state vs desired

Read the loop left to right and back: your config declares the intended state, an out-of-band edit drifts the live cloud, a scheduled read-only refresh compares recorded state against reality, a -detailed-exitcode of 2 raises an alert instead of clobbering anything, and a human reconciles — adopt, override, or ignore — before merging the decision back into code so the next plan is a clean no-op.

Before you start, you should be comfortable with the Terraform core loop — init, plan, apply — and with how state records what Terraform believes exists (a refresher lives in State deep dive: the file, commands, locking, and sensitive data and the concept-level IaC core concepts: state, drift, idempotency). You should be able to read a terraform plan diff and run a command in CI.

After this lesson you can:

Drift is the silent tax on every Terraform estate. Someone clicks a button in the portal during an incident, an autoscaler rewrites a field Terraform thinks it owns, and three weeks later a routine apply proposes to destroy something nobody meant to touch. This is a practitioner’s guide to seeing drift before it bites, reading a drift plan correctly, and reconciling it without turning a small divergence into an outage.

1. What drift actually is: three things that must agree

There are three sources of truth in any Terraform workflow, and “drift” is any disagreement between them:

  1. Configuration — your .tf files: what you declared should exist.
  2. Stateterraform.tfstate: what Terraform last recorded as existing.
  3. The live cloud — the actual resources, as the provider API reports them right now.
   config  <-- plan compares these two -->  state  <-- refresh compares these two -->  cloud
   (.tf)                                  (.tfstate)                                 (real API)

Two distinct gaps get lumped together as “drift,” and conflating them is the root of most bad reconciliation decisions:

The pipeline you build later cares almost entirely about the first kind: detecting state-vs-cloud divergence early, deciding intentionally how to reconcile it, and never letting an apply make that decision for you by accident.

Mental model: plan shows you the gap between config and state. A refresh updates state from the cloud. Real drift detection requires both, in that order.

2. How refresh, plan, and -refresh-only behave

Understanding the exact mechanics here is non-negotiable, because the defaults changed and a lot of folklore is wrong.

terraform plan refreshes by default. Before computing the diff, plan reads every resource from the provider API to update its in-memory view of state, then compares your config against that refreshed view. Crucially, this refresh is in-memory for the plan — it does not persist to the state file. So a normal plan already sees drift; it just folds it into the proposed changes.

-refresh=false skips that API read entirely. Plan trusts whatever is in state. This is faster and is what you want when you’ve just refreshed and don’t want to pay the API round-trips again — but run it blind and you can apply against a stale picture.

-refresh-only is the purpose-built drift detector. It refreshes state from the cloud and reports what changed, but it will never propose to modify, create, or destroy a real resource to match your config. Its only possible action is updating the state file to match reality.

# Pure drift detection: compare state against the live cloud, change nothing real.
terraform plan -refresh-only

# Persist the refreshed reality into state (adopts drift INTO state, not the other way).
terraform apply -refresh-only

The distinction that trips people up: terraform apply -refresh-only does not push your config to the cloud. It does the reverse — it accepts the cloud’s current values as the new contents of state. That’s sometimes right (a manual change you want to keep) and sometimes wrong (one you want to revert). You decide; the flag just makes state agree with reality.

The old standalone terraform refresh command still exists but is deprecated. Use terraform apply -refresh-only, which is the same operation with a plan and an approval prompt in front of it.

Here is the decision in one table:

Command Reads cloud? Can change cloud? Can change state? Use for
plan Yes No No Normal change review
plan -refresh=false No No No Fast plan against trusted state
plan -refresh-only Yes No No Detecting drift safely
apply -refresh-only Yes No Yes Adopting reality into state
apply Yes Yes Yes Making the cloud match config

3. Reading a drift plan: noise versus real divergence

Run terraform plan -refresh-only against a workspace that has drifted and you’ll see a block headed with a note that objects have changed outside of Terraform. The body uses the same diff symbols as a normal plan, but the meaning is inverted: it’s showing how the cloud differs from state, not how your config differs from state.

Note: Objects have changed outside of Terraform

  # azurerm_storage_account.this has changed
  ~ resource "azurerm_storage_account" "this" {
        id                = "/subscriptions/.../storageAccounts/kvprodsa"
      ~ tags              = {
          + "CostCenter" = "FIN-4417"
        }
        # (38 unchanged attributes hidden)
    }

Now the skill: telling benign provider noise apart from real divergence. Not every ~ is a problem.

The diagnostic question for any drift line is: if I ran terraform apply right now, would the proposed action be correct? If Terraform wants to remove a tag a compliance policy legitimately added, the answer is no — reconcile the config, don’t apply. The -refresh-only plan is safe precisely because it forces that question before anything touches production.

4. Importing existing resources with import blocks

When a resource exists in the cloud but not in state — created by hand, by another tool, or by a different Terraform configuration — you import it. As of Terraform 1.5+, prefer import blocks over the imperative terraform import command: they’re declarative, reviewable in a pull request, and they run as part of plan/apply so you can see exactly what will happen first.

# imports.tf
import {
  to = azurerm_resource_group.hotfix
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hotfix"
}

You still need configuration for the resource being imported. Terraform 1.5+ can generate it for you so you don’t hand-write 40 attributes:

# Generate HCL for everything referenced by an import block.
terraform plan -generate-config-out=generated.tf

This writes a best-effort resource block into generated.tf. Treat it as a draft: it often includes read-only or deprecated attributes, won’t carry your variables or for_each, and may need defaults trimmed. Move the cleaned-up resource into your real .tf files, then plan again.

terraform plan   # with import block + config present
# Expect: "1 to import, 0 to add, 0 to change, 0 to destroy"
terraform apply

A clean import shows 0 to change after the import. If the plan wants to change attributes immediately after importing, your config doesn’t yet match the live resource — reconcile the config until the post-import plan is a no-op, then remove the import block (it’s idempotent, but leaving it is clutter).

For bulk or scripted scenarios the imperative form still works and is sometimes more convenient in a loop:

terraform import 'azurerm_resource_group.hotfix' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hotfix"

Import IDs are provider- and resource-specific. For AzureRM it’s almost always the full ARM resource ID; for AWS it varies by resource (an instance ID, an ARN, a name). Check the resource’s docs — guessing the ID format is the number one cause of failed imports.

5. Reconciliation strategies: adopt, override, or ignore with intent

Once you’ve identified real drift, there are exactly three honest responses. Pick deliberately; the worst outcome is picking by reflex.

Adopt the change (the world was right). The manual change should stand. Bring it into your configuration so code matches reality, then make state agree. Update the .tf to reflect the new value, run terraform plan and confirm it’s a no-op, and you’re reconciled. If the change is one Terraform can’t express in config (a value the cloud now owns), terraform apply -refresh-only adopts it into state.

Override the change (your config was right). The manual change was a mistake — a hotfix that bypassed review, an unauthorized edit. Leave your config as-is and let Terraform restore the intended state:

terraform plan    # shows Terraform reverting the manual change
terraform apply   # cloud is brought back into compliance with config

This is the case where a normal apply is the correct reconciliation, because here clobbering the drift is the goal. The danger is only doing it accidentally on drift you meant to keep.

Ignore the attribute (it’s legitimately managed elsewhere). Some fields are owned by another system by design: tags applied by Azure Policy, replica counts owned by an autoscaler, a secret rotated by a separate pipeline. Tell Terraform to stop fighting over them with lifecycle { ignore_changes = [...] }.

resource "azurerm_kubernetes_cluster" "this" {
  name                = "kvprod-aks"
  resource_group_name = azurerm_resource_group.this.name
  location            = var.location
  dns_prefix          = "kvprod"

  default_node_pool {
    name       = "system"
    node_count = 3
    vm_size    = "Standard_D4s_v5"
  }

  identity {
    type = "SystemAssigned"
  }

  lifecycle {
    # The cluster autoscaler owns node_count; do not revert it on every apply.
    ignore_changes = [
      default_node_pool[0].node_count,
      tags["CostCenter"],   # applied by Azure Policy, not by us
    ]
  }
}

ignore_changes is a scalpel, not a mute button. Two rules keep it honest: scope it to specific attributes, never all; and comment why every entry exists. An undocumented ignore_changes is how the next engineer learns that Terraform has silently stopped managing a security-relevant field. If you can’t name the system that legitimately owns the attribute, you shouldn’t be ignoring it — you should be adopting or overriding it.

Strategy When the drift is… Mechanism
Adopt A change you want to keep Edit config to match; apply -refresh-only for state-only values
Override An unauthorized or mistaken change Plain terraform apply restores config’s intent
Ignore Owned by another system by design lifecycle { ignore_changes = [attr] }, scoped and commented

6. A scheduled drift-detection pipeline that reports, not clobbers

The point of automation here is to surface drift on a schedule and notify a human — never to auto-apply. An automated reconciliation that runs terraform apply on a cron is how you turn one manual change into a 3 a.m. page.

The mechanism is terraform plan -refresh-only plus the -detailed-exitcode flag, which makes the result machine-readable:

# .github/workflows/drift-detection.yml
name: drift-detection
on:
  schedule:
    - cron: "0 6 * * 1-5"   # 06:00 UTC, weekdays
  workflow_dispatch: {}

permissions:
  id-token: write           # OIDC to the cloud, no long-lived secrets
  contents: read
  issues: write             # to open a drift report issue

jobs:
  detect:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_wrapper: false

      # Authenticate to Azure via OIDC (federated credentials), read-only role.
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - run: terraform init -input=false

      - name: Refresh-only drift check
        id: drift
        run: |
          set +e
          terraform plan -refresh-only -detailed-exitcode -no-color -input=false -out=drift.tfplan
          echo "exitcode=$?" >> "$GITHUB_OUTPUT"
          set -e

      - name: Render drift summary
        if: steps.drift.outputs.exitcode == '2'
        run: terraform show -no-color drift.tfplan > drift-report.txt

      - name: Open or update drift issue
        if: steps.drift.outputs.exitcode == '2'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const body = "Drift detected by scheduled refresh-only plan.\n\n```\n"
              + fs.readFileSync('drift-report.txt','utf8').slice(0, 60000)
              + "\n```";
            await github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `Terraform drift detected: ${context.workflow} ${new Date().toISOString().slice(0,10)}`,
              body,
              labels: ['drift']
            });

      - name: Fail the run if drift exists
        if: steps.drift.outputs.exitcode == '2'
        run: exit 1

The load-bearing design decisions:

If you run HCP Terraform / Terraform Cloud, you don’t have to build this. It has native, scheduled drift detection at the workspace level (a Plus feature) that runs refresh-only health assessments and surfaces drift in the UI and via notifications. Use it if you have it; the pipeline above is for everyone self-managing state.

7. Preventing drift at the source: policy and RBAC

Detection is treating the symptom. The cure is making out-of-band changes hard to do in the first place, so Terraform stays the only writer of the resources it owns.

Least-privilege RBAC for humans. People should not hold standing write access to Terraform-managed production. Grant Reader by default and require just-in-time elevation (Azure PIM, or the AWS equivalent via assumed roles with approval) for the rare break-glass write. The only identity with durable write access is the CI/CD service principal that runs apply.

# Humans get Reader on the managed subscription/resource group.
az role assignment create \
  --assignee "<user-or-group-object-id>" \
  --role "Reader" \
  --scope "/subscriptions/<sub-id>/resourceGroups/rg-prod"

Deny-by-policy for the changes that matter. Use cloud-native policy to block the mutations you never want made by hand. Azure Policy with a deny effect, or an Azure resource lock (CanNotDelete / ReadOnly) on critical resources, stops the portal click before it happens.

# A management lock that prevents deletion of a production resource group.
az lock create \
  --name "no-delete-prod" \
  --lock-type CanNotDelete \
  --resource-group "rg-prod"

A ReadOnly lock blocks Terraform too. Use CanNotDelete for resources Terraform still manages day-to-day, and reserve ReadOnly for things that are genuinely frozen. Locks protect against accidents; they are not a substitute for RBAC.

Policy-as-code in the pipeline. Gate apply behind OPA/Conftest or Sentinel so even reviewed changes can’t violate org standards (no public blob, mandatory tags, approved regions). This doesn’t stop manual portal drift, but it stops config drift — the slow rot where each PR bends the rules a little.

The combination is what works: RBAC removes the ability to drift, policy and locks remove the easy path, and the scheduled detector catches whatever slips through (a higher-privileged automation, a break-glass session that wasn’t reverted).

8. Incident playbook: reconciling an emergency hotfix

The realistic scenario: production is down, an engineer with break-glass access changes a setting in the portal to restore service, the incident closes. Now the cloud is ahead of Terraform, and your next apply would revert the fix. Here is the controlled sequence.

# 1. SEE the drift before touching anything. Read-only, never an apply.
terraform plan -refresh-only

# 2. CAPTURE what changed, for the record and the post-mortem.
terraform plan -refresh-only -out=incident.tfplan
terraform show -no-color incident.tfplan > incident-drift.txt

3. Decide per-attribute, not per-resource. The hotfix changed a firewall rule (keep it) and also happened to bump a tag (irrelevant). Adopt the firewall change, ignore the tag.

4. Codify the adopted change in config. Edit the .tf so the firewall rule reflects what the engineer set during the incident. This is the step people skip — and skipping it means the next engineer reverts the hotfix, re-triggering the outage. The fix isn’t real until it’s in code and merged.

# 5. PROVE config now matches reality: the plan must be a no-op for the hotfix.
terraform plan
# Expect: no proposed change to the firewall rule. If Terraform wants to
# modify it, your config still disagrees with the live resource -> keep editing.
# 6. RECONCILE state for any cloud-owned values that aren't expressible in config.
terraform apply -refresh-only

7. Open the PR, get the post-incident review, merge. The drift is only truly closed when the change has gone through the same gate every other change goes through. Until then you’ve got an undocumented hotfix that exists in exactly one person’s memory.

The cardinal rule of incident reconciliation: never run a plain terraform apply first. Your opening move is always -refresh-only to observe. A reflexive apply against a freshly hotfixed production is how the cure becomes the second outage.

Going deeper

The sections above get a drift workflow running. This one is for when you own it — the mechanics that decide whether your detector is trustworthy, cheap, and safe, or a noisy 3 a.m. liability.

What a refresh actually does under the hood

“Refresh” sounds vague; it is a precise operation. For every managed resource in state, Terraform calls that resource’s provider Read function (ReadResource in the plugin protocol), which makes one API round-trip to fetch the object’s current attributes, and writes the result into its in-memory copy of state. Data sources are refreshed the same way via their ReadDataSource call. There is no diffing against your config in this phase — refresh only answers “what does the cloud say this object looks like right now?” The config-vs-state comparison is a separate later phase that a -refresh-only plan deliberately skips.

Two consequences fall out of this. First, refresh is roughly one API call per resource, run concurrently up to -parallelism (default 10). A 2,000-resource workspace makes on the order of 2,000 reads every time you refresh — which is why the cost section below matters. Second, refresh is where Terraform notices a resource was deleted out-of-band: the Read returns “not found,” and a normal plan then proposes to recreate it. In a -refresh-only plan that same deletion shows up as the object having “changed outside of Terraform” (specifically, gone), and reconciling it is a decision — recreate via apply, or terraform state rm if it was meant to go.

Why -refresh-only is the safe primitive (and its exit codes precisely)

-refresh-only has been GA since Terraform 0.15.4, and it exists because a full plan is the wrong tool for unattended detection. A full plan refreshes and computes config-vs-refreshed-state, so its proposed actions include reverting the drift — and an operator who reflexively runs apply on that plan clobbers a change someone may have made on purpose. -refresh-only structurally cannot emit a resource create, update, or destroy; the only mutation it can ever produce is to the state file. So even apply -refresh-only cannot touch the cloud. That property — “the worst case is a state-only write” — is exactly what you want running on a schedule with no human watching.

-detailed-exitcode is what turns the human-readable plan into an automatable signal, and its three values are worth memorizing because CI logic hangs off them:

The subtlety that catches people: with set -e in a shell, exit 2 aborts the script before you can branch on it. Capture it deliberately.

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

# -detailed-exitcode returns 2 on drift, so don't let `set -e` abort the script here.
set +e
terraform plan -refresh-only -detailed-exitcode -no-color -input=false -out=drift.tfplan
code=$?
set -e

case "$code" in
  0) echo "No drift — state matches the cloud." ;;
  2) echo "Drift detected — rendering report and alerting."; terraform show -no-color drift.tfplan ;;
  *) echo "Drift check errored (exit $code) — page the on-call, do not ignore." >&2; exit 1 ;;
esac

Note the third branch: an exit 1 is not “no drift.” Treat an errored detector as a failure that needs a human, otherwise a broken run silently reads as green and you stop noticing drift entirely.

Drift on data sources: the kind you can’t import

Managed resources are not the only thing that moves. Data sources are re-read on every refresh, and a changed data result can quietly cascade into changes on the resources that consume it. The classic trap is an AMI (or image) lookup:

# Non-deterministic: resolves to whatever is newest at plan time.
data "aws_ami" "app" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# Deterministic: pinned, so a new AMI release is not read as "drift".
data "aws_ami" "app_pinned" {
  owners = ["amazon"]
  filter {
    name   = "image-id"
    values = ["ami-0abc1234def567890"]   # placeholder — pin the exact AMI
  }
}

With most_recent = true, the day Amazon publishes a new image the data source resolves to a different ID, and any aws_instance that references data.aws_ami.app.id now shows a proposed change — often a replacement. This looks like drift but there is nothing to import or override: a data source has no state you own; it is a live read of someone else’s world. The reconciliation is different in kind — you either pin the query (a specific image ID, a fixed version) so the read is stable, or you accept that this input tracks upstream and let the cascade happen on your schedule, not the cloud’s. This is why a -refresh-only drift report that fingers a data source is telling you to fix your query, not to run an import.

ignore_changes and the refresh-only interaction

ignore_changes is a plan-time instruction: it tells Terraform to ignore the difference between config and prior state for the listed attributes, so a normal plan/apply won’t propose to change them back. It does not stop refresh from reading the real value. That produces a subtlety worth internalizing: a -refresh-only plan compares state to the cloud directly, independent of config, so it still reports drift on an attribute you have chosen to ignore. That is not a bug — the detector is doing its job; your normal apply just won’t act on it. The right response is to filter those known-ignored attributes out of the drift report (or accept them as expected), never to widen ignore_changes to silence the detector.

And when you ignore a noisy attribute, keep a guard on the one next to it that must not drift. A check block turns “this invariant must hold” into a continuous warning without reverting anything:

resource "azurerm_linux_web_app" "this" {
  # ... other config ...

  lifecycle {
    # A separate rotation pipeline owns this setting; don't fight it.
    ignore_changes = [app_settings["LAST_ROTATED"]]
  }
}

# But keep a guard on the security-relevant field you refuse to let drift:
check "https_only_holds" {
  assert {
    condition     = azurerm_linux_web_app.this.https_only
    error_message = "web app dropped HTTPS-only enforcement — investigate drift immediately"
  }
}

The mental model: ignore_changes says “I’ve delegated this attribute — don’t revert it,” while a check says “I still care that this other thing is true — warn me if it isn’t.” Delegating the noisy field and guarding the critical one is how you keep a resource quiet without going blind.

Platform-native drift detection: HCP, Spacelift, env0

If you don’t want to hand-roll the cron job in section 6, several platforms run refresh-only detection for you on a schedule — with an important axis of difference: do they only report, or can they reconcile (auto-apply)?

Platform How it detects Auto-reconcile?
HCP Terraform / TFC Scheduled health assessments run a refresh-only plan per workspace (Plus tier) No — surfaces drift in UI + notifications; you apply
Spacelift Scheduled drift detection runs a proposed run on a cron Optional — reconcile = true triggers a tracked (applying) run
env0 Scheduled drift detection plans per environment Optional — configurable auto-remediation
Atlantis None native (it is PR-driven) No — bolt on a scheduled job like section 6

On Spacelift the whole thing is a resource, which makes the “report, don’t clobber” choice explicit in code:

resource "spacelift_drift_detection" "prod" {
  stack_id  = spacelift_stack.prod.id
  reconcile = false             # DETECT + notify only — never auto-apply in prod
  schedule  = ["0 6 * * 1-5"]   # 06:00 on weekdays
  timezone  = "UTC"
}

Guidance that survives contact with production: enable auto-reconcile only in low-stakes environments (dev/ephemeral) where an unattended apply reverting drift is harmless. In production, keep reconcile = false and put a human in the loop — the entire thesis of this lesson is that reconciliation is a decision, and a scheduler is not qualified to make it.

The cost of looking: refresh at scale

Detection is not free, and the bill is paid in API calls, wall-clock time, and provider rate limits. Because refresh is ~one Read per resource, a monolithic 3,000-resource workspace refreshed hourly makes ~72,000 reads a day — enough to hit AWS API throttling or Azure ARM request limits, which then surface as intermittent 1 exit codes (errors, not drift) and erode trust in the detector. Four levers keep it sane:

Resist the urge to reach for -target to “just check one resource.” It works, but it produces a partial, potentially misleading plan and is documented as a break-glass tool, not a routine one. Prefer splitting the workspace over targeting it.

Notifications a human will actually act on

The GitHub Actions job in section 6 opens an issue on every drift. Run that unchanged and by Friday you have five near-identical issues for the same unreconciled drift, and everyone has learned to ignore the label. Two refinements make the signal survive:

The goal is a detector whose every alert is worth reading. An alert stream people mute is worse than no detector at all, because it manufactures the feeling of coverage without the substance.

Verify

Confirm your drift workflow is sound end to end:

# 1. Refresh-only detects drift and changes nothing real.
terraform plan -refresh-only -detailed-exitcode
echo "exit code: $?"   # 0 = clean, 2 = drift, 1 = error

# 2. An imported resource lands cleanly (no immediate change).
terraform plan        # expect "1 to import ... 0 to change" before apply

# 3. After reconciliation, the workspace is a true no-op.
terraform plan        # expect "No changes. Your infrastructure matches the configuration."

# 4. The drift job's identity genuinely cannot mutate prod (should be denied).
az role assignment list --assignee "<drift-sp-object-id>" \
  --scope "/subscriptions/<sub-id>" -o table

Green means: drift is detected without side effects, imports are non-destructive, reconciliation converges to a no-op, and the detector’s credentials are read-only by construction.

Checklist

Practice challenges

Work these in order — each builds on the last, from a safe read-only check up to reconciling a live incident without a second outage. Try before opening the solution.

<details> <summary><strong>1. (Beginner) Detect drift without changing anything real</strong></summary>

Write the single command that compares state against the live cloud, changes nothing, and returns a machine-readable result — and name the three exit codes.

terraform plan -refresh-only -detailed-exitcode
# exit 0 = no drift · 1 = error · 2 = drift detected

Why: -refresh-only structurally cannot create, modify, or destroy a real resource, and -detailed-exitcode turns the result into something a script (or CI) can branch on safely. </details>

<details> <summary><strong>2. (Beginner→Intermediate) Adopt a manual tag into state — without pushing config</strong></summary>

Someone added a CostCenter tag in the portal and you want to keep it. Which command accepts that cloud value as the new contents of state, without your config overwriting the cloud?

terraform apply -refresh-only
# Accepts the cloud's current values INTO state. It is the reverse of a normal
# apply — it never pushes your config to the cloud.

Why: the number-one misconception is that apply -refresh-only pushes config to the cloud. It does the opposite: cloud → state. (You’d still edit .tf to reflect the tag so a normal plan stays a no-op.) </details>

<details> <summary><strong>3. (Intermediate) Import a hand-created resource so the plan is 0-to-change</strong></summary>

A resource group rg-hotfix was created by hand. Write the declarative import and the command that drafts its config, then state what a clean import shows.

# imports.tf
import {
  to = azurerm_resource_group.hotfix
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-hotfix"
}
terraform plan -generate-config-out=generated.tf   # draft the HCL
terraform plan                                      # expect: 1 to import, 0 to change

Why: import blocks are reviewable in a PR and run as part of plan, so you see the result first; a clean import that shows 0 to change proves your config already matches the live resource. </details>

<details> <summary><strong>4. (Intermediate→Advanced) Tolerate autoscaler drift correctly</strong></summary>

The cluster autoscaler owns node_count. Stop Terraform reverting it on every apply — scoped and documented — and say what you must not write.

lifecycle {
  # The cluster autoscaler owns node_count; do not revert it on every apply.
  ignore_changes = [default_node_pool[0].node_count]
}
# NEVER: ignore_changes = all  — that abandons management of the whole resource.

Why: ignore_changes is a scalpel — scope it to the named attribute and comment the owning system. = all silently stops Terraform managing every field, including the security-relevant ones. </details>

<details> <summary><strong>5. (Advanced) Build a scheduled detector that reports, never applies</strong></summary>

Name the three load-bearing decisions that make a scheduled drift job safe, and the command at its core.

1. Core command: terraform plan -refresh-only -detailed-exitcode  (cannot mutate the cloud)
2. Gate the alert on exit code == 2; open/UPDATE one issue, never issues.create per run
3. Give the job a READ-ONLY cloud identity (Azure Reader / AWS ReadOnly) — it physically cannot apply

Why: defense in depth — the read-only identity means even a fat-fingered apply in the job cannot touch production, so safety doesn’t rest on the flag alone. Dedup keeps the alert worth reading. </details>

<details> <summary><strong>6. (Advanced) Reconcile an incident hotfix without a second outage</strong></summary>

An engineer changed a firewall rule in the portal to end an outage. Put the reconciliation steps in the correct order.

1. terraform plan -refresh-only         # OBSERVE first — never a plain apply
2. terraform show > incident-drift.txt  # CAPTURE for the post-mortem
3. Decide per attribute                 # keep the firewall change; ignore the incidental tag
4. Edit .tf to codify the kept change   # the fix isn't real until it's in code
5. terraform plan                       # PROVE it's a no-op for the hotfix
6. terraform apply -refresh-only        # adopt any cloud-only values into state
7. Open PR → review → merge             # closed only when it's been through the gate

Why: the opening move is always -refresh-only to observe; a reflexive apply reverts the hotfix and re-triggers the outage. The drift is closed only when the change is merged, not when production is back up. </details>

Common beginner mistakes

Glossary

Pitfalls and next steps

The recurring failure modes are predictable. Running terraform apply as the first response to drift, clobbering a change someone made on purpose. Treating apply -refresh-only as if it pushes config to the cloud, when it does the opposite. Reaching for ignore_changes = all and quietly abandoning resources Terraform should still manage. Auto-applying drift on a schedule and discovering at 3 a.m. that “reconciliation” and “outage” can be the same event. And the slow one: never merging hotfixes back into code, so config and reality diverge a little more with every incident.

From here, wire state-change alerting to the source — Azure Activity Log or AWS CloudTrail alerts on write operations against Terraform-managed resource groups give near-real-time drift signal instead of a daily batch. Layer policy-as-code at the apply gate to stop config drift, and tune detection cadence per environment (hourly for production, daily elsewhere). The end state: drift is rare because it’s hard to create, visible within minutes when it happens, and reconciled by a deliberate human decision — never by an automation that mistakes your production for a thing it’s allowed to overwrite.

TerraformDriftImportGitOpsReconciliation
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments