Deploying AWS Fargate: CDK vs Terraform vs SAM
How to deploy Fargate effectively with different IaC tools. Practical patterns, common gotchas, and what works best for each approach.
Deploying Fargate services with CDK, Terraform, or SAM each produces working infrastructure, but the wrong choice creates maintenance overhead that compounds with every new service. For a new AWS-only service, CDK is the default: its high-level constructs collapse dozens of CloudFormation resources into a few lines. Terraform earns the switch when several teams share one change surface, or when a second cloud is realistic. SAM fits only when Fargate is a side note next to Lambda.
The differences rarely show up in the first deployment. They show up a year later, in how a change gets reviewed, how drift is detected, and how much code each new service costs to add.
IaC Tool Comparison for Fargate
CloudFormation - The Foundation
# Verbose but comprehensive
Resources:
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: my-app
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
Cpu: '256'
Memory: '512'
# Requires detailed configuration
Terraform - The Industry Standard
# Declarative and explicit
resource "aws_ecs_task_definition" "app" {
family = "my-app"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
# Good balance of readability and control
}
CDK - The Programming Approach
// High-level abstractions with programming constructs
const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', {
memoryLimitMiB: 512,
cpu: 256,
});
All three snippets define the same task definition at a different abstraction level.
Deploying Fargate with CDK
AWS CDK shines for Fargate deployments when you want programmatic control and high-level abstractions.
The CDK Advantage for Fargate
import * as cdk from 'aws-cdk-lib';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';
export class FargateStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// This single construct creates:
// - VPC, Subnets, NAT Gateways
// - ECS Cluster
// - Fargate Service
// - Application Load Balancer
// - Task Definition
// - Security Groups
// - CloudWatch Logs
const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
taskImageOptions: {
image: ecs.ContainerImage.fromRegistry('nginx'),
containerPort: 80,
environment: {
NODE_ENV: 'production',
API_URL: 'https://api.example.com'
}
},
desiredCount: 3,
domainName: 'app.example.com',
domainZone: hostedZone,
certificate: certificate,
});
// Add auto-scaling
const scaling = fargateService.service.autoScaleTaskCount({
maxCapacity: 10,
minCapacity: 2,
});
scaling.scaleOnCpuUtilization('CpuScaling', {
targetUtilizationPercent: 50,
});
// Add CloudWatch alarms
new cloudwatch.Alarm(this, 'HighMemory', {
metric: fargateService.service.metricMemoryUtilization(),
threshold: 80,
evaluationPeriods: 2,
});
}
}
What this CDK construct creates:
- ~300 lines of CloudFormation
- 15+ AWS resources
- All the IAM roles and policies
- Proper security group rules
- CloudWatch log groups
Fargate-Specific CDK Patterns
1. Service Templates with Environment Variations
interface FargateServiceProps {
serviceName: string;
image: string;
environment: 'dev' | 'staging' | 'prod';
port?: number;
}
class FargateService extends Construct {
constructor(scope: Construct, id: string, props: FargateServiceProps) {
super(scope, id);
// Environment-specific sizing
const configs = {
dev: { cpu: 256, memory: 512, desiredCount: 1 },
staging: { cpu: 512, memory: 1024, desiredCount: 2 },
prod: { cpu: 1024, memory: 2048, desiredCount: 5 }
};
const config = configs[props.environment];
const service = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
taskImageOptions: {
image: ecs.ContainerImage.fromRegistry(props.image),
containerPort: props.port || 80,
},
cpu: config.cpu,
memoryLimitMiB: config.memory,
desiredCount: config.desiredCount,
// Auto-configure ALB, VPC, subnets, security groups
});
// Add Fargate-specific monitoring
this.addFargateMonitoring(service);
}
private addFargateMonitoring(service: ecsPatterns.ApplicationLoadBalancedFargateService) {
// Memory utilization alarm
new cloudwatch.Alarm(this, 'MemoryAlarm', {
metric: service.service.metricMemoryUtilization(),
threshold: 80,
evaluationPeriods: 2,
});
// Running task count alarm (needs Container Insights on the cluster)
new cloudwatch.Alarm(this, 'TaskCountAlarm', {
metric: new cloudwatch.Metric({
namespace: 'ECS/ContainerInsights',
metricName: 'RunningTaskCount',
dimensionsMap: {
ClusterName: service.cluster.clusterName,
ServiceName: service.service.serviceName,
},
}),
threshold: 1,
evaluationPeriods: 2,
comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
});
}
}
2. Handling Fargate Spot with CDK
// Enable the capacity providers on the cluster first:
// cluster.enableFargateCapacityProviders();
const service = new ecs.FargateService(this, 'Service', {
cluster,
taskDefinition,
capacityProviderStrategies: [
{
capacityProvider: 'FARGATE_SPOT',
weight: 4,
base: 0,
},
{
capacityProvider: 'FARGATE',
weight: 1,
base: 2, // Always keep 2 on regular Fargate
}
],
});
CDK Gotchas for Fargate
Issue: ENI Limits
// Every awsvpc task holds a network interface, and the per-Region
// quota starts at 5000. AWS publishes no metric for it, so publish
// your own from a scheduled job and alarm on it.
const eniUsageMetric = new cloudwatch.Metric({
namespace: 'Custom/VPC',
metricName: 'ENIsInUse',
});
new cloudwatch.Alarm(this, 'ENIUsage', {
metric: eniUsageMetric,
threshold: 4500, // 90% of default 5000 limit
});
Deploying Fargate with Terraform
Terraform provides explicit, predictable Fargate deployments with excellent state management. Here’s how to structure your Fargate infrastructure effectively:
Terraform Fargate Foundations
resource "aws_ecs_cluster" "main" {
name = "production"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_task_definition" "app" {
family = "my-app"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "512"
memory = "1024"
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 = "app"
image = "nginx:latest"
portMappings = [{
containerPort = 80
protocol = "tcp"
}]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = var.aws_region
awslogs-stream-prefix = "ecs"
}
}
environment = [
{
name = "NODE_ENV"
value = "production"
}
]
}])
}
resource "aws_ecs_service" "app" {
name = "my-app-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.app_count
launch_type = "FARGATE"
enable_execute_command = true
network_configuration {
security_groups = [aws_security_group.ecs_tasks.id]
subnets = aws_subnet.private[*].id
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_alb_target_group.app.arn
container_name = "app"
container_port = 80
}
depends_on = [aws_alb_listener.front_end]
}
The Reusable Module Pattern
# modules/fargate-service/main.tf
variable "service_name" {}
variable "image" {}
variable "cpu" { default = "256" }
variable "memory" { default = "512" }
variable "desired_count" { default = 2 }
# ... 200 lines of reusable Terraform ...
output "service_url" {
value = aws_alb.main.dns_name
}
# In your main configuration
module "api_service" {
source = "./modules/fargate-service"
service_name = "api"
image = "myapp/api:latest"
cpu = "512"
memory = "1024"
desired_count = 3
}
module "worker_service" {
source = "./modules/fargate-service"
service_name = "worker"
image = "myapp/worker:latest"
cpu = "256"
memory = "512"
desired_count = 5
}
Essential State Management
Proper state management is critical for Terraform deployments. Outdated state files can lead to unintended resource destruction.
# Always review plan output carefully
$ terraform plan
Terraform will perform the following actions:
# aws_ecs_service.app will be destroyed
- resource "aws_ecs_service" "app" {
- name = "production-api" -> null
# ... 50 resources to be destroyed
}
Plan: 0 to add, 0 to change, 52 to destroy.
# Never use auto-approve in production
$ terraform apply # Review and confirm manually
Required: Always use remote state for team environments.
terraform {
backend "s3" {
bucket = "terraform-state-prod"
key = "fargate/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
SAM: The Lambda-First Approach
AWS SAM (Serverless Application Model) is great for Lambda, but for Fargate? It’s like using a screwdriver to hammer nails.
# template.yaml
Transform: AWS::Serverless-2016-10-31
Resources:
FargateCluster:
Type: AWS::ECS::Cluster
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
RequiresCompatibilities:
- FARGATE
NetworkMode: awsvpc
Cpu: '256'
Memory: '512'
# Back to CloudFormation verbosity
# SAM shines when you mix Lambda with Fargate
ProcessorFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: python3.12
Events:
ECSTask:
Type: CloudWatchEvent
Properties:
Pattern:
source:
- aws.ecs
detail-type:
- ECS Task State Change
When SAM makes sense for Fargate:
- You’re primarily Lambda-based with some Fargate
- You need Step Functions orchestration
- You’re already invested in SAM for other services
When it doesn’t:
- Fargate is your primary compute
- You need complex networking
- You want programming language features
Migration Strategies
CloudFormation to Terraform Migration
Migrating existing infrastructure requires careful planning. Consider these challenges:
Migration Process:
- Export existing resources
- Write equivalent Terraform
- Import resources carefully
- Validate before removing CloudFormation
Common Issues:
# The import ID is cluster-name/service-name, not the service name alone
$ terraform import aws_ecs_service.app production/production-app-service
Import successful!
# Imported state carries every AWS-side default your config omits
$ terraform plan
Plan: 0 to add, 12 to change, 3 to destroy.
# Re-importing an address that state already tracks
$ terraform import aws_ecs_cluster.main production
Error: Resource already managed by Terraform
Best Practices:
- Start with non-critical resources
- Use targeted applies:
terraform apply -target=resource - Maintain parallel stacks during transition
- Script resource discovery and import
Terraform to CDK Migration
CDK migrations face import limitations:
class MigrationStack extends cdk.Stack {
constructor(scope: Construct, id: string) {
super(scope, id);
// Limited import support
const cluster = ecs.Cluster.fromClusterArn(
this,
'ImportedCluster',
'arn:aws:ecs:us-east-1:123456789:cluster/production'
);
// CDK import limitations:
// - Task definitions require recreation
// - Complex service configurations
// - Service discovery integration
}
}
Migration Strategy: Consider running both tools temporarily for complex transitions.
The Decision Matrix
When CDK Fits
- Your team knows TypeScript or Python well
- You’re starting fresh, with no legacy stacks to import
- You want high-level abstractions
- You’re all-in on AWS
- You want new service features before a third-party provider ships them
When Terraform Fits
- You need multi-cloud potential
- Your team prefers declarative syntax
- You have existing Terraform modules
- Stability matters more than the newest features
- You value a large provider and module ecosystem
When SAM Fits
- Your architecture is Lambda-first
- You need Step Functions
- You want minimal tooling
- Your Fargate usage is marginal
When Raw CloudFormation Still Fits
- You need AWS Support to debug the deployment path
- You’re publishing through AWS Service Catalog
- A corporate mandate rules out the alternatives
The Patterns That Work Everywhere
Three patterns hold regardless of tool:
1. The Environment Abstraction
// CDK
interface EnvironmentConfig {
cpu: number;
memory: number;
desiredCount: number;
environment: Record<string, string>;
}
const configs: Record<string, EnvironmentConfig> = {
dev: { cpu: 256, memory: 512, desiredCount: 1 },
staging: { cpu: 512, memory: 1024, desiredCount: 2 },
prod: { cpu: 1024, memory: 2048, desiredCount: 5 }
};
# Terraform
locals {
env_config = {
dev = { cpu = 256, memory = 512, count = 1 }
staging = { cpu = 512, memory = 1024, count = 2 }
prod = { cpu = 1024, memory = 2048, count = 5 }
}
config = local.env_config[var.environment]
}
2. The Service Template Pattern
Instead of copying code, create templates:
// CDK: Base service construct
export class BaseEcsService extends Construct {
public readonly service: ecs.FargateService;
constructor(scope: Construct, id: string, props: BaseEcsServiceProps) {
super(scope, id);
// 100 lines of boilerplate
this.service = new ecs.FargateService(this, 'Service', {
// Common configuration
});
// Standard alarms
this.setupAlarms();
// Standard dashboard
this.setupDashboard();
}
}
// Usage
new BaseEcsService(this, 'ApiService', {
image: 'api:latest',
port: 3000,
cpu: 512
});
3. The GitOps Pipeline
# .github/workflows/deploy.yml
name: Deploy Infrastructure
on:
push:
branches: [main]
paths:
- 'infrastructure/**'
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Terraform Plan
run: |
cd infrastructure
terraform init
terraform plan -out=tfplan
- name: Post Plan to PR
uses: actions/github-script@v7
with:
script: |
// Post plan output as PR comment
apply:
needs: plan
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Terraform Apply
run: |
terraform apply tfplan
The Cost of Each Approach
The AWS bill does not move with the tool choice; all four end up as the same ECS resources. The cost lands on team time instead.
| Tool | Learning curve | Maintenance cost | Flexibility | AWS feature lag |
|---|---|---|---|---|
| CDK | Steepest | Medium | High | None via L1 constructs; L2 constructs trail |
| Terraform | Moderate | Low | High | Tied to provider releases |
| SAM | Shallowest | Low | Low | None |
| CloudFormation | Moderate | High | Medium | None |
The larger cost is friction in daily work:
- CloudFormation: slower iterations, more debugging
- Terraform: predictable but verbose workflows
- CDK: faster once the team is comfortable with the construct library
The Verdict
- New projects: CDK with TypeScript
- Existing projects: whatever is already there; migrate only when the current tool blocks something concrete
- Multi-cloud potential: Terraform
- Quick prototypes: SAM
- Raw CloudFormation: only when a mandate leaves no other option
CDK stays the default while the service lives inside AWS and one team owns the definitions. It stops being the default in two situations: when people outside that team need to read and approve infrastructure changes, and when a module library already exists that a new service can join in an afternoon. In both cases the review surface matters more than the line count.
References
- Best practices for developing and deploying cloud infrastructure with the AWS CDK - CDK best practices for IaC organization, construct reuse, and environment-aware deployment pipelines.
- Best practices for using the AWS CDK in TypeScript to create IaC projects - Prescriptive guidance on TypeScript CDK project structure, testing patterns, and version control for constructs.
- Learn AWS CDK core concepts - AWS Cloud Development Kit (AWS CDK) v2 - Foundation concepts: constructs, stacks, apps, environments, and the synthesis-to-CloudFormation pipeline.
- Architect for AWS Fargate for Amazon ECS - Fargate architecture fundamentals that IaC definitions must correctly model (task definitions, services, networking).
- Amazon ECS task definition parameters for Fargate - Complete parameter reference for Fargate task definitions; the authoritative source for valid CDK property values.
- AWS CDK API Reference (v2) - Full API reference for aws-ecs, aws-ecs-patterns, and related constructs used in Fargate IaC.
AWS Fargate Deep Dive Series
Complete guide to AWS Fargate from basics to production. Learn serverless containers, cost optimization, debugging techniques, and Infrastructure-as-Code deployment patterns through real-world experience.
All Posts in This Series
Related posts
Overcome CloudFormation's 500 resource limit with nested stacks, cross-stack references, SSM Parameter Store, and microstack architecture, shown in TypeScript CDK.
A practical guide to AWS Fargate: task definitions, awsvpc networking, cost trade-offs, and when serverless containers beat managing EC2 hosts yourself.
Advanced Fargate patterns learned from running production workloads. From cost optimization to stateful containers, here's what the docs won't tell you.
A lifecycle test for CDK stack layout: give a resource its own long-lived stack when it outlives any single deployer, then reach it by a well-known name.
The exact IAM size, attach, and quota limits you will hit at scale, and the scoped-policy, permission-boundary, and SCP structure that keeps you far from every one.