open atlas
↑ Back to track
AWS, hands-on AWS · 02 · 02

Deploy a container end to end

Build the image, push it to ECR, then run it — either an ECS/Fargate service behind an ALB or a managed App Runner service. The senior details are the two roles, the health check, and the arm64/amd64 trap.

AWS Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The image builds on your laptop, runs locally, and you push it. Minutes later the ECS service is stuck: desired count 1, running count 0, tasks flapping with exec format error. The image is fine — it is just the wrong CPU. You built it on an Apple Silicon Mac (arm64) and the Fargate task is amd64. Nothing in docker push warns you; the mismatch only surfaces when the container tries to run. End-to-end deploy is exactly this: every hop — build, push, run, route traffic — is a place the happy path quietly breaks.

Build the image and push it to ECR

Why does this matter before you touch ECS or App Runner? Because every failure mode in the deploy pipeline is silent until runtime — nothing in the toolchain will stop you from shipping the wrong architecture, pushing to a non-existent repo, or authenticating with an expired token. Knowing each hop’s quiet failure mode is what separates a first deploy from a reliable CI pipeline.

ECR (Elastic Container Registry) is AWS’s private Docker registry. Before you can docker push, you authenticate: aws ecr get-login-password mints a short-lived token that you pipe into docker login. Then you tag the local image with the registry’s fully-qualified name and push.

# 1. Authenticate Docker to your private ECR registry (token is valid ~12h)
aws ecr get-login-password --region eu-central-1 \
  | docker login --username AWS \
      --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

# 2. Build for the architecture the runtime expects — this is the trap.
#    On an Apple Silicon Mac, --platform is mandatory or you ship arm64 to amd64 Fargate.
docker build --platform linux/amd64 -t myapp:1.0 .

# 3. Tag with the full ECR repo URI, then push
docker tag myapp:1.0 123456789012.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.0
docker push   123456789012.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.0

Two things bite people here. First, the repository must already existaws ecr create-repository --repository-name myapppush does not create it. Second, the architecture: an image is built for a specific CPU, and docker push happily uploads an arm64 image to a repo that an amd64 task will pull. The failure is deferred to runtime as exec format error. Build with an explicit --platform, or use docker buildx to produce a multi-arch manifest, and pick a Fargate runtimePlatform (X86_64 or ARM64) that matches.

HopCommand / objectSilent failure if you skip it
Authenticateecr get-login-password | docker loginno basic auth credentials on push
Builddocker build —platform linux/amd64arm64 image → exec format error at run
Repo existsecr create-repositoryname unknown / repository does not exist
Pushdocker tag && docker push <uri>pushing the bare tag, not the registry URI

Together these four hops form a single failure chain: skip any one of them and the error you get is silent until the container tries to run. The architecture mismatch is the nastiest because it passes every validation step before the kernel refuses the binary.

Route A — ECS on Fargate behind an ALB

Fargate runs your container without you managing servers, but you assemble several objects yourself. The task definition is the blueprint: which image, how much CPU/memory, which port, what roles, where logs go.

{
  "family": "myapp",
  "requiresCompatibilities": ["FARGATE"],
  "networkMode": "awsvpc",
  "cpu": "512",
  "memory": "1024",
  "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "taskRoleArn":      "arn:aws:iam::123456789012:role/myappTaskRole",
  "containerDefinitions": [
    {
      "name": "myapp",
      "image": "123456789012.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.0",
      "portMappings": [ { "containerPort": 8080, "protocol": "tcp" } ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/myapp",
          "awslogs-region": "eu-central-1",
          "awslogs-stream-prefix": "myapp"
        }
      }
    }
  ]
}

The two roles are the most-confused part of ECS, and they are not interchangeable:

  • Execution role (executionRoleArn) — used by the ECS agent at launch, before your code runs, to pull the image from ECR and write logs to CloudWatch. The AWS-managed AmazonECSTaskExecutionRolePolicy covers exactly this. If it is missing, the task never starts: you see CannotPullContainerError or no log group.
  • Task role (taskRoleArn) — the identity your application code assumes at runtime. This is what your SDK calls use to read an S3 bucket, a DynamoDB table, or a secret. Scope it least-privilege to the few actions the app actually needs; it has nothing to do with pulling the image.

Then a service keeps N copies running and ties them to a load balancer:

aws ecs create-service \
  --cluster prod \
  --service-name myapp \
  --task-definition myapp \
  --desired-count 2 \
  --launch-type FARGATE \
  --network-configuration 'awsvpcConfiguration={
      subnets=[subnet-aaa,subnet-bbb],
      securityGroups=[sg-app],
      assignPublicIp=DISABLED }' \
  --load-balancers 'targetGroupArn=arn:...:targetgroup/myapp/abc,
      containerName=myapp,containerPort=8080'

Spread the subnets across two Availability Zones so an AZ outage does not take you fully down. An Application Load Balancer sits in front with a target group whose health check probes a path (e.g. GET /healthz). The ALB only sends traffic to targets that pass, and rolling deploys lean on this: ECS starts new-version tasks, waits for them to pass the health check, registers them, then drains and stops old tasks. A health check that is too strict, too fast, or points at a path your app does not serve will make every deploy roll back — the new tasks “fail,” ECS keeps the old ones, and you wonder why your change never shipped.

Route B — App Runner: the managed shortcut

If you do not need the control of ECS, App Runner collapses most of route A into one service. You point it at the ECR image and it provisions the load balancer, TLS certificate, autoscaling, and a public HTTPS URL for you — no task definition JSON, no target group, no subnets to wire.

aws apprunner create-service \
  --service-name myapp \
  --source-configuration '{
    "ImageRepository": {
      "ImageIdentifier": "123456789012.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.0",
      "ImageRepositoryType": "ECR",
      "ImageConfiguration": { "Port": "8080" }
    },
    "AutoDeploymentsEnabled": true,
    "AuthenticationConfiguration": {
      "AccessRoleArn": "arn:aws:iam::123456789012:role/AppRunnerECRAccessRole"
    }
  }' \
  --health-check-configuration '{ "Protocol": "HTTP", "Path": "/healthz" }'

App Runner still has the same two-identity idea: an access role to pull from ECR (the equivalent of the execution role) and an optional instance role for your app’s own AWS calls (the task role). It still runs a health check. And it still cares about architecture — App Runner runs the image as-is, so an arm64 image will not start. The trade is control for convenience: you give up fine-grained networking, custom load-balancer rules, and per-task placement, and you get a working HTTPS service in one call. Reach for ECS/Fargate when you need VPC integration, sidecars, or precise scaling; reach for App Runner when you just want a container on the internet.

Why this works

Why does the execution role pull the image and not the task role? Because the pull happens before your container exists — the ECS agent (AWS infrastructure) needs ECR and CloudWatch permission to even start the task. The task role is assumed inside the running container by your code via the SDK. Conflating them is the classic ECS bug: people put S3 permissions on the execution role (so the agent could read S3, but the app cannot) and wonder why their code gets AccessDenied while the task starts fine.

Quiz

An ECS/Fargate task crashes immediately with 'exec format error' and never serves a request. Most likely cause?

Quiz

Your container starts fine, but its code gets AccessDenied calling s3:GetObject. The task definition has both roles. Where do the S3 permissions belong?

Recall before you leave
  1. 01
    Walk the full path from a local Docker image to live traffic on ECS/Fargate behind an ALB.
  2. 02
    Explain the execution role vs the task role, and the arm64/amd64 trap — why each one fails silently until runtime.
Recap

A container deploy on AWS is four hops, and each hop hides a quiet failure. You build the image — for the right CPU, because docker push will ship an arm64 image to an amd64 task and you only learn at runtime via exec format error. You push to ECR after creating the repo and authenticating with ecr get-login-password into docker login, tagging with the full registry URI. Then you run it. On ECS/Fargate you assemble a task definition (image, cpu/memory, port, runtimePlatform, log config to CloudWatch via the awslogs driver) with two distinct roles — the execution role the ECS agent uses to pull the image and write logs, and the least-privilege task role your code assumes for its own AWS calls — then a service with a desired count, subnets across two AZs, a security group, and an ALB target group whose health check gates traffic and drives rolling deploys. App Runner collapses the load balancer, TLS, autoscaling, and public URL into one create-service call pointed at the ECR image; it keeps the same access-role/instance-role split and still cares about architecture. The recurring lesson: build for the right arch, separate the two roles, and get the health check right — those three are where end-to-end deploys break. Now when you see a deploy stuck at desired count 1 / running count 0, you know to check exec format error first, then the execution role, then the health check path — in that order, before reaching for any other tool.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.