Infrastructure as Code with Terraform

Manage infrastructure as code. Covers Terraform modules, state management, workspace patterns, drift detection, testing IaC, and enterprise-scale Terraform architecture.

Infrastructure as Code with Terraform lets you describe cloud or on-premises resources in declarative configuration files, and reconcile the actual environment to that description through automated planning and apply workflows. It matters when you need consistent, repeatable, and versioned infrastructure across environments, especially when manual provisioning creates configuration drift, when teams adopt multi-cloud or hybrid setups, or when you want to integrate infrastructure changes into CI/CD pipelines. This reference page covers the practical workflows, concrete commands, state management patterns, and the sharp edges you’ll encounter when using Terraform as your IaC tool.

Starting a New Terraform Project

Create a working directory and initialize the configuration:

mkdir terraform-demo && cd terraform-demo

Initialize the working directory, download providers, and configure the backend:

terraform init

Typical output:

Initializing the backend...

Initializing provider plugins...
- Reasing hashicorp/aws v4.60.0...
- Reasing hashicorp/random v3.6.0...

Terraform has been successfully initialized!

Format HCL files in the directory:

terraform fmt

Validate the configuration syntax and internal consistency:

terraform validate

A successful run exits with code 0 and prints:

The configuration is valid.

Here is a minimal main.tf using real HCL syntax:

variable "region" {
  type    = string
  default = "us-east-1"
}

provider "aws" {
  region = var.region
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type

  tags = {
    Name = "web-${terraform.workspace}"
  }
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
}

Planning and Applying Changes

Generate an execution plan before making any changes:

terraform plan -out=plan.out -var="instance_type=t3.micro"

Typical plan output includes a header, resource actions marked with +, -, or ~, and a summary:

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                    = data.aws_ami.amazon_linux.id
      + instance_type          = "t3.micro"
      + tags                   = {
          + Name = "web-default"
        }
      + subnet_id              = (known after apply)
      + vpc_security_group_ids = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Apply the planned changes:

terraform apply plan.out

For automated CI/CD pipelines, add -auto-approve to bypass the interactive confirmation prompt:

terraform apply -auto-approve plan.out

If the configuration already matches the state, the plan reports:

The configuration matches the current state. No changes are needed.

Managing Terraform State

By default Terraform stores state in a local terraform.tfstate file. List all tracked resources:

terraform state list

Show detailed attributes for a specific resource:

terraform state show aws_instance.web

Output includes JSON‑like attributes such as ami, instance_type, and private IP address.

Move a resource to a new address (useful when renaming a module or refactoring):

terraform state mv aws_instance.web aws_instance.old-web
terraform state rm aws_instance.old-web

State locking prevents concurrent runs. Attempting a second apply while a lock is held returns:

Error acquiring the lock. Another Terraform process may be running.

Break a stale lock (e.g., after a crashed terminal) using the lock ID displayed in the error:

terraform force-unlock <lock-id>

For production environments, store state in a remote backend. A common S3 + DynamoDB pattern uses these real configuration keys:

backend "s3" {
  bucket         = "my-terraform-state-store"
  key            = "environments/prod/terraform.tfstate"
  region         = "us-east-1"
  dynamodb_table = "tf-locks-prod"
  encrypt        = true
}

Initialize (or re‑initialize) after changing backend config:

terraform init

Pin provider versions at the module root to avoid drift between the state snapshot and the installed provider:

required_providers {
  aws = {
    source  = "hashicorp/aws"
    version = "~> 4.0"
  }
}

Authoring and Using Modules

Organize reusable infrastructure into a module directory with main.tf, variables.tf, and outputs.tf. A local module referenced from a root configuration:

module "vpc" {
  source  = "./modules/vpc"
  cidr    = "10.0.0.0/16"
  azs     = ["us-east-1a", "us-east-1b"]
}

Pull a module from the public Terraform Registry:

module "database" {
  source  = "terraform-aws-modules/rds/aws"
  version = "5.0.0"
  db_name = "app_db"
  engine  = "postgres"
}

After adding or changing a module source, run terraform init to download or update the module source. Use terraform graph to generate a DOT visualization of the resource dependency graph, then dot -Tpng graph.gv -o graph.png to render it.

Sharp Edges and Gotchas

count versus for_each. Using count assigns sequential integer indices starting at 0. Switching to for_each (which uses a map or set of keys) changes resource addresses and forces Terraform to destroy and recreate resources, potentially breaking dependent infrastructure. Always plan a switch between the two; never mix count and for_each for the same resource type within a module.

Provider version drift. If the provider version installed on your workstation differs from the version that resolved when the state was last written, terraform plan may report spurious changes (e.g., IP address shifts, attribute renames). Pinning with required_providers in the root module, or locking the provider version in a provider block, keeps the resolved version consistent across runs.

terraform destroy stalls. Resources with lifecycle { prevent_destroy = true } or protected security group references can cause the destroy command to hang indefinitely. Inspect terraform plan -destroy output before proceeding, and use terraform force-unlock if the run is stuck waiting for a lock.

Deprecated provisioners. null_resource with provisioner "local-exec" is deprecated in Terraform 1.+ for many use cases. Prefer triggers blocks paired with count or for_each to invoke local scripts only when specific inputs change, avoiding the security and maintenance concerns of built‑in provisioners.

Backend config change without -reconfigure. Modifying the backend block in terraform.tf and running terraform init without the -reconfigure flag leaves the old state file in place. Terraform may report “State already exists” or, worse, silently write to the new backend while the old file remains, creating divergent state. Always use terraform init -reconfigure when moving state between backends.

Workspace confusion. terraform workspace new prod and terraform workspace select prod isolate state, but it’s easy to forget which workspace is active. A stale terraform plan in the wrong workspace shows no changes because it’s reading a different state file. Prefer explicit terraform workspace show in CI scripts to confirm the target before plan or apply.

Lock timeout. By default Terraform blocks indefinitely. In team environments, a long‑running apply can prevent teammates from making changes. Use TF_LOCK_TIMEOUT environment variable (e.g., TF_LOCK_TIMEOUT=30s) to set a maximum lock wait time, after which the command fails with a clear error rather than hanging.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.