11 min read

How to Build a Single Source of Truth for Infrastructure with Terraform

Learn how to track Terraform infrastructure as a reliable source of truth. Inspect state, find drift, import existing resources, and organize visibility across environments.

ByAndré Ribeiro· Founder, Obelinf
How to Build a Single Source of Truth for Infrastructure with Terraform
How to Build a Single Source of Truth for Infrastructure with Terraform · September 30, 2026
On this page

If you want Terraform to be a single source of truth for infrastructure, first be clear about the scope. Terraform configuration in Git records the intended settings for resources your team chooses to manage. Terraform state connects those resource addresses to real objects. Neither one is a complete inventory of everything running in a cloud account, which may also contain resources from other states, console changes, older tools, or urgent manual work.

To track Terraform managed infrastructure reliably, connect three views: configuration in version control, Terraform state for resource identity, and the live provider API for what currently exists. This guide shows the commands to inspect those views, compare them, bring existing resources under management, and decide what belongs in a separate inventory.

Three views combine to explain Terraform infrastructureGit configurationwhat you intend to manageTerraform stateaddresses and object IDsProvider APIwhat exists nowReviewed Terraform planA reliable inventory depends on knowing which configuration and state own each object.

Know what Terraform tracks

Terraform configuration is the versioned description of resources and settings you intend to manage. Terraform state maps each resource address in that configuration to a real object, such as an AWS VPC ID. During a plan, the provider reads the remote object and Terraform compares the observed attributes with configuration and state to decide whether it needs to propose changes.

These roles matter when someone asks for an inventory. A .tf file may describe a resource that has not been applied yet. State may refer to a resource that was removed from the configuration but has not yet been destroyed. The provider may report an object that was modified outside Terraform. None of these views alone answers every question about the full estate.

Find resources in the current state

Run these commands from the directory that uses the backend and workspace you want to inspect. Initialize first if this is a fresh checkout:

terraform init
terraform workspace show
terraform state list

terraform state list prints resource addresses in the selected state. To inspect one address and see its stored attributes, use:

terraform state show aws_vpc.production

If the address uses for_each or count, the address includes its key or index, for example aws_subnet.private["app-a"]. Copy the exact address from terraform state list. If the state contains module resources, the address may start with module.network..

For root module outputs, use terraform output or consume JSON from a script:

terraform output
terraform output -json

Outputs are values deliberately exported by the root module. They are useful for passing connection details to another stage, but they are not a complete listing of every resource attribute and they may expose sensitive values. Avoid printing or saving output in public CI logs.

Remember that these commands inspect one selected state at a time. They do not search every backend, cloud account, workspace, or repository. Maintain a clear registry of environment to backend and working directory so an operator knows where to look, and do not assume a workspace name maps to every state your organization owns.

Keep configuration and state in a team workflow

Put Terraform configuration and module versions in Git. Use pull requests to review changes, and store shared state in a remote backend with access control, encryption, history, and locking. Do not commit terraform.tfstate, backup state files, saved plans, or credentials to the repository. State and plan files may include sensitive values even when the CLI masks them in normal output.

A practical change cycle is:

terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform show -no-color tfplan

Review the plan artifact before applying the same saved plan:

terraform apply tfplan
rm -f tfplan

Protect tfplan as carefully as state while it exists, since saved plans can contain configuration and sensitive values. In CI, publish a redacted human readable plan for review, keep the binary artifact restricted, and apply only after approval against the same commit and state. A lock prevents two operations from writing the same backend state at once, but it does not coordinate different states or reserve shared network addresses.

Build a maintainable Terraform root

A root module is the directory where you run Terraform for one deployment. It owns provider configuration, input values, the backend, and calls to child modules. A child module groups resources that form one reusable part of the system. Keep the root small enough that a reviewer can see the account, region, environment, and major components without reading every resource implementation.

For a simple AWS network root, a repository might contain:

infra/
  modules/
    network/
      main.tf
      variables.tf
      outputs.tf
  environments/
    dev/
      backend.tf
      backend.hcl
      versions.tf
      main.tf
      variables.tf
      dev.tfvars
    prod/
      backend.tf
      backend.hcl
      versions.tf
      main.tf
      variables.tf
      prod.tfvars

The module defines the reusable resource and its explicit inputs. In modules/network/main.tf:

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
    Owner       = var.owner
  }
}

In modules/network/variables.tf, declare the values that must be supplied by each environment:

variable "vpc_cidr" {
  type        = string
  description = "IPv4 range for this environment's VPC."
}

variable "environment" {
  type        = string
  description = "Short environment name such as dev or prod."
}

variable "owner" {
  type        = string
  description = "Team responsible for the VPC."
}

In modules/network/outputs.tf, export only values another root or service actually needs:

output "vpc_id" {
  value       = aws_vpc.main.id
  description = "ID of the managed VPC."
}

An environment root pins provider compatibility, selects a region, and passes environment specific inputs. For example, environments/dev/versions.tf can contain:

terraform {
  required_version = ">= 1.10.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

In environments/dev/main.tf, configure credentials outside the source files and call the local module:

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      ManagedBy   = "Terraform"
      Environment = var.environment
      Owner       = var.owner
    }
  }
}

module "network" {
  source      = "../../modules/network"
  vpc_cidr     = var.vpc_cidr
  environment = var.environment
  owner       = var.owner
}

Store environment values in a nonsecret file such as dev.tfvars, and keep credentials in the cloud identity chain or a CI secret provider:

aws_region   = "us-east-1"
vpc_cidr     = "10.40.0.0/16"
environment  = "dev"
owner        = "platform"

Production should have its own values and backend configuration. Do not put access keys, passwords, or state credentials in .tfvars files. Treat any value that may enter resource state as sensitive even if Terraform masks it in the terminal.

From the repository root, format the code. Then initialize and plan from the environment root:

cd infra
terraform fmt -recursive
cd environments/dev
aws sts get-caller-identity
terraform init -backend-config=backend.hcl
terraform validate
terraform plan -var-file=dev.tfvars -out=tfplan
terraform show -no-color tfplan

Check the identity output before planning. It shows the AWS account and assumed role for the active credential chain; stop if it is not the account intended for this root. The exact resource example is intentionally small. The important pattern is that dev supplies its own inputs, while the module source provides shared implementation. If a module is published in a registry rather than stored beside the roots, add a module version constraint and upgrade it in a reviewed change. Terraform’s dependency lock file records selected provider versions, so commit each root’s .terraform.lock.hcl and upgrade providers deliberately rather than refreshing them on every run.

Separate environments and ownership

Give each independently managed environment a clear configuration path and state boundary. A small repository might look like this:

infra/
  modules/                 shared resource patterns
  environments/
    dev/                   development account and state
    staging/               staging account and state
    prod/                  production account and state

Shared modules keep resource patterns consistent. Environment roots provide their own account, region, inputs, and backend key. Separate state boundaries limit the impact of a change and make it clear which team or pipeline can change an object. Do not manage the same cloud object from two independent state files, even if both configurations point at the same ID.

A state boundary should follow operational ownership. Split states when environments have different approvers, cloud accounts, access policies, failure impact, or deployment cadence. Keep tightly coupled resources together when they are changed and reviewed as one unit. Splitting every resource into a separate state creates a web of dependencies and remote state reads that is hard to reason about. Keeping everything in one global state increases lock contention, blast radius, and the number of people who can read sensitive resource details.

Workspaces can represent several instances of the same root configuration, but they are not access control boundaries. If development and production need different permissions and approval flows, separate roots and backend keys are easier to audit. Always check terraform workspace show before inspecting or changing a workspace based state, and make the environment visible in the plan and resource tags.

For each state, record its owner, repository path, backend, workspace or key, cloud account, region, and deployment identity. Keep account identifiers and state locations discoverable in an access controlled index. This index is not a second resource definition; it is a directory that tells operators where authoritative code and state live.

Use remote state outputs only when one Terraform root truly needs values produced by another. Share a small set of intentional outputs rather than treating another team’s full state as a general database. For values such as account IDs or approved network ranges that need broader access than a single Terraform deployment, use a purpose built registry or inventory with its own ownership rules.

Give each state a durable backend

A shared state file is part of the control plane for your infrastructure. It can contain private addresses, resource metadata, credentials returned by a provider, and enough identifiers to operate resources. A local terraform.tfstate is useful while learning, but do not use it as the team handoff mechanism. For an AWS based workflow, an S3 backend is one option. Create its bucket first in a small bootstrap process or through a platform team. Enable S3 versioning, block public access, restrict the bucket to the Terraform identities, and enable encryption. The backend bucket should not be created by the same state that depends on it.

In an environment root, declare the backend type in backend.tf:

terraform {
  backend "s3" {}
}

A partial backend file such as environments/dev/backend.hcl supplies the nonsecret location and locking settings:

bucket       = "acme-terraform-state"
key          = "environments/dev/network.tfstate"
region       = "us-east-1"
encrypt      = true
use_lockfile = true

Use a different key for production, such as environments/prod/network.tfstate. The backend file can be version controlled if it contains only backend location details. Do not put access keys in it. The S3 backend uses the AWS credential chain, so local users can select a profile and CI can assume a narrowly scoped role through its identity provider.

Terraform 1.10 or later supports S3 native lock files with use_lockfile. The backend role needs access to the state object and the associated .tflock object. S3 versioning gives operators a recovery path if state is overwritten or deleted by mistake. Restrict state access separately from ordinary source code access, and test recovery procedures before relying on them during an incident.

Initialize a new root with:

terraform init -backend-config=backend.hcl

When adopting remote state from an existing local configuration, read the prompt and use the migration option deliberately:

terraform init -migrate-state -backend-config=backend.hcl

Terraform will offer to copy the existing state into the configured backend. Confirm the bucket and key are the intended destination before accepting. -reconfigure instead forgets the previously initialized backend settings and points the directory at a new backend. It is useful when intentionally selecting another already managed state, but it does not migrate data and can make an empty state appear to own nothing. Never use it as a quick fix for an unexpected empty plan.

A backend change changes where Terraform reads its identity map. Record the backend type, bucket or organization, key or workspace, environment, and state owner in the team’s state index. If you split one state into several, plan the ownership transfer resource by resource. Do not copy the same objects into two active states.

Compare desired and observed infrastructure

Terraform normally refreshes managed resource information from provider APIs while preparing a plan. Start with a normal plan and read each proposed action. A change in the plan can mean the configuration changed, a person changed the cloud object, or the provider now reports a different value. Determine which before applying.

When the goal is to see how live changes would update state without proposing configuration changes, ask for a refresh only plan:

terraform plan -refresh-only

This command proposes state updates based on observations. It does not change the remote infrastructure. If the refreshed values are accurate and you want Terraform state to record them, review and run:

terraform apply -refresh-only

Afterward, run a normal terraform plan to see whether configuration still calls for a change. If a manual change was temporary and configuration remains authoritative, the ordinary plan may propose reverting the remote object. Do not apply that plan until the change owner confirms that restoration is safe.

A Terraform drift check compares code, state, and provider observations before actionReview configurationRefresh and planInspect provider stateApprove, correct, or investigateA plan is evidence for review, not proof that every resource in the account is managed.

Bring existing resources under management

If a resource already exists in an account, write configuration that matches the settings you intend to keep before importing it. Then associate the real object with exactly one Terraform resource address. For an existing AWS instance, the command looks like this:

terraform import aws_instance.legacy i-0123456789abcdef0
terraform state show aws_instance.legacy
terraform plan

Import reads the existing object into the selected state. It does not generate a complete configuration or decide whether the current settings are good. Review the next plan. If Terraform proposes changes, either update the configuration to reflect approved settings or schedule a deliberate change. Never edit the state JSON by hand, and do not import one real object into two state addresses.

For a large estate, import in small batches. Tag or otherwise identify the source account, owner, and intended state before each batch. Keep a record of imported, intentionally unmanaged, and retired items so a skipped object is not mistaken for a failed import.

Refactor resource addresses without replacing objects

A resource address is Terraform’s name for an object inside a particular state. Renaming aws_instance.app to aws_instance.web changes the address even if the AWS instance ID stays the same. Without a migration instruction, Terraform can interpret the new address as a new instance and the old address as an object to remove.

For a code reviewed rename or move, add a moved block:

moved {
  from = aws_instance.app
  to   = module.application.aws_instance.web
}

Run a plan and confirm it reports an address move rather than a destroy and create. Keep the moved block available to every environment state that may still use the old address. For a one time manual state operation, terraform state mv OLD_ADDRESS NEW_ADDRESS exists, but a moved block records the migration in version control and is easier for teammates to repeat safely.

Similarly, terraform state rm ADDRESS stops Terraform from managing an object but does not destroy the real object. Only use it when intentionally transferring ownership to another state or tool, and coordinate the receiving owner so the resource does not become untracked.

Track resources outside Terraform

Terraform is a strong record of infrastructure its configurations manage, but a complete operations inventory may include switches, cables, IP assignments, warranties, cloud resources from other tools, and services provisioned manually. Those objects need a clear home even if no Terraform provider manages them. A cloud asset inventory can help discover provider resources. An IPAM or network inventory can hold approved prefixes, VLANs, devices, and their relationships across cloud and physical environments.

Keep the ownership boundary explicit. Terraform may own an AWS subnet’s lifecycle while an IPAM system owns the organization’s allocation plan and records which environment the range serves. In that design, the pipeline reads the approved CIDR before creating the subnet, then reconciliation checks that the live subnet matches the documented assignment. Obelinf can hold those network records; its IP address management page describes the subnet and address context that can complement Terraform state.

Do not claim that an inventory system tracks every cloud resource unless it actually imports or discovers those resource types. Define its coverage, update cadence, and exception workflow. That gives operators a useful answer to both questions: what does Terraform manage, and what else exists around it?

Make the inventory dependable

Start by listing your Terraform roots and states, naming an owner for each, and confirming the remote backend and lock behavior. Then review a sample resource using terraform state show, compare it with a normal plan, and confirm that an operator can find related unmanaged assets in their documented inventory.

The goal is not to force every infrastructure fact into Terraform state. It is to make the boundary understandable: Git describes intended Terraform changes, state connects resource addresses to real objects, providers report live attributes, and an inventory covers relationships and assets outside those state files. When each record has an owner and a reviewable update path, your team can track changes without confusing code, state, and the live environment.

Frequently Asked Questions

How do I see which resources Terraform manages?
Run `terraform state list` from the initialized working directory for the selected state. Use `terraform state show ADDRESS` to inspect one tracked resource, and remember that the list only includes objects recorded in that state.
Is Terraform state the source of truth for infrastructure?
Terraform configuration in version control describes intended settings, state maps resource addresses to real objects, and provider APIs report observed infrastructure. A reliable workflow uses all three and reviews differences before applying changes.
How do I find infrastructure drift with Terraform?
Run `terraform plan` to compare configuration, state, and provider observations. Use `terraform plan -refresh-only` to review state changes that reflect external updates. A plan shows proposed actions, it does not apply them.
How do I manage an existing resource with Terraform?
Write a resource block that matches the existing object, then import that object into one Terraform resource address. Review the next plan carefully so Terraform does not replace settings you meant to preserve.
Should I keep every environment in one Terraform state?
No. Use a separate state boundary for each environment and ownership group that needs independent access, approval, or lifecycle. Share versioned modules and intentional outputs rather than binding unrelated environments to one large state.

Stop reaching for a spreadsheet

Obelinf keeps every subnet, device, circuit, and rack in one live source of truth, with audit logs and a topology view. Free for personal use.

Related Articles