Docs / ECS

ECS

LocalEmu ECS implements all 64 operations. Clusters, task definitions (immutable + revision-numbered), services, container instances, capacity providers, task sets, and account settings are stored in the moto-ext metadata backend. Ten operations are LocalEmu-custom and drive a real Docker container per task: RunTask, StopTask, DescribeTasks, CreateCluster, DeleteCluster, RegisterTaskDefinition, DeregisterTaskDefinition, CreateService, UpdateService, DeleteService. Tasks really run, ports really bind, the AWS SDK inside the container really pulls task-role credentials from the link-local 169.254.170.2 endpoint, and DescribeTasks reflects live Docker state.

Operation-level coverage: see the ECS coverage matrix.

Quick start

Terminal
$ awsemu ecs create-cluster --cluster-name web

$ awsemu ecs register-task-definition --family hello \
    --network-mode bridge \
    --container-definitions '[{
      "name":         "hello",
      "image":        "nginx:1.27-alpine",
      "essential":    true,
      "portMappings": [{"containerPort":80, "protocol":"tcp"}],
      "memory":       128,
      "environment":  [{"Name":"GREETING","Value":"Hello from ECS"}]
    }]' --query 'taskDefinition.taskDefinitionArn' --output text
arn:aws:ecs:us-east-1:000000000000:task-definition/hello:1

$ TASK_ARN=$(awsemu ecs run-task --cluster web --task-definition hello \
    --launch-type FARGATE \
    --query 'tasks[0].taskArn' --output text)

$ awsemu ecs describe-tasks --cluster web --tasks $TASK_ARN \
    --query 'tasks[0].{Status:lastStatus,Health:healthStatus,Port:containers[0].networkBindings[0].hostPort}'
{"Status": "RUNNING", "Health": "UNKNOWN", "Port": 54321}

# The container is really running. Hit the host-mapped port and you get nginx.
$ curl -s http://127.0.0.1:54321/ | head -1
<!DOCTYPE html>

The Docker backend is on by default when a Docker daemon is reachable: set ECS_DOCKER_BACKEND=0 only if you want metadata-only behavior (API responses without real containers). This polarity is the opposite of MQ and MSK, where the Docker backend is opt-in.

Architecture

Code lives at services/ecs/. The entry point is create_ecs_service() at provider.py:1155, dispatched through EcsDispatcher (provider.py:856) which intercepts the 10 custom ops and proxies everything else to moto.

FileRole
provider.pyOp handlers, dispatch, lifecycle hooks, persistence reconciliation.
docker/task_manager.pyDockerTaskManager singleton: pulls images, builds ContainerConfiguration from a task definition, runs the container, tracks live status.
docker/task_credentials.pyLocal HTTP server that serves IAM task-role credentials at /v2/credentials/<task_id>, /v3,/v4/<task_id>.
plugins.pyShutdown hooks: PERSISTENCE=1 stops containers without removing them; otherwise cleans up.

Launch types and network modes

TaskDefinition to Docker translation

ECS fieldDocker translation
imagedocker pull if not present, then run.
portMappings[]Host port allocated via get_free_tcp_port() and mapped to the declared containerPort. Returned in DescribeTasks.containers[].networkBindings[].
environment[]Plus the LocalEmu-injected vars: AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, ECS_CONTAINER_METADATA_URI_V4.
commandshlex.split() into argv.
healthCheckDocker --health-cmd / --health-interval / --health-timeout / --health-retries / --health-start-period.
cpu (ECS units)cpu_shares 1:1.
memory / memoryReservation (MiB)mem_limit.
mountPoints[] + volumes[] with host.sourcePathBind mounts with readOnly honored.

Multi-container task definitions are fully supported: every container in containerDefinitions[] is started, not just the first.

IAM task role: real credentials at 169.254.170.2

When a task definition has taskRoleArn, LocalEmu issues real STS temporary credentials and exposes them to the container through the same link-local endpoint the boto3 SDK expects in production ECS. Implementation (docker/task_manager.py:283-560):

  1. STS assumes the task role and stores the result in TaskCredentialStore keyed by task id.
  2. A local HTTP server (docker/task_credentials.py:56-95) listens on a host port and serves /v2/credentials/<task_id> and /v3,/v4/<task_id>.
  3. The container is started with cap_add=["NET_ADMIN"] and an iptables DNAT rule is installed inside the container's netns: 169.254.170.2:80host.docker.internal:<creds_port>.
  4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/<task_id> is injected as an env var.
  5. Result: any AWS SDK inside the container resolves credentials at http://169.254.170.2/v2/credentials/<task_id>, which boto3 v1.34+ requires as the exact host (it refuses arbitrary metadata IPs).
Terminal
# The task definition asks for an IAM role. Inside the container, the AWS SDK
# picks up real STS-issued temporary credentials at 169.254.170.2.
$ awsemu ecs register-task-definition --family worker \
    --network-mode awsvpc \
    --task-role-arn arn:aws:iam::000000000000:role/EcsTaskRole \
    --execution-role-arn arn:aws:iam::000000000000:role/EcsExecutionRole \
    --container-definitions '[{
      "name":      "boto",
      "image":     "amazon/aws-cli:2.17.0",
      "essential": true,
      "command":   ["s3","ls"]
    }]' >/dev/null

$ TASK_ARN=$(awsemu ecs run-task --cluster web --task-definition worker \
    --launch-type FARGATE \
    --network-configuration 'awsvpcConfiguration={subnets=["subnet-1"],securityGroups=["sg-1"]}' \
    --query 'tasks[0].taskArn' --output text)

# Inside the running container, the SDK fetched creds via 169.254.170.2 and the
# s3 ls call succeeded against LocalEmu (signed with the task-role credentials).
$ docker logs $(docker ps -q --filter "name=<localemu-ecs-...>") | head -1
2026-05-20 18:00:00 my-bucket

awsvpc network attachment

Terminal
# awsvpc mode attaches the task container to the real Docker network
# that LocalEmu maintains for the subnet's VPC.
$ awsemu ec2 create-vpc --cidr-block 10.0.0.0/16 \
    --query 'Vpc.VpcId' --output text
vpc-abc123
$ awsemu ec2 create-subnet --vpc-id vpc-abc123 --cidr-block 10.0.1.0/24 \
    --query 'Subnet.SubnetId' --output text
subnet-def456

$ awsemu ecs run-task --cluster web --task-definition worker \
    --launch-type FARGATE \
    --network-configuration 'awsvpcConfiguration={subnets=["subnet-def456"],securityGroups=["sg-1"]}' >/dev/null

# The Docker network "localemu-vpc-vpc-abc123" now has the task container attached
# and the container can reach other tasks on the same VPC by their docker DNS name.
$ docker network inspect localemu-vpc-vpc-abc123 \
    --format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}'
localemu-ecs-<task-id>

Services and desiredCount reconciliation

CreateService spawns desiredCount tasks immediately. UpdateService reconciles the live count: scale up spawns new containers, scale down stops and removes the excess. DeleteService stops and removes every container the service tracks.

Terminal
$ awsemu ecs create-service \
    --cluster web --service-name web-svc \
    --task-definition hello --desired-count 3 \
    --launch-type FARGATE \
    --query 'service.{Status:status,Desired:desiredCount,Running:runningCount}'
{"Status": "ACTIVE", "Desired": 3, "Running": 3}

# Three real containers were spawned and are tracked under the service.
$ awsemu ecs list-tasks --cluster web --service-name web-svc \
    --query 'length(taskArns)'
3

# Scale up: two more containers will be spawned.
$ awsemu ecs update-service --cluster web --service web-svc \
    --desired-count 5 --query 'service.runningCount'
5

# Scale down: excess containers are stopped + removed.
$ awsemu ecs update-service --cluster web --service web-svc \
    --desired-count 1 --query 'service.runningCount'
1

Features supported

FeatureNotes
Cluster lifecycleCreateCluster (synthetic EC2 instance for EC2 launch type), DeleteCluster (cleans up all running tasks).
Task definitionsImmutable, revision-numbered. Multi-container, port mappings, env vars, command, health checks, cpu/mem, host bind mounts.
Task executionReal Docker container per task. PROVISIONING → RUNNING → STOPPED with timing fields. DescribeTasks enriched from live Docker inspect (lastStatus, exitCode, stoppedReason).
ServicesReal desiredCount reconciliation on CreateService / UpdateService. Service-owned task ARNs tracked in process.
IAM task roleReal STS credentials at 169.254.170.2 via iptables DNAT + cap_add=NET_ADMIN.
Networkingawsvpc (real Docker network attachment), bridge, host, none.
Launch typesFARGATE + EC2.
Persistencemoto cluster/service/task records survive PERSISTENCE=1 restart; on_after_state_load reconciles persisted tasks with live Docker (docker start if container exists, else logs data loss).

End-to-end test scenarios

tests/e2e/docker_emulation/test_ecs.py covers:

Configuration

VariableDefaultPurpose
ECS_DOCKER_BACKENDenabled (when Docker is reachable)Set to 0 to disable the Docker runtime. Tasks become metadata-only records with no containers. Read at provider.py:59.
Terminal
# Set ECS_DOCKER_BACKEND=0 if you only want moto control-plane records and
# no actual containers (faster, no Docker daemon required).
$ ECS_DOCKER_BACKEND=0 localemu start

$ awsemu ecs run-task --cluster web --task-definition hello \
    --query 'tasks[0].lastStatus' --output text
RUNNING

# lastStatus is RUNNING per moto, but no Docker container was started.
$ docker ps --filter "label=localemu.service=ecs" --quiet
(empty)

Integration points

ServiceHow it touches ECS
EC2 / VPCawsvpc mode resolves subnet → VPC via the EC2 backend and attaches the task container to localemu-vpc-<vpc_id>. Synthetic container instance per cluster.
IAM / STSTask role assumed for real; credentials served at 169.254.170.2 in-container.
ECRStandard docker pull against any registry the host can reach.
CloudFormationAWS::ECS::Cluster, AWS::ECS::TaskDefinition, AWS::ECS::Service resource providers route through this provider.
CloudTrailAll ECS API calls appear in the CloudTrail event store and the dashboard.

Known limitations