How Terraform Works
Terraform is a declarative infrastructure-as-code tool: you describe the desired shape of your infrastructure in configuration, and Terraform figures out how to make reality match. Understanding the moving parts behind that promise — the configuration, the state file, the provider plugins, and the plan/apply engine — turns Terraform from a black box into a predictable, debuggable system. This page builds the mental model that every other concept in this section hangs off of.
The big picture
At its heart, Terraform compares three things: what you want (your configuration), what it last recorded (state), and what actually exists (the live cloud APIs). It then computes the smallest set of changes that reconciles them.
The architecture is small and layered. Terraform Core is a single statically-linked binary that parses HCL, builds a dependency graph, and orchestrates execution. It never talks to a cloud directly — instead it speaks to providers, which are separate plugins that translate Terraform’s generic resource operations into concrete API calls.
┌──────────────┐
│ .tf config │ desired state (HCL)
└──────┬───────┘
│ parse + evaluate
▼
┌──────────────────────────┐ ┌──────────────────┐
│ Terraform Core │◄────►│ terraform.tfstate │ recorded state
│ graph · diff · apply │ └──────────────────┘
└──────────┬───────────────┘
│ gRPC (plugin protocol)
▼
┌──────────────────────────┐
│ Providers (plugins) │ aws, azurerm, google, kubernetes...
└──────────┬───────────────┘
│ HTTPS API calls
▼
┌──────────────────────────┐
│ Cloud / SaaS APIs │ actual infrastructure
└──────────────────────────┘
This same architecture is shared by OpenTofu, the open-source fork of Terraform. The configuration language, state format, and provider protocol are compatible, so the model below applies whether you run
terraformortofu.
Configuration: declaring intent
You write configuration in HCL2. A provider block configures a plugin, and resource blocks declare the objects you want to exist. Terraform reads every .tf file in the working directory and merges them into one configuration.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "primary"
}
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
Notice aws_subnet.app references aws_vpc.main.id. That reference is not just a string substitution — it tells Terraform that the subnet depends on the VPC, which feeds directly into the dependency graph.
Initialization: installing providers
Before Terraform can do anything useful it must download the provider plugins declared in required_providers. That happens during terraform init, which populates the .terraform/ directory and writes a .terraform.lock.hcl lock file pinning exact provider versions and checksums.
terraform init
Output:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.61.0...
- Installed hashicorp/aws v5.61.0 (signed by HashiCorp)
Terraform has been successfully initialized!
State: Terraform’s memory
State is the bookkeeping that makes Terraform incremental. After it creates a resource, Terraform records the real-world ID and attributes in a JSON state file (by default terraform.tfstate, or a remote backend like S3). On the next run it reads state to know which objects it manages and what their last-known values were, so it can map your config to real infrastructure without re-discovering everything.
State is also why Terraform can delete things: if a resource block disappears from config but still exists in state, Terraform knows that object should be destroyed.
Treat state as sensitive and authoritative. It can contain secrets in plain text, and hand-editing it can corrupt your deployment. Use a remote backend with locking (for example S3 with DynamoDB, or Terraform/HCP Cloud) for any shared environment.
The dependency graph and the diff
When you run terraform plan, Core does three things in order:
| Step | What happens |
|---|---|
| Build graph | Resources and their references become nodes/edges in a directed acyclic graph (DAG). |
| Refresh | Core asks providers to read the current state of each managed object from the live API. |
| Diff | Core compares desired config against refreshed state and produces a set of create/update/delete actions. |
The graph guarantees correct ordering: the VPC is created before the subnet that references it, and on destroy the order reverses. Independent resources are visited concurrently (controlled by -parallelism, default 10).
terraform plan
Output:
Terraform will perform the following actions:
# aws_subnet.app will be created
+ resource "aws_subnet" "app" {
+ cidr_block = "10.0.1.0/24"
+ id = (known after apply)
+ vpc_id = (known after apply)
}
# aws_vpc.main will be created
+ resource "aws_vpc" "main" {
+ cidr_block = "10.0.0.0/16"
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
(known after apply) marks values that don’t exist yet because the upstream resource hasn’t been created — a direct consequence of graph ordering.
Apply: making it real
terraform apply walks the same graph and executes the planned actions, calling provider functions that hit the cloud API. As each object is created or modified, Core writes the result back into state immediately, so a partial failure leaves an accurate record of what already exists.
terraform apply -auto-approve
Output:
aws_vpc.main: Creating...
aws_vpc.main: Creation complete after 2s [id=vpc-0a1b2c3d]
aws_subnet.app: Creating...
aws_subnet.app: Creation complete after 1s [id=subnet-09f8e7d6]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Because Terraform is declarative and idempotent, running apply again with no config changes produces “No changes” — the diff against refreshed state is empty. If someone edits a resource outside Terraform, the next plan surfaces that drift as a proposed change to bring reality back in line.
Best Practices
- Always run
terraform planand read it beforeapply; never let automation apply an unreviewed plan in production. - Store state in a remote backend with locking to prevent concurrent writes from corrupting it.
- Commit the
.terraform.lock.hclfile so every machine and CI run installs identical provider versions. - Express dependencies through resource references rather than
depends_onwhenever possible, so the graph stays accurate and minimal. - Keep providers pinned with
~>version constraints and upgrade deliberately, reading provider changelogs first. - Use
terraform plan -out=plan.tfbinthenapply plan.tfbinin CI so the applied changes are exactly what was reviewed.