Deploy Containers to AWS ECS with Fargate
How to deploy Docker containers to AWS ECS using Fargate serverless compute with Terraform and GitHub Actions
Overview
AWS Fargate runs your containers on compute you never provision — no EC2 instances to patch, scale, or pay for while idle. Paired with ECS for orchestration, an Application Load Balancer for traffic, and Terraform for the infrastructure, it’s one of the shortest paths from a Dockerfile to a production service on AWS.
This recipe builds the full path: a VPC with public and private subnets, an ECS cluster on Fargate, a task definition with CloudWatch logging, a service behind an ALB, and a GitHub Actions pipeline that builds, pushes, and deploys on every merge to main. The layout mirrors real deployments, so you can copy it into your repo and change names, not structure.
When to Use This
- You want containers in production without managing an EC2 fleet. See Docker Basics for container fundamentals.
- You need tasks to scale on CPU or request count without writing scaling logic. See Load Testing for the baselines to scale against.
- You want every environment reproducible from code. See Terraform AWS VPC for the network layer on its own.
- You deploy often enough that a CI/CD pipeline pays for itself in the first week.
- You run batch or on-demand jobs where paying per task-second beats holding warm instances.
Skip Fargate when the workload needs GPUs, EBS volumes, or privileged containers — none are supported. If you run hundreds of always-on containers, the ~2x per-vCPU premium over EC2 stops being a rounding error and an ECS cluster on EC2 capacity usually wins on price.
Prerequisites
- AWS CLI configured with IAM permissions for ECS, EC2, IAM, ELB, and Secrets Manager
- Docker installed locally
- Terraform 1.5+
- An ECR repository for the app image (
aws ecr create-repository --repository-name myapp)
Solution: Terraform + ECS Fargate
1. Terraform Infrastructure
# main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = "us-east-1" }
# VPC and networking
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "ecs-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
# Tasks in private subnets need outbound internet to pull images from ECR.
# One shared NAT gateway covers both AZs; add one per AZ for HA if needed.
enable_nat_gateway = true
single_nat_gateway = true
enable_dns_hostnames = true
}
# ECS Cluster
resource "aws_ecs_cluster" "app" {
name = "production-cluster"
}
resource "aws_ecs_cluster_capacity_providers" "app" {
cluster_name = aws_ecs_cluster.app.name
capacity_providers = ["FARGATE"]
default_capacity_provider_strategy {
base = 1
weight = 100
capacity_provider = "FARGATE"
}
}
# Task Definition
resource "aws_ecs_task_definition" "app" {
family = "web-app"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_exec.arn
container_definitions = jsonencode([{
name = "web"
image = "nginx:alpine"
portMappings = [{ containerPort = 80, protocol = "tcp" }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = "us-east-1"
awslogs-stream-prefix = "ecs"
}
}
}])
}
# Service
resource "aws_ecs_service" "app" {
name = "web-service"
cluster = aws_ecs_cluster.app.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
deployment_circuit_breaker {
enable = true
rollback = true
}
network_configuration {
subnets = module.vpc.private_subnets
security_groups = [aws_security_group.ecs.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "web"
container_port = 80
}
}
# Application Load Balancer
resource "aws_lb" "app" {
name = "app-alb"
internal = false
load_balancer_type = "application"
subnets = module.vpc.public_subnets
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_target_group" "app" {
name = "app-tg"
port = 80
protocol = "HTTP"
vpc_id = module.vpc.vpc_id
target_type = "ip"
}
resource "aws_lb_listener" "app" {
load_balancer_arn = aws_lb.app.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
2. Deploy
terraform init
terraform apply
3. CI/CD with GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to ECS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t myapp:$IMAGE_TAG .
docker tag myapp:$IMAGE_TAG $ECR_REGISTRY/myapp:$IMAGE_TAG
docker push $ECR_REGISTRY/myapp:$IMAGE_TAG
- name: Update ECS service
run: |
aws ecs update-service \
--cluster production-cluster \
--service web-service \
--force-new-deployment
Fargate removes the EC2 layer — you define the task (image, CPU, memory, environment) and AWS runs it on shared infrastructure you never touch. Two files do the work: the task definition (what runs) and the service (how many and where). Get the CPU/memory right at the task level and Fargate bills you only for what the task uses — no idle EC2 cost.
How It Works
- Fargate provisions compute on demand. With
awsvpcnetworking each task gets its own ENI and private IP inside your subnets, so tasks are first-class network citizens rather than processes sharing a host. - Task Definition is the contract: image, a valid CPU/memory pair (256 CPU only accepts 512–2048 MB on Fargate), ports, environment, secrets, and the log driver.
- Service is the scheduler. It keeps
desired_counttasks alive, registers each task IP in the ALB target group, and rolls out new task definition revisions. Defaults allow up to 200% tasks during a deploy (maximum_percent), which is what makes rolling updates possible. - ALB health-checks every task IP and stops sending traffic to tasks that fail, while the service replaces them.
A deployment is a short chain: the pipeline pushes a new image, update-service triggers a new task definition revision, the scheduler launches replacement tasks, the ALB marks them healthy, and the old tasks drain and die. The circuit breaker in the service config rolls the whole thing back automatically when tasks never reach healthy.
Advanced Configuration
IAM Roles and Security Groups
Two roles, two jobs: the execution role is infrastructure — it pulls the image from ECR, writes to CloudWatch, and fetches secrets. The task role is your application — it carries whatever AWS API permissions the code needs (S3 here). Security groups mirror the traffic path: the ALB accepts port 80 from the internet, tasks accept port 80 only from the ALB.
# iam.tf
# Task execution role
resource "aws_iam_role" "ecs_exec" {
name = "ecs-exec-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_exec" {
role = aws_iam_role.ecs_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# Task role (application permissions)
resource "aws_iam_role" "app_task" {
name = "app-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" "app_task_s3" {
name = "app-s3-access"
role = aws_iam_role.app_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "arn:aws:s3:::my-app-bucket/*"
}]
})
}
# Security groups
resource "aws_security_group" "alb" {
name = "alb-sg"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "ecs" {
name = "ecs-sg"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Auto Scaling Configuration
Target tracking keeps task count proportional to real load: add tasks when average CPU stays above 70%, remove them when memory drops below 80%. The cooldowns matter — Fargate tasks take roughly 30-60 seconds to launch, so scaling too aggressively just thrashes the count.
# autoscaling.tf
resource "aws_appautoscaling_target" "ecs" {
max_capacity = 10
min_capacity = 2
resource_id = "service/${aws_ecs_cluster.app.name}/${aws_ecs_service.app.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
target_value = 70
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
resource "aws_appautoscaling_policy" "memory" {
name = "memory-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
target_value = 80
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
Secrets Manager Integration
Values under secrets get injected as environment variables at task start, pulled through the execution role — which is why ecs_exec needs read access to the secret. Note the trade-off: secrets only refresh on a new deployment, so rotating a value means redeploying the service.
# secrets.tf
resource "aws_secretsmanager_secret" "db_password" {
name = "prod/db/password"
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = jsonencode({ password = var.db_password })
}
# Reference in task definition
resource "aws_ecs_task_definition" "app_with_secrets" {
family = "web-app-secrets"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_exec.arn
task_role_arn = aws_iam_role.app_task.arn
container_definitions = jsonencode([{
name = "web"
image = "myapp:latest"
portMappings = [{ containerPort = 80, protocol = "tcp" }]
secrets = [{
name = "DB_PASSWORD"
valueFrom = aws_secretsmanager_secret.db_password.arn
}]
environment = [
{ name = "NODE_ENV", value = "production" },
{ name = "PORT", value = "80" }
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = "us-east-1"
awslogs-stream-prefix = "ecs"
}
}
}])
}
Health Check Configuration
deregistration_delay = 30 is what connection draining looks like on an ALB: when a task leaves the target group, the load balancer keeps its in-flight requests alive for 30 seconds instead of cutting them mid-flight. Pair it with a real /health endpoint — if the app doesn’t expose one, tasks flap between healthy and unhealthy and the service never stabilizes.
resource "aws_lb_target_group" "app" {
name = "app-tg"
port = 80
protocol = "HTTP"
vpc_id = module.vpc.vpc_id
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
path = "/health"
matcher = "200"
}
deregistration_delay = 30
}
Production Considerations
- Pin a
platform_versionon the service (e.g.1.4.0) instead ofLATESTwhen you need reproducible deployments;LATESTis fine while iterating. - Use Blue/Green deployments with CodeDeploy for zero-downtime updates — set
deployment_controller { type = "CODE_DEPLOY" }on the service and let CodeDeploy shift traffic between task sets. - Add Fargate Spot to the cluster capacity providers for stateless, interruptible tasks — up to ~70% cheaper, with a two-minute interruption notice.
- Enable CloudWatch Container Insights on the cluster for per-service and per-task metrics; without it you only get cluster-level aggregates.
Performance Tips
- Right-size tasks based on CloudWatch metrics. Check CPU and memory utilization over 7 days and adjust:
aws cloudwatch get-metric-statistics \
--namespace AWS/ECS \
--metric-name CPUUtilization \
--dimensions Name=ClusterName,Value=production-cluster \
--start-time $(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Average
On macOS use date -u -v-7d instead of date -u -d '7 days ago'. If tasks sit under 30% CPU all week, drop a size — 256/512 is the cheapest valid pair.
-
Let the deployment circuit breaker roll back failed deploys. Without it, a broken image retries forever while the service reports a failed deployment. The block shown in the service above detects tasks that never turn healthy and rolls back to the last stable revision automatically.
-
Keep
min_capacityabove zero during traffic bursts. Fargate tasks take ~30-60 seconds to reach running state — image pull dominates. A floor of 2 tasks absorbs spikes while scale-out catches up.
Common Mistakes
- Private subnets with no path to ECR. Tasks in private subnets can’t pull images without a NAT gateway or VPC endpoints for
ecr.api,ecr.dkr, and S3 — they sit inPENDINGuntil they time out. Theenable_nat_gatewayflag in the VPC module above isn’t decoration (a NAT gateway costs ~$32/month plus data processing; interface endpoints are the alternative). - Swapping the two IAM roles. The execution role covers infrastructure (image pull, logs, secrets); the task role covers your app’s API calls. If secrets injection fails with
ResourceInitializationError, the missing permission is almost always on the execution role. - Invalid CPU/memory pairs.
terraform applysucceeds and the task fails at launch because Fargate only accepts specific combinations — 256 CPU pairs with 512-2048 MB, 512 CPU with 1024-4096 MB, and so on. - Secrets in plaintext
environment. Plain env vars are visible to anyone withecs:DescribeTaskDefinition. Anything sensitive belongs insecretsbacked by Secrets Manager or SSM Parameter Store. - Health check pointing at a path the app doesn’t serve. The ALB marks every task unhealthy, the circuit breaker rolls back, and the deploy looks broken when the app is fine.
- Long-lived AWS keys in CI. The workflow above uses access keys for readability; in production prefer
role-to-assumewith OIDC inconfigure-aws-credentialsso no static secrets live in the repo.
Further Reading
- AWS ECS on Fargate — the managed compute model
- Terraform AWS Provider — ecs_service resource
- ECS Fargate companion project — the Terraform files and GitHub Actions workflow from this recipe, ready to run
Frequently Asked Questions
How does Fargate pricing compare to EC2?
Fargate is ~2x the compute cost of EC2 but eliminates operational overhead. On small workloads the difference barely registers.
Can I use private container images?
Yes. ECR works out of the box; for Docker Hub or other external registries, set repositoryCredentials in the container definition pointing to a Secrets Manager secret with your registry credentials.
How do I debug failing tasks?
Look at CloudWatch Logs for the task family first. For live debugging, enable enable_execute_command on the service and use aws ecs execute-command to open a shell inside a running container.
Should I use ECS with Fargate or EKS?
ECS + Fargate when you just need containers running without cluster internals. EKS earns its complexity when you need Kubernetes APIs, its tooling ecosystem, or portability across clouds.
Does Fargate support spot capacity?
Yes — the FARGATE_SPOT capacity provider cuts costs by up to ~70% in exchange for possible interruption with a two-minute warning. Good fit for queues, workers, and stateless services.
Related Resources
Infrastructure as Code — Terraform and Pulumi
A practical guide to managing infrastructure as code: benefits of declarative vs imperative approaches, state management, modules, and testing infrastructure changes.
DocDocker Image Hardening Checklist
Checklist for hardening Docker container images for production: base image selection, user permissions, file system restrictions, network isolation, resource limits, secret management, vulnerability scanning, and CI/CD integration with Dockerfile examples.
RecipeAWS CLI Bash Scripts
A working set of Bash scripts that wrap the AWS CLI to provision, tag, audit, and clean up AWS resources, including multi-account deploys.
RecipeImmutable Infrastructure
Build immutable infrastructure with versioned machine images and containers to eliminate configuration drift and ensure reproducible deployments.
RecipeSetup CI with GitLab Pipelines
How to configure GitLab CI/CD pipelines for testing, building, and deploying applications using .gitlab-ci.yml with stages, jobs, caching, and runners.