ECS Fargate with Terraform: patterns, pitfalls and fixes
Containers give us repeatable deployments, but managing the underlying EC2 instances adds operational burden. AWS Fargate removes that burden by letting you run containers without provisioning or managing servers—you pay only for the resources your containers actually consume. In this guide, we'll walk through the patterns, pitfalls and fixes for running ECS Fargate workloads with Terraform, covering everything from cluster setup to logging and IAM roles.
Why ECS Fargate matters in your infrastructure
Fargate is appealing because it eliminates the server management layer. You define task size (CPU and memory), and AWS handles placement, scaling and patching. For teams running microservices, CI/CD pipelines or batch jobs, Fargate reduces operational toil. However, the abstraction introduces new failure modes: misconfigured IAM, missing log groups, incorrect resource sizing and deployment configuration mistakes. We've seen production incidents caused by tasks failing silently because logs weren't wired up, or services stuck in degraded mode because the load balancer wasn't properly integrated.
Cluster and task definition — Image, CPU, memory and environment
The ECS cluster is where tasks run. With Fargate, the cluster itself is minimal—it's just a logical grouping. The real work happens in the task definition, which is a template that specifies your container image, resource allocation, and environment configuration.
Let's build a realistic example: a Node.js API service. Here's how we define the cluster:
resource "aws_ecs_cluster" "main" {
name = "api-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_cluster_capacity_providers" "main" {
cluster_name = aws_ecs_cluster.main.name
capacity_providers = ["FARGATE", "FARGATE_SPOT"]
default_capacity_provider_strategy {
base = 1
weight = 100
capacity_provider = "FARGATE"
}
}
Notice we've enabled Container Insights for monitoring and set up capacity providers. FARGATE is standard; FARGATE_SPOT is cheaper but interruptible—good for non-critical workloads.
Now the task definition, which is where most of the configuration lives:
resource "aws_ecs_task_definition" "api" {
family = "api-service"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
task_role_arn = aws_iam_role.ecs_task_role.arn
container_definitions = jsonencode([
{
name = "api"
image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/api:latest"
essential = true
portMappings = [
{
containerPort = 3000
hostPort = 3000
protocol = "tcp"
}
]
environment = [
{
name = "NODE_ENV"
value = "production"
},
{
name = "LOG_LEVEL"
value = "info"
}
]
secrets = [
{
name = "DB_PASSWORD"
valueFrom = aws_secretsmanager_secret_version.db_password.arn
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.api.name
"awslogs-region" = "us-east-1"
"awslogs-stream-prefix" = "ecs"
}
}
}
])
}
Key decisions here: CPU and memory must be valid Fargate combinations (256 with 512–2048 MB, 512 with 1024–4096 MB, etc.). We're using awsvpc network mode, which is required for Fargate. The image field should be your ECR URI or Docker Hub reference. Environment variables are fine for non-sensitive config; secrets come from Secrets Manager.
Pitfall: Many teams hardcode the image tag as latest, which breaks blue-green deployments and makes troubleshooting difficult. Use explicit version tags from your CI/CD pipeline and manage them with Terraform variables or a separate deployment tool.
Service — Desired count, load balancer and deployment settings
The ECS service manages how many task copies run and keeps them healthy. It integrates with load balancers for traffic distribution and orchestrates deployments.
resource "aws_ecs_service" "api" {
name = "api-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = 3
launch_type = "FARGATE"
platform_version = "LATEST"
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = 3000
}
deployment_configuration {
maximum_percent = 200
minimum_healthy_percent = 100
}
depends_on = [aws_lb_listener.api]
}
The desired_count is what you're aiming for. With maximum_percent = 200, ECS can temporarily run up to 6 tasks during deployment (200% of 3), allowing zero-downtime rolling updates. minimum_healthy_percent = 100 means at least 3 must stay healthy—this prevents over-aggressive termination.
Pitfall: Setting maximum_percent too low (e.g., 110%) causes deployment bottlenecks. Each new task takes 30–60 seconds to be healthy, so 110% allows only one simultaneous start. In a 3-task service, this stretches deployments to minutes. Use 200% or higher for faster deploys.
Another common mistake: forgetting the depends_on clause. If the load balancer listener isn't ready, the service creation can fail mysteriously. Terraform doesn't infer this dependency.
IAM roles — Separate execution and task roles
This is where we see the most confusion. There are two roles:
- Execution role: Used by the ECS agent to pull images, write logs, and fetch secrets. This is AWS-side permission.
- Task role: Used by your application code running inside the container. Your Node.js app assumes this role to call AWS APIs.
Let's build both:
resource "aws_iam_role" "ecs_task_execution_role" {
name = "ecs-task-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" {
role = aws_iam_role.ecs_task_execution_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "ecs_task_execution_secrets" {
name = "ecs-task-execution-secrets"
role = aws_iam_role.ecs_task_execution_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue",
"kms:Decrypt"
]
Resource = [
aws_secretsmanager_secret_version.db_password.arn
]
}
]
})
}
resource "aws_iam_role" "ecs_task_role" {
name = "ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy" "ecs_task_s3_access" {
name = "ecs-task-s3"
role = aws_iam_role.ecs_task_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject"
]
Resource = "${var.bucket_arn}/*"
}
]
})
}
Pitfall: Putting all permissions in the execution role. The execution role should only have permissions for image pulls, log writes, and secret fetches—nothing more. If your app needs S3 access, that goes in the task role. Many security incidents stem from over-permissive execution roles.
Logs — CloudWatch log group per service
Logs are critical. Without them, you're debugging blind. Fargate tasks log to CloudWatch by default if configured correctly, but the log group must exist first.
resource "aws_cloudwatch_log_group" "api" {
name = "/ecs/api-service"
retention_in_days = 7
tags = {
Name = "api-service-logs"
Service = "api"
}
}
resource "aws_cloudwatch_log_stream" "api" {
name = "api-stream"
log_group_name = aws_cloudwatch_log_group.api.name
}
In your task definition (shown earlier), the logConfiguration block references this group. The prefix ecs creates streams like ecs/api/container-id, making it easy to trace individual task executions.
Pitfall: Setting retention too high (e.g., 30 days for high-volume services). CloudWatch charges per GB ingested and stored. Tune this based on your compliance needs and cost tolerance. For most non-critical services, 7 days is reasonable; for audit workloads, maybe 30.
Another pitfall: Forgetting to create the log group. If it doesn't exist and your task definition references it, the task fails to start with a cryptic error. Always pre-create the log group.
Common mistakes
- Hardcoding image tags: Use CI/CD to update Terraform with specific version tags, not
latest. - Tight deployment percentages:
maximum_percent = 110%causes deployment gridlock. Use 200% or higher. - Missing load balancer dependency: Add
depends_onto the service resource. - Over-permissive execution role: Keep execution role minimal. Push app-level permissions to the task role.
- Skipping log groups: Always pre-create with Terraform. Include retention settings.
- Invalid CPU/memory combinations: Fargate has specific valid pairs. Verify against AWS documentation before applying.
- No health checks on load balancer: Configure meaningful health check paths and thresholds to catch unhealthy tasks early.
Key takeaways
- Use FARGATE capacity provider for standard workloads; FARGATE_SPOT for cost-sensitive, interruptible jobs.
- Keep execution role minimal; push app-level AWS permissions to the task role.
- Set
maximum_percentto 200 or higher to avoid deployment delays. - Pre-create CloudWatch log groups with retention policies aligned to your compliance and cost needs.
- Use explicit image tags from CI/CD, never
latestin production Terraform. - Configure load balancer health checks with meaningful container paths and reasonable thresholds.
- Always add
depends_onbetween services and load balancers to prevent race conditions. - Enable Container Insights on your cluster for visibility into task performance and resource usage.






Comments
Loading comments…