Introduction to ECS Best Practices
Amazon Elastic Container Service (ECS) is a fully managed container orchestration platform that powers thousands of production workloads across every industry. While launching your first ECS cluster is straightforward, operating it efficiently at scale requires deliberate attention to three critical pillars: cost optimization, security hardening, and performance tuning. This tutorial walks through actionable best practices for each pillar, complete with code examples you can apply immediately to your own infrastructure.
Cost Optimization
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →What It Is
Cost optimization in ECS means aligning your compute, networking, and observability spend with actual workload requirements — not over-provisioning resources that sit idle, and not under-provisioning to the point of throttling your application. The goal is to achieve the lowest possible cost per request while maintaining reliability targets.
Why It Matters
Containerized workloads often run 24/7 across multiple environments. A 20% reduction in per-container cost can translate to thousands of dollars saved annually. Without intentional cost governance, teams frequently leave money on the table through oversized task definitions, idle compute capacity, and unoptimized logging pipelines.
How to Use Cost-Saving Features
ECS offers several levers to control cost. The most impactful decisions come down to launch type, pricing model, and resource right-sizing.
Choosing Between Fargate and EC2
Fargate eliminates the need to manage EC2 instances and charges per vCPU-second and GB-second of memory. It shines for variable or bursty workloads where you want to avoid idle instance costs. EC2 launch type requires you to manage the underlying instances but gives you access to Savings Plans, Reserved Instances, and Spot pricing, which can yield 40-70% savings for predictable, steady-state workloads.
# Example CloudFormation snippet: EC2 launch type with Spot Instances
MyECSService:
Type: AWS::ECS::Service
Properties:
Cluster: !Ref MyCluster
LaunchType: EC2
TaskDefinition: !Ref MyTaskDef
DesiredCount: 3
CapacityProviderStrategy:
- CapacityProvider: FARGATE_SPOT
Weight: 1
Base: 0
Leveraging Compute Savings Plans
Compute Savings Plans provide a discounted hourly rate in exchange for a one- or three-year commitment. They apply automatically to Fargate usage across any region, regardless of family, size, or OS. This is the simplest way to reduce Fargate costs without locking into specific instance types.
# AWS CLI: Purchase a Compute Savings Plan
aws savingsplans create-savings-plan \
--savings-plan-type ComputeSavingsPlans \
--commitment 1.0 \
--term Years \
--payment-option NoUpfront \
--purchase-time $(date +%s)
Right-Sizing Task Definitions
Many teams copy-paste task definitions and over-allocate CPU/memory "just in case." Use CloudWatch Container Insights and AWS Compute Optimizer to identify actual usage patterns, then tighten task sizes.
# Example: Task definition with right-sized resources
# After analyzing metrics, you discover your app peaks at 256 MiB
{
"family": "web-app",
"containerDefinitions": [{
"name": "nginx",
"image": "nginx:alpine",
"memory": 256,
"memoryReservation": 128,
"cpu": 256,
"essential": true
}]
}
The memoryReservation soft limit allows the container to burst up to the hard memory limit while giving the scheduler flexibility to pack containers more densely on EC2 instances.
Cleaning Up Unused Resources
Orphaned task definitions, old ECR images, unattached Elastic Network Interfaces, and idle load balancers accumulate over time. Implement lifecycle policies and regular cleanup scripts.
# ECR lifecycle policy to retain only 10 most recent images
{
"rules": [{
"rulePriority": 1,
"description": "Expire images older than 10 versions",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": { "type": "expire" }
}]
}
# Apply via AWS CLI
aws ecr put-lifecycle-policy \
--repository-name my-repo \
--lifecycle-policy-text file://policy.json
Cost Best Practices Summary
- Use Spot for stateless, fault-tolerant services — ECS supports Fargate Spot and EC2 Spot via capacity providers, cutting costs by up to 70%.
- Set auto-scaling based on real metrics — avoid fixed
DesiredCountvalues; use target tracking on CPU, memory, or custom CloudWatch metrics. - Monitor cost per service — enable AWS Cost Explorer resource-level tags and tag all ECS services with
Environment,Team, andAppName. - Archive logs aggressively — CloudWatch Logs costs balloon quickly; set retention policies and consider shipping logs to S3 via Firehose for long-term storage.
- Review unattached resources monthly — automate checks for orphaned ALBs, empty clusters, and dangling EBS volumes.
Security Hardening
What It Is
ECS security spans the container image supply chain, runtime isolation, network segmentation, secrets management, and compliance auditing. It's about minimizing the blast radius of a compromised container and ensuring only authorized entities can pull images, read secrets, or talk to your services.
Why It Matters
Containers share the host kernel (in EC2 launch type) or run in lightweight Firecracker microVMs (Fargate). A single vulnerable container can expose sensitive environment variables, allow privilege escalation, or pivot into your VPC. The 2023 Sysdig report found that 87% of container images contained high or critical vulnerabilities — hardening is not optional.
How to Implement Security Controls
Task IAM Roles — Not Container Credentials
Never bake AWS credentials into images or pass them as environment variables. Use TaskRole for application-level permissions (e.g., S3, DynamoDB) and ExecutionRole for ECS infrastructure needs (e.g., pulling images, writing logs).
# CloudFormation: Separate task role and execution role
MyTaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
ExecutionRoleArn: !Ref ExecutionRole # Pulls images, writes logs
TaskRoleArn: !Ref TaskRole # App accesses S3, SQS
ContainerDefinitions:
- Name: app
Image: my-app:latest
Environment:
- Name: AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
Value: /v2/credentials/...
# ExecutionRole policy (minimal permissions)
ExecutionRolePolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- ecr:GetAuthorizationToken
- ecr:BatchCheckLayerAvailability
- ecr:GetDownloadUrlForLayer
- ecr:BatchGetImage
Resource: '*'
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:*'
Secrets Management with Parameter Store and Secrets Manager
Inject secrets securely at runtime using the secrets block in task definitions. ECS automatically fetches and decrypts them via KMS, exposing them as environment variables or mounted files without ever persisting them in image layers.
{
"containerDefinitions": [{
"name": "web",
"image": "my-app:1.2",
"secrets": [
{
"name": "DATABASE_PASSWORD",
"valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/db-password"
},
{
"name": "API_KEY",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:api-key:abcd-efgh"
}
]
}]
}
Combine this with KMS key policies that restrict which IAM roles can decrypt specific parameters, ensuring even if a container is compromised, cross-environment secrets remain safe.
Security Groups and Network Segmentation
Apply granular security groups to each ECS service. Avoid broad CIDR rules. Use service mesh or App Mesh for east-west traffic encryption and authorization between microservices.
# Terraform example: tight security group rules
resource "aws_security_group" "ecs_service" {
name = "my-service-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # Only from ALB
description = "Allow traffic from load balancer"
}
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Outbound HTTPS to internet
description = "Allow outbound HTTPS"
}
# No other rules — principle of least privilege
}
Runtime Security: Read-Only Root Filesystem and No Root
Configure containers with readonlyRootFilesystem: true and run as a non-root user. This prevents attackers from installing tools or modifying binaries post-exploitation. For ephemeral writes, mount a dedicated /tmp volume.
{
"containerDefinitions": [{
"name": "secure-app",
"image": "my-app:hardened",
"readonlyRootFilesystem": true,
"user": "1000",
"mountPoints": [{
"sourceVolume": "tmp-volume",
"containerPath": "/tmp",
"readOnly": false
}],
"linuxParameters": {
"capabilities": {
"drop": ["ALL"],
"add": ["NET_BIND_SERVICE"]
}
}
}],
"volumes": [{
"name": "tmp-volume",
"dockerVolumeConfiguration": {
"scope": "task",
"driver": "local"
}
}]
}
Dropping all Linux capabilities and adding back only NET_BIND_SERVICE allows binding to privileged ports (< 1024) while stripping every other kernel capability.
Image Scanning and Signed Images
Enable ECR basic scanning or deploy Inspector for vulnerability assessment. For production pipelines, enforce image signing using AWS Signer and only deploy images with valid signatures.
# Enable ECR image scanning on push
aws ecr put-image-scanning-configuration \
--repository-name production-repo \
--image-scanning-configuration scanOnPush=true
# Block deployment of unscanned images via IAM condition
# Add to your task execution role's trust policy:
"Condition": {
"StringEquals": {
"ecr:ImageScanStatus": "COMPLETE"
}
}
Security Best Practices Summary
- Use distinct task and execution roles — never reuse a broad role for both purposes.
- Store secrets in Parameter Store or Secrets Manager — never in plaintext environment variables checked into source control.
- Apply per-service security groups — avoid the default "allow all" pattern; segment by microservice.
- Enable read-only root filesystem — reduces attack surface dramatically with minimal effort.
- Scan images before deployment — block critical or high CVEs from reaching production.
- Use VPC Flow Logs and DNS Firewall — monitor unexpected outbound connections that could indicate exfiltration.
Performance Tuning
What It Is
Performance tuning in ECS means configuring task definitions, placement strategies, scaling policies, and networking to deliver consistent low-latency responses under varying load. It's about eliminating bottlenecks — CPU contention, memory pressure, slow service discovery, or inefficient load balancing — before they impact users.
Why It Matters
A poorly tuned ECS service might pass health checks at low traffic and fail catastrophically under peak load. Performance regressions in containerized environments compound quickly: a slow upstream causes cascading timeouts, connection pool exhaustion, and eventual outage. Proactive tuning keeps p99 latency stable even during traffic spikes.
How to Optimize Performance
Task Placement Strategies: Binpack vs. Spread
On EC2 launch type, placement strategies control how tasks land on instances. Binpack based on CPU or memory packs tasks densely onto fewer instances, maximizing utilization and reducing instance count. Spread distributes tasks evenly across instances or Availability Zones for high availability.
# AWS CLI: Create a service with binpack placement by memory
aws ecs create-service \
--cluster my-cluster \
--service-name high-density-service \
--task-definition batch-worker:3 \
--placement-strategy type=binpack,field=memory \
--placement-constraints type=distinctInstance
# For latency-sensitive services, spread across AZs
aws ecs create-service \
--cluster my-cluster \
--service-name api-gateway \
--task-definition api:5 \
--placement-strategy type=spread,field=attribute:ecs.availabilityZone \
--placement-constraints type=distinctInstance
Use task placement constraints to enforce that no two tasks from the same service share an instance, reducing the blast radius of an instance failure.
Fine-Tuning CPU Shares and cgroup Limits
ECS allows setting relative CPU shares (cpu in task definition) and hard limits via ulimits. For multi-container tasks, allocate shares proportionally to each container's workload profile.
{
"containerDefinitions": [
{
"name": "nginx-proxy",
"image": "nginx:alpine",
"cpu": 128, // 128 CPU units (12.8% of a vCPU on Fargate)
"memory": 128,
"essential": true
},
{
"name": "app-server",
"image": "app:latest",
"cpu": 768, // 768 units — gets 6x the CPU time of nginx
"memory": 1024,
"essential": true,
"ulimits": [
{ "name": "nofile", "softLimit": 65535, "hardLimit": 65535 }
]
}
]
}
Adjust ulimits for connection-heavy services (proxies, databases) to prevent "too many open files" errors under load.
Container-Level Health Checks
ECS supports Docker health checks that report container readiness separately from process liveness. Use them to prevent traffic from reaching containers still warming up caches or establishing database connections.
{
"containerDefinitions": [{
"name": "java-app",
"image": "java-app:4.1",
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 60 // Grace period for JVM warm-up
},
"startTimeout": 120,
"stopTimeout": 30
}]
}
The startPeriod gives JVM-based applications time to compile bytecode and warm connection pools before health checks begin failing the container.
Service Auto-Scaling with Target Tracking
Replace static DesiredCount with Application Auto-Scaling policies that track real utilization metrics. Target tracking on CPUUtilization or MemoryUtilization is simplest; custom CloudWatch metrics (request count, queue depth) work for event-driven workloads.
# CloudFormation: Auto-scaling based on CPU target tracking
MyScalableTarget:
Type: AWS::ApplicationAutoScaling::ScalableTarget
Properties:
MaxCapacity: 10
MinCapacity: 2
ResourceId: !Sub 'service/${MyCluster.Name}/${MyService.Name}'
ScalableDimension: ecs:service:DesiredCount
ServiceNamespace: ecs
MyScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyType: TargetTrackingScaling
TargetTrackingScalingPolicyConfiguration:
TargetValue: 70.0 # Keep CPU at 70%
ScaleInCooldown: 300
ScaleOutCooldown: 60
PredefinedMetricSpecification:
PredefinedMetricType: ECSServiceAverageCPUUtilization
Set shorter ScaleOutCooldown (60s) than ScaleInCooldown (300s) to respond quickly to spikes while scaling down conservatively to avoid flapping.
Service Discovery with Cloud Map and Graceful Drain
ECS integrates with AWS Cloud Map for DNS-based service discovery. This allows services to locate each other without hardcoded endpoints. Combine with deregistration delay on your ALB target groups to ensure in-flight requests complete before containers are killed.
# Enable service discovery on ECS service creation
aws ecs create-service \
--cluster production \
--service-name payment-service \
--task-definition payment:2 \
--service-registries registryArn="arn:aws:servicediscovery:..." \
--desired-count 3
# Terraform: ALB target group with graceful drain
resource "aws_lb_target_group" "ecs" {
name = "app-tg"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
deregistration_delay = 300 # 5 minutes for in-flight requests
health_check {
path = "/health"
interval = 30
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
matcher = "200-299"
}
}
Monitoring and Continuous Tuning
Enable Container Insights for detailed per-container CPU, memory, network, and disk metrics. Use these to validate your right-sizing decisions and detect anomalies before they become incidents.
# Enable Container Insights on an existing cluster
aws ecs update-cluster-settings \
--cluster my-cluster \
--settings name=containerInsights,value=enabled
# Query CloudWatch Logs Insights for p99 response times
# (requires app emitting structured logs with duration_ms field)
fields @timestamp, duration_ms
| filter @message like /POST \/checkout/
| stats pct(duration_ms, 99) as p99_latency by bin(1h)
| sort @timestamp desc
Performance Best Practices Summary
- Profile your workload before sizing — run load tests, capture CPU/memory peaks, and add a 15-20% buffer, not 100%.
- Use binpack for batch jobs, spread for APIs — optimize for density or availability based on the workload's fault tolerance.
- Implement container health checks with startPeriod — prevents premature traffic during warm-up.
- Configure asymmetric scale-in/scale-out cooldowns — fast up, slow down to avoid oscillation.
- Enable Container Insights and build dashboards — visibility into per-container performance is essential for root cause analysis.
- Tune connection limits and file descriptors — ulimits and kernel parameter tweaks prevent "no file descriptors available" at peak concurrency.
Bringing It All Together: A Production-Ready Template
Below is a consolidated example that applies cost, security, and performance best practices in a single CloudFormation stack. It deploys a Fargate service with Spot capacity provider, tight security group rules, secrets injection, health checks, auto-scaling, and Container Insights enabled.
AWSTemplateFormatVersion: '2010-09-09'
Description: Production ECS Service — cost-optimized, hardened, performant
Parameters:
VpcId:
Type: AWS::EC2::VPC::Id
SubnetIds:
Type: List
AlbSecurityGroupId:
Type: AWS::EC2::SecurityGroup::Id
Resources:
# --- Security: Tight per-service security group ---
ServiceSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Restrict ingress to ALB only
VpcId: !Ref VpcId
SecurityGroupIngress:
- SourceSecurityGroupId: !Ref AlbSecurityGroupId
FromPort: 8080
ToPort: 8080
IpProtocol: tcp
Description: Allow from load balancer
# --- Execution role with minimal permissions ---
ExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: ecs-tasks.amazonaws.com }
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub 'arn:aws:iam::${AWS::AccountId}:policy/MinimalECSExecution'
# --- Task role scoped to app needs ---
TaskRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: ecs-tasks.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: AppAccess
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: [s3:GetObject, s3:PutObject]
Resource: !Sub 'arn:aws:s3:::app-bucket-${AWS::AccountId}/*'
# --- Task definition: hardened + performance tuned ---
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: production-web
Cpu: 1024
Memory: 2048
NetworkMode: awsvpc
ExecutionRoleArn: !GetAtt ExecutionRole.Arn
TaskRoleArn: !GetAtt TaskRole.Arn
RuntimePlatform:
OperatingSystemFamily: LINUX
CpuArchitecture: ARM64 # Cost savings: Graviton ~20% cheaper
ContainerDefinitions:
- Name: app
Image: !Sub '${AWS::AccountId}.dkr.ecr.us-east-1.amazonaws.com/app:latest'
Cpu: 768
Memory: 1536
ReadonlyRootFilesystem: true
User: '1000'
LinuxParameters:
Capabilities:
Drop: [ALL]
Add: [NET_BIND_SERVICE]
Secrets:
- Name: DB_PASSWORD
ValueFrom: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/prod/db-password'
HealthCheck:
Command: [CMD-SHELL, curl -f http://localhost:8080/health || exit 1]
Interval: 30
Timeout: 5
Retries: 3
StartPeriod: 60
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref LogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: web
# --- CloudWatch Logs with retention ---
LogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub '/ecs/production-web-${AWS::StackName}'
RetentionInDays: 30 # Cost control: auto-expire logs
# --- Cluster with Container Insights ---
Cluster:
Type: AWS::ECS::Cluster
Properties:
ClusterSettings:
- Name: containerInsights
Value: enabled
CapacityProviders: [FARGATE, FARGATE_SPOT]
DefaultCapacityProviderStrategy:
- CapacityProvider: FARGATE_SPOT
Weight: 1
Base: 0
# --- Service with auto-scaling ---
Service:
Type: AWS::ECS::Service
Properties:
Cluster: !Ref Cluster
TaskDefinition: !Ref TaskDefinition
DesiredCount: 2
LaunchType: FARGATE
NetworkConfiguration:
AwsvpcConfiguration:
Subnets: !Ref SubnetIds
SecurityGroups: [!Ref ServiceSecurityGroup]
AssignPublicIp: DISABLED
ServiceConnect:
Enabled: true
LoadBalancers:
- ContainerName: app
ContainerPort: 8080
TargetGroupArn: !Ref TargetGroup
# --- Auto-scaling resources ---
ScalableTarget:
Type: AWS::ApplicationAutoScaling::ScalableTarget
Properties:
MaxCapacity: 10
MinCapacity: 2
ResourceId: !Sub 'service/${Cluster.Name}/${Service.Name}'
ScalableDimension: ecs:service:DesiredCount
ServiceNamespace: ecs
ScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyType: TargetTrackingScaling
TargetTrackingScalingPolicyConfiguration:
TargetValue: 70
ScaleInCooldown: 300
ScaleOutCooldown: 60
PredefinedMetricSpecification:
PredefinedMetricType: ECSServiceAverageCPUUtilization
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Port: 8080
Protocol: HTTP
VpcId: !Ref VpcId
DeregistrationDelay: 300
HealthCheckEnabled: true
HealthCheckPath: /health
HealthCheckIntervalSeconds: 30
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
TargetType: ip
Conclusion
Operating ECS at production scale demands more than launching containers — it requires a continuous feedback loop across cost, security, and performance dimensions. By choosing the right launch type and pricing model, enforcing least-privilege IAM and network policies, hardening the container runtime, and tuning placement and scaling to actual workload patterns, you build a foundation that is both economical and resilient. The consolidated template above gives you a starting point that embeds these practices. From there, iterate: monitor CloudWatch metrics, review Cost Explorer reports, scan images weekly, and run regular chaos experiments to validate that your scaling and security controls behave as expected under real-world conditions.