Skip to content
Stribog

Delivery

All writing

Terraform vs Ansible: State You Own, State You Converge

Terraform needs a database to map config to reality. Ansible needs none. That one difference decides who owns drift, what a dry run proves, and exit cost.

Stribog20 min read

This comparison is usually settled on the wrong axis. Sort the two tools by whether they are declarative and the answer predicts nothing — and the real boundary then gets discovered one incident at a time, in the place where neither tool was looking.

Procedural versus declarative sorts modules, not tools

Retire that framing carefully — narrowed, not abolished. Ansible's glossary, under Resource Model, is unambiguous: the resource models "are idempotent meaning change commands are not run unless needed, and Ansible will bring the system back to a desired state regardless of the actual state -- rather than you having to tell it how to get to the state." Convergence to a declared end state.

The same project draws the limit, with one word most comparisons drop: "Most modules accept parameters that describe the desired state of the system." Most, not all. The clearest exception says so in its own source: in ansible-core 2.21.4, ansible.builtin.command declares check_mode support as partial, noting that "while the command itself is arbitrary and cannot be subject to the check mode semantics it adds creates/removes options as a workaround."

The axis is real, then, but cuts *inside* Ansible's module set rather than between the tools. A playbook of file, package and service tasks is as declarative as anything in HCL; one leaning on command and shell is not, and the module documentation tells you which you are writing.

The axis that does separate them is structural. Terraform's documentation states that "State is a necessary requirement for Terraform to function". Ansible's architecture page says the opposite about itself: "there are no servers, daemons, or databases required." One tool remembers what it converged on last time. The other does not. The rest is consequence.

Both converge on a desired state. Only one keeps a record of what it converged on, and the hexagons are what each record cannot see.

Terraform: a database of the world, and what it compares

Knowing that terraform plan compares three things rather than two is most of the skill in reading its output. By default Terraform "Reads the current state of any already-existing remote objects"; it "Compares the current configuration to the prior state and noting any differences"; and it "Proposes a set of change actions that should, if applied, make the remote objects match the configuration."

Three inputs: the live read, the prior state, the configuration. The default response to a mismatch is equally explicit — "By default, Terraform detects any difference in the current settings of a real infrastructure object and plans to update the remote object to match configuration." Configuration wins. Someone changed a security-group rule in a console last Thursday; the next apply quietly changes it back, and whether that is repair or outage depends on facts Terraform does not have.

The counter-move arrives in the same breath. Refresh-only planning mode "creates 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" — for when "you've intentionally changed one or more remote objects outside of the usual workflow (e.g. while responding to an incident)". One mode asserts configuration over the world; the other reconciles Terraform's records with it. Reconciling the record is not adopting the change: refresh-only leaves the configuration untouched, so a refreshed value that still disagrees with configuration gets planned back on the next normal apply — unless you edit the configuration to match, or list that attribute under ignore_changes.

Three declarations carry most of what the record will and will not notice.

hcl
# Three declarations that decide what Terraform's record will and will not notice.

resource "aws_instance" "api" {
  ami           = var.ami_id
  instance_type = "m7i.large"

  tags = {
    Name = "api"
  }

  lifecycle {
    # Drift you are deliberately choosing NOT to own. The scope is narrower than
    # it looks: "Terraform only ignores attributes defined by the resource type",
    # and you "can't apply ignore_changes to itself or to any other
    # meta-arguments". It is also wider than "drift declined": these arguments
    # are considered when planning a create and ignored when planning an update,
    # so a later edit to var.ami_id will NOT replace this instance either.
    # Everything listed here becomes somebody else's problem, so write down whose.
    ignore_changes = [ami, tags]
  }
}

# Stop managing an object WITHOUT destroying it. Deleting the resource block on
# its own plans a destroy, and prevent_destroy does not save you: that rule
# "doesn't prevent Terraform from destroying the resource if you remove the
# resource configuration". After this applies, Terraform "no longer manages that
# infrastructure's lifecycle" — the object keeps running, unmanaged.
removed {
  from = aws_instance.legacy_worker

  lifecycle {
    destroy = false
  }
}

# Adopt an object that already exists. `to` is required, and so is exactly one
# of `id` or `identity`. The docs table marks only `to` as required, but the
# v1.16.4 decoder rejects a block carrying neither: "At least one of 'id' or
# 'identity' must be specified." They are two ways of naming the same object and
# cannot both be set — "You cannot use the id argument and identity argument in
# the same import block." Which forms exist is the provider's business: the AWS provider
# documents id for aws_instance (Terraform >= 1.5) and identity = { id = "..." }
# (>= 1.12), so either names this instance. The id form is used here.
import {
  to = aws_instance.api
  id = "i-0123456789abcdef0"
}
ignore_changes decides what drift you decline to own; removed relinquishes an object without destroying it; import adopts one that already exists.

Ansible: no database, by design

Ansible's loop has no prior to compare against, and the architecture page explains why mechanically: Ansible "executes these modules (over SSH by default), and removes them when finished." The module lands, reads the host as it finds it, acts if the host differs from the task, then is deleted. The only truth is the host, read fresh every run.

That is a genuine architectural strength, usually undersold. Nothing goes stale because nothing is kept: no state file to lock, corrupt, leak or migrate. A host rebuilt from scratch converges identically to one running three years, neither having a history the tool consults — the discipline that makes GitOps controllers trustworthy at scale.

The cost lands in one place: absence carries no meaning. Delete a task from a playbook and nothing happens to the host, because Ansible has no record saying it once put something there. Deletion exists only where you wrote it — the 2.21.4 file module's state: absent is the branch where "directories will be recursively deleted, and files or symlinks will be unlinked." Terraform derives destruction from a diff against its record; Ansible cannot, having no record to diff.

Omission is not neutral in the other direction either. In ansible-core 2.21.4 the file module defaults state to "the current state of the file if it exists, directory if recurse=yes, or file otherwise" — and on that file branch the path "will be modified if it exists but will NOT be created if it does not exist." Nor does it pass quietly: the module rejects any prior state that is not a file or a hard link, so a missing path fails the task — file (...) is absent, cannot continue — in a live run and under --check alike. Creating a file is state: touch, copy or template.

Failure modes: where each model disagrees with the world

Both tools fail in the same shape — reporting clean while the world moved — by different routes. Take Terraform's four separately; conflating them manufactures false coverage.

  • Write-only arguments. Since Terraform 1.11, providers can declare "managed resource attributes that are configured by practitioners but are not persisted to the Terraform plan or state artifacts." In the schema, absent from the record — so nothing downstream of state reports drift on them. The exception runs the other way: a provider marking one RequiresReplace forces a replacement on *every* plan.
  • ignore_changes. Deliberate blindness, correctly used and easily forgotten. It suppresses only attributes the resource type itself defines, and cannot be applied to itself or to another meta-argument — a narrower escape hatch than it looks.
  • Objects never imported. Not in state means not in the comparison. The plan says nothing about them, and saying nothing looks exactly like clean.
  • Plaintext local state. "If you are developing with Terraform locally, Terraform stores your state in a plaintext file, which includes any secret values you defined in your configuration." The guidance is to "Treat your state file as sensitive data" — the database that makes drift detectable is also an artefact to protect like a credential store.

One of those is routinely got backwards. HashiCorp's SDKv2 guide tells provider *authors* that "It's essential that all attributes defined in the schema are recorded and kept up-to-date in state." That is advice to implementers, not a guarantee to users — and write-only arguments are the documented counter-example in the same product. The real bound on drift detection is what the provider stores, not the size of the schema.

Ansible's blind spots concentrate in what teams most want to trust: the dry run. Check mode is documented as a simulation, and the third sentence is the one that matters — "Modules that support check mode report the changes they would have made. Modules that do not support check mode report nothing and do nothing." A clean --check run is not evidence that nothing would change, only that nothing reported a change. The same page adds that check mode "will not generate output for tasks that use conditionals based on registered variables" — so a conditional chain goes dark exactly where the interesting behaviour lives.

command is the sharpest case, and the 2.21.4 wording is specific: check_mode support partial, diff_mode support none. Check mode works when passing creates or removes — the module then "will check for the existence of the file and report the correct changed status" — and "If these are not supplied, the task will be skipped." Skipped, not evaluated: a changed_when there never sees a result. Diff mode is selective too, and because --diff "can reveal sensitive information, you can disable it for a task by specifying diff: false."

Drawing the boundary so drift has an owner

The consensus answer here — "they are complementary, use both" — is true and nearly worthless, stopping one sentence short of the question that matters: which tool owns which fact, and who owns the facts neither models. A boundary that fits on one page:

  1. Terraform owns anything a provider API can read back. Resource identity, lifecycle, network and account topology. The test is mechanical: if a provider can READ it, it belongs in the record that tracks it.
  2. Ansible owns in-OS state on a host that already exists. Packages, files, services, users, kernel parameters — anything inside a machine's operating system belongs to the tool that reads that machine directly.
  3. The seam is an artefact, not a convention. terraform output -json, rendered into an inventory by a script that fails loudly on a missing output. A boundary maintained by habit erodes the first time somebody is in a hurry.
  4. The gap gets a named owner. Properties outside every provider schema, write-only arguments inside it, in-OS state no task asserts, anything a command task did without a creates guard. Neither record models this class, so by default it is nobody's — which is how it becomes an unfunded liability.

The fourth point is load-bearing, and organisational rather than technical. Name a role — the platform owner signing the boundary document — with a standing review of what each side declined to model: the ignore_changes list, the write-only arguments in play, the resources still outside state, the command tasks with no creates guard. Four lists, reviewed on a schedule. Same failure mode that makes golden paths without enforcement decorative: a standard nobody is accountable for is a preference.

Worked example, not a case study. The two zones are the easy part; the dashed region is where the accountability decision actually gets made.
bash
#!/usr/bin/env bash
set -euo pipefail

# The handoff contract. Terraform holds these facts in state already; this script
# renders them into an Ansible inventory so the boundary is a file somebody can
# read, diff and review — not a convention that lives in three people's heads.
#
# What must NOT cross: secrets (a local state file is plaintext), and any in-OS
# fact Ansible will read fresh from the host anyway. Copying either one creates a
# second authority for the same fact, which is how the two records start lying.

required=(app_hosts db_hosts environment)

outputs="$(terraform output -json)"

for key in "${required[@]}"; do
  if ! jq -e --arg k "$key" 'has($k)' <<<"$outputs" >/dev/null; then
    echo "handoff contract broken: terraform output '$key' is missing" >&2
    exit 1
  fi
done

# Redirect stdout to a .json FILE and point -i at that file. This is a STATIC
# inventory read by the yaml inventory plugin, whose default extensions include
# .json — which is why hosts is a mapping here. Do not chmod +x this script and
# hand it to -i directly: that invokes the script plugin instead, which speaks
# --list/--host and wants hosts as a LIST. Different plugin, different shape.
#
# all is emitted FIRST on purpose: the yaml plugin's own note is that all must be
# the first entry when you set vars on it.
jq '{
  all: { vars: { env: .environment.value } },
  app: { hosts: (.app_hosts.value | map({ (.): { ansible_host: . } }) | add // {}) },
  db:  { hosts: (.db_hosts.value  | map({ (.): { ansible_host: . } }) | add // {}) }
}' <<<"$outputs" >"${INVENTORY_OUT:-./inventory.json}"
The seam as a file. Terraform's outputs become an Ansible inventory, and a missing output is a hard failure rather than an empty group.

Proving drift: the commands, both sides

Drift you cannot produce on demand is an opinion. Both tools emit a scheduled, machine-readable report on their free tier with no control plane — but you build both, and on the Ansible side you switch the evidence on first.

Terraform's version is an artefact, not terminal output. Its machine-readable plan format carries a resource_drift field — "a description of the changes Terraform detected when it compared the most recent state to the prior saved state" — reached with terraform show -json <FILE>. Drift becomes a file you retain, diff and attach to a ticket.

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

# Refresh-only drift report, free tier, no control plane.
#
# The saved plan is the EVIDENCE and is never applied. Applying a refresh-only
# plan writes the remote values INTO state, which means the drift is absorbed
# rather than resolved and tomorrow's report comes back empty with nothing fixed.
# Reconciling the record is a deliberate decision a human makes, not a side
# effect of running the report.

stamp="$(date -u +%Y-%m-%dT%H-%M-%SZ)"
out_dir="${DRIFT_DIR:-./drift}"
mkdir -p "$out_dir"

plan_file="$out_dir/$stamp.tfplan"
report="$out_dir/$stamp.drift.json"

terraform init -input=false >/dev/null
terraform plan -refresh-only -input=false -out="$plan_file"

terraform show -json "$plan_file" |
  jq --arg stamp "$stamp" --arg ws "$(terraform workspace show)" '{
    recorded_at: $stamp,
    workspace:   $ws,
    drift:       (.resource_drift // []),
    drift_count: ((.resource_drift // []) | length)
  }' >"$report"

count="$(jq -r '.drift_count' "$report")"
echo "drift objects: $count -> $report"

# Scope note for whoever reads this file in six months: an empty array proves
# only that Terraform saw no drift in attributes the PROVIDER stores in state.
# Write-only arguments never reach plan or state, and objects never imported are
# not in the comparison at all. Note what does NOT shrink this array:
# ignore_changes only stops a NORMAL plan from proposing an update toward
# configuration. This array is built by comparing prior state to refreshed
# state, so a drifted attribute you ignore still shows up here — which is the
# useful behaviour. You keep seeing the drift you declined to correct.

# Exit status is a SIGNAL, not a build failure. Wire it to a ticket, not a rollback.
[ "$count" -eq 0 ]
A scheduled drift report on free Terraform. The plan file is the evidence and is never applied — applying it absorbs the drift instead of surfacing it.

Two things this report is not. It is not HCP Terraform's health assessment, a hosted feature: health assessments "are available in HCP Terraform Standard and Premium editions." And the two do not measure the same thing — that page says so: "Configuration drift differs from state drift. Drift detection does not detect state drift." Keep the words apart in your runbook.

Ansible's equivalent is a scheduled convergence run where the changed count *is* the drift signal. The catch: nothing is recorded until you ask. ansible-core's configuration definitions give DEFAULT_LOG_PATH a null default described as "File to which Ansible will log on the controller. When not set the logging is disabled." Callbacks are the same story — CALLBACKS_ENABLED defaults to an empty list because "not all callbacks need enabling, but many of those shipped with Ansible do as we don't want them activated by default."

ini
[defaults]
# Off by default. ansible-core's config definition for DEFAULT_LOG_PATH ships a
# null default and states plainly: "When not set the logging is disabled."
# Rotate and permission this file like any other audit artefact.
log_path = /var/log/ansible/convergence.log

# Also off by default. CALLBACKS_ENABLED is an empty list because "many of those
# shipped with Ansible do [need enabling] as we don't want them activated by
# default". junit ships in ansible-core and writes one machine-readable file per
# run, which is what turns a convergence run into retainable evidence. Two
# caveats before you rely on it: the callback reads its output directory from the
# JUNIT_OUTPUT_DIR environment variable rather than from this file, defaulting to
# ~/.ansible.log, and a changed task counts as a JUnit PASS unless
# JUNIT_FAIL_ON_CHANGE=true — the changed bit is only inside system-out. log_path
# above is the artefact that records changed unconditionally.
callbacks_enabled = ansible.builtin.junit

# A task skipped under check mode is the single most important line in a dry-run
# report, so do not hide it.
display_skipped_hosts = True
ansible.cfg turning on the evidence that ships off. Without these two lines a convergence run leaves nothing behind to retain.
yaml
---
- name: Converge the application host
  hosts: app
  become: true
  gather_facts: true

  tasks:
    - name: Remove the retired drop-in, because absence is never inferred
      # Deletion happens ONLY where state: absent is written. Deleting this task
      # from the playbook deletes nothing from the host — there is no record of
      # a previous run from which a removal could be derived.
      ansible.builtin.file:
        path: /etc/systemd/system/api.service.d/10-legacy.conf
        state: absent
      notify: Reload systemd

    - name: Ship the unit drop-in directory
      # state is always explicit. Omitting it is not a null instruction: in
      # ansible-core 2.21.4 the default is "the current state of the file if it
      # exists, directory if recurse=yes, or file otherwise", and the file branch
      # FAILS on a missing path — "file (<path>) is absent, cannot continue" —
      # rather than creating it. Use touch, copy or template to create.
      ansible.builtin.file:
        path: /etc/systemd/system/api.service.d
        state: directory
        owner: root
        group: root
        mode: "0755"

    - name: Write the runtime configuration
      ansible.builtin.template:
        src: api.env.j2
        dest: /etc/api/api.env
        owner: root
        group: api
        mode: "0640"
      # --diff "can reveal sensitive information", so it is disabled here rather
      # than left to whoever runs the playbook next.
      diff: false
      notify: Restart api

    - name: Rebuild the search index only when the marker is absent
      # command ships check_mode support "partial" and diff_mode support "none".
      # creates is the documented workaround: under --check the module checks for
      # the file and reports the correct changed status. Supply neither creates
      # nor removes and "the task will be skipped" — a skipped result never has
      # its changed_when evaluated at all. Note what is NOT here: no changed_when.
      # Steady state is changed=false with rc=0, stdout "skipped, since … exists",
      # and NO skipped key — so a hand-rolled rc-and-skipped expression is reading
      # a field that does not exist on this result. The module's own flag is
      # already the drift signal; overriding it
      # inverts the thing this run exists to measure.
      ansible.builtin.command:
        cmd: /usr/local/bin/rebuild-index --output /var/lib/api/index.bin
        creates: /var/lib/api/index.bin

    - name: Report what the package set would change, without changing it
      # check_mode: true pins this task to dry-run in EVERY run, including a live
      # one. The check-mode changed flag IS the preview, so nothing overrides it:
      # a changed_when: false here would force the registered result and the
      # recap to changed=false and the task would report nothing at all.
      # Upgrades belong to a different playbook with a human on it.
      ansible.builtin.apt:
        name: nginx
        state: present
      check_mode: true
      register: package_preview

  handlers:
    - name: Reload systemd
      ansible.builtin.systemd_service:
        daemon_reload: true

    - name: Restart api
      ansible.builtin.systemd_service:
        name: api
        state: restarted
The convergence model written honestly: explicit removal, a command task carrying creates so check mode means something, and a task pinned to dry-run.

One more piece of the free-tier picture, stated narrowly. AWX — which its own README calls "one of the upstream projects for Red Hat Ansible Automation Platform", not the platform itself — has cut no release since 24.6.1 on 2 July 2024, and that README says "Releases of this project are now paused during a large scale refactoring." So the free, self-hostable control plane is static for now: scheduled convergence with retained evidence gets built from systemd timers, log_path and a callback — the files above.

What an auditor asks, and which tool can answer

Assessors do not ask which tool is more declarative. They ask two questions teams collapse into one, and the tools answer them differently.

The first is about deviations from settings. NIST SP 800-53 Rev. 5 CM-6 item c asks an organisation to "Identify, document, and approve any deviations from established configuration settings" for an organisation-defined set of system components, based on organisation-defined operational requirements. A list of differences from a declared baseline — Terraform's resource_drift, Ansible's changed tasks — is the *identify* step only; document and approve stay a human record either way. Neither tool emits the list out of the box, and the Ansible side emits nothing until log_path and a callback are set.

The second is about out-of-band change, and it sits in guidance rather than a control statement. Rev. 5's discussion for CM-3 says configuration change control "includes changes to baseline configurations, configuration items of systems, operational procedures, configuration settings for system components, remediate vulnerabilities, and unscheduled or unauthorized changes" — guidance text, quoted as guidance. CM-3 item e separately asks that records of configuration-controlled changes be retained for an organisation-defined period, and CM-2 asks for a "current baseline configuration of the system" under configuration control. That is what a two-tool split strains: the baseline lives in two places, the retained record in two formats.

The honest answer is layered, and the layering is the point: Terraform's refresh-only report covers objects it holds in state, in attributes the provider stores; the Ansible convergence log covers in-OS state on hosts the inventory names, for modules that report changes; the gap is covered by a named owner and a review cadence, not a tool. Mapping that onto control families is the 800-53 exercise for a cluster you run yourself, carrying the trap of a benchmark read as evidence rather than a checklist: a clean report is only as broad as what was looked at.

Exit ramps: what you carry out of each tool

Leaving Ansible is mechanically cheap and intellectually expensive. There is no state to migrate because there is no state — but the playbooks encode years of knowledge about hosts, and that knowledge is the asset, not the YAML. Licence-wise nothing to plan around: the ansible/ansible repository ships the GNU General Public License Version 3.

Leaving Terraform is the opposite shape: the state file is both what you must carry and what makes leaving tractable. Two blocks do the work. removed relinquishes management without destroying — "To remove a resource from Terraform state without destroying it, replace the resource block with a removed block", after which "Terraform no longer manages that infrastructure's lifecycle." import runs the same road in reverse. Generating the matching configuration rather than writing it? terraform plan -generate-config-out is still documented as experimental, warning that "the generated configuration format may change in future versions" — a poor foundation for HCL you are about to make authoritative.

The licence arithmetic is where teams reason from a headline instead of the file. Terraform 1.6.0 and later ships the Business Source License 1.1 with "International Business Machines Corporation (IBM)" as Licensor, and its Additional Use Grant bars offering the work to third parties "on a hosted or embedded basis in order to compete with IBM Corp.'s paid version(s)". The same licence then carves out the case most readers are in: "Hosting or using the Licensed Work(s) for internal purposes within an organization is not considered a competitive offering." Nor is it permanent — the Change Date is "Four years from the date the Licensed Work is published", the Change License is MPL 2.0, and it "applies separately for each version" with a Change Date that "may vary for each version" — no single calendar date applies to Terraform as a whole. OpenTofu ships under the Mozilla Public License 2.0. If that is the path you are pricing, the registry, not the binary, is the hard part, and pricing an exit as a number applies here too.

The long game: which record survives the team

Over a decade the durable asset is not the tool. Latest stable releases on 30 September 2026 were Terraform v1.16.4 (23 September 2026), ansible-core v2.21.4 (8 September 2026) and OpenTofu v1.12.6 (19 August 2026); all three will move several times before these decisions are revisited. What survives is the shape of the record you keep and the boundary you wrote down.

Terraform's state is a genuine organisational asset — a machine-readable inventory of what exists, which nobody reconstructs from memory after a reorganisation. It is also an access-control liability, because locally it is plaintext including configured secrets. Ansible's playbooks are a different asset: they encode how a host is supposed to be and stay runnable long after their authors leave, depending on no stored history. Neither covers the other. The team that keeps both, explicit about which is authoritative for which fact, is the one still able to answer questions about its estate in 2036 — the argument that makes provisioning you own end to end worth the setup cost.

Keep three artefacts and the tooling question stops being load-bearing: the boundary document naming who owns each zone and the gap; the drift and convergence reports, retained; and the four lists of what each side declined to model. All three are portable across tools, and no vendor comparison page will hand you one.

§FAQ/Common questions

Frequently asked

What is the real difference between Terraform and Ansible?

Whether the tool keeps a persistent model of the world. Terraform's documentation states that "State is a necessary requirement for Terraform to function" and that it "requires some sort of database to map Terraform config to the real world". Ansible's architecture documentation states that "there are no servers, daemons, or databases required" and that modules are executed over SSH and removed when finished. That one structural fact decides the rest: Terraform can derive a deletion from a diff against its record and Ansible cannot, Terraform can report drift on attributes the provider stores in state while Ansible reads the host fresh every run, and the two leave behind entirely different artefacts when you go. The procedural-versus-declarative framing does not sort the tools, because Ansible's own glossary describes its modules as idempotent resource models that bring a system back to a desired state.

Is Ansible declarative or procedural?

Both, and the module documentation tells you which one you are writing. Ansible's glossary entry for Resource Model says the models "are idempotent meaning change commands are not run unless needed, and Ansible will bring the system back to a desired state regardless of the actual state". Its architecture page then scopes that with one important word: "Most modules accept parameters that describe the desired state of the system." Most, not all. In ansible-core 2.21.4, ansible.builtin.command declares check_mode support as partial with the note that the command itself "is arbitrary and cannot be subject to the check mode semantics", and diff_mode support as none. So the axis cuts inside Ansible's module set rather than between Ansible and Terraform: a playbook of file, package and service tasks is declarative, and one built on command and shell is not.

Can I detect infrastructure drift with Terraform for free?

Yes, locally, with two commands and a scheduler. Run terraform plan -refresh-only -out=FILE, then terraform show -json FILE, and read the resource_drift field — documented as "a description of the changes Terraform detected when it compared the most recent state to the prior saved state". Never apply that plan: applying a refresh-only plan writes the remote values into state, absorbing the drift rather than resolving it. Two limits matter. The report is bounded by what the provider actually records in state, so write-only arguments (Terraform 1.11+, "not persisted to the Terraform plan or state artifacts") and objects never imported are invisible to it. Attributes under ignore_changes are not: that meta-argument only stops a normal plan proposing an update, so drift in an ignored attribute still appears in this array. And it is not the same artefact as HCP Terraform's drift detection, which is a health assessment available in Standard and Premium editions, and whose own page says it "does not detect state drift".

Does ansible-playbook --check prove that nothing will change?

No, and Ansible's own documentation is explicit about why. Check mode runs without making changes, and "Modules that support check mode report the changes they would have made. Modules that do not support check mode report nothing and do nothing." A clean run therefore proves that nothing reported a change, which includes every module that was never going to report one. The same page notes that check mode "will not generate output for tasks that use conditionals based on registered variables". ansible.builtin.command is the sharpest case: in ansible-core 2.21.4 it declares check_mode support partial and diff_mode support none, and its notes say check mode works when passing creates or removes, while "If these are not supplied, the task will be skipped" — so a changed_when on that task never sees a result at all.

Where should the boundary between Terraform and Ansible sit?

Terraform owns anything a provider API can read back — resource identity, lifecycle, network and account topology. Ansible owns in-OS state on a host that already exists — packages, files, services, users, kernel parameters. Make the seam an artefact rather than a convention: render terraform output -json into an inventory with a script that fails when a required output is missing, and let no secret cross it: a local state file is plaintext including any secret values defined in the configuration. Then name an owner for the drift that neither tool models. That class includes properties outside every provider schema, write-only arguments inside it, in-OS state no task asserts, and anything a command task did without a creates guard. A gap with no owner is not a gap, it is an unfunded liability.

terraform vs ansibleansible vs terraformansible and terraformansible with terraformterraform state drift detectionansible check mode limitations

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.