Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 029 / 100

ECS Fargate with Terraform: patterns, pitfalls and fixes

Run containers without servers.

Expona Admin7 min read

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:

hcl
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:

hcl
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.

hcl
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:

hcl
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.

hcl
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_on to 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_percent to 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 latest in production Terraform.
  • Configure load balancer health checks with meaningful container paths and reasonable thresholds.
  • Always add depends_on between services and load balancers to prevent race conditions.
  • Enable Container Insights on your cluster for visibility into task performance and resource usage.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles