1. Important Points#

Terraform 不是脚本执行器,而是 desired state engine:代码描述目标状态,provider 调云平台 API,state 记录 Terraform address 和真实资源 ID 的映射。plan 的价值不是“看看命令能不能跑”,而是让团队在改基础设施前审查 create / update / replace / destroy。

Terraform mental model:
    HCL config = desired state
    provider   = cloud API adapter
    state      = address -> real resource ID mapping
    plan       = proposed diff
    apply      = execute reviewed diff

生产环境最重要的原则:

one state = one ownership boundary
remote backend + locking is default for team work
review every destroy and replacement before apply
use for_each for named resources; avoid count index drift
do not put secrets in tfvars, state, backend config, or plan files
manual console changes create drift; fix code, then reconcile

2. Core Workflow#

日常流程保持简单,重点是让 plan 可以被 review。

# Format all Terraform files.
terraform fmt -recursive

# Download providers/modules and initialize backend.
terraform init

# Check syntax and internal consistency.
terraform validate

# Validate local modules without touching remote backend.
terraform init -backend=false

# Preview changes.
terraform plan -var-file=envs/dev.tfvars

# Save a reviewed plan file.
terraform plan -var-file=envs/dev.tfvars -out=tfplan

# Apply exactly the reviewed plan.
terraform apply tfplan

# Show outputs and current state view.
terraform output
terraform show

Shared environments should avoid blind apply:

# Avoid in shared dev / uat / prod.
terraform apply -auto-approve

3. Project Structure#

Keep one root module small enough to match one operational boundary.

infra-live
├── backend.tf
├── provider.tf
├── variables.tf
├── locals.tf
├── main.tf
├── outputs.tf
├── envs
│   ├── dev.tfvars
│   ├── uat.tfvars
│   └── prod.tfvars
└── modules
    └── s3-bucket
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

File ownership:

backend.tf:
    remote state backend only

provider.tf:
    required providers, provider versions, provider config

variables.tf:
    typed input contract

locals.tf:
    naming, common tags, derived values

main.tf:
    resources and module calls

outputs.tf:
    IDs, ARNs, DNS names consumed by people or other states

envs/*.tfvars:
    environment values, not logic

4. Minimal AWS Root Module#

provider.tf#

terraform {
  required_version = ">= 1.6.0"

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

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = local.common_tags
  }
}

backend.tf#

Use S3 backend with native lockfile when the team runs Terraform against AWS. The S3 backend supports use_lockfile = true; HashiCorp recommends S3 bucket versioning for state recovery in the S3 backend docs.

terraform {
  backend "s3" {
    bucket       = "acme-tfstate-123456789012-ap-east-1"
    key          = "order/dev/default/ap-east-1/network/terraform.tfstate"
    region       = "ap-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

Backend checklist:

S3 bucket versioning enabled
state locking enabled with use_lockfile
backend key includes system / env / variant / workload region / component
no access_key or secret_key in backend config
state bucket policy allows only required state prefixes

AWS S3 backend naming and policy details are covered in Terraform AWS.

variables.tf#

variable "env" {
  type        = string
  description = "Environment name: dev, uat, prod"
}

variable "aws_region" {
  type        = string
  description = "AWS region"
}

variable "vpc_cidr" {
  type        = string
  description = "VPC CIDR"
}

locals.tf#

locals {
  name_prefix = "order-${var.env}"

  common_tags = {
    Project     = "order"
    Environment = var.env
    ManagedBy   = "terraform"
  }
}

main.tf#

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${local.name_prefix}-vpc"
  }
}

outputs.tf#

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

output "vpc_cidr" {
  value = aws_vpc.main.cidr_block
}

envs/dev.tfvars#

env        = "dev"
aws_region = "ap-east-1"
vpc_cidr   = "10.10.0.0/16"

5. HCL Patterns#

resource and data source#

resource "aws_s3_bucket" "logs" {
  bucket = "${local.name_prefix}-logs"
}

data "aws_caller_identity" "current" {}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}
resource:
    creates and manages infrastructure

data source:
    reads existing infrastructure or provider metadata

resource address:
    aws_s3_bucket.logs

for_each over count#

Prefer for_each when each resource has a stable name. count is fine for identical disposable objects, but index shifts are dangerous for named infrastructure.

Bad pattern:

variable "subnet_names" {
  type    = list(string)
  default = ["public-a", "public-b", "private-a"]
}

resource "aws_subnet" "this" {
  count = length(var.subnet_names)

  vpc_id     = aws_vpc.main.id
  cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)

  tags = {
    Name = var.subnet_names[count.index]
  }
}

Problem:

if public-b is removed:
    private-a moves from index 2 to index 1
    resource address changes
    plan becomes hard to trust

Better pattern:

variable "subnets" {
  type = map(object({
    cidr = string
    az   = string
  }))
}

resource "aws_subnet" "this" {
  for_each = var.subnets

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = {
    Name = each.key
  }
}

Result:

resource address is aws_subnet.this["private-a"]
removing public-b does not shift private-a
plan is easier to review

depends_on and lifecycle#

Terraform usually infers dependencies from references. Use depends_on only when the real dependency is not visible in expressions.

resource "aws_cloudwatch_log_group" "app" {
  name = "/aws/ecs/${local.name_prefix}"
}

resource "aws_ecs_service" "app" {
  name = local.name_prefix

  depends_on = [
    aws_cloudwatch_log_group.app
  ]
}

Use lifecycle rules as guardrails, not as a way to hide drift.

resource "aws_db_instance" "main" {
  identifier = "${local.name_prefix}-db"

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_ecs_service" "app" {
  name            = local.name_prefix
  task_definition = aws_ecs_task_definition.app.arn

  lifecycle {
    ignore_changes = [
      task_definition
    ]
  }
}

6. Module Design#

Module 是稳定模式的封装,不是把所有资源藏起来。好的 module 减少重复,但仍然暴露重要决策。

root module owns:
    environment values
    state boundary
    provider aliases
    high-level wiring between systems

shared module owns:
    repeatable resource pattern
    naming shape
    safe defaults
    outputs needed by callers

Minimal module:

modules/s3-bucket
├── main.tf
├── variables.tf
└── outputs.tf
# modules/s3-bucket/variables.tf
variable "name" {
  type = string
}
# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.name
}
# modules/s3-bucket/outputs.tf
output "bucket_name" {
  value = aws_s3_bucket.this.bucket
}

Caller:

module "app_logs" {
  source = "./modules/s3-bucket"

  name = "${local.name_prefix}-app-logs"
}

Module checklist:

use typed variables with descriptions
return stable outputs
keep provider config in root module unless there is a strong reason
do not fork public modules just to rename variables
wrap public modules only when your team has repeated conventions

7. State#

State 是 Terraform 的事实数据库,不是 cache。

state stores:
    resource address
    provider type
    cloud resource ID
    attributes used for diff
    dependency mapping

Never:

do not edit state manually
do not commit terraform.tfstate
do not share local state by chat
do not run apply from two machines at the same time
do not merge unrelated ownership boundaries into one state

Useful commands:

# List resources tracked by state.
terraform state list

# Show one resource from state.
terraform state show aws_vpc.main

# Show one resource inside a module.
terraform state show 'module.default.aws_lb.this[0]'

# Move resource address after refactor.
terraform state mv aws_s3_bucket.old aws_s3_bucket.new

# Remove resource from state without deleting real infrastructure.
terraform state rm aws_s3_bucket.legacy

8. Import Existing Resource#

Use import when a resource already exists and Terraform should start managing it. Terraform’s import workflow requires a target address and a provider-specific remote identity; the official import docs cover both config-driven import and generated configuration.

existing cloud resource
    -> write matching Terraform config
    -> import remote ID into the exact Terraform address
    -> terraform plan
    -> adjust config until the diff is expected

config-driven import#

import {
  to = aws_s3_bucket.logs
  id = "company-prod-logs"
}

resource "aws_s3_bucket" "logs" {
  bucket = "company-prod-logs"
}
# Review the import and resulting diff.
terraform plan

# Apply import into state.
terraform apply

After the import has been applied and state contains the resource, remove the import block.

CLI import#

# Root-module resource.
terraform import aws_s3_bucket.logs company-prod-logs

# Resource inside a normal module call.
terraform import \
  'module.network.aws_vpc.this' \
  vpc-0123456789abcdef0

# Resource inside a normal module call, when the resource uses count.
terraform import \
  'module.alb.aws_lb.this[0]' \
  'arn:aws:elasticloadbalancing:ap-east-1:123456789012:loadbalancer/app/order-prod-alb/0123456789abcdef'

# Resource inside a module created with for_each.
terraform import \
  'module.ecs_service["api"].aws_ecs_service.this' \
  'order-prod-cluster/api'

# Resource inside a module created with for_each, when the resource uses count.
terraform import \
  'module.alb["default"].aws_lb.this[0]' \
  'arn:aws:elasticloadbalancing:ap-east-1:123456789012:loadbalancer/app/order-prod-alb/0123456789abcdef'

Quote addresses that contain brackets, such as [0] or ["api"], so the shell does not interpret them.

import with modules#

If the resource is inside a module, import into the resource address inside that module, not into the module call.

The full address must include every instance selector that exists in configuration:

module.<module_name>:
    normal module call

module.<module_name>["<key>"]:
    module call created with for_each

<resource_type>.<resource_name>:
    normal resource inside the module

<resource_type>.<resource_name>[0]:
    resource inside the module created with count

<resource_type>.<resource_name>["<key>"]:
    resource inside the module created with for_each

Do not add ["default"], ["api"], or [0] by guessing from resource names in the cloud console. Add them only when the Terraform configuration has for_each or count at that specific level.

["api"] comes from the module block’s for_each key. In this example, var.services is a map and api is one key in that map.

variables.tf:

variable "services" {
  type = map(object({
    desired_count = number
    image         = string
  }))
}

envs/prod.tfvars:

services = {
  api = {
    desired_count = 2
    image         = "123456789012.dkr.ecr.ap-east-1.amazonaws.com/order-api:2026-07-08"
  }

  worker = {
    desired_count = 1
    image         = "123456789012.dkr.ecr.ap-east-1.amazonaws.com/order-worker:2026-07-08"
  }
}

Root module:

module "ecs_service" {
  for_each = var.services

  source = "./modules/ecs-service"

  name        = each.key
  cluster_arn = module.ecs.cluster_arn
  desired_count = each.value.desired_count
  image         = each.value.image
}

If the module contains:

resource "aws_ecs_service" "this" {
  name    = var.name
  cluster = var.cluster_arn
}

Then the ECS service created by services.api imports to:

terraform import \
  'module.ecs_service["api"].aws_ecs_service.this' \
  'order-prod-cluster/api'

For aws_ecs_service, the CLI import ID is cluster-name/service-name, not the ECS service ARN. This is different from many AWS resources that import by ARN.

In that address:

module.ecs_service:
    the module block name

["api"]:
    the for_each key from var.services

aws_ecs_service.this:
    the resource address inside ./modules/ecs-service

order-prod-cluster/api:
    AWS provider import ID, formatted as cluster-name/service-name
    order-prod-cluster is the ECS cluster name
    api is the ECS service name

Common AWS import IDs:

Resource Import ID pattern
aws_ecs_cluster cluster name or cluster ARN
aws_ecs_service cluster-name/service-name
aws_ecs_task_definition task definition ARN, including revision
aws_lb load balancer ARN
aws_lb_target_group target group ARN
aws_lb_listener listener ARN
aws_cloudwatch_log_group log group name
aws_iam_role role name

For existing ECS / ALB infrastructure:

1. write module/resource config with matching names, ports, subnets, security groups, roles, and health checks
2. import shared edge resources first: ALB, listeners, target groups
3. import ECS cluster
4. import IAM roles and CloudWatch log groups
5. import ECS service
6. decide separately whether Terraform should manage task definitions
7. run plan until there is no unexpected replacement

ECS image ownership:

Owner Terraform behavior
Terraform owns task definition image each release updates Terraform input and applies
CI/CD owns image deployment Terraform must avoid rolling the service back to an old task definition

When CI/CD owns deployments, use a narrow lifecycle rule in the service resource or module design so future Terraform applies do not revert the running task definition.

9. Team Workflow#

developer:
    edit .tf
    terraform fmt -recursive
    terraform validate
    terraform plan
    open PR with plan summary

reviewer:
    check create / update / replace / destroy
    check IAM permissions
    check public exposure
    check state boundary
    check names and tags

pipeline:
    init
    fmt -check
    validate
    plan
    manual approval
    apply reviewed plan

CI commands:

# PR validation without backend access.
terraform init -backend=false
terraform fmt -check -recursive
terraform validate

# Deployment pipeline.
terraform init
terraform plan -var-file=envs/prod.tfvars -out=tfplan
terraform apply tfplan

Production approval checklist:

no unexpected destroy
no unexpected replacement
IAM changes reviewed
public networking reviewed
state backend and workspace are correct
plan file comes from the reviewed commit

10. Provider Cache#

Terraform normally downloads providers into each root module’s .terraform/ directory. On macOS, a per-user plugin cache avoids repeated large downloads. The Terraform CLI config supports plugin_cache_dir, and Terraform expects that directory to already exist, as documented in the CLI config docs.

mkdir -p "$HOME/.terraform.d/plugin-cache"
touch "$HOME/.terraformrc"

Add this to ~/.terraformrc:

plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

If the file already has HCP Terraform credentials, keep them:

credentials "app.terraform.io" {
  token = "xxxxxx"
}

plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

Temporary shell override:

export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
terraform init

Verification:

terraform init
du -sh "$HOME/.terraform.d/plugin-cache"
find "$HOME/.terraform.d/plugin-cache" -maxdepth 6 -type f | head

Rules:

cache directory must already exist
commit .terraform.lock.hcl
do not commit .terraform/ or plugin-cache
do not use the same directory as provider filesystem mirror
do not share one writable cache across many concurrent terraform init processes
manually clean old provider versions when the cache grows too large

Platform notes:

Apple Silicon:
    darwin_arm64

Intel Mac:
    darwin_amd64

CI Linux runner:
    linux_amd64 or linux_arm64

For team projects, keep .terraform.lock.hcl committed and add platform checksums when developers and CI run different architectures:

terraform providers lock \
  -platform=darwin_arm64 \
  -platform=darwin_amd64 \
  -platform=linux_amd64

11. Readiness Checklist#

configuration:
    required_version and provider versions pinned
    variables are typed
    environment values live in tfvars
    common tags are applied by provider default_tags or locals

state:
    remote backend configured
    locking enabled
    bucket versioning enabled
    one state maps to one ownership boundary

security:
    no static cloud credentials in code
    no secrets in tfvars
    IAM changes reviewed before apply
    public exposure reviewed before apply

operations:
    plan is reviewed before apply
    replacement and destroy are explicitly approved
    outputs expose only useful IDs, ARNs, and endpoints
    import and state moves are performed in small batches

12. Common Mistakes#

Mistake Correct way
one huge state for everything split by ownership / blast radius
local state for team work remote backend with locking
apply without reviewing plan save and apply reviewed plan
commit .terraform/ commit .terraform.lock.hcl, ignore .terraform/
use count for named resources use for_each
hardcode env in resources use tfvars and locals
fix drift only in console fix code, then reconcile
import into the wrong address import into the exact resource address, including module path
use ignore_changes everywhere use only for fields intentionally owned elsewhere