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 reconcile2. 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 showShared environments should avoid blind apply:
# Avoid in shared dev / uat / prod.
terraform apply -auto-approve3. 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.tfFile 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 logic4. 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 prefixesAWS 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.logsfor_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 trustBetter 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 reviewdepends_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 callersMinimal 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 conventions7. State#
State 是 Terraform 的事实数据库,不是 cache。
state stores:
resource address
provider type
cloud resource ID
attributes used for diff
dependency mappingNever:
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 stateUseful 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.legacy8. 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 expectedconfig-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 applyAfter 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_eachDo 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 nameCommon 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 replacementECS 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 planCI 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 tfplanProduction 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 commit10. 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 initVerification:
terraform init
du -sh "$HOME/.terraform.d/plugin-cache"
find "$HOME/.terraform.d/plugin-cache" -maxdepth 6 -type f | headRules:
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 largePlatform notes:
Apple Silicon:
darwin_arm64
Intel Mac:
darwin_amd64
CI Linux runner:
linux_amd64 or linux_arm64For 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_amd6411. 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 batches12. 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 |