← Back to DevBytes

ECS Best Practices: Cost, Security, and Performance

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

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

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles