Why Terraform over CloudFormation
CloudFormation is AWS-only and its YAML/JSON syntax is painful for anything non-trivial. Terraform's HCL is readable, supports modules, and works across providers. When we added Cloudflare DNS and Datadog monitoring, Terraform managed all three from one codebase.
Project structure that scales
infra/
├── modules/
│ ├── vpc/
│ ├── ecs/
│ └── rds/
├── environments/
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── production/
│ ├── main.tf
│ ├── variables.tf
│ └── terraform.tfvars
└── backend.tf
Modules are reusable components. Environments compose modules with different variables. Staging and production use the same modules with different sizes and replica counts.
Remote state is mandatory
Local state files are a single point of failure. Use S3 with DynamoDB locking:
terraform {
backend "s3" {
bucket = "myapp-terraform-state"
key = "production/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
The DynamoDB table prevents two engineers from applying at the same time and corrupting state.
A real module example
# modules/ecs/main.tf
resource "aws_ecs_service" "api" {
name = var.service_name
cluster = var.cluster_id
task_definition = aws_ecs_task_definition.api.arn
desired_count = var.desired_count
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
load_balancer {
target_group_arn = var.target_group_arn
container_name = var.service_name
container_port = var.container_port
}
}
resource "aws_ecs_task_definition" "api" {
family = var.service_name
requires_compatibilities = ["FARGATE"]
cpu = var.cpu
memory = var.memory
network_mode = "awsvpc"
execution_role_arn = var.execution_role_arn
container_definitions = jsonencode([{
name = var.service_name
image = var.image
portMappings = [{ containerPort = var.container_port }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = var.log_group
"awslogs-region" = var.region
"awslogs-stream-prefix" = var.service_name
}
}
}])
}
Plan before apply, always
terraform plan -out=tfplan
# Review the output carefully
terraform apply tfplan
Never run terraform apply without a plan. The plan shows exactly what will be created, modified, or destroyed. We run plan in CI on every PR and post the output as a comment. The apply only runs after merge.
Handling secrets
Never put secrets in .tfvars files. Use AWS Secrets Manager or SSM Parameter Store and reference them:
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "production/db-password"
}
resource "aws_db_instance" "main" {
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
The rule I enforce
If it exists in AWS but not in Terraform, it does not exist. No exceptions. Every manual change gets terraform import-ed or recreated through code.
