Terraform Architecture: Core, Providers, State, and How Teams Split It
Terraform architecture has a core that parses configuration and builds a dependency graph, provider plugins that call cloud APIs, a state file mapping configuration to real resources, and a backend that stores and locks that state. In production, the key design decision is how state is split, because each state file is a blast radius.
The moving parts
Terraform has a small core and a large edge. The core is the CLI binary. It parses configuration, builds a dependency graph, compares desired configuration with recorded state, and decides what to create, update, replace, or destroy. It knows nothing about any particular cloud.
Everything cloud-specific lives in providers. A provider is a separate plugin process that the core starts and talks to over a gRPC plugin protocol. The provider defines resource schemas and translates create, read, update, and delete calls into API requests. When a plan looks wrong, the cause is usually in one of three places: your configuration, the provider's interpretation of the API, or state that no longer matches reality.
OpenTofu, the community fork maintained under the Linux Foundation, shares this architecture, so everything below applies to both.
State is the system of record, and the main hazard
State maps each resource address in your configuration to a real object ID in the provider. Without it, Terraform cannot know that aws_instance.web is a particular instance. State also caches attribute values and records dependency information that the configuration alone does not hold.
Three properties make state the thing to design around. It is authoritative: if a resource drops out of state, Terraform stops managing it, and a later apply may try to create a duplicate. It is sensitive: state stores attribute values in plain text, including generated passwords and keys, so access to state is access to secrets. And it is shared: two people applying against the same state at once can corrupt it, which is why locking exists.
Backends: where state lives and how it is locked
A backend decides where state is stored and whether operations are locked. Local state is fine for experiments and wrong for anything shared. Remote backends store state in an object store or a managed service and take a lock for the duration of a plan or apply.
Common choices include an S3 bucket (older setups paired it with a DynamoDB table for locking, while recent Terraform releases can lock with a lock file in the bucket itself), Azure Blob Storage, Google Cloud Storage, and HCP Terraform. Whatever the choice, enable versioning on the storage so a corrupted or mistaken state write can be rolled back, encrypt it, and restrict who can read it as tightly as you restrict production credentials.
Plan and apply: the graph
On plan, Terraform refreshes state by reading each managed object from the provider, evaluates configuration, and builds a graph where edges come from references between resources. It then diffs desired against current and prints the actions. Apply walks the same graph, running independent nodes in parallel (ten at a time by default) and dependent nodes in order.
Two consequences matter in practice. First, a plan is only as good as the refresh: if the provider cannot see something, the plan cannot account for it. Second, the dangerous lines in a plan are replacements, shown as destroy-then-create or create-then-destroy. A one-character change to an immutable attribute can replace a database. Teams that read plans in review should be trained to search for replacements first.
Saving a plan to a file and applying exactly that file is the safest pattern in CI. It guarantees that what was reviewed is what runs, rather than a fresh plan computed minutes later against a changed world.
Modules: an interface, not a folder
A module is any directory of configuration. The root module is where you run Terraform. Child modules are called from it with inputs and expose outputs. Good modules behave like library functions: a narrow set of inputs, sensible defaults, outputs other code needs, and no hidden reads of global context.
The common mistakes are modules that wrap a single resource and add nothing, and modules so general they take dozens of variables and contain conditional logic for every case. Both make plans harder to read. Pin module versions, and treat a module version bump like a dependency upgrade, with its own plan reviewed across every caller.
Repository and state layout for blast-radius control
The architecture decision that matters most is how state is split, because a state file is a blast radius. Everything in one state can be changed by one apply, is locked by one lock, and is refreshed by every plan. One giant state means slow plans, contention between teams, and a mistake that can reach everything at once.
Teams usually split state along two axes. By environment, so dev, staging, and production never share a state or a credential. And by layer or lifetime: networking and identity foundations that change rarely, shared platform services, and application stacks that change daily. Each gets its own root module and its own state. Lower layers expose outputs, and higher layers read them through data sources or the terraform_remote_state data source, ideally through a narrow published interface rather than reading another state wholesale.
A typical repository layout has a modules directory of versioned reusable modules, and a live or environments directory where each leaf is a small root module with its own backend configuration. Tools such as Terragrunt exist largely to reduce the repetition this layout creates. CLI workspaces can separate state for near-identical copies, but most teams avoid them for environment separation because they share one configuration and one backend, which makes it easy to apply production changes from the wrong workspace.
Operational habits that the architecture assumes
Run plan in CI on every pull request and post it for review. Apply from a pipeline, not from laptops, so credentials and versions are consistent. Detect drift on a schedule by running plan against production and alerting on unexpected diffs. Use moved blocks for refactors so resources are renamed in state rather than destroyed and recreated, and import blocks to adopt existing infrastructure. Test modules before they reach shared environments; emulators help here within limits, as covered in emulating cloud services locally.
Infrastructure changes are changes to production. Infrastructure as code improves the evidence available for review, but it does not replace deciding who approves which kind of change; the distinction between configuration management and change management is worth keeping clear. Configuration changes have caused large outages on their own, as in the Google Cloud Storage 2019 metadata incident.
Frequently asked questions
- What are the main components of Terraform architecture?
- Terraform core parses configuration, builds a dependency graph, and computes a plan. Providers are separate plugins that translate resource operations into API calls. State records which real objects belong to which configuration addresses. Backends decide where state is stored and how it is locked. Modules package configuration into reusable units with inputs and outputs.
- Why should Terraform state be split into multiple files?
- Each state is a blast radius. One apply can change everything in it, one lock serialises everyone using it, and every plan refreshes all of it. Splitting by environment and by layer keeps production away from development, lets teams work in parallel, speeds up plans, and limits how much a single mistaken apply can damage.
- Is it safe to store secrets in Terraform state?
- Assume state contains secrets, because it stores resource attributes in plain text, including generated passwords and keys. Protect it accordingly: encrypt the backend, restrict read access as tightly as production credentials, enable versioning, and avoid committing state to version control. Where possible, have resources read secrets from a secrets manager at runtime rather than passing them through Terraform.
- Should you use Terraform workspaces for environments?
- Many teams avoid CLI workspaces for separating dev, staging, and production. Workspaces share one configuration and one backend, so environments cannot easily differ in structure or credentials, and applying to the wrong workspace is an easy mistake. Separate root modules per environment, each with its own backend and credentials, make the boundary explicit and harder to cross by accident.