DevOpsil
AWS
90%
Needs Review

AWS ECS Fargate: Serverless Container Deployment Without Managing Nodes

Aareez AsifAareez Asif6 min read

ECS vs EKS: When to Choose Fargate

Fargate removes EC2 node management from the equation — you define CPU and memory per task, and AWS handles the underlying compute. Choose ECS Fargate over EKS when:

  • You have fewer than 50 services and don't need Kubernetes-native features
  • Your team doesn't want to manage cluster upgrades, node groups, and Kubernetes complexity
  • You're already deep in the AWS ecosystem (CodePipeline, App Mesh, CloudMap)
  • You need serverless burst scaling with per-task billing

Choose EKS when you need Helm ecosystem, Kubernetes operators, multi-cloud portability, or advanced scheduling.


Core Concepts

  • Cluster: A logical grouping of tasks. Just a namespace — Fargate clusters have no EC2 instances.
  • Task Definition: Blueprint for a container — image, CPU, memory, environment variables, IAM role, logging.
  • Task: A running instance of a task definition (like a pod).
  • Service: Keeps N copies of a task running, integrates with load balancers, handles rolling deploys.
  • Task Role: IAM role assumed by the running container (for AWS API access).
  • Execution Role: IAM role used by ECS to pull images and write logs (not the container).

IAM Roles Setup

Two distinct roles are needed:

# 1. Task Execution Role — ECS agent uses this to pull ECR images and write to CloudWatch
aws iam create-role \
  --role-name ecsTaskExecutionRole \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}'

aws iam attach-role-policy \
  --role-name ecsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

# Add SSM/Secrets Manager access if using secrets injection
aws iam attach-role-policy \
  --role-name ecsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/SecretsManagerReadWrite  # scope this down in production

# 2. Task Role — your application assumes this to call AWS APIs
aws iam create-role \
  --role-name myapp-task-role \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}'

# Attach only what the app needs (S3 read in this example)
aws iam attach-role-policy \
  --role-name myapp-task-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

Task Definition

A task definition is a JSON blueprint registered with ECS:

{
  "family": "myapp",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::123456789:role/ecsTaskExecutionRole",
  "taskRoleArn": "arn:aws:iam::123456789:role/myapp-task-role",
  "containerDefinitions": [
    {
      "name": "myapp",
      "image": "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest",
      "portMappings": [
        {
          "containerPort": 8080,
          "protocol": "tcp"
        }
      ],
      "environment": [
        { "name": "APP_ENV", "value": "production" },
        { "name": "LOG_LEVEL", "value": "info" }
      ],
      "secrets": [
        {
          "name": "DB_PASSWORD",
          "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/myapp/db-password-AbCdEf"
        },
        {
          "name": "API_KEY",
          "valueFrom": "arn:aws:ssm:us-east-1:123456789:parameter/prod/myapp/api-key"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/myapp",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "healthCheck": {
        "command": ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 60
      },
      "essential": true
    }
  ]
}

Register it:

aws ecs register-task-definition \
  --cli-input-json file://task-definition.json

Secrets from Secrets Manager and SSM

Never put secrets in environment — use secrets which pulls values at task start time and injects them as environment variables. The task execution role needs read access to the secret.

Secrets Manager:

# Create a secret
aws secretsmanager create-secret \
  --name prod/myapp/db-password \
  --secret-string "my-super-secure-password"

# Reference in task definition — use full ARN or name
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/myapp/db-password"

SSM Parameter Store (cheaper, simpler):

# Create a SecureString parameter
aws ssm put-parameter \
  --name /prod/myapp/api-key \
  --value "abc123-secret-key" \
  --type SecureString

# Reference in task definition
"valueFrom": "arn:aws:ssm:us-east-1:123456789:parameter/prod/myapp/api-key"
# Or shorthand:
"valueFrom": "/prod/myapp/api-key"

ECS Service with Application Load Balancer

# Create the ECS cluster
aws ecs create-cluster --cluster-name production

# Create CloudWatch log group
aws logs create-log-group --log-group-name /ecs/myapp

# Create the service
aws ecs create-service \
  --cluster production \
  --service-name myapp \
  --task-definition myapp:1 \
  --desired-count 3 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-abc,subnet-def],securityGroups=[sg-xyz],assignPublicIp=DISABLED}" \
  --load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:...,containerName=myapp,containerPort=8080" \
  --health-check-grace-period-seconds 60 \
  --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200"

minimumHealthyPercent=100 means rolling deploys keep all current tasks running while new ones start. maximumPercent=200 means ECS can run up to 2x the desired count during deployment.


Deploying New Versions (Rolling Update)

# Build and push to ECR
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.com

docker build -t myapp .
docker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:${GIT_SHA}
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:${GIT_SHA}

# Register new task definition revision with updated image
aws ecs register-task-definition \
  --cli-input-json "$(cat task-definition.json | sed "s|:latest|:${GIT_SHA}|g")"

# Update the service to use the new revision
aws ecs update-service \
  --cluster production \
  --service myapp \
  --task-definition myapp:2  # or use the new revision number

# Wait for deployment to complete
aws ecs wait services-stable \
  --cluster production \
  --services myapp

Auto Scaling

ECS Application Auto Scaling adjusts the number of running tasks:

# Register the scalable target
aws application-autoscaling register-scalable-target \
  --service-namespace ecs \
  --resource-id service/production/myapp \
  --scalable-dimension ecs:service:DesiredCount \
  --min-capacity 2 \
  --max-capacity 20

# Scale based on CPU usage
aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/production/myapp \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name myapp-cpu-scaling \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 70.0,
    "PredefinedMetricSpecification": {
      "PredefinedMetricType": "ECSServiceAverageCPUUtilization"
    },
    "ScaleInCooldown": 300,
    "ScaleOutCooldown": 60
  }'

# Also scale on ALB request count per target (better for HTTP services)
aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/production/myapp \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name myapp-request-scaling \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration '{
    "TargetValue": 1000,
    "PredefinedMetricSpecification": {
      "PredefinedMetricType": "ALBRequestCountPerTarget",
      "ResourceLabel": "app/myapp-alb/abc123/targetgroup/myapp-tg/xyz789"
    }
  }'

Terraform Example

resource "aws_ecs_cluster" "main" {
  name = "production"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_ecs_task_definition" "myapp" {
  family                   = "myapp"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = 512
  memory                   = 1024
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.myapp_task.arn

  container_definitions = jsonencode([{
    name  = "myapp"
    image = "${aws_ecr_repository.myapp.repository_url}:latest"
    portMappings = [{ containerPort = 8080, protocol = "tcp" }]
    secrets = [
      { name = "DB_PASSWORD", valueFrom = aws_secretsmanager_secret.db_password.arn }
    ]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.myapp.name
        "awslogs-region"        = var.aws_region
        "awslogs-stream-prefix" = "ecs"
      }
    }
    essential = true
  }])
}

resource "aws_ecs_service" "myapp" {
  name            = "myapp"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.myapp.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = var.private_subnet_ids
    security_groups = [aws_security_group.myapp.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.myapp.arn
    container_name   = "myapp"
    container_port   = 8080
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  lifecycle {
    ignore_changes = [task_definition]  # Let CI/CD manage image updates
  }
}

Cost Breakdown

Fargate pricing is per vCPU-hour and GB-hour of memory:

  • Linux/x86: $0.04048/vCPU-hour, $0.004445/GB-hour
  • A 0.5 vCPU / 1 GB task running continuously: ~$16/month
  • A 2 vCPU / 4 GB task: ~$65/month

Compared to an EC2 m5.large ($0.096/hour = ~$70/month) running at 30% utilization, Fargate can be cheaper if tasks are bursty. For steady high-utilization, EC2 + Savings Plans wins.

Fargate Spot: Up to 70% discount for fault-tolerant workloads. Specify capacityProviderStrategy with FARGATE_SPOT to use it for non-critical tasks.

Share:

Was this article helpful?

Aareez Asif
Aareez Asif

Senior Kubernetes Architect

10+ years orchestrating containers in production. Battle-tested opinions on everything from pod scheduling to service mesh. I've seen clusters burn and helped rebuild them better.

Related Articles

AWSTutorialBeginnerNeeds Review

AWS EKS: Production Kubernetes Cluster Setup from Scratch

Step-by-step guide to launching a production-ready EKS cluster on AWS — node groups, IAM roles, VPC configuration, managed add-ons, kubeconfig setup, and cost optimization. Both eksctl and Terraform approaches covered.

Aareez Asif·
6 min read

More in AWS

View all →
AWSQuick RefBeginnerNeeds Review

Fix AWS S3 'Access Denied' Errors

Systematically troubleshoot and fix AWS S3 Access Denied errors caused by IAM policies, bucket policies, ACLs, and encryption settings.

Sarah Chen·
3 min read
AWSQuick RefBeginnerNeeds Review

AWS CLI: Cheat Sheet

AWS CLI cheat sheet with copy-paste commands for EC2, S3, IAM, Lambda, ECS, CloudFormation, SSM, and Secrets Manager operations.

Dev Patel·
3 min read

Discussion