DEV Community

Practical Terraform Tips for Secure and Reliable AWS Environments

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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]
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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" { ... }
Enter fullscreen mode Exit fullscreen mode

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" { ... }
Enter fullscreen mode Exit fullscreen mode

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" { ... }
Enter fullscreen mode Exit fullscreen mode

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.

References

Top comments (0)