Terraform
Describe servers, DNS and cloud resources in code, review the plan, then apply it.
In 30 seconds
Terraform lets you write your infrastructure (servers, databases, DNS records, buckets, permissions) as code files. It compares that code with what really exists and shows a plan of exactly what it will create, change or delete. After you approve, it makes the changes through each provider's API. Infrastructure becomes reviewable, repeatable and documented in Git.
Not in my daily stack — I know the concepts and would learn the details when a project needs it.
Why it matters
- Infrastructure as code matters because clicking in a web console is not repeatable, not reviewable and forgotten within a month.
- A team needs it when it manages more than a handful of resources, several environments or several people changing infrastructure.
- The plan step is a safety net: you see that a change will destroy a database before it happens, not after.
- My current servers are few enough to manage by hand with documented runbooks; Terraform is the natural next step once environments multiply or need to be rebuilt quickly and identically.
How it works
01The idea: infrastructure as code
Infrastructure as code means your servers, networks and DNS are defined in text files, kept in Git and changed through pull requests like application code. Terraform uses a declarative language, HCL: you describe the end state you want (one server of this size, one DNS record pointing to it), not the steps to get there. Terraform works out the steps itself. The benefits are concrete. A staging environment can be an exact copy of production. A new team member reads the code to understand the infrastructure. Changes are reviewed before they happen, and Git history shows who changed what and why. Disaster recovery becomes faster because you can recreate infrastructure from code, although data still has to come from backups.
- Declarative: describe what, not how.
- Versioned and reviewed like application code.
- Terraform provisions infrastructure; tools like Ansible configure what runs inside servers.
02Providers and resources
Terraform itself knows nothing about AWS or DigitalOcean. Providers are plugins that translate your code into API calls for one platform; there are providers for AWS, Google Cloud, Azure, DigitalOcean, Hetzner, Cloudflare, GitHub, Kubernetes and hundreds more. You declare which providers you need and pin their versions, then terraform init downloads them and records exact versions in a lock file that you commit. A resource block describes one object to manage: a server, a bucket, a DNS record. A data block reads something that already exists without managing it. Variables make code reusable, outputs expose useful values such as an IP address, and credentials come from environment variables rather than from the code.
terraform {
required_version = ">= 1.10"
required_providers {
digitalocean = { source = "digitalocean/digitalocean", version = "~> 2.0" }
cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.0" }
}
}
# tokens come from DIGITALOCEAN_TOKEN and CLOUDFLARE_API_TOKEN env vars
provider "digitalocean" {}
provider "cloudflare" {}
variable "ssh_key_fingerprint" { type = string }
variable "cloudflare_zone_id" { type = string }
03Example: a server and its DNS record
Here is a complete, small piece of real infrastructure: an Ubuntu server and a DNS record pointing a hostname at it. Notice the reference digitalocean_droplet.web.ipv4_address inside the DNS record. Terraform reads these references to build a dependency graph, so it knows the server must exist before the record can be created, and it can create independent resources in parallel. If you later change the server size, the plan will show an in-place update; if you change the image, it will show that the server must be destroyed and recreated, which is exactly the kind of surprise the plan is there to reveal. The output prints the new IP after apply.
resource "digitalocean_droplet" "web" {
name = "web-1"
image = "ubuntu-24-04-x64"
region = "fra1"
size = "s-1vcpu-2gb"
ssh_keys = [var.ssh_key_fingerprint]
}
resource "cloudflare_dns_record" "app" {
zone_id = var.cloudflare_zone_id
name = "app.example.com"
type = "A"
content = digitalocean_droplet.web.ipv4_address
ttl = 1
proxied = true
}
output "server_ip" { value = digitalocean_droplet.web.ipv4_address }
04Plan and apply
The everyday workflow has a fixed rhythm. terraform init prepares the working folder and downloads providers. terraform fmt and validate catch style and syntax problems. terraform plan compares three things: your code, the state file, and the real resources, then prints every action with a symbol: plus for create, tilde for update in place, minus for destroy, and minus-plus for replace. Read the plan carefully, especially any destroy. Saving the plan to a file and applying exactly that file guarantees that what you reviewed is what runs. In a team, CI runs plan on every pull request and posts the result for review; apply runs after merge, from CI, not from someone's laptop. Use prevent_destroy on precious resources like databases.
terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
# inspect and troubleshoot
terraform state list
terraform output server_ip
05State, remote state and locking
Terraform keeps a state file: a map from each resource in your code to the real object it manages, with its ID and attributes. Without state, Terraform cannot know that web in your code is server 12345 at the provider. By default state is a local file, which breaks as soon as two people or a CI job are involved. Store it remotely, in an S3 bucket or compatible storage, Terraform Cloud, or another backend, with versioning enabled so you can recover a bad write. Locking stops two applies from running at once and corrupting state. The S3 backend now supports native locking with use_lockfile; the older DynamoDB table method is deprecated. State can contain secrets such as database passwords, so encrypt it and restrict access.
terraform {
backend "s3" {
bucket = "example-terraform-state"
key = "prod/terraform.tfstate"
region = "eu-central-1"
encrypt = true
use_lockfile = true
}
}
06Modules, drift and the tool landscape
A module is a folder of Terraform code with inputs and outputs, like a function. Write a web-server module once and call it for staging and production with different sizes. Keep modules small and focused, pin versions of shared ones, and keep environments in separate state files so a mistake in staging cannot touch production. Drift is when reality no longer matches the code, usually because someone changed something by hand in the console. terraform plan reveals it; then you either update the code to match or apply to restore the declared state. As for tools: HashiCorp moved Terraform to the Business Source License in 2023 and is now part of IBM. OpenTofu is the open-source fork, largely compatible, with extras such as built-in state encryption. Pulumi uses TypeScript, Python or Go instead of HCL.
module "web_staging" {
source = "./modules/web-server"
name = "web-staging"
size = "s-1vcpu-1gb"
}
module "web_prod" {
source = "./modules/web-server"
name = "web-prod"
size = "s-2vcpu-4gb"
}
Use it when
- Several environments (staging, production) that must stay identical in shape.
- Teams where more than one person changes infrastructure and changes need review.
- Cloud setups with many interconnected resources: networks, IAM, databases, DNS, CDN.
- When you need to rebuild infrastructure quickly after a disaster or in a new region.
Skip it when
- One or two long-lived servers that rarely change; a documented runbook may be enough.
- Configuring software inside servers (packages, files, services); that is the job of Ansible, cloud-init or Docker images.
- Frequent application deploys; use CI/CD for code, Terraform for the platform under it.
Alternatives
- OpenTofu
Open-source fork of Terraform, drop-in for most projects, adds state encryption.
- Pulumi
Infrastructure as code in TypeScript, Python, Go or C#, with real loops and tests.
- AWS CloudFormation / CDK
AWS-native IaC; deep AWS integration, but AWS only.
- Ansible
Configuration management over SSH; complements Terraform rather than replacing it.
Best practices
- Use remote state with versioning, encryption and locking from the first day.
- Pin Terraform and provider versions and commit the dependency lock file.
- Run plan in CI on every pull request and apply only from CI after review.
- Separate state per environment and per large component to limit blast radius.
- Never hardcode secrets; pass them through environment variables or a secret manager.
- Protect critical resources with prevent_destroy and review every destroy in a plan.
- Run plan on a schedule to detect drift early.
Common mistakes
- Committing terraform.tfstate to Git, exposing secrets and causing conflicts.
- Applying without reading the plan and replacing a database instead of updating it.
- Changing resources by hand in the console, then being surprised when apply reverts them.
- One giant state for everything, so a small change requires a slow, risky plan.
- Losing the state file and having to import every resource manually.
Deep dive
07How plan really works: the graph, unknowns and replacement order
Terraform turns your configuration into a dependency graph. Every resource, data source, variable and output is a node, and every reference creates an edge. During plan it refreshes each managed resource from the provider, compares the refreshed state with the configuration, and asks the provider what action is needed. Values that only exist after creation, such as a new server's IP, appear as known after apply and flow onward as unknowns, which is why some plans show a cascade of changes. Terraform walks the graph in parallel, ten operations at a time by default, adjustable with -parallelism. When an argument cannot be updated in place, the provider marks it as forcing replacement, and by default Terraform destroys the old object before creating the new one. create_before_destroy reverses the order to avoid downtime, as long as names do not collide. Use depends_on only for hidden dependencies that no reference expresses.
resource "aws_launch_template" "web" {
name_prefix = "web-" # unique names allow create-before-destroy
image_id = var.ami_id
instance_type = "t4g.small"
lifecycle {
create_before_destroy = true
ignore_changes = [tags["LastScannedAt"]] # set by another tool
}
}
08count versus for_each, and refactoring without destroying
count creates a list of instances addressed by index: server[0], server[1], server[2]. Remove the middle item from the input list and every later index shifts, so Terraform plans to destroy and recreate servers that did not really change. for_each creates a map addressed by key, server["api"], server["worker"], so removing one key affects only that instance; prefer it whenever items have a natural identity. Renaming a resource or moving it into a module changes its address too, and without help Terraform would destroy the old address and create the new one. A moved block declares the rename in code, so the plan shows a move with no changes, and teammates and CI pick it up automatically, which terraform state mv cannot do. To stop managing a resource without destroying it, use a removed block with lifecycle destroy = false. To adopt an existing resource, use an import block.
resource "digitalocean_droplet" "app" {
for_each = { api = "s-2vcpu-4gb", worker = "s-1vcpu-2gb" }
name = "app-${each.key}"
size = each.value
image = "ubuntu-24-04-x64"
region = "fra1"
}
moved {
from = digitalocean_droplet.web
to = digitalocean_droplet.app["api"]
}
import {
to = cloudflare_dns_record.legacy
id = "zone_id/record_id"
}
09Secrets: sensitive, ephemeral and write-only values
Marking a variable or output sensitive = true only hides it in plan output and logs; the value is still written to the state file in plain text. That is why state must be encrypted and access-controlled. Newer releases go further. Terraform 1.10 added ephemeral input variables, outputs and ephemeral resources, values that exist during a run but are never written to plan or state files, such as a password fetched from a secret manager. Terraform 1.11 added write-only arguments, named with a _wo suffix, which let managed resources accept those ephemeral values: the provider sends the secret to the API but never stores it. Because Terraform cannot see the stored value, you bump a companion version argument to rotate it. An even simpler option, where the platform supports it, is to let the service generate and manage the secret itself, as RDS can with manage_master_user_password, so Terraform never sees it.
ephemeral "random_password" "db" {
length = 32
}
resource "aws_db_instance" "main" {
identifier = "app-prod"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
username = "app"
password_wo = ephemeral.random_password.db.result
password_wo_version = 1 # bump to rotate
}
10Guardrails: validation, checks, tests and policy as code
Terraform has several layers of built-in checking. Variable validation blocks reject bad input before planning, such as an unknown environment name or an instance size outside an allowed list. Preconditions and postconditions inside a resource's lifecycle state assumptions that must hold, for example that the chosen image is in the right architecture. Check blocks run assertions after apply, such as an HTTP request to the new endpoint, and report warnings without blocking. The terraform test command, with files ending in .tftest.hcl, runs plan or apply against test inputs and asserts on the results, which is how module authors catch regressions. Outside Terraform itself, tflint finds provider-specific mistakes, scanners such as Checkov or Trivy flag insecure settings like open security groups, and policy engines such as OPA with Conftest, or Sentinel in HCP Terraform, enforce organisation rules on the plan before apply.
variable "environment" {
type = string
validation {
condition = contains(["staging", "prod"], var.environment)
error_message = "environment must be staging or prod."
}
}
check "site_up" {
data "http" "health" { url = "https://app.example.com/healthz" }
assert {
condition = data.http.health.status_code == 200
error_message = "Health check did not return 200."
}
}
Real-world scenarios
The state is locked and nobody is running anything
Problem
A CI job was cancelled halfway through an apply. Every new plan now fails with an error that the state is locked, showing a lock ID, a user and a time from an hour ago.
How to solve it
First confirm that the run really stopped: check the CI system and ask the person named in the lock. Never unlock while an apply might still be writing, because two writers corrupt state. Then run terraform force-unlock with the lock ID from the error message. Because the apply was interrupted, some resources may exist without being fully recorded, so run terraform plan and read it carefully; anything created but missing from state can be imported. Check the state bucket's version history if anything looks wrong. To prevent it, configure CI so apply jobs are never cancelled automatically, using a concurrency group without cancel-in-progress.
The plan wants to replace the production database
Problem
A small pull request that adjusted database settings produces a plan showing the production database will be destroyed and recreated. The reviewer almost approved it.
How to solve it
Stop and find the line marked forces replacement in the plan; it names the argument responsible, often the engine version, storage type, identifier, subnet group or availability zone. Decide whether that change is really needed. Often it is not, and reverting it or changing it through a supported in-place path, such as a minor engine upgrade, is the fix. If another tool or the console changed the value, ignore_changes may be appropriate. If replacement is truly required, plan it as a migration with a snapshot and downtime window, not as a routine apply. Add lifecycle prevent_destroy to the database and a CI policy that blocks plans deleting critical resources.
Bringing console-built infrastructure under Terraform
Problem
A company has run for two years on resources created by hand in the console: a VPC, servers, a database, DNS zones. They want Terraform without rebuilding anything or causing downtime.
How to solve it
Inventory what exists and decide what to manage, starting with the least risky layer such as DNS. Write import blocks mapping each existing ID to a resource address, then run terraform plan -generate-config-out=generated.tf, which drafts configuration from the real resources. Clean the generated code: replace hard-coded IDs with references, extract variables and group it into modules. Iterate until terraform plan shows no changes, which proves code and reality match, then apply to record the imports in state. Repeat layer by layer, with remote state and CI from the start. Finally, remove console write access in production so the code stays the truth.
Cheat sheet
- Review drift without changing anything
terraform plan -refresh-only- Release a stuck lock (after checking)
terraform force-unlock <LOCK_ID>- Generate config for import blocks
terraform plan -generate-config-out=generated.tf- Force recreation of one resource
terraform plan -replace=digitalocean_droplet.web- Lock providers for every CI and laptop platform
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64- Show one resource's state
terraform state show digitalocean_droplet.web- Try expressions interactively
terraform console- Run module tests
terraform test- Upgrade providers within constraints
terraform init -upgrade- Draw the dependency graph
terraform graph | dot -Tsvg > graph.svg
Questions & answers
Q1What is Terraform state and why should it be remote?
State is Terraform's record of which real resource corresponds to each resource in the code, along with its attributes and dependencies. Terraform uses it to calculate the plan and to track things the provider API cannot easily describe. A local state file works only for one person. Remote state, for example in a versioned S3 bucket or Terraform Cloud, gives the team and CI a single source of truth, history to recover from mistakes, and locking so two applies cannot run at once. Because state can hold sensitive values like passwords, it must be encrypted and access-controlled, and never committed to Git.
Q2What is the difference between terraform plan and terraform apply?
Plan is read-only. It refreshes the known state of real resources, compares it with the configuration and prints the actions needed: create, update in place, replace or destroy. Nothing changes. Apply executes those actions through the provider APIs and updates the state file. If you run apply without a saved plan, it calculates a new plan and asks for confirmation. In a team the safe pattern is to run plan in CI on a pull request, review it with the code, save it, and after merge apply exactly that saved plan, so the reviewed changes and the executed changes are guaranteed to be identical.
Q3What is drift and how do you handle it?
Drift is any difference between the real infrastructure and what the Terraform configuration and state describe, typically caused by manual console changes, another tool, or the provider changing defaults. Running terraform plan shows it as changes Terraform wants to make. Then you decide: if the manual change was correct, update the code so the plan becomes empty; if it was not, apply to restore the declared state. To reduce drift, limit console write access in production, make all changes through pull requests, and run a scheduled plan in CI that alerts when it is not empty. Some teams use refresh-only plans to review drift before acting.
Q4What happens if you lose the state file?
Terraform no longer knows which real resources it manages. Running apply would try to create everything again, causing duplicates or name conflicts. Recovery means restoring the state from backup, which is why remote state with versioning matters, or rebuilding it with import. Modern Terraform supports import blocks in the configuration, so you declare which existing resource ID maps to which address, review it in a plan, and apply. It is slow for large setups, so prevention is the real answer: versioned, encrypted remote state, restricted access, locking, and never editing state files by hand except through terraform state commands.
Q5Terraform, OpenTofu or Pulumi: how do you choose?
Terraform has the largest ecosystem, documentation and hiring pool, and HCL is readable for operations people. Since 2023 it uses the Business Source License, which is fine for most companies but restricts building competing products, and HashiCorp is now part of IBM. OpenTofu is the open-source fork under the Linux Foundation, compatible with most Terraform code and providers, and it adds features like client-side state encryption; it suits teams that want an open licence. Pulumi lets you write infrastructure in TypeScript, Python or Go, which developers like for loops, abstractions and unit tests. I would pick based on licence needs, team skills and existing code.
Q6Terraform or Ansible?
They solve different layers and work well together. Terraform provisions infrastructure through APIs: servers, networks, load balancers, DNS, databases, IAM. It is declarative and tracks state, so it knows what to change or destroy. Ansible configures what runs inside machines over SSH: packages, users, config files, services, and it is mostly procedural and stateless. A common flow is Terraform creating a server and its DNS record, then Ansible or cloud-init hardening and configuring it. With containers, much of Ansible's job moves into Docker images, and Terraform plus a container platform covers most needs, leaving Ansible for base server setup.
Senior-level questions
S1How do you structure Terraform for several environments and teams?
Separate state by environment and by component, so a mistake in staging cannot touch production and each plan stays small and fast. A common layout is reusable modules in one place, then a root folder per environment and component, such as prod/network, prod/database, prod/app, each with its own backend key, calling the shared modules with environment-specific values. Components exchange values through outputs read with terraform_remote_state or, better, through data sources or a parameter store, keeping coupling explicit. Workspaces suit identical copies, such as short-lived preview environments, but are easy to misuse for prod versus staging because a wrong selected workspace is invisible. Tools such as Terragrunt or HCP Terraform stacks reduce the repetition. The trade-off is many small states, which means more coordination for cross-cutting changes but much smaller blast radius.
S2How would you run Terraform safely in CI?
Plans on every pull request, applies only from the main branch, and never from laptops for shared environments. Authenticate CI with OIDC to short-lived cloud roles: a read-mostly role for plan and a stronger role for apply, available only to the protected main branch or environment. On a pull request, run fmt, validate, tflint, a security scanner and plan, and post the plan as a comment so reviewers read the actual changes, not just the code. After merge, apply the saved plan from that commit, or re-plan and require approval if the plan changed. Serialise runs per state with a concurrency group and never cancel a running apply. Add a scheduled drift plan. Platforms such as HCP Terraform, Spacelift or Atlantis provide much of this out of the box.
S3A provider's new major version renames resources. How do you upgrade?
Treat it as a project, not a version bump. Read the upgrade guide, which lists renamed resources, changed arguments and removed defaults; the Cloudflare provider's version 5, for example, renamed several resources and changed many attribute shapes. Upgrade in a branch with terraform init -upgrade and fix configuration until validate passes. For renamed resource types, use moved blocks where the provider supports moving between types, or remove the old address from state with a removed block and import the same object under the new address. The goal is a plan that shows no replacements of real infrastructure. Do it per state, starting with the least critical environment, and keep the lock file change in the same pull request. Pinning major versions with the pessimistic operator ensures such upgrades only happen deliberately.
Glossary
- Provider
- A plugin that translates Terraform resources into API calls for one platform, such as AWS or Cloudflare.
- Resource address
- The unique path of a resource in configuration, such as module.web.aws_instance.app["api"].
- State
- Terraform's record mapping each resource address to the real object it manages, with its attributes.
- Backend
- Where state is stored and locked, for example a local file, an S3 bucket or HCP Terraform.
- State lock
- A marker that prevents two Terraform runs from writing the same state at once.
- Drift
- Differences between real infrastructure and what the configuration and state describe, often from manual changes.
- Module
- A reusable folder of Terraform code with input variables and outputs, called like a function.
- moved block
- Configuration that tells Terraform a resource changed address, so it is moved in state instead of recreated.
- Ephemeral value
- A value available during a run but never written to plan or state files, used for secrets.
- Saved plan
- A plan written to a file with -out, so apply executes exactly the reviewed changes.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.