Terraform Drift Detection: Find It, Fix It, Prevent It

Platform Engineering Cloud Governance Terraform Infrastructure as code Drift detection Cloud governance Devops Automation Multi cloud
Published: Sep 17, 2026
Terraform Drift Detection: Find It, Fix It, Prevent It

TL;DR: Terraform drift detection is the practice of comparing what your state file believes exists against what your cloud providers actually have. You find it by running a refresh-only plan, you automate it with a scheduled CI job that reads the plan’s exit code, and you fix it by deciding — deliberately — whether reality or your configuration is the source of truth.

Key Takeaways:

  • Drift is a reconciliation problem, not a bug. Terraform state is a record of what Terraform last saw. When something changes a resource outside the workflow, the record and the world disagree, and every subsequent plan is built on a stale premise.
  • Two flags do most of the work. -refresh-only produces a plan whose only goal is updating state to match remote objects, and -detailed-exitcode turns the result into a number your CI can branch on.
  • Nightly detection beats discovering drift mid-incident. A scheduled pipeline that opens an issue on exit code 2 converts a silent divergence into a ticket someone owns.
  • Fixing drift is a decision, not a command. Accepting reality, restoring the configuration and importing an unmanaged resource are three different answers, and choosing the wrong one destroys something.
  • At scale the problem changes shape. Forty state files across three providers and a dozen accounts is no longer a Terraform question — it is a visibility question, and per-repository CI does not answer it.

Automation workflow dashboard showing infrastructure pipeline runs

What drift actually is

Terraform keeps a state file that records what it believes your infrastructure looks like. That record is accurate only for as long as Terraform is the sole thing changing your cloud. The moment something else makes a change — a console edit, another tool, the provider itself — the state file describes a world that no longer exists.

The gap matters more than it first appears. Terraform does not compare your configuration against your cloud; it compares your configuration against state, then refreshes state from the provider API. If that refresh surfaces an unexpected difference, every planned change downstream is being calculated against a premise you have not reviewed. A plan that looks like a two-line change can quietly include a resource replacement nobody asked for.

A concrete version: during a Friday-night incident, an engineer resizes an RDS instance in the AWS console to get through the weekend. Nothing breaks. The following Thursday, an unrelated pull request touching a security group runs terraform apply, and the instance silently resizes back to the value in the configuration. The drift was not the incident — the drift was the six days nobody knew.

Where drift comes from

HashiCorp’s own guidance names three sources: manual changes, cloud provider updates, and unauthorized modifications. In practice those three cover a wider set of everyday situations than the phrasing suggests.

Console changes during incidents are the most common and the most forgivable. Under pressure, people use the fastest tool available, and that is rarely a pull request. The problem is not the change; it is that no step in the incident process records it.

Other tools touching the same resources. A Kubernetes operator that manages load balancers, a backup tool that sets tags, a security platform that remediates open ports — each is doing its job, and each writes to objects Terraform believes it owns.

Provider-side changes. Cloud providers update defaults, deprecate settings, and occasionally add fields to existing resources. None of this appears in your repository, and all of it can surface as a diff.

Manual IAM edits, which deserve their own mention because they are the least visible and the highest consequence. Permissions granted by hand during onboarding tend to survive for years.

Partial applies. An apply that fails halfway leaves some resources changed and some not. Terraform records what it managed to do, but a failure mid-run is a reliable way to end up with state and reality disagreeing about a handful of objects.

Detecting drift from the command line

The core mechanism is refresh-only mode. The documentation describes it as creating “a plan whose goal is only to update the Terraform state and any root module output values to match changes made to remote objects outside of Terraform” — useful, in HashiCorp’s own framing, when you have intentionally changed remote objects outside the usual workflow while responding to an incident.

# Terraform 1.9.x
terraform plan -refresh-only

This reads every managed resource from the provider API and reports what it found, without proposing any change to your infrastructure. It answers one question: has anything moved?

Running it by hand is fine for a single workspace and useless as a practice. What makes it automatable is pairing it with -detailed-exitcode, which the documentation says “changes the exit codes and their meanings to provide more granular information about what the resulting plan contains”:

Exit codeMeaning
0Succeeded with empty diff — no changes, no drift
1Error
2Succeeded with non-empty diff — changes present
# Terraform 1.9.x — exit 0 = clean, 1 = error, 2 = drift found
terraform plan -refresh-only -detailed-exitcode -no-color

One flag combination to avoid: -refresh=false disables the state synchronisation that drift detection depends on. The docs are explicit that it “causes Terraform to ignore external changes, which could result in an incomplete or incorrect plan,” and that it cannot be used in refresh-only mode at all, “because it would effectively disable the entirety of the planning operation.” If a pipeline runs with -refresh=false for speed, that pipeline is not detecting drift regardless of what it is named.

Automating Terraform drift detection in CI

A scheduled job turns detection from something you remember to do into something that happens. The pattern is small: run a refresh-only plan on a timer, branch on the exit code, and raise something a human owns.

# .github/workflows/drift.yml
name: Drift detection

on:
  schedule:
    - cron: "0 3 * * *"   # 03:00 UTC daily
  workflow_dispatch:

permissions:
  contents: read
  issues: write

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

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.9.8

      - run: terraform init -input=false

      - id: plan
        continue-on-error: true
        run: |
          terraform plan -refresh-only -detailed-exitcode -no-color > drift.txt 2>&1
          echo "exitcode=$?" >> "$GITHUB_OUTPUT"

      - if: steps.plan.outputs.exitcode == '2'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const body = fs.readFileSync('drift.txt', 'utf8');
            await github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `Infrastructure drift detected — ${new Date().toISOString().slice(0,10)}`,
              body: '```\n' + body.slice(0, 60000) + '\n```',
            });

Two details matter more than the YAML. continue-on-error: true is required, because exit code 2 is a successful run reporting drift, and without it the step fails and the issue never gets created. And the credentials this job uses should be read-only — a drift detector has no business being able to change anything.

HashiCorp also offers this as a managed capability: HCP Terraform health assessments run periodic refresh-only operations across workspaces, and check blocks let you assert functional conditions — connectivity, security settings — rather than only configuration equality. If you are already on HCP Terraform, start there rather than building the above.

Why this stops working at scale

The workflow so far assumes one state file and one pipeline. Most organisations running Terraform seriously have neither.

A realistic mid-size estate is thirty to fifty state files, split across repositories by team and environment, spanning more than one cloud provider and a dozen or more accounts or subscriptions. Per-repository drift detection still works mechanically — each pipeline correctly reports on its own workspace. What disappears is the answer to the question leadership actually asks: is our infrastructure consistent right now? Thirty-eight green pipelines and two red ones, scattered across repositories with different owners and different schedules, do not add up to an answer.

The second problem is that drift is not evenly interesting. A tag added by a backup tool and an IAM policy widened by hand both show up as exit code 2, and both generate an identical issue. Without a way to rank drift by consequence, teams learn to close the tickets. That is the failure mode to design against — not undetected drift, but detected drift that nobody reads.

This is the visibility gap that unified multi-cloud management is meant to close: one inventory across providers and accounts, with configuration divergence treated as a property of the estate rather than a property of each pipeline. At Cloud2gether we see the same pattern the tooling above implies — the teams that struggle most are not the ones without drift detection, but the ones whose detection is distributed so widely that no one holds the whole picture. Explore how Cloud2Gether unifies AWS, Azure, and Google Cloud management.

Fixing drift: three answers, not one

Once drift is found, the remediation is a judgement about which version of the world is correct. Getting this wrong is how drift detection causes an outage instead of preventing one.

SituationActionWhat it doesWhat it costs
The manual change was correct and should stayterraform apply -refresh-onlyUpdates state to match reality; infrastructure untouchedConfiguration and reality still disagree — the code must be updated separately or the next apply reverts it
The manual change was wrongterraform applyRestores infrastructure to match configurationDestroys the manual change, including any fix it was making
The resource was created outside Terraform entirelyimport block, then applyBrings an unmanaged resource under managementRequires writing the configuration first; not every resource is importable

The first row is the one people get wrong. Applying a refresh-only plan reconciles the record, not the code. If the console change was a legitimate permanent fix, accepting it into state without also updating the configuration simply moves the problem: the next ordinary apply will revert it, and this time it will look intentional.

Import deserves a specific warning. The CLI command imports resources into state only and does not generate configuration — you must write the matching resource block by hand first, or use an import block, which can be reviewed through the normal plan and apply workflow. The docs also note that each remote object should be bound to a single resource address, and that importing the same object more than once “may result in unwanted behavior.” Not all resources support import at all; that depends on the provider.

Preventing drift before it starts

Detection is a safety net. The durable fix is reducing the number of ways a change can happen outside the workflow.

Make the console read-only by default. Most engineers do not need write access to production consoles day to day. Removing it eliminates the largest category of drift outright, and does so without requiring anyone to remember a policy.

Provide a break-glass path that records itself. People will need emergency write access, and a process that pretends otherwise gets bypassed. Time-bound elevated access that automatically opens a ticket on use converts an invisible change into a tracked one — which is the entire objective.

Set the account structure up so this is enforceable. Guardrails applied at the account and organisation level are what make “read-only by default” a configuration rather than an aspiration. This is easier to establish early than to retrofit; we covered the foundations in Why Startups Need a Cloud Landing Zone from Day One.

Treat drift as an operational metric. Track how long drift survives between detection and resolution, and review it like any other reliability number. Teams that measure it close drift in days; teams that do not, close it never.

Frequently asked questions

What is Terraform drift detection?

It is the practice of comparing the infrastructure recorded in Terraform state against what the cloud provider actually has, in order to find changes made outside the Terraform workflow. It is performed with a refresh-only plan, which updates state to match remote objects without changing any infrastructure.

How often should drift detection run?

Daily is a reasonable default for production. The useful question is not how often drift occurs but how long you are willing to operate on a stale picture — a nightly schedule caps that at 24 hours, which is short enough that the change is still fresh in someone’s memory.

Does terraform plan detect drift on its own?

Partly. A normal plan refreshes state before calculating changes, so drift influences the result, but it is mixed in with your intended changes and easy to miss. Refresh-only mode isolates the question, which is why it is the right tool for a scheduled check.

Can drift detection change my infrastructure?

terraform plan -refresh-only cannot — it only reads. The risk lies in what happens next: terraform apply -refresh-only writes to state, and a plain terraform apply writes to infrastructure. Give the detection job read-only credentials and the question does not arise.

Is drift always a problem?

No. Some drift is provider-side noise, and some is a correct emergency fix that has not made it back to code yet. What is always a problem is drift that nobody has classified, because the cost of unreviewed divergence compounds with every subsequent apply.

Continue Your Cloud Governance Journey

Drift is one symptom of a larger question: how much of your estate can you actually see at once? Continue your reading here:

➡️ A Guide to Managing Multi-Cloud Complexity and Costs

➡️ How to Avoid Cloud Vendor Lock-In in Multi-Cloud Strategies

Share:

About the Author:

Michel Borges is the CEO of Cloud2Gether, a technology leader specialized in cloud solutions and SaaS platforms. With a strong background in software engineering and a business degree from ESADE, he combines deep technical expertise with strategic leadership. Michel has built and scaled digital products, led high-impact teams, and driven innovation in the cloud ecosystem across Europe and beyond.

Cloud2Gether

Ready to simplify your cloud?

Cloud2Gether gives your team unified visibility, AI-driven automation, and modern governance for your cloud infrastructure.

Get Started Free →