How to Deploy a Docker Container to AWS: ECS, Fargate and EC2

How to Deploy a Docker Container to AWS: ECS, Fargate and EC2

Reading time1 min
#aws#docker#ecs#fargate#ec2

How to Deploy a Docker Container to AWS: ECS, Fargate and EC2

AWS has several services that run Docker images. They differ mostly in how much infrastructure you manage yourself. This guide helps you pick one, then walks through the three paths most teams use: ECS Express Mode, ECS on Fargate set up step by step, and Docker on a single EC2 instance.

Which AWS service runs Docker containers

  • Amazon ECS on Fargate. The default choice for most web services and workers. You define a task (image, CPU, memory, ports) and AWS runs it with no servers to patch.
  • ECS Express Mode. Released in late 2025. One call creates a Fargate service with a load balancer, HTTPS endpoint and autoscaling.
  • ECS on EC2 instances. Same ECS model, but on instances you choose. Useful for GPUs, specific instance types, or steady load where reserved capacity is cheaper.
  • Amazon EKS. Managed Kubernetes. Choose it when you want Kubernetes itself, not just a place to run containers.
  • A single EC2 instance with Docker. The smallest step up from running the container locally. You own patching, restarts and scaling.
  • Lightsail container services and Elastic Beanstalk's Docker platform. Simpler packaged options with less control.
  • Lambda container images. For event-driven functions, not long-running servers.

Two services from older guides: App Runner stopped accepting new customers on April 30, 2026, and AWS points to ECS Express Mode instead. The Copilot CLI reached end of support on June 12, 2026.

Step 1: push the image to Amazon ECR

Every option below pulls the image from a registry. ECR is the natural one on AWS.

export AWS_REGION=eu-central-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGISTRY=$ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com

aws ecr create-repository --repository-name my-app \
  --image-scanning-configuration scanOnPush=true \
  --image-tag-mutability IMMUTABLE

aws ecr get-login-password | docker login --username AWS --password-stdin $REGISTRY

docker build --platform linux/amd64 -t $REGISTRY/my-app:1.0.0 .
docker push $REGISTRY/my-app:1.0.0

--platform linux/amd64 matters if you build on an Apple Silicon Mac. Without it, an arm64 image fails on x86 tasks with exec format error. The alternative is running the tasks on ARM64 (Graviton). Immutable tags mean 1.0.0 always refers to the same image, so a rollback really goes back to the old code. The ECR login token expires after 12 hours.

Option A: ECS Express Mode

aws ecs create-express-gateway-service \
  --service-name my-app \
  --primary-container "image=$REGISTRY/my-app:1.0.0,containerPort=8080" \
  --execution-role-arn arn:aws:iam::$ACCOUNT_ID:role/ecsTaskExecutionRole \
  --infrastructure-role-arn arn:aws:iam::$ACCOUNT_ID:role/my-ecs-infrastructure-role \
  --health-check-path /healthz

ECS creates the Fargate service, an Application Load Balancer with a certificate and an AWS-provided URL, security groups and autoscaling in your account. Set the port and health check path explicitly, because the defaults are port 80 and /ping. Tasks default to 0.25 vCPU and 512 MiB (--cpu, --memory to change). The execution role lets ECS pull from ECR and write logs. The infrastructure role lets ECS create the load balancer and networking. The console can create both roles for you. aws ecs delete-express-gateway-service removes everything Express Mode created.

Option B: ECS on Fargate, step by step

Use this when you need full control over networking, the load balancer or the deployment strategy, or when you manage infrastructure as code.

Create a cluster and a log group:

aws ecs create-cluster --cluster-name my-cluster
aws logs create-log-group --log-group-name /ecs/my-app

Write the task definition, taskdef.json:

{
  "family": "my-app",
  "requiresCompatibilities": ["FARGATE"],
  "networkMode": "awsvpc",
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
  "containerDefinitions": [
    {
      "name": "my-app",
      "image": "123456789012.dkr.ecr.eu-central-1.amazonaws.com/my-app:1.0.0",
      "essential": true,
      "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
      "secrets": [
        { "name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:eu-central-1:123456789012:secret:my-app/db" }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/my-app",
          "awslogs-region": "eu-central-1",
          "awslogs-stream-prefix": "app"
        }
      }
    }
  ]
}

The execution role (with the AmazonECSTaskExecutionRolePolicy managed policy, plus read access to the secret) is what ECS uses to pull the image, fetch secrets and send logs. If your code calls AWS APIs, give it a separate task role (taskRoleArn). Mixing up these two roles causes a lot of failed first deployments.

Register it and create a service behind an existing ALB target group:

aws ecs register-task-definition --cli-input-json file://taskdef.json

aws ecs create-service \
  --cluster my-cluster \
  --service-name my-app \
  --task-definition my-app \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-0aaa1111,subnet-0bbb2222],securityGroups=[sg-0ccc3333],assignPublicIp=DISABLED}" \
  --load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:eu-central-1:123456789012:targetgroup/my-app/0123456789abcdef,containerName=my-app,containerPort=8080" \
  --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}"

Things that commonly go wrong:

  • The target group must use target type ip. Fargate tasks have their own network interfaces, not instance IDs.
  • Tasks in private subnets need a NAT gateway or VPC endpoints for ECR (ecr.api, ecr.dkr, an S3 gateway endpoint) and CloudWatch Logs. Without them you get CannotPullContainerError. In a public subnet, set assignPublicIp=ENABLED instead.
  • The task security group must allow the container port from the load balancer's security group.

Deploying a new version. Push a new tag, register a new revision of the task definition with that image, and point the service at it:

aws ecs register-task-definition --cli-input-json file://taskdef.json   # with image my-app:1.1.0
aws ecs update-service --cluster my-cluster --service my-app --task-definition my-app
aws ecs wait services-stable --cluster my-cluster --services my-app

ECS does a rolling update by default. The circuit breaker rolls back automatically if new tasks keep failing health checks. ECS also has built-in blue/green deployments with two target groups, which used to require CodeDeploy. For cost, Fargate Spot can run interruptible workloads at a discount, and Application Auto Scaling can scale the task count on CPU or request count.

Option C: Docker on a single EC2 instance

Good for internal tools, demos and low-traffic apps. Launch an Amazon Linux 2023 instance with an instance profile that includes AmazonEC2ContainerRegistryReadOnly and AmazonSSMManagedInstanceCore, then pass this as user data:

#!/bin/bash
dnf install -y docker
systemctl enable --now docker
usermod -aG docker ec2-user

REGION=eu-central-1
REGISTRY=123456789012.dkr.ecr.$REGION.amazonaws.com
aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $REGISTRY
docker run -d --name my-app --restart unless-stopped -p 80:8080 $REGISTRY/my-app:1.0.0

The instance profile replaces access keys on the instance. --restart unless-stopped brings the container back after a crash or reboot. Open only ports 80 and 443 in the security group and use Session Manager instead of opening SSH. Amazon Linux's own docker package does not include the Compose plugin. If you want docker compose, an Ubuntu AMI with Docker's official repository is the simpler path.

To deploy a new version, connect, pull the new tag, then docker rm -f my-app and run the new one. That means a few seconds of downtime. Once that becomes a problem, or you need more than one instance, move to ECS.

Before going to production

  • Keep secrets in Secrets Manager or SSM Parameter Store and reference them from the task definition, not in the image or plain environment variables.
  • Define the cluster, service, roles and load balancer in Terraform, CloudFormation or CDK.
  • Deploy from CI. GitHub Actions can assume an AWS role through OIDC and use the official ECR login and ECS deploy actions, so no long-lived keys are needed.
  • Watch CloudWatch logs and the service's deployment events. They explain most failed rollouts.

Quick reference

aws ecr get-login-password | docker login --username AWS --password-stdin $REGISTRY
docker build --platform linux/amd64 -t $REGISTRY/my-app:TAG . && docker push $REGISTRY/my-app:TAG

# Fastest: ECS Express Mode
aws ecs create-express-gateway-service --primary-container "image=...,containerPort=8080" \
  --execution-role-arn ... --infrastructure-role-arn ...

# Full control: ECS on Fargate
aws ecs register-task-definition --cli-input-json file://taskdef.json
aws ecs create-service ... --launch-type FARGATE
aws ecs update-service --cluster my-cluster --service my-app --task-definition my-app

# Single host: EC2 + Docker
docker run -d --restart unless-stopped -p 80:8080 $REGISTRY/my-app:TAG