Guides / terraform
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.
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
| Command | What it does |
|---|---|
terraform init | Downloads providers and modules, configures the state backend. Runs once per directory (and again when providers change) |
terraform fmt | Formats files in the standard style |
terraform validate | Checks syntax and types, without touching the cloud |
terraform plan | Reads the real state of resources, compares it with the code and shows what will change. Changes nothing |
terraform apply | Executes the plan (asks for confirmation) and updates the state |
terraform destroy | Destroys 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 }}terraform init # downloads the provider and creates .terraform.lock.hclterraform plan # "Plan: 2 to add, 0 to change, 0 to destroy."terraform apply # confirm with "yes" and open http://localhost:8080Now watch Terraform react to changes:
# 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 webterraform plan # Terraform notices it is gone and plans to recreate it
terraform destroy # cleans everything upThe 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:
| Symbol | Means | Risk |
|---|---|---|
+ | Create | Low |
~ | Update in place | Medium: check the attribute |
-/+ | Replace: destroy and create again | High: 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 |
- | Destroy | High |
<= | Read a data source | None |
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:
terraform plan -out=tfplanterraform apply tfplan # applies this plan, without recomputingThe language: the main blocks
| Block | Purpose |
|---|---|
terraform | Required Terraform version, providers and state backend |
provider | A provider’s configuration (region, credentials, alias) |
resource | An object Terraform creates and manages |
data | An object that already exists and is only read (the latest AMI, a DNS zone) |
variable | An input to the configuration |
output | A value exposed at the end of apply or to another configuration |
locals | Intermediate values, to avoid repeating expressions |
module | Use 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).
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:
- Never in git. It holds sensitive attributes in plain text (database passwords, generated keys) and changes on every
apply. - Never edit it by hand. Use the
terraform statecommands (list,show,mv,rm) or, better, the refactoring blocks described below. - In a team, use a remote backend with locking. If two people run
applyat 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:
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
ephemeralexist 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
planslow 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 Terraformimport { to = aws_s3_bucket.legacy id = "acme-legacy-bucket"}
# Stop managing it without destroying the real resourceremoved { from = aws_s3_bucket.old_logs lifecycle { destroy = false }}| Block | Since | Purpose |
|---|---|---|
moved | 1.1 | Rename resources or move them between modules |
import | 1.5 | Adopt existing resources; with terraform plan -generate-config-out=generated.tf, Terraform writes the resource for you. Since 1.16, it works inside modules |
removed | 1.7 | Remove 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-onlyshows what changed outside Terraform, without proposing changes to resources.- Run a scheduled
planin 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:
| Layer | Tool | What it catches |
|---|---|---|
| Format and syntax | terraform fmt -check, terraform validate | Typos, types, references that do not exist |
| Input rules | validation block in variables | Disallowed values, before any API call |
| Continuous guarantees | check blocks (1.5) | Assertions evaluated on every plan/apply, such as “the site returns 200” |
| Tests | terraform test (1.6) | Module behavior, with command = plan (creates nothing) or apply (creates and destroys real resources) |
| Policies | Linters and policy as code (tflint, Checkov, OPA, Sentinel) | Public bucket, untagged resource, oversized instance |
A test lives in a .tftest.hcl file:
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:
| Terraform | OpenTofu | |
|---|---|---|
| License | BSL 1.1 | MPL 2.0 |
| Maintainer | HashiCorp (IBM) | Community, under the Linux Foundation |
| Stable version (Oct 2026) | 1.16 (1.17 in release candidate) | 1.13 (September 2026) |
| Notable exclusives | HCP Terraform integration (Stacks, policies, drift), actions, terraform query | State 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
| Version | Date | Highlights |
|---|---|---|
| 1.10 | Nov 2024 | Ephemeral values and resources; native S3 locking (experimental) |
| 1.11 | Feb 2025 | Write-only attributes; native S3 locking stable and DynamoDB deprecated |
| 1.14 | Nov 2025 | action 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.16 | Aug 2026 | import inside modules; destroy = false in lifecycle |
| 1.17 | RC in Oct 2026 | Terraform 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
| Symptom | What it usually is |
|---|---|
Error acquiring the state lock | Another 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 change | An attribute that forces replacement changed (sometimes through a new provider default), or a count was reordered |
| The plan shows changes every time, even after apply | The API normalizes the value (reordered JSON, casing) and the provider does not handle the difference; use lifecycle { ignore_changes = [...] } carefully |
Provider produced inconsistent result | A bug or limitation in the provider; upgrade it |
Resource already exists on apply | The resource was created by hand or by another state; adopt it with an import block |
Cycle in the plan | Two resources reference each other; break the cycle with a separate resource (e.g. the security group rule outside the group) |
| Plan is too slow | The 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.
applyis 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 workflow | AWS only, and the team wants the native tool: CloudFormation or CDK |
| Teams that review infrastructure in pull requests | A small project on a managed platform (Vercel, Fly.io, Render), where there is hardly any infrastructure to describe |
| Environments that must be identical and recreatable | Infrastructure in a programming language: Pulumi or CDK for Terraform |
| The cluster foundation: network, managed Kubernetes, databases | What 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
- Terraform in 100 Seconds (Fireship): the big picture in under two minutes (it is the video embedded at the top).
References
- Terraform documentation: the official reference for the language, the CLI and backends.
- Getting started tutorial with Docker: the basis for this guide’s lab.
- S3 backend: configuration with
use_lockfileand the DynamoDB deprecation. - Tests:
.tftest.hclsyntax and mocks. - Import resources: the
importblock and configuration generation. - Terraform changelog: what changed in each version.
- HashiCorp adopts the BSL: the license change announcement, from August 2023.
- OpenTofu: the open source fork, with documentation and the differences from Terraform.
- HashiConf 2025 announcements: Stacks GA, Terraform search and actions.
- IBM completes HashiCorp acquisition (TechRepublic).