Terraform is an open-source tool that creates, changes, and destroys infrastructure from configuration files. You describe what you want in HashiCorp Configuration Language: a VPC, three subnets, a database, an IAM role. Terraform compares that description against a record of what it built last time, called the state file, and against what the provider API says exists right now. It then prints a plan listing every create, update, and destroy needed to close the gap. You read the plan, you approve it, and Terraform makes the API calls in dependency order.
That loop is the whole idea. terraform init downloads providers and connects the backend, terraform plan shows the diff, terraform apply executes it. Everything else below exists to make that loop safe once a second engineer, a CI pipeline, and real production traffic are involved.
Key Takeaways
- State is the whole trick. It maps your resource addresses to real cloud IDs. Store it remotely, lock it, encrypt it, version it, never edit it by hand.
- Apply a saved plan file.
terraform plan -out=tfplanthenterraform apply tfplanguarantees you ship what you reviewed. - Use
for_each, notcount, for anything you might remove from the middle. Index addressing renumbers and destroys neighbors. - An empty plan is the success condition for an import. Generate the config, trim it, re-plan until Terraform reports no changes.
Every block below is real HCL or a real command. The examples use AWS, but the concepts move directly to Google Cloud, Azure, Cloudflare, or any other provider in the public registry.
Install Terraform and Apply Your First Configuration
Install the CLI, write a provider block and one resource, then run init, plan, and apply. A first working configuration is about fifteen lines.
On macOS, install through the HashiCorp tap. On Debian or Ubuntu, add the HashiCorp apt repository. If you need several Terraform versions on one machine, a version manager such as tfenv or mise is worth the setup.
# macOS
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Verify. Anything on the 1.x line works with this guide.
terraform -version
# Optional: shell autocompletion
terraform -install-autocomplete
Create an empty directory and one file. The terraform block pins CLI and provider versions, the provider block sets region and credentials, and each resource block declares one object you want to exist.
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0" # pin the major you start on
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "artifacts" {
bucket = "acme-artifacts-prod-7f3a"
tags = {
Environment = "prod"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
The second resource references aws_s3_bucket.artifacts.id, and that reference is what tells Terraform the bucket must exist first. You never write ordering by hand; the graph comes from the references. depends_on is the escape hatch for dependencies the arguments do not express, and needing it often means the configuration can be simplified.
Credentials come from the usual AWS sources: aws configure, an AWS_PROFILE environment variable, an instance role, or an OIDC token in CI. Do not put keys in the provider block.
terraform init # download the AWS provider, write .terraform.lock.hcl
terraform fmt # canonical formatting, in place
terraform validate # types and syntax, no API calls
terraform plan # read the diff before it happens
terraform apply # type yes at the prompt
# Tear it back down when you are done experimenting
terraform destroy
Providers, Version Pinning, and the Lock File
A provider is a plugin that turns HCL into API calls. Pin its major version, commit .terraform.lock.hcl, and use alias when one configuration touches two regions or accounts.
Terraform itself knows nothing about S3 buckets or Kubernetes namespaces; providers do. The public registry carries providers for the major clouds and for most SaaS platforms with an API, and a single configuration can use several at once. That is how a load balancer, its DNS record, and its monitoring alert end up in one plan.
Version constraints matter more than people expect. ~> 6.0 means at least 6.0 and less than 7.0, so patch and minor upgrades arrive automatically while a breaking major release does not. Without a constraint, a colleague running init next week gets a different provider than you did and the plan will not match.
The lock file records exact provider versions and checksums. Commit it. It is the difference between reproducible plans and mysterious diffs. Refresh it deliberately with terraform init -upgrade and review that change like any other dependency bump.
# Record hashes for every platform your team and CI run on,
# otherwise Linux CI rejects a lock file written on a Mac.
terraform providers lock \
-platform=darwin_arm64 \
-platform=linux_amd64
# --- second region via an aliased provider ---
provider "aws" {
alias = "west"
region = "us-west-2"
}
resource "aws_s3_bucket" "dr" {
provider = aws.west
bucket = "acme-artifacts-dr-7f3a"
}
The Plan and Apply Loop, Command by Command
Save the plan to a file and apply that file. It removes the window in which the world changes between review and execution.
A plan is not a loose preview. It is a concrete, ordered set of API operations, and Terraform can serialize it. Applying a saved plan skips the confirmation prompt, because you confirmed by reviewing it, and guarantees the applied change is the reviewed change. Read the symbols the way you read a diff.
| Symbol | Meaning | What to check |
|---|---|---|
+ | Create | Names and identifiers you cannot change later |
~ | Update in place | Whether the update causes downtime on that service |
- | Destroy | Anything stateful. Stop here if you did not expect it. |
-/+ | Replace | Which argument forces replacement, printed in the plan |
<= | Read data source | Nothing changes; this is a lookup |
terraform init -input=false
terraform fmt -check -recursive # fail the build on unformatted files
terraform validate
terraform plan -input=false -lock-timeout=5m -out=tfplan
terraform show -no-color tfplan # human-readable, for the PR comment
terraform show -json tfplan > plan.json # machine-readable, for policy checks
terraform apply -input=false tfplan # applies exactly what was reviewed
# Drift job: 0 = no changes, 1 = error, 2 = changes pending
terraform plan -detailed-exitcode
Two commands shorten debugging. terraform console is a REPL against your current state and variables, the fastest way to check what an expression returns. TF_LOG=DEBUG terraform plan prints the provider's HTTP calls when a resource behaves in a way the documentation does not explain.
State: The File You Never Edit by Hand
State maps each address in your configuration to a real object ID, caches attribute values, and records dependencies. It exists because no cloud API can answer "which of these did Terraform create?"
Open terraform.tfstate once so it stops being mysterious. It is JSON: a serial number, a lineage identifier, and a list of resources with addresses, provider, and full attribute values. That last part matters. State contains secrets in plaintext. A generated database password, a private key, a token returned by an API, all of it lands in state whether or not the argument is marked sensitive. Setting sensitive = true hides a value from CLI output and nothing more.
So: store state in a bucket with encryption and tight IAM, never commit it to git, rotate every credential in it if that has already happened, and treat read access to state as equivalent to read access to your secrets.
Because state is JSON it looks editable. It is not. Use the subcommands, and prefer configuration-level refactoring blocks over CLI surgery, since those are reviewable in a pull request and run for everyone.
terraform state list # every managed address
terraform state show aws_s3_bucket.artifacts # one resource, fully
terraform state rm aws_s3_bucket.legacy # stop managing, do NOT destroy
terraform apply -refresh-only # sync state to reality, change nothing
# --- prefer these blocks over 'terraform state mv' ---
moved {
from = aws_s3_bucket.artifacts
to = module.storage.aws_s3_bucket.artifacts
}
# Terraform 1.7+: drop a resource from state, leave it running
removed {
from = aws_s3_bucket.legacy
lifecycle {
destroy = false
}
}
Remote Backends: S3, Locking, and Working as a Team
Local state fails the moment a second person or a CI job runs. A remote backend puts state in shared storage and takes a lock during operations so two applies cannot interleave.
The S3 backend is the common choice on AWS. Since Terraform 1.10 it can lock using S3 conditional writes through use_lockfile, which removes the DynamoDB table earlier versions required. On an older CLI, set dynamodb_table instead and create that table with a LockID string partition key.
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true # Terraform 1.10+; replaces dynamodb_table
}
}
Set the state bucket up properly once: versioning on so a bad write can be rolled back, public access blocked, encryption enabled, and a policy that only your Terraform roles can read. There is a bootstrapping order problem, since the bucket must exist before it can hold the state that manages it. The usual answers are a tiny separate configuration with local state, or creating the bucket by hand and importing it later.
Backend blocks cannot use variables or interpolation, so to keep one configuration and swap environments, pass the rest at init time.
# envs/prod.s3.tfbackend
bucket = "acme-tfstate"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
# Then, at the CLI:
terraform init -backend-config=envs/prod.s3.tfbackend
# Moving from local to remote state, or between backends:
terraform init -migrate-state
If you would rather not run backends yourself, HCP Terraform (formerly Terraform Cloud) stores state and runs plans remotely, and you point at it with a cloud block. Azure and Google Cloud have first-party backends. The requirement is the same everywhere: shared storage plus locking.
Variables, Outputs, and Locals
Variables are typed inputs with optional validation, locals are named expressions computed once, and outputs are the values a module publishes to its caller or to you at the CLI.
variable "environment" {
type = string
description = "Deployment environment name"
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be dev, stage, or prod."
}
}
variable "subnet_cidrs" {
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "db_password" {
type = string
sensitive = true # hides CLI output only, NOT state
}
locals {
name_prefix = "acme-${var.environment}"
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
output "bucket_arn" {
value = aws_s3_bucket.artifacts.arn
description = "ARN other stacks reference"
}
Values resolve in a fixed order, later sources winning: defaults, then TF_VAR_ environment variables, then terraform.tfvars, then any *.auto.tfvars alphabetically, then -var-file, then -var. Knowing that order resolves most "why is it still using the old value" confusion.
Keep secrets out of .tfvars files, which get committed by accident more often than anything else in a Terraform repository. Pass them as TF_VAR_db_password from your CI secret store, or read them at plan time from a secrets manager data source. Add *.tfvars, *.tfstate*, and .terraform/ to .gitignore on day one.
Modules: Writing One and Calling It
A module is a directory of .tf files with inputs and outputs. The directory you run Terraform in is the root module; everything it calls is a child module.
Factor code into a module when the same shape of infrastructure appears in more than one place, or when a boundary makes ownership clearer. Resist wrapping a single resource in a module that exposes one variable per argument, which adds indirection and buys nothing. A good module owns one coherent thing and exposes a small number of inputs.
# Layout
# modules/service/{main.tf,variables.tf,outputs.tf}
# envs/prod/main.tf
module "api" {
source = "../../modules/service"
name = "api"
environment = var.environment
desired_count = 3
}
# Registry module: pin the major you tested against
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "acme-prod"
cidr = "10.0.0.0/16"
}
# Git source, pinned to a tag
module "logging" {
source = "git::https://github.com/acme/tf-modules.git//logging?ref=v1.4.0"
}
One rule prevents more incidents than anything else here: use for_each rather than count whenever the collection can change in the middle. With count, resources are addressed by index, so deleting the first of five renumbers the rest and Terraform proposes destroying and recreating all of them. With for_each, each resource has a stable key and removing one touches only that one.
resource "aws_s3_bucket" "logs" {
for_each = toset(["app", "audit", "billing"])
bucket = "${local.name_prefix}-logs-${each.key}"
tags = local.common_tags
}
# Address: aws_s3_bucket.logs["audit"] (key, not index)
# Removing "app" leaves "audit" and "billing" untouched.
Multi-Environment Patterns That Hold Up
A directory per environment is the safer default. CLI workspaces are for identical copies of one stack, such as per region or per tenant, not for environments that genuinely differ.
With a directory per environment, each has its own backend key, state, and credentials, and the differences between dev and prod live in files rather than in conditionals. Plans stay short, and a mistake in dev cannot reach prod state.
infra/
modules/
network/
service/
envs/
dev/
main.tf # calls modules, dev-sized inputs
backend.tf # key = dev/terraform.tfstate
terraform.tfvars
stage/
prod/
main.tf
backend.tf # key = prod/terraform.tfstate
terraform.tfvars
Workspaces solve a different problem. terraform workspace new eu-west gives you a second state file under the same configuration and backend, with the name exposed as terraform.workspace. That fits stamping one stack into many regions. It goes wrong for dev and prod, because the configuration fills with expressions like terraform.workspace == "prod" ? 3 : 1 and the environments quietly stop being comparable.
On larger systems, split further by lifecycle: network in one state, data stores in another, applications in a third. Applications change daily and networks change quarterly, and separate states keep a routine deploy from planning against your VPC. Pass values across the boundary through outputs plus a terraform_remote_state data source, or publish them to a parameter store. That data source needs read access to the other state file, which as established is read access to its secrets.
Drift: How to Detect It and What to Do
Drift is real infrastructure diverging from what Terraform recorded. Detect it with a scheduled terraform plan -detailed-exitcode, which returns 2 when changes are pending.
Drift comes from someone fixing an outage in the console at 2 a.m., another automation editing the same resource, an autoscaler adjusting capacity, or a provider changing a default. It is normal. The danger is that it accumulates unnoticed until an unrelated apply proposes to undo it. When a scheduled plan reports pending changes, there are three possible responses.
ignore_changes with a comment naming the owner.resource "aws_db_instance" "primary" {
identifier = "${local.name_prefix}-primary"
instance_class = "db.r6g.large"
# ...
lifecycle {
prevent_destroy = true # plan errors instead of destroying
# Patch version is owned by the DBA maintenance window
ignore_changes = [engine_version]
}
}
Two habits keep drift small: tag everything Terraform owns with ManagedBy = "terraform" so it is obvious in the console, and remove human write access to the production console, leaving a documented break-glass role that pages someone when used.
How to Import Existing Infrastructure Into Terraform
Add an import block naming the address and the provider ID, run terraform plan -generate-config-out=generated.tf to have Terraform draft the resource block, trim it, and re-plan until the plan is empty.
Almost nobody starts on a clean account. Import is how existing infrastructure comes under management, and since Terraform 1.5 the configuration-driven form is far easier than the old CLI command, because Terraform writes the first draft of the HCL for you.
# 1. Declare what you are adopting
import {
to = aws_s3_bucket.artifacts
id = "acme-artifacts-prod-7f3a"
}
# 2. Let Terraform draft the resource block
terraform plan -generate-config-out=generated.tf
# 3. Review and trim generated.tf, move it into your real files,
# then plan again. Empty plan = the import is correct.
terraform plan
# 4. Record it in state
terraform apply
# Older CLI form, still supported. Write the resource block first.
terraform import aws_s3_bucket.artifacts acme-artifacts-prod-7f3a
Generated configuration includes every attribute the provider knows about, including defaults and computed fields you should not set, so deleting most of it is the normal workflow. Keep only what you intend to control.
Two warnings. One console object often maps to several Terraform resources: a modern S3 bucket splits versioning, encryption, lifecycle rules, and policy into separate resources, each imported separately. And import IDs vary by resource type, with the exact format documented at the bottom of each resource page in the registry.
Sequence the adoption instead of attempting a whole account at once. Pick one low-risk, high-churn area such as S3 buckets or IAM roles, import it, reach an empty plan, put it behind CI, then move on. Networking and databases go last, because that is where a mistaken plan is expensive.
The Mistakes Beginners Actually Hit
State committed to git
It holds plaintext secrets and invites merge conflicts on a file that cannot be merged. If it happened, rotate every credential in it.
No locking
Two applies at once against one state produce duplicated resources and a state file that no longer matches reality.
count over a changing list
Remove the first element and every later resource renumbers, so Terraform plans to destroy and recreate all of them.
Unpinned providers
Without a constraint and a committed lock file, yesterday's clean plan fails today because a new provider shipped overnight.
-target as a habit
A break-glass flag. Routine use leaves state partially applied and hides the dependency problem behind it.
One enormous root module
Plans take twenty minutes, everyone queues on one lock, and a typo can touch production networking.
Two more worth naming. Provisioners such as local-exec and remote-exec look like an easy finish, but they run once, are not tracked in state, and turn a declarative tool into a fragile script runner. And terraform apply -auto-approve against a live configuration, rather than a saved plan, is how teams ship changes nobody read.
terraform apply -auto-approve
Re-plans at apply time, so what runs may differ from what was reviewed in the pull request. The gap is small and occasionally expensive.
plan -out=tfplan, then apply tfplan
The artifact you reviewed is the artifact that runs. Store it as a build artifact and the audit trail is real.
When Not to Use Terraform
Terraform is a desired-state tool for long-lived resources behind an API. Several common jobs sit outside that description.
- Configuring software inside a running machine. Package installs, config files, and service restarts belong to a configuration tool, cloud-init, or a baked image.
- Application deploys many times a day. A plan-and-approve loop is the wrong shape for a pipeline that ships twenty times before lunch. Let your CD system own that.
- Kubernetes application manifests. The kubernetes provider is fine for cluster bootstrap, but Helm with Argo CD or Flux reconciles continuously, which is what rollouts need.
- Per-request or runtime infrastructure. If resources appear and disappear on user actions, write against the cloud SDK. Terraform is not a runtime control plane.
- A single server you touch twice a year. State, backend, and CI overhead outweigh the benefit at that size.
Terraform vs OpenTofu vs Pulumi vs CloudFormation vs Ansible
| Tool | You write | State | Scope | Best for |
|---|---|---|---|---|
| Terraform | HCL | You host it | Any provider | Multi-cloud and SaaS infrastructure, largest module community |
| OpenTofu | HCL (same) | You host it, encryptable client-side | Any provider | Teams needing an MPL license or built-in state encryption |
| Pulumi | TypeScript, Python, Go, C#, Java | Hosted or self-managed | Any provider | Teams that want real loops, types, and unit tests in one language |
| CloudFormation | YAML or JSON (or CDK) | AWS manages it | AWS only | All-AWS shops that want no state file to lose, plus StackSets |
| Ansible | YAML playbooks | None | Machines and some cloud APIs | Configuring servers, orchestrating ordered operational tasks |
OpenTofu is the fork created after Terraform moved to the Business Source License in August 2023, and it is now under the Linux Foundation. It reads the same HCL and state format, so migration is usually a binary name change in CI. Its distinguishing feature is client-side state encryption, a direct answer to the plaintext-secrets problem above. Terraform keeps the larger registry and the bigger pool of community modules.
Pulumi trades HCL for a general-purpose language. That is a real gain when infrastructure logic needs abstraction or tests, and a real cost when a colleague writes three layers of class hierarchy and the result becomes hard to read in review. HCL's limited expressiveness is partly a feature.
CloudFormation removes the state problem entirely, since AWS holds it, and has change sets and drift detection built in. The price is being AWS-only and writing more verbose templates, which is what CDK exists to soften.
Ansible is complementary rather than competing. It is procedural and excellent at reaching into machines, but it does not build a dependency graph of desired state, so it cannot tell you what would change before it changes. A common split is Terraform for the infrastructure and Ansible for what runs on it.
What to Build Next
One project closes most of the gap between reading Terraform and running it. Take a small piece of infrastructure you already operate by hand, import it, reach an empty plan, move the state to S3 with locking, and put a plan-on-pull-request job in front of it with GitHub Actions. That single loop teaches state, backends, imports, and CI at once. After that, package the result as a module and call it from two environments, run terraform show -json output through a scanner such as tfsec or Checkov, and wire the outputs into your observability stack so new environments arrive already monitored. If this area is new, the DevOps overview and the IAM guide cover ground Terraform assumes, and our free course library has hands-on tracks for the surrounding tools.
Frequently Asked Questions
What does Terraform actually do?
It reads HCL files describing the infrastructure you want, compares that against a state file recording what it built before and against what the provider API reports now, and prints a plan of every create, update, and destroy needed to close the gap. After you approve, it calls the APIs in dependency order and updates state. Three commands cover most days: terraform init, terraform plan, terraform apply.
Should I use Terraform or OpenTofu in 2026?
Both read the same HCL and state format, so the decision is mostly licensing plus a couple of features. Terraform has been under the Business Source License since August 2023, which allows nearly every normal use but restricts building a competing product. OpenTofu is the MPL-licensed fork under the Linux Foundation, and it added client-side state encryption. If the license is a problem for your organization, or encrypted state matters, OpenTofu is reasonable. Otherwise Terraform's larger community makes it the practical default.
Can Terraform manage infrastructure I created by hand?
Yes, through import. Since Terraform 1.5 you add an import block with the target address and the provider ID, then run terraform plan -generate-config-out=generated.tf to draft the resource block. Trim the generated file, move it into your real configuration, and re-plan until Terraform reports no changes. An empty plan is the acceptance test. The older terraform import ADDRESS ID command still works but needs the resource block written first.
Do I need one state file or many?
Many. One per environment is the minimum, and splitting further by lifecycle is common on larger systems. Separate states bound the blast radius of a bad apply, keep plan times short, and let production use different credentials. The cost is that values crossing a boundary must be published deliberately through outputs and a data source or a parameter store, rather than referenced directly.
Is it safe to edit the state file by hand?
No. State is JSON, so it looks editable, but hand edits break resource addressing, dependency records, and the serial number used for locking. Use terraform state list, show, mv, and rm instead, and prefer moved and removed blocks in configuration, since those are reviewable in a pull request and run for everyone. Keep versioning on the state bucket so a bad write can be rolled back.
Reference documentation: Terraform documentation, Terraform Registry, OpenTofu documentation