Claude Code with Terraform: Modules, State, Workspaces
Using Claude Code on Terraform projects
Terraform is the most state-sensitive tool Claude Code will touch. A bad apply destroys production. A broken state file locks a team out of their own infrastructure. A provider upgrade without a plan reorders resources in ways that recreate live databases. Claude knows HCL and the major providers, but it does not know which of your environments tolerate experimentation and which require a four-eye review.
Without a project-specific CLAUDE.md, Claude generates modules with implicit provider configurations, omits lifecycle blocks where they matter, suggests terraform import against state it has not read, and proposes terraform apply -auto-approve because that is what most tutorials show. Each of those defaults is a production incident waiting to happen.
This guide covers the CLAUDE.md patterns that prevent those failures. The Claude Code setup guide covers installation. The CLAUDE.md explained guide covers the file format. The patterns below apply whether your providers are AWS, Azure, or GCP.
The Terraform CLAUDE.md template
The CLAUDE.md at your project root is read before every Claude Code session. For Terraform, it declares the Terraform version, provider versions, backend, workspace model, module layout, and the rules that govern every plan and apply Claude proposes.
# Terraform project rules
## Stack
- Terraform: 1.9.x (pinned in .terraform-version)
- Provider versions: pinned in versions.tf at root and per module
- Backend: S3 + DynamoDB lock table (env: backend-config/{env}.hcl)
- Workspace model: directory-per-environment (NOT terraform workspaces)
- Secrets: SSM Parameter Store or HashiCorp Vault, never plaintext in tfvars
- Formatter: terraform fmt -recursive (pre-commit enforced)
- Linter: tflint with .tflint.hcl at repo root
- Security scan: tfsec, blocks high-severity findings
## Repo layout
- modules/: reusable modules, semver-tagged
- envs/dev/, envs/staging/, envs/prod/: per-environment roots
- envs/{env}/main.tf: module instantiations only, no inline resources
- envs/{env}/{variables,outputs,backend,providers}.tf
- backend-config/{env}.hcl: backend init configuration
## Module conventions
- Every module has: main.tf, variables.tf, outputs.tf, versions.tf, README.md
- Every variable has type + description
- Every output has a description
- No provider blocks inside modules (pass via required_providers)
- No backend blocks inside modules (backends are root-only)
- Module sources pinned by git tag, never branch ref
## Plan and apply rules (HARD)
- ALWAYS terraform plan -out=tfplan before apply
- ALWAYS apply against the saved file: terraform apply tfplan
- NEVER use -auto-approve outside dev
- NEVER apply to prod without explicit per-run approval
- NEVER run terraform destroy, state rm, or import without approval
- If a plan shows resource destruction in prod, STOP for review
## Secret rules (HARD)
- NEVER commit *.tfvars except *.tfvars.example
- NEVER put secrets in variable defaults
- Sensitive variables: sensitive = true
- Secrets read from SSM or Vault data sources at plan time
- terraform.tfstate never committed; state in remote backend only
Three rules prevent the most common failures.
The directory-per-environment rule is the most consequential. Terraform's built-in workspaces share state across environments and use string interpolation to select values, which is the wrong model for separating dev from prod. Claude reaches for terraform.workspace because it dominates introductory documentation. Pinning the rule to directory-per-environment produces a layout that scales and audits cleanly.
The plan-file rule prevents race conditions. A terraform apply without a saved plan re-runs the plan phase, and if state changed between review and apply, the executed plan is not the plan you read.
The destructive-command rule is the antidote to the most expensive failure mode. terraform destroy, terraform state rm, and terraform import all touch state in ways that can be irreversible. Claude will reach for terraform import to tidy up a manually-created resource, and if the address does not match the configuration, the next plan suggests destroying it.
Module structure with variables.tf and outputs.tf
Modules earn their keep when conventions are tight. A reusable module has a stable interface: typed variables in, named outputs out, no surprises in the middle.
# modules/vpc/versions.tf
terraform {
required_version = "~> 1.9.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.70" }
}
}
# modules/vpc/variables.tf
variable "name" {
type = string
description = "Name prefix for VPC resources."
}
variable "cidr_block" {
type = string
description = "CIDR block for the VPC, e.g. 10.40.0.0/16."
validation {
condition = can(cidrnetmask(var.cidr_block))
error_message = "cidr_block must be a valid CIDR notation."
}
}
variable "availability_zones" {
type = list(string)
description = "AZs to deploy subnets into."
}
variable "tags" {
type = map(string)
description = "Tags applied to all resources."
default = {}
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, { Name = var.name, Module = "vpc" })
}
resource "aws_subnet" "private" {
for_each = toset(var.availability_zones)
vpc_id = aws_vpc.this.id
availability_zone = each.key
cidr_block = cidrsubnet(var.cidr_block, 4, index(var.availability_zones, each.key))
tags = merge(var.tags, { Name = "${var.name}-private-${each.key}" })
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "The ID of the created VPC."
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "Private subnet IDs keyed by AZ."
value = { for az, s in aws_subnet.private : az => s.id }
}
The versions.tf block pins both Terraform and the provider with pessimistic constraints (~>) that allow patch upgrades but block minor ones without review. Without explicit pinning, Claude generates modules that float on the latest provider, which is how a routine terraform init silently upgrades you from aws 5.70 to aws 5.95 mid-sprint.
The validation block on cidr_block prevents the typo-driven incident where someone writes 10.40.0.0 instead of 10.40.0.0/16. Claude generates these when they are in CLAUDE.md, omits them otherwise. The for_each on aws_subnet.private produces stable addresses keyed by AZ, so adding a new AZ later does not reorder existing subnets in state.
Remote state with S3 plus DynamoDB or Terraform Cloud
Remote state with locking is non-negotiable. The two patterns Claude needs to know are S3 plus DynamoDB (self-managed) and Terraform Cloud (managed). Pick one per project, document it, and lock Claude to it.
# envs/prod/backend.tf
terraform {
backend "s3" {
# Configured via backend-config/prod.hcl at init time
# terraform init -backend-config=../../backend-config/prod.hcl
}
}
# backend-config/prod.hcl
bucket = "acme-tf-state-prod"
key = "envs/prod/terraform.tfstate"
region = "eu-west-2"
dynamodb_table = "acme-tf-locks"
encrypt = true
kms_key_id = "arn:aws:kms:eu-west-2:123456789012:key/abc-123"
The partial backend pattern keeps the backend block free of environment-specific values. Each env has its own backend-config/{env}.hcl, and terraform init -backend-config=... wires them together. This stops the common mistake where a dev session writes to the prod state bucket. The S3 bucket needs versioning, KMS encryption, and full public-access blocking. The DynamoDB lock table prevents two concurrent applies from corrupting state.
# infra/state/main.tf (one-time bootstrap, separate from main envs)
resource "aws_s3_bucket" "state" {
bucket = "acme-tf-state-prod"
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.state.arn
}
}
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "locks" {
name = "acme-tf-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute { name = "LockID", type = "S" }
point_in_time_recovery { enabled = true }
}
The bootstrap lives in its own directory with a local state file, because the state for your state backend cannot itself be in the backend until the backend exists. After it applies once, the local terraform.tfstate is migrated and never touched again.
For Terraform Cloud the setup is shorter. The backend block declares the organization and workspace, and Terraform Cloud handles locking, state, and plan history.
# envs/prod/backend.tf (Terraform Cloud variant)
terraform {
cloud {
organization = "acme"
workspaces {
name = "infra-prod"
}
}
}
Either backend choice belongs in CLAUDE.md so Claude does not generate local backends by default. Local state is acceptable only for the bootstrap and for throwaway experiments. Anywhere else it leads to lost state, race conditions, or conflicting plans against the same resources.
Workspaces for dev, staging, and prod
Terraform offers two competing patterns for environment separation, and they look similar enough that engineers (and Claude) reach for the wrong one constantly.
## Workspace pattern (HARD)
Use: directory-per-environment.
- envs/dev/, envs/staging/, envs/prod/, each with its own backend, state, variables
- terraform commands run inside the env directory
- Module versions can differ between envs during rollout
Do NOT use: terraform workspaces.
- Shares one backend, one state file path interpolated by ${terraform.workspace}
- Encourages string-interpolation conditionals (count = var.env == "prod" ? 1 : 0)
- Cannot pin different module or provider versions per env
- A dev session can damage prod state
Exception: terraform workspaces are fine for ephemeral feature branches on
top of a stable env, where the lifecycle is hours not months.
The directory pattern produces isolation. Each environment is its own root, with its own backend, variables, and state. Promoting from dev to prod is a code change reviewed by humans, not a terraform workspace select prod typed at the terminal.
# envs/staging/main.tf
module "vpc" {
source = "git::ssh://git@github.com/acme/tf-modules.git//vpc?ref=v1.4.0"
name = "staging"
cidr_block = "10.30.0.0/16"
availability_zones = ["eu-west-2a", "eu-west-2b"]
tags = { Environment = "staging", Owner = "platform" }
}
module "app" {
source = "git::ssh://git@github.com/acme/tf-modules.git//app?ref=v2.1.0"
environment = "staging"
vpc_id = module.vpc.vpc_id
private_subnet_ids = values(module.vpc.private_subnet_ids)
instance_type = "t3.medium"
desired_capacity = 2
}
# envs/prod/main.tf
module "vpc" {
source = "git::ssh://git@github.com/acme/tf-modules.git//vpc?ref=v1.4.0"
name = "prod"
cidr_block = "10.40.0.0/16"
availability_zones = ["eu-west-2a", "eu-west-2b", "eu-west-2c"]
tags = { Environment = "prod", Owner = "platform" }
}
module "app" {
source = "git::ssh://git@github.com/acme/tf-modules.git//app?ref=v2.0.3"
environment = "prod"
vpc_id = module.vpc.vpc_id
private_subnet_ids = values(module.vpc.private_subnet_ids)
instance_type = "m6i.large"
desired_capacity = 6
}
Prod is on v2.0.3 of the app module while staging is on v2.1.0. This is the staggered rollout the directory pattern enables and terraform.workspace interpolation makes painful. Git-tagged module sources also produce deterministic plans: a fresh checkout from six months ago plans identically to the apply at the time. The Claude Code best practices guide covers deterministic configuration across project types.
Variable files belong outside version control. Each environment has a gitignored terraform.tfvars or pulls values from SSM at plan time.
For workspace-scoped IAM and per-environment credentials, the Claude Code permissions guide covers settings.json patterns. For monorepo layouts mixing Terraform with application code, the Claude Code monorepo guide covers scoping CLAUDE.md per-package.
Plan, apply, providers, and secrets
The day-to-day workflow is plan, review, apply, repeat. Every step has a Claude failure mode worth pre-empting in CLAUDE.md.
## Workflow
### Standard flow
1. cd envs/{env}
2. terraform init -backend-config=../../backend-config/{env}.hcl
3. terraform fmt -check -recursive
4. tflint --recursive
5. tfsec . (block high-severity)
6. terraform validate
7. terraform plan -out=tfplan
8. Human reviews the plan
9. terraform apply tfplan (plan file is single-use, deleted after)
### Forbidden
- terraform apply without -out=tfplan
- terraform apply -auto-approve outside dev
- terraform destroy in any env without explicit approval
- terraform state push/rm, terraform import (state surgery = four-eye review)
- terraform init -reconfigure (use -migrate-state with backup)
### Provider versioning
- versions.tf at every root and module
- ~> for libraries, exact pin for production roots
- .terraform.lock.hcl committed, refreshed via terraform init -upgrade only on intentional bumps
- Provider upgrades: separate PR, plan-only first, then dev before staging
The plan file flow is the heart of safe Terraform. -out=tfplan records the exact resource changes, and apply tfplan executes that record without re-planning. Without the plan file, two terraform apply commands at different times produce different results.
# providers.tf at envs/prod root
terraform {
required_version = "1.9.5" # exact pin for production root
required_providers {
aws = { source = "hashicorp/aws", version = "5.74.0" }
random = { source = "hashicorp/random", version = "3.6.3" }
}
}
provider "aws" {
region = "eu-west-2"
default_tags {
tags = { Environment = "prod", ManagedBy = "terraform", Repo = "acme/infra" }
}
}
Exact pins at production roots, pessimistic constraints in shared modules. The default_tags block tags every resource the provider creates, eliminating the cost-allocation hole where one engineer forgets to tag a resource and finance cannot attribute spend.
Secret handling is where Claude defaults are weakest, because the easiest path is var.api_key with a value in terraform.tfvars and a .gitignore covering it. That is not safe. Tfvars files leak into commits, CI logs, and .terraform.tfstate itself.
# Read secrets from SSM at plan time, never store in code or tfvars
data "aws_ssm_parameter" "db_password" {
name = "/prod/app/db_password"
with_decryption = true
}
resource "aws_db_instance" "app" {
identifier = "prod-app"
password = data.aws_ssm_parameter.db_password.value
}
# Any output that exposes a secret value must be marked sensitive
output "db_connection_string" {
value = "postgresql://app:${data.aws_ssm_parameter.db_password.value}@${aws_db_instance.app.endpoint}/app"
sensitive = true
}
sensitive = true keeps values out of plan output and CLI display, but does not encrypt state. The state file contains the secret in plaintext, which is why the S3 bucket has KMS encryption and restricted access. The pattern that removes secrets from state entirely is Vault dynamic credentials, where Terraform requests short-lived credentials at plan time. The Claude Code environment variables guide covers patterns at the Claude Code layer.
Claude Code's permission system controls which Bash commands Claude runs autonomously. For Terraform projects, the allow and deny lists give Claude useful capabilities (plan, fmt, validate) while denying the dangerous ones (destroy, state surgery, prod apply).
{
"permissions": {
"allow": [
"Bash(terraform fmt*)",
"Bash(terraform validate*)",
"Bash(terraform init*)",
"Bash(terraform plan -out=tfplan*)",
"Bash(terraform plan --target*)",
"Bash(terraform show tfplan)",
"Bash(terraform output*)",
"Bash(tflint*)",
"Bash(tfsec*)",
"Bash(cd envs/dev && terraform apply tfplan)"
],
"deny": [
"Bash(terraform apply -auto-approve*)",
"Bash(terraform destroy*)",
"Bash(terraform state rm*)",
"Bash(terraform state push*)",
"Bash(terraform import*)",
"Bash(terraform workspace*)",
"Bash(cd envs/prod && terraform apply*)",
"Bash(cd envs/staging && terraform apply*)"
]
}
}
Claude can plan against any environment, validate, format, lint, scan, and apply against dev. Apply against staging or prod requires a human. The state-surgery primitives are denied across the board. The Claude Code deploy guide covers how this slots into a broader deploy pipeline.
Hard rules and final guardrails
A short list of non-negotiable rules belongs at the bottom of every Terraform CLAUDE.md.
## Hard rules
1. NEVER terraform apply without a saved plan from -out=tfplan.
2. NEVER terraform apply -auto-approve outside dev.
3. NEVER terraform destroy, state rm, state push, or import without explicit per-operation approval.
4. NEVER commit *.tfvars except *.tfvars.example.
5. NEVER commit terraform.tfstate; state lives in the remote backend.
6. NEVER put a backend block inside a module.
7. NEVER put a provider block inside a module (pass via required_providers).
8. NEVER use terraform.workspace for environment separation.
9. NEVER float provider or Terraform versions; pin in versions.tf at every root.
10. If a plan shows destruction or replacement in staging or prod, STOP and surface the diff.
Claude can hold ten constraints in mind and apply them consistently. Twenty become probabilistic. These ten cover the failure modes that produce real Terraform incidents: lost state, accidental destroys, leaked secrets, version drift, and unreviewed production applies.
The "STOP and surface" rule deserves emphasis. Plans are dense, and destructive operations hide inside long diffs. Telling Claude to halt and call out destruction in staging or prod converts a silent risk into a deliberate decision.
Building production Terraform with Claude Code
The configuration above produces a development environment where modules are typed and tagged, state is remote and locked, environments are isolated by directory, providers are pinned, secrets stay out of state and source, and applies never happen without a saved plan and a human in the loop beyond dev.
Without CLAUDE.md, Claude generates modules with embedded providers, suggests terraform workspace for environment separation, omits validation blocks, and reaches for terraform import to silently reconcile reality with config. With the configuration above it follows the directory pattern, pins versions, validates inputs, and asks before touching state. Claudify includes a Terraform-specific CLAUDE.md template, pre-configured for module conventions, S3 plus DynamoDB backends, directory-per-environment workspaces, and destructive-command guardrails.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify