Docs / Use Cases / ECS Docker Containers

ECS Docker Containers

Run real Docker containers from ECS task definitions. Create clusters, register task definitions, run tasks, and see actual containers running on your machine.

What the demo does. Creates a cluster, registers two task definitions (nginx:alpine web tier with a port mapping, plus a small Python worker that loops on stdout), invokes RunTask for both, waits for describe-tasks to report RUNNING, resolves the host port the nginx container was published on via docker port, curls the welcome page back, tails the worker's stdout, then stops both tasks by their full ARN (the one run-task returned), deregisters the task definitions, and deletes the cluster. Docker backend is on by default when Docker is reachable; set ECS_DOCKER_BACKEND=0 to fall back to the metadata-only Moto path. Source: 16-ecs-demo/ in the examples repo.
📦

Containers on your host

Each container in a task definition runs as a Docker container managed by the LocalEmu ECS Docker backend, labeled localemu.service=ecs.

🌐

Port Mappings

Container ports are mapped to random host ports. Access your services via localhost.

Full Lifecycle

Create clusters, register tasks, run, describe, stop, and delete. Complete ECS workflow.

Step-by-Step Walkthrough

Step 1: Start LocalEmu

Terminal
$ localemu start

# Docker backend is on by default. Set ECS_DOCKER_BACKEND=0 to disable.

The Docker backend turns on automatically when LocalEmu can reach a Docker socket. Override with ECS_DOCKER_BACKEND=0 to run ECS metadata-only (no real containers), useful for ARN/lifecycle smoke tests that do not need to actually pull images.

Step 2: Create a cluster

Terminal
$ awsemu ecs create-cluster --cluster-name my-app

cluster:
  clusterArn: arn:aws:ecs:us-east-1:000000000000:cluster/my-app
  clusterName: my-app
  status: ACTIVE
  registeredContainerInstancesCount: 0
  runningTasksCount: 0
  pendingTasksCount: 0
  activeServicesCount: 0

The cluster is created with ACTIVE status. All task state is tracked under this cluster name.

Step 3: Register task definitions

Terminal
$ awsemu ecs register-task-definition \
    --family le-web \
    --container-definitions '[{
      "name": "nginx",
      "image": "nginx:alpine",
      "portMappings": [{"containerPort": 80, "hostPort": 0}],
      "essential": true,
      "memory": 128
    }]'

taskDefinition:
  taskDefinitionArn: arn:aws:ecs:us-east-1:000000000000:task-definition/le-web:1
  family: le-web
  status: ACTIVE

$ awsemu ecs register-task-definition \
    --family le-worker \
    --container-definitions '[{
      "name": "worker",
      "image": "python:3.12-slim",
      "command": ["python", "-c", "import time; [print(f\"tick {i}\", flush=True) or time.sleep(1) for i in range(3600)]"],
      "essential": true,
      "memory": 256
    }]'

taskDefinition:
  taskDefinitionArn: arn:aws:ecs:us-east-1:000000000000:task-definition/le-worker:1
  family: le-worker
  status: ACTIVE

$ awsemu ecs list-task-definitions

taskDefinitionArns:
  - arn:aws:ecs:us-east-1:000000000000:task-definition/le-web:1
  - arn:aws:ecs:us-east-1:000000000000:task-definition/le-worker:1

Two task definitions registered: le-web runs nginx with port 80 exposed, and le-worker runs a Python script. Both use real Docker images.

Step 4: Run tasks

Terminal
$ awsemu ecs run-task --cluster my-app --task-definition le-web

tasks:
  - taskArn: arn:aws:ecs:us-east-1:000000000000:task/my-app/a1b2c3d4e5f6
    clusterArn: arn:aws:ecs:us-east-1:000000000000:cluster/my-app
    taskDefinitionArn: arn:aws:ecs:us-east-1:000000000000:task-definition/le-web:1
    lastStatus: RUNNING
    desiredStatus: RUNNING
    containers:
      - name: nginx
        lastStatus: RUNNING
        networkBindings:
          - containerPort: 80
            hostPort: 56816
            protocol: tcp

$ awsemu ecs run-task --cluster my-app --task-definition le-worker

tasks:
  - taskArn: arn:aws:ecs:us-east-1:000000000000:task/my-app/f7e8d9c0b1a2
    clusterArn: arn:aws:ecs:us-east-1:000000000000:cluster/my-app
    taskDefinitionArn: arn:aws:ecs:us-east-1:000000000000:task-definition/le-worker:1
    lastStatus: RUNNING
    desiredStatus: RUNNING
    containers:
      - name: worker
        lastStatus: RUNNING

Each task starts real Docker containers. The nginx container gets host port 56816 mapped to container port 80. Both tasks show RUNNING status immediately.

Step 5: Verify containers on the host

Terminal
$ docker ps --filter "label=localemu.service=ecs"

CONTAINER ID   IMAGE              COMMAND                   STATUS          PORTS                                     NAMES
3f8a1b2c4d5e   nginx:alpine       "/docker-entrypoint.…"   Up 12 seconds   0.0.0.0:56816->80/tcp                     localemu-ecs-a1b2c3d4e5f6-nginx
7e6d5c4b3a2f   python:3.12-slim   "python -c import t…"    Up 8 seconds                                              localemu-ecs-f7e8d9c0b1a2-worker

$ curl -s http://localhost:56816 | head -4
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

The localemu.service=ecs label is set on every container the ECS Docker backend starts, so a single docker ps --filter covers every cluster, every task, every container. The nginx HTTP response is the upstream nginx:alpine welcome page, served on the host port LocalEmu chose at RunTask time and reported back in networkBindings[].hostPort.

Step 6: Describe and monitor

Terminal
$ awsemu ecs describe-tasks --cluster my-app \
    --tasks a1b2c3d4e5f6 f7e8d9c0b1a2

tasks:
  - taskArn: arn:aws:ecs:us-east-1:000000000000:task/my-app/a1b2c3d4e5f6
    lastStatus: RUNNING
    taskDefinitionArn: arn:aws:ecs:us-east-1:000000000000:task-definition/le-web:1
    containers:
      - name: nginx
        lastStatus: RUNNING
  - taskArn: arn:aws:ecs:us-east-1:000000000000:task/my-app/f7e8d9c0b1a2
    lastStatus: RUNNING
    taskDefinitionArn: arn:aws:ecs:us-east-1:000000000000:task-definition/le-worker:1
    containers:
      - name: worker
        lastStatus: RUNNING

$ awsemu ecs list-tasks --cluster my-app

taskArns:
  - arn:aws:ecs:us-east-1:000000000000:task/my-app/a1b2c3d4e5f6
  - arn:aws:ecs:us-east-1:000000000000:task/my-app/f7e8d9c0b1a2

DescribeTasks shows real container status. ListTasks returns all task ARNs in the cluster. Same API responses as real AWS.

Step 7: Stop and cleanup

Terminal
$ awsemu ecs stop-task --cluster my-app --task a1b2c3d4e5f6

task:
  taskArn: arn:aws:ecs:us-east-1:000000000000:task/my-app/a1b2c3d4e5f6
  lastStatus: STOPPED
  desiredStatus: STOPPED

$ awsemu ecs stop-task --cluster my-app --task f7e8d9c0b1a2

task:
  taskArn: arn:aws:ecs:us-east-1:000000000000:task/my-app/f7e8d9c0b1a2
  lastStatus: STOPPED
  desiredStatus: STOPPED

$ docker ps --filter "label=localemu.service=ecs"

CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
(empty - containers stopped)

$ awsemu ecs delete-cluster --cluster-name my-app

cluster:
  clusterArn: arn:aws:ecs:us-east-1:000000000000:cluster/my-app
  clusterName: my-app
  status: INACTIVE

StopTask kills the Docker containers. DeleteCluster marks the cluster as INACTIVE. No orphaned containers, no leftover resources.

How It Works

Metadata layer

Cluster records, task definitions, and task state are stored in the in-process registry so every Describe/List/Run call returns the same shape AWS returns.

Behavior layer (Docker)

Every container in a task definition runs as a real Docker container. Port mappings, env vars, commands, and exit codes come straight from the runtime, not a stub. This is what actually serves traffic.

Full lifecycle

RunTask starts containers, DescribeTasks shows real status, StopTask kills containers, DeleteCluster cleans everything up.

Supported Operations

  • CreateCluster, DeleteCluster
  • RegisterTaskDefinition, ListTaskDefinitions, DescribeTaskDefinition
  • RunTask with real Docker containers
  • StopTask with real container shutdown
  • DescribeTasks with real container status
  • ListTasks
  • Port mappings (random host port assignment)
  • Environment variables from task definition
  • Custom commands per container
  • Container labels for identification