Cursor Rules for Terraform: The Complete Guide to AI-Assisted Terraform Development
Terraform is the tool where a single sloppy command can delete the production database. The plan looks innocent. The reviewer skims. apply runs. The AWS console turns red. The incident retrospective documents every warning the team got — the prevent_destroy that was never set, the count = 0 that was supposed to be count = 1, the for_each that shifted the key ordering when a name was renamed, the Terraform state that lived in one developer's laptop, the provider "aws" block with no version constraint so the next laptop got a different API surface, the module that took vpc_id and subnets as variables but also quietly read data "aws_default_vpc" and drifted between regions. The infrastructure doesn't collapse in one dramatic moment; it corrodes through a thousand small decisions that each felt reasonable alone.
Then you add an AI assistant.
Cursor and Claude Code were trained on a decade of Terraform, most of it HCL0.11 or HCL0.12 from tutorials that predate terraform_remote_state being a warning sign, resource "aws_s3_bucket" examples that hard-code every attribute and don't separate aws_s3_bucket from aws_s3_bucket_policy (the 4.x provider split), modules that take six variables and output one, state files committed to git "just temporarily," terraform apply -auto-approve in Makefiles, count = var.enabled ? 1 : 0 patterns that break when the variable flips, and data sources that implicitly depend on a region that's never declared. Ask for "Terraform for a staging and production VPC," and you get one main.tf with a var.environment toggle, the same state backend for both, and a comment that says "TODO: separate envs." The apply succeeds. The audit fails six months later when someone notices staging and production share IAM policies because the module was "reused."
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic, production-grade Terraform looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end. Examples use AWS, but the rules apply equally to Azure, GCP, and provider-agnostic code.
How Cursor Rules Work for Terraform Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for any non-trivial Terraform repo). For Terraform I recommend modular rules so module-authoring conventions don't bleed into root-module composition, and so plan/apply guidance only fires on .tf files near the pipeline:
.cursor/rules/
tf-module.mdc # module structure, inputs, outputs, README
tf-state.mdc # remote backend, locking, one state per env
tf-variables.mdc # typed vars, validation, sensitive, defaults
tf-plan.mdc # plan artifacts, no -auto-approve, review
tf-envs.mdc # env separation, naming, tagging
tf-providers.mdc # pinned versions, aliases, required_version
tf-lifecycle.mdc # prevent_destroy, ignore_changes, for_each vs count
tf-secrets.mdc # no secrets in state, KMS, SSM/SM references
Frontmatter controls activation: globs: ["**/*.tf", "**/*.tfvars", "**/modules/**"] with alwaysApply: false. Now the rules.
Rule 1: Module Structure — Real Contracts, Real READMEs, Root Modules Compose
Terraform has exactly one reuse primitive: the module. And yet the most common AI-generated Terraform structure is a single main.tf at the repo root with resources, data sources, and provider blocks mixed together, no variables.tf, no outputs.tf, no versions.tf, no README — and the next person who needs "the same but for another team" copies the whole file and starts editing. A month later nobody knows which changes in each copy were intentional. The rule is: modules are libraries with a signed contract (inputs, outputs, side effects) and root modules are thin compositions that wire them together.
The rule:
MODULE LAYOUT — for every module (./modules/foo/ and each root):
main.tf — resources + locals + data sources
variables.tf — ALL input variables with type, description, default?
outputs.tf — ALL outputs with description
versions.tf — terraform { required_version; required_providers {...} }
README.md — purpose, inputs, outputs, example usage
Optional: iam.tf, network.tf, data.tf — split when main.tf exceeds ~200
lines or a clear domain emerges.
MODULES HAVE A CONTRACT
- Every variable has a description. Never `variable "foo" {}` with no
type / description.
- Every output has a description.
- Inputs are explicit. A module must NOT silently read a data source
that depends on the caller's environment (e.g. data
"aws_default_vpc"). Take a vpc_id var instead.
- Modules do NOT declare provider { ... } blocks (they inherit from
the caller). They MAY declare `required_providers` with source +
version constraints in versions.tf.
- A module is ONE conceptual resource. `rds-postgres`, `vpc-3az`,
`ecs-service`. Not `everything-for-team-x`.
ROOT MODULES COMPOSE
- Root modules (envs/production, envs/staging) contain module calls,
provider blocks, backend configuration, and locals. NO direct resources
in a root module except very thin glue (random_pet, tags locals).
- Root modules are tiny (< 200 lines). If they grow, factor into a
new module.
NAMING
- snake_case for variable / resource / module names. kebab-case for
tags / AWS resource names that show up in consoles.
- Resource-type prefixes aren't necessary: `aws_s3_bucket.logs` not
`aws_s3_bucket.logs_bucket`.
- Module instance names describe role: `module.vpc_prod`, not
`module.terraform-aws-vpc` (a published source path is different).
NO DEEP NESTING
- Modules call modules at most one level deep. A module that calls
two modules that each call two more is a red flag — extract flat.
VERSIONING
- Modules in this repo are referenced by relative path. Modules in
OTHER repos are referenced by `source = "git::...//path?ref=v1.2.3"`
or a Terraform Registry version. Never `ref=main` — pin.
Before — single main.tf with everything, implicit provider reads, no contract:
# main.tf (root)
provider "aws" { region = "us-east-1" }
data "aws_default_vpc" "default" {}
resource "aws_db_instance" "db" {
identifier = "prod-${random_id.suffix.hex}"
engine = "postgres"
engine_version = "14"
instance_class = "db.t3.medium"
vpc_security_group_ids = [aws_security_group.db.id]
# 40 more attributes inline
}
No module boundary. aws_default_vpc ties this to whatever "default" means in the caller's account. Any rename breaks every consumer.
After — root module composes, rds module has a contract:
# envs/production/main.tf (root)
module "vpc" {
source = "../../modules/vpc-3az"
name = "prod"
cidr_block = "10.20.0.0/16"
private_subnet_bits = 4
}
module "orders_db" {
source = "../../modules/rds-postgres"
name = "orders"
environment = local.environment
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.t3.medium"
deletion_protection = true
tags = local.tags
}
# modules/rds-postgres/variables.tf
variable "name" { type = string; description = "Logical DB name (snake_case, used in identifiers)." }
variable "environment" { type = string; description = "One of production | staging | dev." }
variable "vpc_id" { type = string; description = "VPC the DB lives in." }
variable "subnet_ids" { type = list(string); description = "Private subnets for the DB subnet group." }
variable "instance_class" { type = string; default = "db.t3.micro"; description = "RDS instance class." }
variable "deletion_protection" { type = bool; default = true; description = "Set false only in ephemeral envs." }
variable "tags" { type = map(string); default = {}; description = "Extra tags merged with module defaults." }
Explicit contract. No environment assumptions. Reusable.
Rule 2: State Management — Remote Backend, Locked, One State Per Env
Terraform state is the source of truth for what Terraform believes exists. Lose it or corrupt it and you're back to importing every resource by hand. Cursor's default is terraform { } with no backend — so state lives in terraform.tfstate next to the code, on whoever-ran-it's laptop, uncommitted, irrecoverable. The second-most-common failure is one state for the whole company: s3://company-terraform/state.tfstate with all environments, where a careless terraform destroy in dev also deletes production resources because they're in the same state. The third is terraform.tfstate checked into git — which leaks secrets (every *_password, every SSH private key, every cert) and overwrites silently on merge.
The rule:
REMOTE BACKEND, MANDATORY
- Every root module declares a backend (S3 + DynamoDB lock table,
Terraform Cloud, GCS with locking, Azure Storage). No local
backends in production or staging. Local backend is OK ONLY
for throwaway sandboxes, and those never contain production
resources.
- The S3 bucket has: versioning ON, encryption ON, block public
access ON, MFA delete ON (where ops tolerate), lifecycle rule
to expire non-current versions after N days.
- The DynamoDB lock table has `LockID` as the partition key.
ONE STATE PER ENVIRONMENT
- Envs are separated by state, not by variable. `envs/production/`
and `envs/staging/` each have their own backend + state file.
Cross-env blast radius is zero by construction.
- Do NOT use workspaces for environment separation. Workspaces share
one backend and one set of credentials; a mistake in the CLI
targets the wrong workspace. Workspaces are fine for ephemeral
previews off one env.
NO STATE IN GIT
- .gitignore includes: *.tfstate, *.tfstate.backup, .terraform/,
.terraform.lock.hcl is COMMITTED.
- `.terraform.lock.hcl` is committed — it locks provider versions
for reproducible plans.
STATE ACCESS
- Only CI has write access to production state (via OIDC/IAM role).
Engineers have read-only locally — enough to `plan`, not enough to
`apply`.
- Any `terraform state mv` / `terraform import` / manual state edit
is reviewed like a migration. It IS a migration.
CROSS-STATE READS
- When another stack's output is needed, use `data
"terraform_remote_state"` READING ONLY, with explicit outputs.
Never mutate another stack's state. Prefer SSM Parameter Store /
consul / explicit outputs over remote_state coupling.
Before — local state, env toggled by variable, one blast radius:
terraform {
# no backend — state in ./terraform.tfstate on a laptop
}
variable "environment" { default = "staging" }
resource "aws_db_instance" "db" {
identifier = "${var.environment}-orders"
# one resource, two envs, one state
}
terraform destroy with the wrong var destroys production. State on a laptop is gone if the laptop is gone.
After — remote backend per env, strict separation:
# envs/production/versions.tf
terraform {
required_version = "~> 1.7"
backend "s3" {
bucket = "acme-tfstate-production"
key = "orders/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-tfstate-lock"
encrypt = true
}
}
# envs/staging/versions.tf
terraform {
required_version = "~> 1.7"
backend "s3" {
bucket = "acme-tfstate-staging"
key = "orders/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-tfstate-lock"
encrypt = true
}
}
Two states, two buckets, two lock tables. A mistake in staging cannot touch production. CI runs with role-assumption per env.
Rule 3: Variable Hygiene — Typed, Validated, Sensitive, Defaulted Only When It's Truly Safe
A variable "port" {} with no type can receive a string, a number, or a list. Cursor generates variables without types because every tutorial does, then the module breaks when a caller passes "5432" instead of 5432. Defaults are worse: variable "enable_deletion_protection" { default = false } — "because dev doesn't need it" — becomes production's default because production inherits the module and forgot to override. Validation blocks catch impossible values at plan time, not at 2am during an apply. And sensitive = true keeps secrets out of logs — but only when it's set.
The rule:
TYPED
- Every variable declares `type`. Primitive types (string, number,
bool) OR structured (list(string), map(string), object({...}),
tuple([...])). Never untyped.
DESCRIBED
- Every variable has `description`. Description answers "what does
this control and what are the valid values". Not "the foo".
VALIDATED
- `validation {}` block on any variable with a constrained domain.
environment, region, instance class, cidr block, port range,
retention days.
DEFAULTS
- Defaults are SAFE for the most cautious environment. If in doubt,
no default — make it required. `deletion_protection` defaults to
true. `skip_final_snapshot` defaults to false.
- No defaults that depend on `environment == "production"`. If
production needs a different value, pass it explicitly.
SENSITIVE
- `sensitive = true` on any variable carrying a password, API key,
token, private key material, or PII. Terraform masks in output;
it does NOT encrypt state — secrets still land in state. Prefer
reading secrets from SSM Parameter Store / Secrets Manager /
Vault at apply time (see Rule 8).
NULLABLE
- Prefer not nullable. `nullable = false` by default; optional args
with defaults, not `null`.
NO GOD VARIABLES
- No `var.config` object with 40 keys. Group related inputs into
small typed objects:
variable "rds" { type = object({ instance_class, storage_gb,
multi_az }) }
ENV AS AN ENUM
- `variable "environment" { type = string; validation { condition
= contains(["production","staging","dev"], var.environment) ... } }`.
- Use that variable only for TAGS and NAMING. Conditional resources
based on environment live in locals, not inline.
Before — untyped variables, unsafe defaults, no validation:
variable "environment" {}
variable "port" { default = 5432 }
variable "password" { default = "changeme" }
resource "aws_db_instance" "db" {
port = var.port
password = var.password
}
Password defaults to a well-known value. If the variable is unset, terraform happily applies. State file contains the (possibly real) password.
After — typed, validated, sensitive, no bad default:
variable "environment" {
type = string
description = "Environment this stack belongs to."
validation {
condition = contains(["production", "staging", "dev"], var.environment)
error_message = "environment must be production | staging | dev."
}
}
variable "port" {
type = number
default = 5432
description = "Postgres listen port."
validation {
condition = var.port >= 1024 && var.port <= 65535
error_message = "port must be between 1024 and 65535."
}
}
variable "master_password_ssm_name" {
type = string
description = "SSM Parameter Store name holding the master password. Parameter is resolved at apply time."
sensitive = true
}
No hard-coded default secret. Environment is an enum. Invalid port fails at plan.
Rule 4: Plan Review — Plan Is An Artifact, -auto-approve Is A Smell
terraform plan && terraform apply in a CI job that runs both in the same step is two mistakes. The plan produced by plan and the plan executed by apply CAN DIFFER because the world changed between them (a manual console tweak, another PR merging, an AWS-side change). The only safe pattern is plan -out=tfplan, review the plan, then apply tfplan using the exact captured plan. Cursor defaults to apply -auto-approve in pipeline YAML because it's in every quickstart. The rule: treat the plan as an artifact, make reviewers read it, and make -auto-approve only work on explicit plan files.
The rule:
PLAN IS AN ARTIFACT
- CI runs: `terraform plan -out=tfplan -input=false -lock-timeout=10m`
on PR. The plan binary + a pretty-printed summary (terraform show
tfplan) are uploaded as a PR artifact / comment.
- Apply runs: `terraform apply -input=false tfplan` in the
deploy job, using the saved plan. Never `apply` without `tfplan`.
- If the plan is stale (e.g. older than 24h), CI re-plans and
requires re-approval.
REVIEW IS MANDATORY
- Every destructive line in the plan (resources marked "-" or
"-/+") requires explicit reviewer signoff. PR description includes
a block like: "Destructive changes: aws_db_instance.old (delete,
confirmed safe because migrated to new)".
- Reviewers look for: replaced resources that imply data loss,
IAM policy widening, security group 0.0.0.0/0 rules, RDS /
EBS / S3 deletion without snapshot-before-destroy, unexpected
provider upgrades.
POLICY-AS-CODE
- OPA / Sentinel / tflint / checkov / tfsec run in CI before apply:
* no 0.0.0.0/0 ingress on SG except 443
* no wildcard IAM actions
* no S3 buckets without encryption + block-public-access
* no unencrypted EBS / RDS
* required tags present
Violations block merge.
`-auto-approve` IS ALLOWED ONLY WITH A PLAN FILE
- `terraform apply -auto-approve tfplan` is acceptable (the plan is
reviewed).
- `terraform apply -auto-approve` with NO plan file is forbidden
and should fail in CI (grep the pipeline for it).
NO `terraform apply` FROM DEVELOPER LAPTOPS IN STAGING/PROD
- Engineers plan locally (read-only creds). Apply happens via CI
with short-lived role-assumption credentials.
Before — plan + apply in the same step, no artifact, no review:
# .github/workflows/deploy.yml (dangerous)
- run: terraform init
- run: terraform plan
- run: terraform apply -auto-approve
PR reviewer sees no plan output; diff between plan and apply is possible.
After — plan artifact, comment on PR, apply uses saved plan:
# .github/workflows/terraform.yml
jobs:
plan:
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- run: terraform plan -out=tfplan -input=false -lock-timeout=10m
- run: terraform show -no-color tfplan > plan.txt
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: |
tfplan
plan.txt
- run: gh pr comment "$PR" --body-file plan.txt
apply:
needs: plan
if: github.ref == 'refs/heads/main'
environment: production # GH environment with required reviewers
steps:
- uses: actions/download-artifact@v4
with: { name: tfplan }
- run: terraform init -input=false
- run: terraform apply -input=false tfplan
Reviewer reads the plan in the PR. Apply uses the reviewed plan. CI can't apply without one.
Rule 5: Multi-Environment Patterns — Separate Directories, Shared Modules, No Boolean Gymnastics
Every Terraform repo eventually faces "how do we do staging and production?". The wrong answer is count = var.environment == "production" ? 1 : 0 scattered across resources, because a small change to one environment ripples through every other one via the same module code, the state has orphaned resources when a count flips, and for_each with conditional keys shuffles the ordering of everything. The right answer is structural: a modules/ directory for reusable modules (no env assumptions) and an envs/<env>/ directory for each environment's root module. Environments share MODULE code, not STATE or VARIABLES.
The rule:
DIRECTORY LAYOUT
modules/
vpc-3az/
rds-postgres/
ecs-service/
...
envs/
production/
main.tf
versions.tf (backend for production)
terraform.tfvars (env-specific inputs)
staging/
main.tf
versions.tf
terraform.tfvars
dev/
...
Each envs/<env> is its own root module with its own state, backend,
and credentials.
TFVARS
- envs/production/terraform.tfvars holds the inputs for that env.
No committed tfvars for sandboxes (they may include PII).
- Secrets NEVER in tfvars. Reference SSM/SM names instead (see Rule 8).
ENV DIFFERS BY VALUES, NOT BY STRUCTURE
- Production and staging should call the SAME modules with different
inputs. If a module needs a conditional resource, model it as a
module input (e.g. `enable_read_replica = true/false`), not as an
env-name check inside the module.
TAGS
- Every module accepts a `tags` map. Root module defines:
locals { tags = { environment = var.environment, owner = "team-x",
managed_by = "terraform", repo = "acme/infra" } }
passes to every module. No hand-setting tags per resource.
NAMING
- Every resource name carries the env prefix/suffix: `prod-orders`,
`staging-orders`. Module receives `name` and `environment` and
composes.
WORKSPACES — SPARINGLY
- Workspaces are for ephemeral preview envs off a single root
(PR previews). They share state backend — fine for short-lived.
- NEVER workspaces for production vs staging.
CROSS-ENV REFERENCES
- Read another env's outputs via `data "terraform_remote_state"`
with narrow, explicit outputs. Prefer SSM Parameter Store for
loose coupling.
Before — one root with environment toggles, orphaned state on flip:
variable "environment" { default = "staging" }
resource "aws_db_instance" "replica" {
count = var.environment == "production" ? 1 : 0
# ...
}
Flipping environment from staging to production in the same state creates a resource that staging thought didn't exist. Data was never in staging; now it is in the state because the same directory ran both.
After — two root modules, shared module, inputs differ:
# envs/production/main.tf
module "orders_db" {
source = "../../modules/rds-postgres"
name = "orders"
environment = "production"
instance_class = "db.t3.medium"
multi_az = true
read_replicas = 2
deletion_protection = true
tags = local.tags
}
# envs/staging/main.tf
module "orders_db" {
source = "../../modules/rds-postgres"
name = "orders"
environment = "staging"
instance_class = "db.t3.small"
multi_az = false
read_replicas = 0
deletion_protection = false
tags = local.tags
}
Same module, two inputs, two states. Blast radius contained.
Rule 6: Provider Versioning — Pin Everything, Lock File Committed
Terraform providers are downloaded on init. Without version constraints, terraform init pulls whatever is latest — which may introduce breaking schema changes between providers (a v4 -> v5 AWS provider bump that renames aws_db_instance.name to aws_db_instance.identifier). Cursor's default provider "aws" {} block with no version constraint is the cause of "works on my laptop, fails in CI" outages. Combined with an unpinned required_version, the whole plan output becomes non-reproducible. The rule is: pin both Terraform and every provider, commit the lock file, and upgrade deliberately.
The rule:
REQUIRED_VERSION
- `terraform { required_version = "~> 1.7" }` on every root module.
Explicit minor constraint. Bump deliberately.
REQUIRED_PROVIDERS
- `required_providers { aws = { source = "hashicorp/aws", version = "~> 5.40" } }`
on every module (root + sub). `~> 5.40` allows patch bumps,
blocks 6.0.
- Lock file `.terraform.lock.hcl` is committed. CI runs with
`-lockfile=readonly`. Provider upgrades are explicit PRs that
update the lock file.
PROVIDER CONFIGURATION
- Root modules configure providers with explicit region, profile
or IAM-assume-role, and default_tags.
- Sub-modules never configure providers. They INHERIT. To target
a different region / account from a sub-module, use provider
ALIASES passed in.
ALIASES FOR MULTI-REGION / MULTI-ACCOUNT
- Root: `provider "aws" { alias = "us_west"; region = "us-west-2" }`.
Module call: `providers = { aws.replica = aws.us_west }`.
Module declares: `configuration_aliases = [aws.replica]`.
DEFAULT TAGS
- Every provider block sets default_tags merged with environment /
managed_by / owner. A missing tag on any resource is a drift.
NO "latest"
- No `source = "git::...//path"` without `ref=`. No `version = ">= 5.0"`
with no upper bound. No `terraform_version = ">=1.0"`. Always
bounded ranges.
UPGRADES
- Provider upgrade PR: change version constraint -> run `terraform
init -upgrade` -> commit updated lock file -> run plan, include
diff in PR, review, merge.
Before — no version pin, no lock file in git, breaking upgrade in CI:
terraform {}
provider "aws" {
region = "us-east-1"
}
First init after a provider 4->5 bump breaks every resource using renamed attributes. Lock file not committed, so CI and laptop don't agree.
After — pinned, aliased, default-tagged, lock committed:
# versions.tf
terraform {
required_version = "~> 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
# providers.tf
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
environment = var.environment
managed_by = "terraform"
owner = "platform"
repo = "acme/infra"
}
}
}
provider "aws" {
alias = "dr"
region = "us-west-2"
default_tags { tags = { environment = var.environment, managed_by = "terraform" } }
}
Lock file .terraform.lock.hcl committed. CI uses -lockfile=readonly. Upgrades deliberate.
Rule 7: Lifecycle Guards — prevent_destroy on Data, ignore_changes for Drift, for_each Over count
Two AWS resources you cannot recreate from code: RDS databases (with data) and S3 buckets (with objects). If Terraform decides to replace them, the data is gone. lifecycle { prevent_destroy = true } is a panic button that refuses to run the destroy — the operator must remove the block, commit, and apply again. ignore_changes suppresses drift for attributes that legitimately change outside Terraform (ECS task definition image tags from a CI pipeline, Auto Scaling group desired count from an autoscaler). And for_each over count: with count, removing an item shifts every subsequent index, which Terraform interprets as "delete everything past the removed item and recreate"; with for_each, keys are stable.
The rule:
prevent_destroy
- On any resource holding data that cannot be recreated from code:
aws_db_instance, aws_rds_cluster, aws_s3_bucket (with content),
aws_dynamodb_table, aws_kinesis_stream, aws_kms_key, cloudwatch
log groups with retained logs.
- `lifecycle { prevent_destroy = true }`. To intentionally destroy,
remove the block in a PR (visible in review) then apply.
ignore_changes
- For attributes Terraform doesn't own. ECS task def image when CD
rolls images; ASG desired_capacity when autoscaler manages it;
aws_s3_bucket.tags when another system writes tags.
- NARROW list of attributes, not `all`. `ignore_changes = all` is a
code smell — the resource probably shouldn't be in Terraform.
create_before_destroy
- For resources where replacement means downtime: load balancers,
target groups, IAM policies in active use. `lifecycle {
create_before_destroy = true }` plus a unique name suffix (e.g.
random_pet) so the old and new coexist briefly.
for_each OVER count
- Iterating over a list/map: `for_each = var.items` with keys
stable. `count = length(var.items)` reshuffles on removal and
recreates resources unnecessarily.
- `count = var.enabled ? 1 : 0` is fine for optional single
resources; `count` as a loop is not.
DEPENDENCIES
- Implicit via references is preferred. `depends_on` ONLY when the
dependency can't be expressed through references (e.g. waiting
for an IAM policy to propagate before a consumer assumes it).
- `depends_on` on a module is supported; use sparingly.
IMPORTING EXISTING RESOURCES
- `terraform import` followed by writing the matching resource block.
Verify the plan is no-op after import. Never `import` without
writing code.
Before — no lifecycle on a DB, count over a renameable list:
resource "aws_db_instance" "main" {
# no prevent_destroy; a small config mistake can delete the data
}
variable "services" { type = list(string); default = ["orders", "payments", "shipping"] }
resource "aws_ecs_service" "svc" {
count = length(var.services)
name = var.services[count.index]
}
Delete "orders" from the list, terraform wants to destroy services 0 and 1 (payments, shipping) because indices shifted, and recreate them under new names.
After — prevent_destroy on data, for_each, create_before_destroy where replacement means downtime:
resource "aws_db_instance" "main" {
# ... attributes
deletion_protection = true
lifecycle {
prevent_destroy = true
ignore_changes = [password] # managed via SSM rotate
}
}
variable "services" { type = set(string); default = ["orders", "payments", "shipping"] }
resource "aws_ecs_service" "svc" {
for_each = var.services
name = each.key
lifecycle { create_before_destroy = true }
}
Data protected. Removing a service from the set destroys ONLY that service. Zero-downtime replace via create_before_destroy.
Rule 8: Secrets — Never In State, Never In Plan, Fetched At Apply Time
terraform.tfstate is JSON, and it stores every resource attribute. A aws_db_instance with password = var.master_password writes the password into state. Every engineer with state read access now has the password. sensitive = true masks it in CLI output but DOES NOT encrypt state. Cursor will happily generate password = "changeme" in a .tf file because the resource schema requires password. The fix is to keep the secret outside Terraform entirely — in SSM Parameter Store (SecureString), Secrets Manager, or Vault — and pass the REFERENCE (arn / name) into Terraform, resolving at apply time where the provider knows how to send it to the AWS API without persisting it to state.
The rule:
NO LITERAL SECRETS IN .tf OR .tfvars
- Never `password = "..."`. Never `token = "..."`. Never private keys
in heredoc. CI grep step fails PRs with high-entropy strings in .tf
(pre-commit detect-secrets / truffleHog).
FETCH AT APPLY TIME
- SSM Parameter Store / Secrets Manager stores the secret. Terraform
reads the parameter via `data "aws_ssm_parameter" "x" { name = ... }`
-> feed into the resource attribute. Data source is re-fetched on
every plan/apply.
- For AWS RDS: generate the password OUTSIDE Terraform, store in SM,
reference by ARN in `aws_db_instance.manage_master_user_password =
true` (let AWS rotate). Or use the `random_password` resource + SM
secret, and mark `ignore_changes = [password]`.
STATE ENCRYPTION
- Remote backend with encryption at rest (S3 SSE-KMS). Access to the
backend bucket is tightly scoped (separate from application roles).
- Do not share state file screenshots / dumps.
OUTPUTS
- Outputs that would reveal secrets are marked `sensitive = true`.
Still: they end up in state. Prefer not exporting secrets through
Terraform at all — downstream consumers fetch from SSM/SM directly.
IAM / CERTIFICATES / KEYS
- ACM certs: use the AWS-issued ones via data "aws_acm_certificate".
Private keys NEVER live in state — `aws_acm_certificate` with
`private_key` is forbidden.
- IAM: inline policies are fine (they're not secret); never paste
access keys.
TOOLING
- pre-commit: terraform fmt, tflint, tfsec, detect-secrets.
- CI: tfsec / checkov / KICS + a grep for known secret patterns
in the .tf diff.
Before — password in .tf, password in state, password in plan output:
resource "aws_db_instance" "main" {
username = "admin"
password = "SuperSecret123" # ends up in state, in plan, in git history
}
The state file (in S3) now holds a real password. Anyone with read on the state bucket is a DB admin.
After — AWS manages + rotates, Terraform references:
resource "aws_db_instance" "main" {
identifier = "${var.environment}-${var.name}"
engine = "postgres"
username = "rds_admin"
manage_master_user_password = true
master_user_secret_kms_key_id = aws_kms_key.rds.key_id
# password not in state; AWS Secrets Manager holds + rotates it.
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
deletion_protection = true
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
lifecycle { prevent_destroy = true }
tags = merge(var.tags, { name = "${var.environment}-${var.name}" })
}
output "db_master_secret_arn" {
value = aws_db_instance.main.master_user_secret[0].secret_arn
description = "ARN of the Secrets Manager secret holding the master password. Consumers fetch from SM at runtime."
}
No secret in state. No secret in plan. App reads the secret from SM with a scoped IAM role.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Terraform — Production Patterns
## Module Structure
- Every module: main.tf, variables.tf, outputs.tf, versions.tf, README.md.
- Every variable has type + description. Every output has description.
- Modules do NOT declare provider blocks. They declare required_providers.
- Modules take explicit inputs — never read environment-dependent data
sources (e.g. aws_default_vpc).
- Root modules compose (module calls + providers + backend). No direct
resources in root modules beyond thin glue.
- Module source references are pinned (ref=v1.2.3 or Registry version).
## State Management
- Remote backend on every root (S3+DynamoDB / TF Cloud / GCS / Azure).
No local backends in staging/prod.
- One state per environment. envs/production + envs/staging, each with
own backend. NEVER workspaces for env separation.
- *.tfstate .gitignore'd. .terraform.lock.hcl committed.
- Manual state edits reviewed like migrations.
## Variable Hygiene
- Typed + described + validated. No untyped variables.
- Defaults safe for production. No env-dependent defaults.
- `sensitive = true` on secrets (but secrets still hit state — see Rule 8).
- nullable = false unless genuinely optional.
- Group related inputs into small object() types, not god-variables.
- environment variable is a validated enum; used for tags/naming only.
## Plan Review
- CI runs `terraform plan -out=tfplan`, uploads artifact + PR comment.
- Apply uses the saved plan: `terraform apply tfplan`.
- Destructive lines in the plan require explicit reviewer signoff.
- Policy-as-code (tfsec, checkov, OPA) blocks: 0.0.0.0/0, wildcard IAM,
unencrypted storage, missing required tags.
- `-auto-approve` allowed only with a saved plan file. No plan+apply
in the same unreviewed CI step. No laptop apply to staging/prod.
## Multi-Environment
- envs/<env>/ directory per environment. Same modules, different inputs.
- No `environment == "production" ? ...` toggles inside modules. If a
resource is optional, make it a module input.
- Tags map propagates from root locals to every module.
- Workspaces only for ephemeral preview envs.
## Provider Versioning
- required_version pinned (~> 1.7). required_providers pinned (~> 5.40).
- Lock file committed. CI uses -lockfile=readonly.
- Providers configured in ROOT modules only. Sub-modules declare
configuration_aliases.
- default_tags on every provider with env/managed_by/owner.
## Lifecycle
- `prevent_destroy = true` on all data-bearing resources (RDS, S3,
DynamoDB, KMS).
- `ignore_changes` narrow and purposeful — never `= all`.
- `create_before_destroy` on resources whose replacement would cause
downtime, with unique name suffixes.
- `for_each` over `count` when iterating a list/map.
- `depends_on` only when references can't express the dependency.
## Secrets
- No literal secrets in .tf / .tfvars. Pre-commit detect-secrets.
- Secrets live in SSM Parameter Store / Secrets Manager / Vault.
Terraform reads via data sources OR passes the reference (ARN).
- Prefer provider-managed secrets (e.g. aws_db_instance
manage_master_user_password = true) so secrets never hit state.
- Outputs that could reveal secrets: avoid. If necessary,
sensitive = true, but still out of state is better.
End-to-End Example: A Production RDS Instance
Without rules: inline password, local state, unpinned provider, no prevent_destroy.
provider "aws" { region = "us-east-1" }
variable "environment" { default = "production" }
resource "aws_db_instance" "db" {
identifier = "${var.environment}-orders"
engine = "postgres"
username = "admin"
password = "Password1"
allocated_storage = 20
instance_class = "db.t3.micro"
}
Password in code. No backend. No version pin. No deletion protection. No tags.
With rules: module with contract, typed vars, remote state, pinned provider, AWS-managed secret, lifecycle guard.
# envs/production/main.tf
module "orders_db" {
source = "../../modules/rds-postgres"
name = "orders"
environment = "production"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
instance_class = "db.t3.medium"
allocated_storage = 100
multi_az = true
deletion_protection = true
tags = local.tags
}
# modules/rds-postgres/main.tf
resource "aws_db_instance" "main" {
identifier = "${var.environment}-${var.name}"
engine = "postgres"
engine_version = var.engine_version
instance_class = var.instance_class
allocated_storage = var.allocated_storage
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
username = "rds_admin"
manage_master_user_password = true
master_user_secret_kms_key_id = aws_kms_key.rds.key_id
multi_az = var.multi_az
deletion_protection = var.deletion_protection
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
backup_retention_period = 14
skip_final_snapshot = false
final_snapshot_identifier = "${var.environment}-${var.name}-final"
tags = merge(var.tags, { name = "${var.environment}-${var.name}" })
lifecycle {
prevent_destroy = true
ignore_changes = [password]
}
}
Password never touches state. DB protected from accidental destroy. Provider pinned. CI plan reviewed. Module reusable in staging with different inputs. The next prompt you write at this repo will produce something that already fits.
Get the Full Pack
These eight rules cover the Terraform patterns where AI assistants consistently reach for a tutorial that was published before the current provider existed. Drop them into .cursorrules and the next module you generate will have a contract, a backend, pinned providers, lifecycle guards, and no secrets in state — without having to re-prompt.
If you want the expanded pack — these eight plus rules for drift detection, OPA/Sentinel policy libraries, Terragrunt patterns for DRY backends, CDKTF interop, cross-account role-assumption topologies, cost estimation in CI (infracost), disaster-recovery Terraform, and the testing conventions I use on production infra — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Terraform you would actually merge.
Top comments (0)