Who this is for
You can click your way around a cloud console and you have decided that is not going to scale. You do not need to know Go, and you do not need to have used configuration management before. If you already run Terraform in production, skip to Where it actually bites.
The problem it solves
Infrastructure built by hand has no answer to two questions that get asked at the worst moments: what is actually deployed right now, and can we build this again. A console tells you what exists today. It does not tell you why that security group has a rule in it, who added it, or what else was changed in the same ten minutes.
Terraform answers both by making the configuration the source of truth. The useful consequence is not "no more clicking" — it is that an infrastructure change becomes a diff somebody can review before it happens, in the same place and with the same tooling as a code change.
Installing it
# macOS
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Linux — see releases.hashicorp.com for the current version and checksums
curl -fsSL -o terraform.zip \
"https://releases.hashicorp.com/terraform/<VERSION>/terraform_<VERSION>_linux_amd64.zip"
unzip terraform.zip && sudo install -m 0755 terraform /usr/local/bin/
terraform version
Pin the version wherever this runs in CI. A newer Terraform can write a state file an older one refuses to read, and the first time you find that out should not be during an incident.
Your first configuration
terraform {
required_version = "~> 1.11"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
type = string
default = "us-east-1"
}
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "app_assets" {
bucket = "my-app-assets-${random_id.suffix.hex}"
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
output "bucket_name" {
value = aws_s3_bucket.app_assets.bucket
}
Two things in there are worth naming. required_version, the ~> 6.0
provider constraint and a committed .terraform.lock.hcl mean this configuration behaves the same on your laptop and
in CI. And random_id exists because S3 bucket names are globally unique —
my-app-assets is already taken by somebody, and the failure arrives at apply
time rather than plan time.
The workflow
terraform init # download providers, configure the backend
terraform plan # show what would change, and why
terraform apply # make it so
terraform destroy # tear it down
plan is the one that matters. Read its symbols rather than skimming for the
summary line:
| Symbol | Meaning |
|---|---|
+ | create |
- | destroy |
~ | update in place |
-/+ | destroy and recreate — the dangerous one |
A -/+ on a database or a volume is how people lose data with a tool that was
supposed to prevent exactly that. If you see one you did not expect, stop and
find out which attribute forced it. The plan output names it: "forces
replacement".
State, and why it is the whole game
Terraform stores a mapping from the addresses in your configuration to the real
identifiers in your account — aws_s3_bucket.app_assets is bucket
my-app-assets-a1b2c3d4. Without that mapping Terraform cannot tell the
difference between "this bucket does not exist yet" and "this bucket exists and
I made it", so it would create a second one.
Three consequences follow, and they are most of what people hit:
State can disagree with the world. If somebody changes a setting in the console, the real resource no longer matches what state records. Terraform notices on the next plan and offers to change it back. That is drift, and it is working as designed — see State drift.
Two applies at once corrupt it. Both read the same state, both write, and the second overwrites the first's record of what it created. Those resources are now orphaned: real, costing money, and invisible to Terraform. This is what locking prevents.
It contains secrets in plaintext. An RDS password, a generated key, a token
an API returned — if a resource has it, state has it — unless you pass it through a write-only argument such as password_wo on aws_db_instance (Terraform 1.11 and later), which Terraform never stores. State is not a
configuration file you can treat casually.
So for anything beyond a personal experiment, put it in a remote backend with locking and encryption:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
encrypt = true is server-side encryption on the object. It protects the state
at rest in S3; it does not stop anyone who can read the bucket from reading the
secrets inside. Lock the bucket down accordingly.
Deeper on this: Why Terraform needs state.
Modules
A module is a directory of Terraform files you can call more than once. Reach for one when you are about to copy a block for the second time, not before — premature modules are harder to read than the duplication they replace.
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
}
module "ecs" {
source = "./modules/ecs"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
Terraform works out that ecs depends on vpc from the reference to
module.vpc.vpc_id. You almost never need depends_on — if you find yourself
adding it, check whether you are passing a value where you could be passing a
reference.
Where it actually bites
Renaming a resource destroys it. Change aws_s3_bucket.app_assets to
aws_s3_bucket.assets and Terraform sees one resource gone and one new one, so
it plans a destroy and a create. Use a moved block instead, which tells it the
address changed and the resource did not:
moved {
from = aws_s3_bucket.app_assets
to = aws_s3_bucket.assets
}
Deleting the block does not delete the resource — until you apply. Removing
config stages a destroy. It happens on the next apply, which may be in
somebody else's pull request.
-target is an escape hatch, not a workflow. It applies part of the graph
and leaves state only partly reconciled. Useful to break a deadlock; a habit
worth noticing if you find yourself reaching for it weekly.
A clean plan is not a guarantee. Plan is computed against current state and
the provider's view of the API. Between plan and apply, a quota, a permission or
somebody else's change can still make apply fail. In CI, use a saved plan file
(terraform plan -out) so apply runs exactly what was reviewed.
One configuration, several environments
Added 25 October 2026. The obvious way to give each environment its own
state, key = "${var.env}/terraform.tfstate" in the backend block, does not
work: HashiCorp's documentation says a backend block cannot refer to named
values such as variables or locals. Reproduced on Terraform 1.14.0 with the
local backend, terraform init stopped with Error: Variables not allowed.
Leave the arguments that differ out of the block, and supply them at
init from one small file per environment (partial configuration):
terraform {
backend "s3" {
bucket = "my-terraform-state"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
# prod.tfbackend contains one line: key = "prod/terraform.tfstate"
terraform init -reconfigure -backend-config=prod.tfbackend
Switching to another file in the same working directory stops with
Backend configuration changed until you pass -reconfigure, or
-migrate-state if you mean to move the state. Keep credentials out of these
files: the documentation warns that -backend-config values are written in
plain text to .terraform and to plan files.
Variables in CI have a quieter trap. A missing secret usually reaches the job
as an empty TF_VAR_region, and Terraform accepts an empty string as a value:
reproduced, the plan went ahead with "" and no error. A validation block
makes it fail loudly (nullable = false does not, because an empty string is
not null):
variable "region" {
type = string
validation {
condition = length(var.region) > 0
error_message = "region must not be empty."
}
}
The two errors in full: Variables not allowed and No value for required variable.
Common mistakes
- Committing state.
*.tfstate*belongs in.gitignoreon day one — see the secrets point above. If it is already committed, rotate what is in it; removing the file from the latest commit does not remove it from history. - One state file for everything. A single state for all environments means a mistake in staging plans a change in production. Separate state per environment, by backend key or a separate backend. CLI workspaces share one backend and its credentials, which HashiCorp says makes them a poor fit for environments that need separate access.
- Unpinned providers.
version = "~> 6.0"is a floor and a ceiling, and a committed.terraform.lock.hclrecords the exact version chosen. Without both, a provider release can change your plan on a day you changed nothing. -auto-approveon a terminal. It belongs in a pipeline where a human has already reviewed the plan. Typed by hand it removes the only confirmation step between you and a-/+on a database.
If this did not work
terraform planwants to create everything that already exists. Usually the backend is not configured the way you think, so Terraform has loaded empty state and believes nothing exists. Checkterraform initoutput for which backend it resolved.- "Error acquiring the state lock." Somebody is applying, or a previous run died holding the lock. The obvious fix is the dangerous one — see Error acquiring the state lock.
- Plan is empty but the console shows a difference. The resource is probably
not managed by Terraform at all.
terraform importadopts an existing resource into state.
Next steps
Put terraform plan in CI on every pull request so infrastructure changes get
reviewed like code, and terraform fmt -check and terraform validate in the
same job so formatting debates never reach review. Apply on merge, from a saved
plan file.
Related on this site
- Why Terraform needs state — the file everyone resents until they understand what it is for.
- Error acquiring the state lock — the stuck lock, and why the obvious fix corrupts state.
- State drift — when the world stopped matching the file.
Mentioned in this article
- Error: Inconsistent dependency lock file — TerraformProblem · Terraform
- Error: Backend configuration changed — TerraformProblem · Terraform
- Amazon VPC explainedAWS guide
Enjoyed this article?
Get more DevOps insights delivered to your inbox.
Get new posts by email
Subscribe to get an email when a new blog post is published. Skip anytime.
No spam, unsubscribe anytime.
Terms used here (4)plain definitions
Short definitions for the vocabulary on this page, in case any of it is new. Each links to the full entry.
- Idempotent
- A property of an operation meaning that executing it multiple times has the same effect as executing it once. Idempotency is critical in DevOps for configuration management, API design, and deployment scripts where retries must be safe.
- Immutable Infrastructure
- An approach where servers or containers are never modified after deployment. Instead of patching running systems, you build a new image with the changes and replace the old instances entirely, eliminating configuration drift and improving reliability.
- Infrastructure as Code
- The practice of managing and provisioning infrastructure through machine-readable configuration files rather than manual processes. IaC tools like Terraform and Ansible make infrastructure reproducible, version-controlled, and auditable.
- Terraform
- An Infrastructure as Code tool by HashiCorp that lets you define and provision cloud and on-premises infrastructure using declarative configuration files (HCL). Since version 1.6 it has been source-available under the Business Source License rather than open source; OpenTofu is the open-source fork. Terraform manages the full lifecycle of resources through providers, plugins for cloud platforms and other APIs that number in the thousands.
Related Posts
Discussion
0 comments
Sign in to join the conversation.
Be the first to comment
Start a conversation about this post



