Recently, I had the opportunity to migrate our AWS setup from a single account to a multi-account architecture, which led me to completely redesign my Terraform repository from scratch.
Although both HashiCorp and AWS now officially provide MCP-based Terraform integrations, I wanted to document some practical tips and lessons I’ve picked up through the redesign process. Before implementing them, I studied this excellent book:
https://www.oreilly.co.jp/books/9784814400133/
I initially thought I already knew most of the content, but as I flipped through it (well, “swiped” through on Kindle), I realized how many important things I had overlooked.
It was honestly worth every yen, and I highly recommend it to anyone who’s been using Terraform “somewhat casually.”
In this post, I’ll introduce several beginner-friendly tips that I actually adopted in my AWS environment and found helpful.
Automatically Generate Module READMEs with terraform-docs
When I was managing the infrastructure alone, I didn’t really think about documentation for other team members. But as the team grew, I realized how bad tribal knowledge can be.
To address this, I started using terraform-docs
to automatically generate documentation for each module’s Inputs, Outputs, and Resources.
Here’s my .terraform-docs.yml
configuration:
formatter: "markdown table"
sections:
show:
- header
- requirements
- providers
- modules
- resources
- inputs
- outputs
output:
file: "README.md"
mode: inject
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->
sort:
enabled: true
by: name
settings:
hide-empty: true
required: true
sensitive: true
type: true
anchor: true
Then, I created a helper script to generate docs for all modules:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v terraform-docs >/dev/null 2>&1; then
echo "ERROR: terraform-docs not found. Please install it." >&2
exit 1
fi
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG="${PROJECT_ROOT}/.terraform-docs.yml"
cd "${PROJECT_ROOT}/modules"
for module_dir in */ ; do
[ -d "$module_dir" ] || continue
echo "📝 Generating docs for ${module_dir}"
pushd "$module_dir" >/dev/null
if [ ! -f "README.md" ]; then
cat > README.md <<'EOF'
# Module
<!-- BEGIN_TF_DOCS -->
<!-- END_TF_DOCS -->
EOF
fi
terraform-docs --config "$CONFIG" . >/dev/null
popd >/dev/null
done
Now, CI automatically updates each module’s README when changes are made — preventing missing documentation in pull requests.
S3 Backend Locking with use_lockfile
When configuring the S3 backend in versions.tf
, I added the use_lockfile = true
option to prevent state inconsistencies during simultaneous terraform plan
or apply
executions.
This feature became available starting with Terraform v1.11 — previously, you needed a separate DynamoDB table for state locking.
terraform {
required_version = "~> 1.13.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.11.0"
}
}
backend "s3" {
bucket = "terraform-state"
key = "environments/dev/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true
}
}
Enable Versioning on the S3 State Bucket
Always enable versioning on your state bucket.
I learned this the hard way — in a previous setup, we forgot to do this, and I shudder to think what could have happened if someone deleted the state file by mistake.
Simplify Subnet Calculation with cidrsubnet
I used to manually calculate subnet ranges from the VPC CIDR and hardcode them, but cidrsubnet()
makes this much simpler:
locals {
vpc_cidr = "192.168.0.0/20"
}
resource "aws_subnet" "a" {
cidr_block = cidrsubnet(local.vpc_cidr, 4, 0)
# 192.168.0.0/24
}
resource "aws_subnet" "b" {
cidr_block = cidrsubnet(local.vpc_cidr, 4, 1)
# 192.168.1.0/24
}
A small but very handy function.
Use default_tags
to Avoid Tag Omissions
Previously, I manually added common tags like Project
and Environment
to each resource. Naturally, some were missing.
With provider-level default_tags
, all resources now include them automatically.
I also added ManagedBy = "terraform"
for quick visual identification in the AWS Console.
provider "aws" {
region = "ap-northeast-1"
default_tags {
tags = {
Project = "MyApp"
Environment = "prod"
ManagedBy = "terraform"
}
}
}
Protect Critical Resources with prevent_destroy
I once accidentally deleted an ElastiCache (Valkey) cluster because I didn’t review the plan carefully enough before applying.
To avoid repeating that mistake, I now set prevent_destroy = true
for critical resources like databases and KMS keys.
resource "aws_rds_cluster" "aurora_cluster" {
...
lifecycle {
prevent_destroy = true
}
}
You can also use ignore_changes
to skip diff detection for specific attributes — useful when Blue/Green deployments cause dynamic changes like target group switches.
resource "aws_lb_listener_rule" "rules" {
for_each = var.listener_rules
listener_arn = aws_lb_listener.https[tostring(each.value.listener_port)].arn
priority = each.value.priority
action {
type = each.value.action_type
target_group_arn = aws_lb_target_group.tg[each.value.target_group].arn
}
condition {
host_header {
values = [each.value.host]
}
}
dynamic "condition" {
for_each = each.value.paths != null ? [each.value.paths] : []
content {
path_pattern {
values = condition.value
}
}
}
lifecycle {
ignore_changes = [action[0].target_group_arn]
}
}
Import Existing Resources with the import
Block
For manually created resources (e.g., S3, Amplify, AppConfig), I used to run terraform import
via CLI.
Since Terraform 1.5.0, the import
block allows for a more declarative and readable approach.
resource "aws_s3_bucket" "example" {
bucket = "my-bucket"
}
import {
to = aws_s3_bucket.example
id = "my-bucket"
}
When you run terraform plan
, Terraform will display the differences between the existing resources and your code.
Update your configuration to match the actual state, and then run terraform apply
— the resources will be imported into your state file.
You can also auto-generate configurations using:
terraform plan -generate-config-out=generated.tf
Rename Resources Without Recreating Them via terraform state mv
If you mistakenly name a resource (e.g., aws_s3_bucket.exampl
), renaming it directly in code can trigger a destroy/create.
Instead, use terraform state mv
to safely rename without recreating:
terraform state mv aws_s3_bucket.exampl aws_s3_bucket.example
Always back up your state before doing this — versioning helps!
Granularity of Environment File Splitting
The part I struggled with the most during the repository redesign was how to structure the files under environments/{env}/
.
At first, I dumped everything into a single main.tf
file.
But as the number of resources grew, the file quickly became bloated — and scrolling through it turned into pure hell.
So I decided to split the configuration by functional domains.
applications.tf # Cognito, Amplify, SES
compute.tf # ECS, ECR, ALB, Auto Scaling
db.tf # RDS, ElastiCache
monitoring.tf # CloudWatch Logs, SNS, Chatbot
networking.tf # VPC, Subnet, Route Table
security.tf # IAM, Security Group, KMS
s3.tf
lambda.tf
locals.tf
providers.tf
versions.tf
...
When deciding how to split files, my guiding principle was simple:
“When I need to modify this resource, which file do I want to open?”
For example, when adding a new ALB listener rule, I usually need to check the ECS service and target groups at the same time — so I grouped them together in compute.tf
.
# compute.tf
module "ecs" { ... }
module "ecr" { ... }
module "alb" { ... }
module "ecs_autoscaling_visitor" { ... }
module "ecs_autoscaling_company" { ... }
On the other hand, for networking, I often need to see the VPC, subnets, and route tables as a set, so I put them all together in networking.tf
.
# networking.tf
module "vpc" { ... }
module "subnet" { ... }
module "route_table" { ... }
module "internet_gateway" { ... }
module "nat_gateway" { ... }
module "vpc_endpoint" { ... }
module "network_associations" { ... }
However, security.tf
has now grown to over 1,000 lines…
That’s what happens when you pack IAM roles, policies, security groups, and KMS keys all in one file.
In hindsight, I probably should have split it up like this (and plan to refactor soon):
-
iam.tf
– IAM roles and policies -
security_groups.tf
– Security groups -
kms.tf
– KMS keys
On the flip side, grouping ElastiCache and Aurora together in db.tf
turned out to be a great decision.
Database-related changes often involve both cache and Aurora, so this granularity feels just right.
I also really like applications.tf
:
# applications.tf
module "cognito" { ... }
module "amplify" { ... }
module "ses" { ... }
This file collects application-layer services such as authentication, frontend hosting, and email delivery.
Whenever I add new user-facing functionality, this is usually the file I open first.
In the end, there’s no single “correct” answer for file granularity.
The right structure depends on your team’s development style and the project’s scale.
In my case, I settled on a simple rule of thumb:
“Group resources that are closely related, and separate those with different update frequencies.”
That balance has worked very well for us.
Conclusion
The reference book above contains far more useful knowledge beyond these tips.
If you’re using Terraform somewhat intuitively, I strongly recommend revisiting your setup with these practices in mind.
Top comments (0)