Aller au contenu principal

Reusable Terraform Module

This example shows how to create a professional, reusable Terraform module.

Command used

/ops:ops-infra-code "Create a Terraform module for AWS infrastructure with VPC, ECS and RDS"

Generated structure

modules/
└── ecs-service/
├── main.tf # Main resources
├── variables.tf # Input variables
├── outputs.tf # Outputs
├── versions.tf # Version constraints
├── locals.tf # Local values
├── data.tf # Data sources
├── iam.tf # IAM roles
└── README.md # Documentation

Module Code

versions.tf

terraform {
required_version = ">= 1.5.0"

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

variables.tf

# ====================
# Required Variables
# ====================

variable "name" {
description = "ECS service name"
type = string

validation {
condition = can(regex("^[a-z0-9-]+$", var.name))
error_message = "Name must contain only lowercase letters, digits and dashes."
}
}

variable "environment" {
description = "Environment (dev, staging, production)"
type = string

validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging or production."
}
}

variable "vpc_id" {
description = "VPC ID"
type = string
}

variable "subnet_ids" {
description = "Subnet IDs for the service"
type = list(string)
}

variable "container_image" {
description = "Container Docker image"
type = string
}

# ====================
# Optional Variables
# ====================

variable "cpu" {
description = "CPU units for the task (256, 512, 1024, 2048, 4096)"
type = number
default = 256

validation {
condition = contains([256, 512, 1024, 2048, 4096], var.cpu)
error_message = "CPU must be 256, 512, 1024, 2048 or 4096."
}
}

variable "memory" {
description = "Memory in MB for the task"
type = number
default = 512
}

variable "desired_count" {
description = "Number of service instances"
type = number
default = 2
}

variable "container_port" {
description = "Port exposed by the container"
type = number
default = 3000
}

variable "health_check_path" {
description = "Path for the health check"
type = string
default = "/api/health"
}

variable "environment_variables" {
description = "Environment variables for the container"
type = map(string)
default = {}
}

variable "secrets" {
description = "Secrets to inject (SSM or Secrets Manager ARN)"
type = list(object({
name = string
valueFrom = string
}))
default = []
}

variable "enable_autoscaling" {
description = "Enable autoscaling"
type = bool
default = true
}

variable "min_capacity" {
description = "Minimum capacity for autoscaling"
type = number
default = 1
}

variable "max_capacity" {
description = "Maximum capacity for autoscaling"
type = number
default = 10
}

variable "enable_execute_command" {
description = "Enable ECS Exec for debugging"
type = bool
default = false
}

variable "tags" {
description = "Additional tags for resources"
type = map(string)
default = {}
}

locals.tf

locals {
# Common tags
common_tags = merge(
{
Name = var.name
Environment = var.environment
ManagedBy = "terraform"
Module = "ecs-service"
},
var.tags
)

# Full name with environment
full_name = "${var.name}-${var.environment}"

# Container configuration
container_definition = {
name = var.name
image = var.container_image
cpu = var.cpu
memory = var.memory
essential = true

portMappings = [
{
containerPort = var.container_port
hostPort = var.container_port
protocol = "tcp"
}
]

environment = [
for key, value in var.environment_variables: {
name = key
value = value
}
]

secrets = var.secrets

logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.main.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "ecs"
}
}

healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:${var.container_port}${var.health_check_path} || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
}
}

data.tf

data "aws_region" "current" {}

data "aws_caller_identity" "current" {}

data "aws_vpc" "selected" {
id = var.vpc_id
}

main.tf

# ====================
# ECS Cluster
# ====================
resource "aws_ecs_cluster" "main" {
name = local.full_name

setting {
name = "containerInsights"
value = var.environment == "production" ? "enabled": "disabled"
}

tags = local.common_tags
}

resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name

capacity_providers = ["FARGATE", "FARGATE_SPOT"]

default_capacity_provider_strategy {
capacity_provider = var.environment == "production" ? "FARGATE": "FARGATE_SPOT"
weight = 1
base = var.environment == "production" ? 1: 0
}
}

# ====================
# Task Definition
# ====================
resource "aws_ecs_task_definition" "main" {
family = local.full_name
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.cpu
memory = var.memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn

container_definitions = jsonencode([local.container_definition])

tags = local.common_tags
}

# ====================
# ECS Service
# ====================
resource "aws_ecs_service" "main" {
name = local.full_name
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.main.arn
desired_count = var.desired_count

# Fargate
launch_type = "FARGATE"

# Network
network_configuration {
subnets = var.subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}

# Load Balancer
load_balancer {
target_group_arn = aws_lb_target_group.main.arn
container_name = var.name
container_port = var.container_port
}

# Deployment
deployment_circuit_breaker {
enable = true
rollback = true
}

deployment_controller {
type = "ECS"
}

# ECS Exec
enable_execute_command = var.enable_execute_command

# Avoid conflicts with autoscaling
lifecycle {
ignore_changes = [desired_count]
}

depends_on = [aws_lb_listener.https]

tags = local.common_tags
}

# ====================
# Security Group
# ====================
resource "aws_security_group" "ecs_tasks" {
name = "${local.full_name}-ecs-tasks"
description = "Security group for ECS tasks"
vpc_id = var.vpc_id

ingress {
description = "Allow from ALB"
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}

egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}

tags = local.common_tags
}

# ====================
# CloudWatch Logs
# ====================
resource "aws_cloudwatch_log_group" "main" {
name = "/ecs/${local.full_name}"
retention_in_days = var.environment == "production" ? 30: 7

tags = local.common_tags
}

# ====================
# Application Load Balancer
# ====================
resource "aws_security_group" "alb" {
name = "${local.full_name}-alb"
description = "Security group for ALB"
vpc_id = var.vpc_id

ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}

ingress {
description = "HTTP (redirect to HTTPS)"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}

egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}

tags = local.common_tags
}

resource "aws_lb" "main" {
name = local.full_name
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.subnet_ids

enable_deletion_protection = var.environment == "production"

tags = local.common_tags
}

resource "aws_lb_target_group" "main" {
name = local.full_name
port = var.container_port
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"

health_check {
enabled = true
healthy_threshold = 2
interval = 30
matcher = "200"
path = var.health_check_path
port = "traffic-port"
protocol = "HTTP"
timeout = 5
unhealthy_threshold = 3
}

tags = local.common_tags
}

resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn

default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}

tags = local.common_tags
}

resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"

default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}

tags = local.common_tags
}

# ====================
# Auto Scaling
# ====================
resource "aws_appautoscaling_target" "main" {
count = var.enable_autoscaling ? 1: 0

max_capacity = var.max_capacity
min_capacity = var.min_capacity
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}

resource "aws_appautoscaling_policy" "cpu" {
count = var.enable_autoscaling ? 1: 0

name = "${local.full_name}-cpu"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.main[0].resource_id
scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension
service_namespace = aws_appautoscaling_target.main[0].service_namespace

target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 70.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}

resource "aws_appautoscaling_policy" "memory" {
count = var.enable_autoscaling ? 1: 0

name = "${local.full_name}-memory"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.main[0].resource_id
scalable_dimension = aws_appautoscaling_target.main[0].scalable_dimension
service_namespace = aws_appautoscaling_target.main[0].service_namespace

target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}
target_value = 70.0
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}

iam.tf

# ====================
# Execution Role (for ECS agent)
# ====================
resource "aws_iam_role" "execution" {
name = "${local.full_name}-execution"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})

tags = local.common_tags
}

resource "aws_iam_role_policy_attachment" "execution" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role_policy" "execution_secrets" {
count = length(var.secrets) > 0 ? 1: 0
name = "${local.full_name}-secrets"
role = aws_iam_role.execution.id

policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ssm:GetParameters",
"secretsmanager:GetSecretValue"
]
Resource = [for s in var.secrets: s.valueFrom]
}
]
})
}

# ====================
# Task Role (for the application)
# ====================
resource "aws_iam_role" "task" {
name = "${local.full_name}-task"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})

tags = local.common_tags
}

# Policy for ECS Exec
resource "aws_iam_role_policy" "ecs_exec" {
count = var.enable_execute_command ? 1: 0
name = "${local.full_name}-ecs-exec"
role = aws_iam_role.task.id

policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
}
]
})
}

outputs.tf

output "cluster_id" {
description = "ECS cluster ID"
value = aws_ecs_cluster.main.id
}

output "cluster_name" {
description = "ECS cluster name"
value = aws_ecs_cluster.main.name
}

output "service_name" {
description = "ECS service name"
value = aws_ecs_service.main.name
}

output "task_definition_arn" {
description = "Task definition ARN"
value = aws_ecs_task_definition.main.arn
}

output "alb_dns_name" {
description = "ALB DNS name"
value = aws_lb.main.dns_name
}

output "alb_zone_id" {
description = "ALB Zone ID (for Route53)"
value = aws_lb.main.zone_id
}

output "security_group_id" {
description = "ECS tasks security group ID"
value = aws_security_group.ecs_tasks.id
}

output "log_group_name" {
description = "CloudWatch log group name"
value = aws_cloudwatch_log_group.main.name
}

output "task_role_arn" {
description = "Task role ARN"
value = aws_iam_role.task.arn
}

Module usage

module "api_service" {
source = "./modules/ecs-service"

name = "api"
environment = "production"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
container_image = "ghcr.io/myorg/api:v1.2.3"
certificate_arn = aws_acm_certificate.main.arn

cpu = 512
memory = 1024

environment_variables = {
NODE_ENV = "production"
LOG_LEVEL = "info"
}

secrets = [
{
name = "DATABASE_URL"
valueFrom = aws_ssm_parameter.database_url.arn
}
]

enable_autoscaling = true
min_capacity = 2
max_capacity = 10

tags = {
Team = "backend"
}
}

Key points

AspectImplementation
ValidationVariables with constraints
LocalsCentralized logic
SecurityIAM least privilege
FlexibilityOptional variables
DocumentationDescriptions on each variable
  • /ops:ops-ci - Pipeline with terraform plan/apply
  • /ops:ops-proxmox - Module for Proxmox infrastructure
  • /qa:qa-security - Terraform security audit

Terraform Registry

Publish your modules on the Terraform Registry to reuse them easily.