Skip to content

How-to Guides

Scope

Task recipes for Terraform 1.16 (current stable, 2026-09): install and upgrade, state backends and locking, refactoring and import, workspaces, CI/CD and drift detection, security setup (backend hardening, dynamic credentials, keeping secrets out of state), tests, CDKTF migration, performance, and troubleshooting. Facts and tables are in Reference; background is in Explanation.

Install and Upgrade Terraform

Terraform ships as a single binary from releases.hashicorp.com and HashiCorp's package repositories.

# Debian / Ubuntu: HashiCorp apt repository
wget -O- https://apt.releases.hashicorp.com/gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform

# macOS: HashiCorp tap (formula tracks current releases, 1.16.4 as of 2026-09-25)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

terraform version

Pin the version per configuration

Declare the supported range so CI and teammates fail fast on the wrong binary:

terraform {
  required_version = "~> 1.16"
}

Upgrade one minor at a time, read the "UPGRADE NOTES" in each branch changelog (for example 1.16 changed bastion_host_key handling in provisioners), and run terraform plan to confirm no unexpected diffs before applying.

State Management

Backend Configuration

Use S3 native locking (use_lockfile, GA in 1.11). The DynamoDB arguments (dynamodb_table) are deprecated. Other backends are compared in Reference.

# Production S3 backend (Terraform 1.11+)
terraform {
  backend "s3" {
    bucket       = "company-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    kms_key_id   = "alias/terraform-state"
    use_lockfile = true
  }
}

Migrate S3 Locking from DynamoDB

  1. Upgrade all users and pipelines to Terraform 1.11 or later.
  2. Add use_lockfile = true next to the existing dynamodb_table. With both set, Terraform takes both locks, so older and newer runs stay safe during rollout.
  3. Re-initialize: terraform init -reconfigure.
  4. After every pipeline uses the new config, remove dynamodb_table, run terraform init -reconfigure again, and delete the DynamoDB table.

The IAM principal needs s3:PutObject and s3:DeleteObject on the <key>.tflock object in addition to state access.

Refactor with Blocks Instead of State Commands

Configuration-driven refactoring is reviewable in pull requests and works in HCP Terraform, where you cannot run terraform state mv against remote runs.

# Rename a resource without destroy/create
moved {
  from = aws_instance.old
  to   = aws_instance.new
}

# Stop managing a resource but keep the real object (1.7+)
removed {
  from = aws_instance.decommissioned
  lifecycle {
    destroy = false
  }
}

# Protect a resource from ever being destroyed by Terraform (1.16+)
resource "aws_s3_bucket" "audit" {
  bucket = "company-audit-logs"
  lifecycle {
    destroy = false
  }
}

The imperative equivalents (terraform state mv, terraform state rm) are in Commands & Recipes.

Import Existing Infrastructure

Import with import Blocks

# imports.tf (1.5+; for_each on import since 1.7; import inside modules since 1.16)
import {
  to = aws_instance.web
  id = "i-1234567890abcdef0"
}
# Generate HCL for imported resources that have no configuration yet
terraform plan -generate-config-out=generated.tf
# Review and clean generated.tf, then apply
terraform apply

Discover Resources in Bulk with terraform query

Since 1.14, providers that implement list resources can enumerate existing objects. Support varies by provider and resource type.

# discover.tfquery.hcl
list "aws_instance" "all" {
  provider = aws
}
terraform query                                   # print discovered resources
terraform query -generate-config-out=imported.tf  # emit import blocks + config
terraform validate -query                         # offline check of .tfquery.hcl files

Workspace Patterns

Environment Isolation

# Per-environment CLI workspaces
terraform workspace new staging
terraform workspace new production
terraform workspace select production
terraform workspace list -json   # machine-readable (1.16+)
# Use the workspace name in configuration
locals {
  env = terraform.workspace
  instance_type = {
    staging    = "t3.small"
    production = "t3.xlarge"
  }
}

Workspace vs directory vs Stacks

For environments that differ in structure, credentials, or approval flow, use separate root modules with shared modules instead of CLI workspaces, which share one backend and variable shape. On HCP Terraform, Stacks deployments model "same components, many environments/regions" natively.

CI/CD Integration

GitHub Actions Pattern

- uses: hashicorp/setup-terraform@v3
  with:
    terraform_version: "1.16.4"

- name: Terraform Plan
  run: |
    terraform init -backend-config=backend.hcl -input=false
    terraform plan -out=tfplan -input=false
    terraform show -json tfplan > plan.json

- name: Terraform Apply
  if: github.ref == 'refs/heads/main'
  run: terraform apply -input=false tfplan

A saved plan applies without a prompt, so gate the apply job behind review (protected environment or manual approval). Use OIDC to the cloud rather than stored keys.

Drift Detection

# Detect drift without applying
terraform plan -detailed-exitcode
# Exit code: 0 = no changes, 1 = error, 2 = changes detected

# Accept out-of-band changes into state without touching infrastructure
terraform apply -refresh-only

Schedule the plan (cron in CI) and alert on exit code 2. HCP Terraform offers built-in drift detection (health assessments) on higher tiers; check the pricing page for availability.

Security Setup

Harden a Self-Managed Backend

terraform {
  backend "s3" {
    bucket       = "terraform-state-prod"
    key          = "infra/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    kms_key_id   = "arn:aws:kms:us-east-1:111122223333:key/EXAMPLE-KEY-ID"
    use_lockfile = true
  }
}
  • Encrypt with a customer-managed KMS key and restrict kms:Decrypt to the principals that run Terraform.
  • Enable bucket versioning to recover from bad writes; block public access.
  • Restrict bucket access with least-privilege IAM; enable access logging or CloudTrail data events.
  • Equivalent controls exist for GCS (CMEK, uniform bucket-level access) and Azure Blob (RBAC, soft delete).

Configure Dynamic Provider Credentials

Example for AWS on HCP Terraform (Azure, GCP, Vault, and Kubernetes follow the same pattern with their own variables):

  1. In AWS IAM, create an OIDC identity provider for https://app.terraform.io with audience aws.workload.identity.
  2. Create a role whose trust policy allows sts:AssumeRoleWithWebIdentity with a condition on app.terraform.io:sub, for example organization:my-org:project:infra:workspace:prod-network:run_phase:*.
  3. Set workspace (or variable-set) environment variables:
TFC_AWS_PROVIDER_AUTH=true
TFC_AWS_RUN_ROLE_ARN=arn:aws:iam::111122223333:role/tfc-prod-network
  1. Remove any static AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY variables. Use separate plan and apply roles (TFC_AWS_PLAN_ROLE_ARN, TFC_AWS_APPLY_ROLE_ARN) for a read-only plan phase.

When OIDC is unavailable: keep keys in environment variables or sensitive variable sets (never in .tf files), generate short-lived credentials from Vault, and rotate on a schedule.

Keep Secrets Out of State

Use ephemeral resources and write-only arguments (1.10+/1.11+) so the secret never lands in plan or state files. The provider must support them (for example random_password as an ephemeral resource, and password_wo on aws_db_instance in recent hashicorp/aws releases; check your provider docs).

ephemeral "random_password" "db" {
  length  = 24
  special = true
}

resource "aws_db_instance" "main" {
  identifier          = "app-db"
  engine              = "postgres"
  instance_class      = "db.t4g.medium"
  allocated_storage   = 20
  username            = "app"
  password_wo         = ephemeral.random_password.db.result
  password_wo_version = 1 # bump to rotate
}

variable "api_token" {
  type      = string
  ephemeral = true # accepted at plan/apply, never persisted
}

For values that are not secrets but must not be shown, mark outputs and variables sensitive = true; this only redacts CLI output, it does not keep them out of state.

Pin Modules and Providers

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.0" # exact pin
}

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.0, < 7.0"
    }
  }
}
# Record checksums for every platform CI and developers use
terraform providers lock \
  -platform=linux_amd64 -platform=linux_arm64 \
  -platform=darwin_arm64 -platform=windows_amd64
git add .terraform.lock.hcl

Write a Sentinel Policy

import "tfplan/v2" as tfplan
import "version"

main = rule {
    version.new(tfplan.terraform_version).greater_than("1.5.0")
}

Attach it to a policy set in HCP Terraform / TFE with an enforcement level (advisory, soft-mandatory, hard-mandatory). Categories and levels are listed in Reference.

Test Modules with terraform test

# tests/vpc.tftest.hcl
mock_provider "aws" {}

variables {
  cidr = "10.0.0.0/16"
  name = "test"
}

run "vpc_uses_requested_cidr" {
  command = plan

  assert {
    condition     = aws_vpc.main.cidr_block == var.cidr
    error_message = "VPC CIDR does not match input"
  }
}
terraform test                              # all *.tftest.hcl files
terraform test -filter=tests/vpc.tftest.hcl
terraform test -junit-xml=test-results.xml  # CI report (GA in 1.11)

mock_provider needs no credentials; drop it and use command = apply for integration tests against a sandbox account. Terraform destroys test-created resources at the end of the file.

Migrate from CDKTF

CDKTF was archived on 2025-12-10 and receives no updates. To move to plain HCL:

cdktf synth --hcl        # writes .tf files under cdktf.out/stacks/<stack>
cd cdktf.out/stacks/my-stack
terraform init
terraform plan           # expect no changes against the existing state

Keep the same backend and state; review and restructure the generated HCL into modules. Teams deeply tied to AWS CDK may migrate to AWS CDK instead (sunset notice).

Performance Optimization

Strategy Impact When to use
-parallelism=n More concurrent operations (default 10) Large graphs; lower it if providers throttle
-refresh=false Skip refresh Short-lived runs when state is known to be current
-minimal-refresh Refresh only resources with proposed changes 1.17+ (beta as of 2026-09)
-target Operate on specific resources Emergency fixes only; not routine CI
State splitting Smaller plans, smaller blast radius 50+ resources per state or slow plans
TF_PLUGIN_CACHE_DIR Reuse downloaded providers CI pipelines, many root modules

Common Issues

Issue Root cause Resolution
State lock stuck Crashed or cancelled apply Confirm no run is active, then terraform force-unlock <ID>
Provider version conflict Loose constraints or stale lock file Constrain versions; terraform init -upgrade and commit the lock file
Cycle detected Circular references Restructure; split resources or use data sources
State drift Manual changes terraform plan to see it; terraform apply -refresh-only to accept, or apply to revert
Slow plan Large state Split state; -parallelism; -minimal-refresh (1.17)
Saved plan is stale State changed after planning Re-run terraform plan
Lock file missing platform hashes Lock generated on a different OS/arch terraform providers lock -platform=...

Commands & Recipes

Core Workflow

terraform init                    # providers, modules, backend
terraform fmt -recursive
terraform validate
terraform plan -out=plan.tfplan
terraform apply plan.tfplan
terraform destroy
terraform graph -format=mermaid   # 1.16+
terraform console -scope=module.vpc   # 1.16+

State Commands

terraform state list
terraform state show aws_instance.web
terraform state show -json aws_instance.web       # 1.16+
terraform state mv aws_instance.old aws_instance.new
terraform state rm aws_instance.orphan            # forget, do not destroy
terraform import aws_instance.web i-1234567890abcdef0   # prefer import blocks
terraform force-unlock <LOCK_ID>

Module Pattern

# modules/vpc/main.tf
variable "cidr" {
  type    = string
  default = "10.0.0.0/16"
}

variable "name" {
  type = string
}

resource "aws_vpc" "main" {
  cidr_block           = var.cidr
  enable_dns_hostnames = true
  tags                 = { Name = var.name }
}

output "vpc_id" {
  value = aws_vpc.main.id
}

# Root module usage
module "vpc" {
  source = "./modules/vpc"
  cidr   = "10.0.0.0/16"
  name   = "production"
}

Workspace Commands

terraform workspace new staging
terraform workspace select staging
terraform workspace list
terraform workspace delete staging

Sources