The Code Codex
Theme
← Guides

Terraform

Infrastructure as code with Terraform: the plan and apply cycle, state and locking, the dependency graph, modules, refactoring without destroying resources, testing, the BSL license and OpenTofu, with a local lab using Docker.

21 Oct 2026· 17 min· 19 sections

In this guide · 19 sections

What it is

Terraform is an infrastructure as code tool created by HashiCorp in 2014. You describe in .tf files the infrastructure you want (networks, machines, databases, DNS, queues, users, even GitHub repositories), and Terraform compares that description with what exists, shows what will change and, once you confirm, makes the API calls needed.

The files are written in HCL (HashiCorp Configuration Language), a declarative language designed to be read by people. The name comes from terraforming: turning an empty planet into a habitable place.

The problem

Without a tool like this, infrastructure is born from clicks in the cloud console (the infamous ClickOps) or from imperative scripts. Both paths lead to the same problems:

  • Nobody knows exactly what exists. The knowledge lives in the head of whoever clicked.
  • Environments drift apart. Staging and production should be identical, but each one got different manual tweaks.
  • Changes are not reviewed. A firewall rule that is too open goes to production without anyone looking at it.
  • Scripts do not know the current state. Running the script that creates a VM twice creates two VMs.
  • Rebuilding is archaeology. If an account is compromised or a region goes down, rebuilding the environment takes days.

Terraform solves this by treating infrastructure as code: versioned in git, reviewed in pull requests and applied idempotently (applying twice gives the same result as applying once).

How it works

Terraform itself (the core) knows nothing about AWS, Azure or Docker. It reads the configuration, builds a dependency graph, compares it with the state and delegates each operation to a provider: a plugin that translates “create this resource” into calls to a service’s API.

.tf files

desired state

Terraform core

graph + plan

State

what Terraform

has created

aws provider

github provider

docker provider

AWS API

GitHub API

Local Docker

.tf files

desired state

Terraform core

graph + plan

State

what Terraform

has created

aws provider

github provider

docker provider

AWS API

GitHub API

Local Docker

Providers are downloaded from the Terraform Registry during terraform init. That is why the same workflow works for anything with an API: there are official and community providers for clouds, databases, SaaS, monitoring and much more.

The workflow

adjust

ok

Write

.tf

terraform init

downloads providers

terraform plan

computes the diff

Review

the plan

terraform apply

runs and saves state

adjust

ok

Write

.tf

terraform init

downloads providers

terraform plan

computes the diff

Review

the plan

terraform apply

runs and saves state

CommandWhat it does
terraform initDownloads providers and modules, configures the state backend. Runs once per directory (and again when providers change)
terraform fmtFormats files in the standard style
terraform validateChecks syntax and types, without touching the cloud
terraform planReads the real state of resources, compares it with the code and shows what will change. Changes nothing
terraform applyExecutes the plan (asks for confirmation) and updates the state
terraform destroyDestroys everything this configuration manages

The split between plan and apply is the heart of Terraform’s safety: every change is shown before it happens.

Lab: a container with Terraform

To experiment without a cloud account, the Docker provider creates containers on your machine. This example is based on HashiCorp’s official getting-started tutorial. With Docker and Terraform installed (brew install hashicorp/tap/terraform or the binary from developer.hashicorp.com), create a folder with a main.tf:

terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {}
resource "docker_image" "nginx" {
name = "nginx:latest"
keep_locally = false
}
resource "docker_container" "web" {
name = "web"
image = docker_image.nginx.image_id # reference: creates the dependency
ports {
internal = 80
external = 8080
}
}
Terminal window
terraform init # downloads the provider and creates .terraform.lock.hcl
terraform plan # "Plan: 2 to add, 0 to change, 0 to destroy."
terraform apply # confirm with "yes" and open http://localhost:8080

Now watch Terraform react to changes:

Terminal window
# 1. Change external = 8080 to 8081 in main.tf and run:
terraform plan # the container will be replaced (-/+): a port does not change "in place"
# 2. Delete the container outside Terraform and run plan again:
docker rm -f web
terraform plan # Terraform notices it is gone and plans to recreate it
terraform destroy # cleans everything up

The second experiment shows drift: reality changed without going through the code, and the next plan reveals the difference.

Reading a plan

The plan is what you review before every change, and each symbol carries a different weight:

SymbolMeansRisk
+CreateLow
~Update in placeMedium: check the attribute
-/+Replace: destroy and create againHigh: the resource disappears for a while and may lose data (a database, a disk)
+/-Replace by creating the new one first (with create_before_destroy)Medium
-DestroyHigh
<=Read a data sourceNone

The line that deserves the most attention is # forces replacement: it points to the attribute that forces the replacement. Renaming a managed database, for example, usually means deleting the database.

In teams, the standard is to save the plan and apply exactly what was reviewed:

Terminal window
terraform plan -out=tfplan
terraform apply tfplan # applies this plan, without recomputing

The language: the main blocks

BlockPurpose
terraformRequired Terraform version, providers and state backend
providerA provider’s configuration (region, credentials, alias)
resourceAn object Terraform creates and manages
dataAn object that already exists and is only read (the latest AMI, a DNS zone)
variableAn input to the configuration
outputA value exposed at the end of apply or to another configuration
localsIntermediate values, to avoid repeating expressions
moduleUse of a reusable set of resources

An AWS example putting the blocks together:

variable "environment" {
type = string
default = "staging"
validation {
condition = contains(["staging", "production"], var.environment)
error_message = "Use staging or production."
}
}
locals {
tags = { Environment = var.environment, ManagedBy = "terraform" }
}
data "aws_availability_zones" "available" {}
resource "aws_s3_bucket" "assets" {
bucket = "acme-assets-${var.environment}"
tags = local.tags
}
output "bucket_arn" {
value = aws_s3_bucket.assets.arn
}
Many similar resources: count and for_each
resource "aws_s3_bucket" "logs" {
for_each = toset(["app", "audit", "billing"])
bucket = "acme-logs-${each.key}"
}

Prefer for_each over count for collections. With count, each resource is identified by its position (logs[0], logs[1]); removing the first item of the list shifts all the others, and Terraform plans to destroy and recreate resources that did not change. With for_each, the identity is the key (logs["audit"]) and does not depend on order.

The dependency graph

You do not tell Terraform in which order to create things. Every reference from one resource to another (aws_subnet.private.id) becomes an edge in a graph, and Terraform first creates what depends on nothing, in parallel whenever possible (10 concurrent operations by default).

explicit

depends_on

aws_vpc.main

aws_subnet.private

aws_security_group.app

aws_instance.app

aws_db_instance.main

aws_s3_bucket.assets

explicit

depends_on

aws_vpc.main

aws_subnet.private

aws_security_group.app

aws_instance.app

aws_db_instance.main

aws_s3_bucket.assets

On destroy, the graph is walked in reverse: the instance goes before the subnet, which goes before the VPC. depends_on exists for dependencies Terraform cannot see through references (an IAM permission that must exist before the application starts, for example). Use it sparingly: it makes the plan more conservative.

To see your configuration’s graph: terraform graph | dot -Tsvg > graph.svg.

State

The state (by default, the terraform.tfstate file) is the map between your code and the real world: it records that aws_instance.app in the code corresponds to instance i-0abc123 in AWS, along with all known attributes. Without it, Terraform would not know which resources are its own or what changed.

Three rules about state:

  1. Never in git. It holds sensitive attributes in plain text (database passwords, generated keys) and changes on every apply.
  2. Never edit it by hand. Use the terraform state commands (list, show, mv, rm) or, better, the refactoring blocks described below.
  3. In a team, use a remote backend with locking. If two people run apply at the same time with local copies of the state, one overwrites the other.
Remote backend and locking

A remote backend keeps the state in a shared place (S3, Azure Blob, GCS, HCP Terraform) and prevents concurrent runs with a lock:

CIS3 backendAnaCIS3 backendAnaacquire lock (creates .tflock)okacquire lockerror: lock held by Anaread state, apply, write new staterelease lockacquire lockok (reads the updated state)
CIS3 backendAnaCIS3 backendAnaacquire lock (creates .tflock)okacquire lockerror: lock held by Anaread state, apply, write new staterelease lockacquire lockok (reads the updated state)

On AWS, until recently locking required a DynamoDB table. Since version 1.11 (February 2025), S3 itself handles the lock with a .tflock file, and using DynamoDB for this is deprecated:

terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "network/production.tfstate"
region = "us-east-1"
use_lockfile = true # native S3 locking (1.11+)
}
}

Enable versioning and encryption on the bucket: a corrupted or deleted state can be recovered from the previous version.

Secrets out of the state

Historically, any password passed to a resource ended up in the state. Two recent additions tackle this:

  • Ephemeral values and resources (1.10): variables, outputs and resources marked as ephemeral exist only during the run and never reach the plan or the state (for example, reading a secret from a vault just to configure a provider).
  • Write-only attributes (1.11): a provider can declare attributes that are sent to the API but never stored in the state, such as a database’s initial password.

Both depend on provider support; older attributes still go into the state.

Modules

A module is just a folder of .tf files. Every configuration is already a module (the root module); calling another folder with a module block is like calling a function, with input variables and outputs:

module "network" {
source = "terraform-aws-modules/vpc/aws" # from the Registry
version = "~> 6.0" # always pin the version
name = "acme-${var.environment}"
cidr = "10.0.0.0/16"
}
resource "aws_instance" "app" {
subnet_id = module.network.private_subnets[0]
# ...
}

The source can be a local folder (./modules/network), the Registry, a git repository (git::https://...?ref=v1.2.0) or a bucket.

Practices that save pain:

  • One module, one responsibility (network, database, service). Modules that do everything end up with endless parameters.
  • Pin versions of modules and providers, and commit .terraform.lock.hcl, which locks the exact version and hashes of the providers.
  • Separate states per environment and per layer. A single state with the whole company makes every plan slow and every mistake dangerous. Splitting it (network, data, applications; staging and production) limits the blast radius.

Refactoring without destroying

Because the state links code addresses to real resources, renaming a resource in the code looks to Terraform like “destroy the old one and create a new one”. Three blocks handle this declaratively, reviewable in the plan:

# Renamed aws_instance.web to aws_instance.app (or moved it into a module)
moved {
from = aws_instance.web
to = aws_instance.app
}
# A bucket created by hand in the console becomes managed by Terraform
import {
to = aws_s3_bucket.legacy
id = "acme-legacy-bucket"
}
# Stop managing it without destroying the real resource
removed {
from = aws_s3_bucket.old_logs
lifecycle {
destroy = false
}
}
BlockSincePurpose
moved1.1Rename resources or move them between modules
import1.5Adopt existing resources; with terraform plan -generate-config-out=generated.tf, Terraform writes the resource for you. Since 1.16, it works inside modules
removed1.7Remove from state without destroying

The old commands (terraform import, terraform state mv) still exist, but they change the state immediately, with no plan and no review. The blocks go into the pull request like any other change.

Drift and what Terraform does not do

Kubernetes is also declarative, but it runs a continuous reconciliation loop: if someone deletes a pod, another one appears within seconds. Terraform does not: it only compares and fixes when someone runs plan and apply. Between runs, manual changes (drift) go unnoticed.

Ways to deal with it:

  • terraform plan -refresh-only shows what changed outside Terraform, without proposing changes to resources.
  • Run a scheduled plan in CI (daily, for example) and alert when there is a difference. HCP Terraform has built-in drift detection.
  • Restrict write permissions in the console for production accounts: if only the pipeline can make changes, drift practically disappears.

Testing and validation

From cheapest to most expensive:

LayerToolWhat it catches
Format and syntaxterraform fmt -check, terraform validateTypos, types, references that do not exist
Input rulesvalidation block in variablesDisallowed values, before any API call
Continuous guaranteescheck blocks (1.5)Assertions evaluated on every plan/apply, such as “the site returns 200”
Teststerraform test (1.6)Module behavior, with command = plan (creates nothing) or apply (creates and destroys real resources)
PoliciesLinters and policy as code (tflint, Checkov, OPA, Sentinel)Public bucket, untagged resource, oversized instance

A test lives in a .tftest.hcl file:

tests/bucket.tftest.hcl
variables {
environment = "staging"
}
run "bucket_name_has_environment" {
command = plan
assert {
condition = aws_s3_bucket.assets.bucket == "acme-assets-staging"
error_message = "The bucket name should end with the environment."
}
}

Since 1.7, mock_provider lets you test without credentials by simulating the provider’s responses.

The team workflow

The pattern that works: nobody runs apply against production from their own machine. Each pull request runs fmt, validate, tests and plan, and the plan shows up as a comment on the PR for review. apply only happens after the merge, through the pipeline, with credentials only it has. Tools like Atlantis, HCP Terraform, Spacelift and env0 automate this workflow.

License, IBM and OpenTofu

Up to version 1.5, Terraform was open source under the MPL 2.0 license. In August 2023, HashiCorp announced the switch to the Business Source License (BSL 1.1) for later versions. The BSL allows using, modifying and redistributing Terraform, commercially included, except to offer a product that competes with HashiCorp’s. For those who only use Terraform to manage their own infrastructure, nothing changes in practice; for companies selling Terraform automation platforms, everything did.

The reaction was a fork: OpenTofu, created in August 2023 (first as OpenTF) and maintained under the Linux Foundation, with an MPL license. In February 2025, IBM completed its acquisition of HashiCorp for 6.4 billion dollars.

Both projects started from the same code and remain very similar (same language, same providers, equivalent commands with tofu instead of terraform), but they diverge with every release:

TerraformOpenTofu
LicenseBSL 1.1MPL 2.0
MaintainerHashiCorp (IBM)Community, under the Linux Foundation
Stable version (Oct 2026)1.16 (1.17 in release candidate)1.13 (September 2026)
Notable exclusivesHCP Terraform integration (Stacks, policies, drift), actions, terraform queryState encryption in the CLI itself (1.7), for_each on providers (1.9)

Migrating from Terraform to OpenTofu (or the other way) is simple for older configurations and gets harder as you adopt features exclusive to one side. Version numbers do not line up: OpenTofu 1.13 is not equivalent to Terraform 1.13.

Recent changes

VersionDateHighlights
1.10Nov 2024Ephemeral values and resources; native S3 locking (experimental)
1.11Feb 2025Write-only attributes; native S3 locking stable and DynamoDB deprecated
1.14Nov 2025action block for operations outside the lifecycle (e.g. invalidating a cache, triggering a playbook); list resources and terraform query to find and bulk-import existing resources
1.16Aug 2026import inside modules; destroy = false in lifecycle
1.17RC in Oct 2026Terraform Policy stable; -minimal-refresh, which refreshes only resources with proposed changes

Outside the CLI, HCP Terraform (formerly Terraform Cloud) gained Stacks, generally available since September 2025: a way to deploy several modules and environments together, with the dependencies between them resolved by the platform. It is a feature of the paid service, not of the binary.

Symptoms and causes

SymptomWhat it usually is
Error acquiring the state lockAnother run is in progress, or one died without releasing the lock. Make sure nobody is applying before using terraform force-unlock <id>
The plan wants to replace (-/+) something you did not changeAn attribute that forces replacement changed (sometimes through a new provider default), or a count was reordered
The plan shows changes every time, even after applyThe API normalizes the value (reordered JSON, casing) and the provider does not handle the difference; use lifecycle { ignore_changes = [...] } carefully
Provider produced inconsistent resultA bug or limitation in the provider; upgrade it
Resource already exists on applyThe resource was created by hand or by another state; adopt it with an import block
Cycle in the planTwo resources reference each other; break the cycle with a separate resource (e.g. the security group rule outside the group)
Plan is too slowThe state is too large; split it into smaller configurations

Limits

  • It is not continuous. Without periodic runs, drift stays invisible (see above).
  • The state is a sensitive point. It concentrates secrets and is the source of truth; losing or corrupting it is an incident.
  • apply is not transactional. If apply fails midway, what was already created stays created. The state records the progress and the next apply picks up where it left off, but there is no automatic rollback.
  • HCL is not a full programming language. Complex logic with for, conditionals and functions gets hard to read quickly. Those who prefer writing infrastructure in TypeScript, Python or Go usually pick Pulumi or the CDK.
  • Quality depends on the provider. New cloud features can take a while to reach the provider, and each provider has its quirks.
  • It does not configure what runs inside the machine. Installing packages and configuring the operating system is a job for prebuilt images (Packer, containers) or configuration tools (Ansible).

When to use it (and when not)

Use Terraform (or OpenTofu)Prefer something else
Infrastructure on one or several clouds, plus SaaS (DNS, CDN, monitoring, GitHub) in the same workflowAWS only, and the team wants the native tool: CloudFormation or CDK
Teams that review infrastructure in pull requestsA small project on a managed platform (Vercel, Fly.io, Render), where there is hardly any infrastructure to describe
Environments that must be identical and recreatableInfrastructure in a programming language: Pulumi or CDK for Terraform
The cluster foundation: network, managed Kubernetes, databasesWhat runs inside the cluster: manifests, Helm and GitOps (Argo CD, Flux), or Crossplane if the idea is to manage the cloud from Kubernetes itself

A common split: Terraform creates the network, the Kubernetes cluster and the managed databases; the applications inside the cluster are deployed through GitOps.

See also: Infrastructure as code, Kubernetes, Understanding containers and The Twelve Factors.

Videos

References

1/19 · What it is