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.
- Terraform documentation (developer.hashicorp.com)
- Terraform Registry (registry.terraform.io)
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.
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).
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.
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}"
Conditionals
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
Both result values must have compatible types.
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.
Explicit dependencies
resource "aws_instance" "app" {
# ...
depends_on = [aws_iam_role_policy.app]
}
Use depends_on only for dependencies Terraform cannot infer.
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.
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
0 Comments for this cheatsheet. Write yours!