Getting started

Introduction

Terraform is an infrastructure-as-code tool for building, changing, and versioning infrastructure safely and efficiently. This reference covers the CLI, configuration, state, and modules.

Install

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

terraform -version
terraform -help
terraform plan -help

See: Install Terraform

Core workflow

terraform init                 # Initialize providers and backend
terraform fmt -recursive       # Format configuration
terraform validate             # Check syntax and consistency
terraform plan                 # Preview changes
terraform apply                # Preview, approve, and apply
terraform destroy              # Destroy managed infrastructure

Run commands from the root module directory.

See: Core workflow

Saved plans

terraform plan -out=tfplan
terraform show tfplan
terraform apply tfplan

terraform show -json tfplan > tfplan.json

Saved plans can contain sensitive configuration and values. JSON output exposes sensitive values in plain text, so protect generated JSON files.

See: Create a plan

Useful options

terraform -chdir=environments/prod plan
terraform plan -var-file=prod.tfvars
terraform plan -out=tfplan -detailed-exitcode
terraform apply -auto-approve
terraform plan -refresh-only
terraform apply -refresh-only
terraform plan -minimal-refresh

-detailed-exitcode returns 0 for no changes, 1 for errors, and 2 for changes. -minimal-refresh refreshes only resources with planned changes.

See: Terraform CLI

Configuration

Terraform and providers

terraform {
  required_version = ">= 1.17.0"

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

provider "aws" {
  region = "us-east-1"
}
terraform providers lock \
  -platform=linux_amd64 \
  -platform=darwin_arm64

Commit .terraform.lock.hcl; do not commit .terraform/. Lock entries are per-platform, so lock each platform your CI runs.

See: Provider requirements, providers lock

Resources

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"

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

# Reference: aws_instance.web.id

References create implicit dependencies.

See: Resource blocks

Data sources

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-*-amd64-server-*"]
  }
}

# Reference: data.aws_ami.ubuntu.id

Data sources read information without managing its lifecycle.

See: Data sources

Input variables

variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "dev"

  validation {
    condition = contains(
      ["dev", "staging", "prod"],
      var.environment
    )
    error_message = "Use dev, staging, or prod."
  }
}

variable "api_token" {
  type      = string
  sensitive = true
  nullable  = false
}

See: Input variables

Set input values

terraform apply -var='environment=prod'
terraform apply -var-file='prod.tfvars'
export TF_VAR_environment=prod
# terraform.tfvars
environment = "prod"

Precedence increases from environment variables, terraform.tfvars(.json), *.auto.tfvars(.json), to command-line options (-var, -var-file).

See: Assign variable values

Local values and outputs

locals {
  name = "${var.project}-${var.environment}"
  tags = {
    Project     = var.project
    Environment = var.environment
  }
}

output "instance_ip" {
  description = "Public IP address"
  value       = aws_instance.web.public_ip
}

output "token" {
  value     = var.api_token
  sensitive = true
}
terraform output
terraform output -raw instance_ip
terraform output -json

Naming a sensitive output, or using -raw or -json, exposes its value in plain text.

See: Local values, Output values

Ephemeral values

variable "db_password" {
  type      = string
  sensitive = true
  ephemeral = true
}

resource "aws_db_instance" "main" {
  # ...
  password_wo         = var.db_password
  password_wo_version = 1
}

Ephemeral values are omitted from plan and state files; write-only arguments accept them.

See: Manage sensitive data

Expressions

Value types

"hello"                    # string
true                       # bool
42                         # number
["a", "b"]                 # tuple/list
{ name = "web", port = 80 } # object/map
null                       # absence of a value

See: Types and values

String templates

name = "web-${var.environment}"

script = <<-EOT
  #!/bin/bash
  echo "${local.name}"
EOT

message = "Enabled: %{if var.enabled}yes%{else}no%{endif}"

See: Strings and templates

Conditionals

instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

Both result values must have compatible types.

See: Conditional expressions

For expressions

[for name in var.names : upper(name)]

{
  for user in var.users :
  user.name => user.id
  if user.enabled
}

See: For expressions

Splat expressions

aws_instance.web[*].id
aws_instance.web[*].private_ip

Splat syntax works with lists, sets, and tuples.

See: Splat expressions

Common functions

length(var.names)
lookup(var.tags, "Name", "default")
merge(local.common_tags, var.extra_tags)
toset(var.names)
convert(var.tags, map(string))
try(local.value.deep, "fallback")
can(regex("^[a-z]+$", var.name))
jsonencode(local.policy)
file("${path.module}/script.sh")

See: Expressions, Functions

Repetition and lifecycle

count

resource "aws_instance" "web" {
  count = 3

  ami           = var.ami_id
  instance_type = "t3.micro"
  tags          = { Name = "web-${count.index}" }
}

# aws_instance.web[0].id

Use count for nearly identical instances indexed by number.

See: count meta-argument

for_each

resource "aws_s3_bucket" "logs" {
  for_each = toset(["app", "audit"])

  bucket = "${var.prefix}-${each.key}"
}

# aws_s3_bucket.logs["app"].id

Use for_each when instances need stable keys.

See: for_each meta-argument

Explicit dependencies

resource "aws_instance" "app" {
  # ...
  depends_on = [aws_iam_role_policy.app]
}

Use depends_on only for dependencies Terraform cannot infer.

See: depends_on meta-argument

Lifecycle rules

resource "aws_instance" "web" {
  # ...

  lifecycle {
    create_before_destroy = true
    prevent_destroy       = true
    ignore_changes        = [tags["LastModified"]]
  }
}

prevent_destroy does not protect an object after its configuration is removed. Set destroy = false to remove it from state without destroying it.

See: Meta-arguments, Lifecycle

Actions

action "aws_lambda_invoke" "example" {
  config {
    function_name = "my_function"
  }
}
terraform apply -invoke=action.aws_lambda_invoke.example

Actions run provider-defined day-2 operations; trigger them from the CLI or lifecycle.action_trigger.

See: Invoke an action

Modules

Call a module

module "network" {
  source = "./modules/network"

  cidr_block  = "10.0.0.0/16"
  environment = var.environment
}

# Reference: module.network.vpc_id

See: Module blocks

Registry module

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"

  name = local.name
  cidr = "10.0.0.0/16"
}

Pin registry module versions and run terraform init after changing sources.

See: Using modules

Module structure

.
├── main.tf
├── outputs.tf
├── variables.tf
├── versions.tf
└── modules/
    └── network/
        ├── main.tf
        ├── outputs.tf
        └── variables.tf

All .tf files in a directory form one module.

See: Modules

State and existing resources

Backends

terraform {
  backend "s3" {
    bucket       = "my-tf-state"
    key          = "prod/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
  }
}
terraform init -migrate-state   # after changing the backend
terraform init -reconfigure     # ignore saved backend config
terraform init -backend-config=prod.s3.tfbackend

S3 backend locking needs use_lockfile; the older dynamodb_table is deprecated. Keep credentials out of configuration and pass them with -backend-config.

See: Backends, S3 backend

Inspect state

terraform state list
terraform state show aws_instance.web
terraform show
terraform output

State can contain secrets; store remote state securely and restrict access.

See: state list

Move or remove addresses

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

Prefer configuration-driven moved and removed blocks for reviewable changes.

moved {
  from = aws_instance.old
  to   = aws_instance.web
}

removed {
  from = aws_instance.legacy
  lifecycle { destroy = false }
}

See: Refactoring, removed block

Import existing resources

import {
  to = aws_instance.web
  id = "i-0123456789abcdef0"
}
terraform plan
terraform apply

# Imperative alternative
terraform import aws_instance.web i-0123456789abcdef0

Import associates an existing object with a resource address. Use identity instead of id when the provider defines a resource identity.

See: Import

Query resources

# aws.tfquery.hcl
list "aws_instance" "web" {
  provider = aws

  config {
    filter {
      name   = "instance-state-name"
      values = ["running"]
    }
  }
}
terraform query
terraform query -generate-config-out=import.tf
terraform query -json

Queries find unmanaged resources; generated config contains import and resource blocks.

See: Import resources in bulk

Workspaces

terraform workspace list
terraform workspace list -json
terraform workspace new staging
terraform workspace select staging
terraform workspace show
terraform workspace select default
terraform workspace delete staging

CLI workspaces share configuration but use separate state instances.

See: State, Workspaces

Provisioners and checks

Provisioners

resource "aws_instance" "web" {
  # ...

  provisioner "remote-exec" {
    inline = ["sudo systemctl restart app"]
  }

  provisioner "local-exec" {
    command = "echo ${self.private_ip} >> hosts"
  }
}

Provisioners run only at create or destroy time, never on updates. Destroy-time provisioners use a when argument. Prefer purpose-built resources.

See: Provisioners

check blocks

check "health_check" {
  data "http" "app" {
    url = "https://example.com"
  }

  assert {
    condition     = data.http.app.status_code == 200
    error_message = "Health check failed"
  }
}

Checks run after plan and apply; a failing assert warns instead of failing the run. Data sources nested in a check block are read last.

See: check block

Debugging and automation

Evaluate expressions

terraform console

> cidrsubnet("10.0.0.0/16", 8, 2)
"10.0.2.0/24"

See: terraform console

Logging

TF_LOG=DEBUG terraform plan
TF_LOG=TRACE TF_LOG_PATH=terraform.log terraform apply

Logs may contain sensitive values.

See: Debugging

CI checks

# Validate without initializing the backend:
terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validate

# Initialize the backend before planning:
terraform init -input=false
terraform plan -input=false -no-color -detailed-exitcode

See: Automation

Testing

# tests/web.tftest.hcl
run "plan" {
  command = plan

  assert {
    condition     = aws_instance.web.instance_type == "t3.micro"
    error_message = "Unexpected instance type"
  }
}
terraform test
terraform test -filter=tests/web.tftest.hcl
terraform test -junit-xml=report.xml

Tests run in separate in-memory state, starting empty. Use -verbose to print each run's plan or state.

See: terraform test

Targeting

terraform plan -target=aws_instance.web
terraform apply -replace=aws_instance.web

Use -target only for exceptional recovery, not routine workflows.

See: Resource targeting, Automation

Also see

0 Comments for this cheatsheet. Write yours!