AWS – ECR: The Registry Your Containers Come From

January 30, 20244 min readUpdated 8/24/2026

Every container you run on ECS, EKS, App Runner or Lambda is pulled from a registry. On AWS that registry is ECR, and it is the piece people skip past on the way to the interesting services — right up until a deploy fails because an image cannot be pulled.

Private and public

ECR has two halves. Private registries are the default: one per account per region, IAM-controlled, and what you push your own images to. Public ECR is the gallery at public.ecr.aws, which is where AWS's own base images live and a useful alternative to Docker Hub's rate limits.

A private repository holds one image's tags — stayhub-api holds 1.4.0, 1.4.1 and so on. You create a repository per image, not per project.

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

Both of those flags matter, and both default the other way. They are covered below.

Logging in, and the token that expires

ECR does not take a password. You exchange your AWS credentials for a token that is valid for twelve hours, and hand that to Docker:

aws ecr get-login-password --region us-west-2 \
  | docker login --username AWS --password-stdin 111122223333.dkr.ecr.us-west-2.amazonaws.com

The username is literally AWS for every account. Note the pipe: passing the token as --password would put it in your shell history and in the process list, and Docker warns about exactly that.

The twelve-hour expiry is the practical consequence. A developer laptop needs a fresh login about once a day, and any build script that runs longer than that — or a long-lived agent — has to re-authenticate rather than assume a login persists. In CI it is simply the first step of every build.

Pushing

REGISTRY=111122223333.dkr.ecr.us-west-2.amazonaws.com

docker build -t stayhub-api:1.4.0 .
docker tag stayhub-api:1.4.0 $REGISTRY/stayhub-api:1.4.0
docker push $REGISTRY/stayhub-api:1.4.0

The registry hostname is part of the tag — that is how Docker knows where to push. A docker push that tries to contact Docker Hub is a tag missing its registry prefix.

Build for the architecture you will run on. An image built on an Apple Silicon laptop is arm64, and deploying it to an x86 ECS task fails at runtime with an exec format error rather than at push. Pass --platform linux/amd64, or match your compute to your laptop and run Graviton.

Immutable tags

The single most valuable setting here, and it is off by default.

With mutable tags — the default — pushing 1.4.0 twice overwrites the first image. Two deploys of "the same version" then run different code, and nothing anywhere records that. It is the same failure as :latest, just wearing a version number.

aws ecr put-image-tag-mutability \
  --repository-name stayhub-api --image-tag-mutability IMMUTABLE

With IMMUTABLE, a second push to an existing tag is rejected. A tag then means exactly one image forever, which is what makes "roll back to 1.3.9" a meaningful instruction.

Tag with the git commit as well as the version. The commit is the one identifier that is unambiguous, and it turns "what is running in production" into a question with an answer.

Lifecycle policies, or you pay for 2019 forever

ECR charges per GB-month of storage, and nothing is ever deleted on its own. A repository built by CI accumulates an image per commit, indefinitely, and the bill grows quietly because no single image is large.

aws ecr put-lifecycle-policy --repository-name stayhub-api --lifecycle-policy-text '{
  "rules": [
    {
      "rulePriority": 1,
      "description": "keep the last 10 released images",
      "selection": {
        "tagStatus": "tagged",
        "tagPrefixList": ["v"],
        "countType": "imageCountMoreThan",
        "countNumber": 10
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "untagged images are build debris",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 7
      },
      "action": { "type": "expire" }
    }
  ]
}'

Rules are evaluated by priority, and the first matching rule wins — so a broad rule with a low priority number shadows every specific rule after it. Put the narrow rules first.

Untagged images are the ones people forget. Every time you push a tag that already existed under mutable tags, the previous image loses its tag and becomes untagged — still stored, still billed, and invisible in the console's default view.

Test a policy before it deletes anything:

aws ecr start-lifecycle-policy-preview --repository-name stayhub-api
aws ecr get-lifecycle-policy-preview --repository-name stayhub-api \
  --query "previewResults[].[imageTags[0],action.type]" --output table

Scanning

Basic scanning checks your image against the CVE database on push, at no cost. Enhanced scanning uses Amazon Inspector, continuously rescans images already in the registry, and looks inside application dependencies as well as OS packages — for a per-image charge.

aws ecr describe-image-scan-findings \
  --repository-name stayhub-api --image-id imageTag=1.4.0 \
  --query "imageScanFindings.findingSeverityCounts"

Turn basic scanning on for everything; it is free and occasionally tells you something urgent. The thing worth understanding is that a finding is usually in your base image rather than your code, so the fix is normally rebuilding on a current base rather than changing anything you wrote.

The pull that fails from a private subnet

The failure that costs an afternoon. A task in a private subnet cannot pull its image, and the error mentions a timeout rather than networking.

ECR is a public API endpoint, so a private subnet needs a route to it. There are two options and the second is usually right:

  • A NAT gateway — works, and charges per hour plus per GB. Container images are large and pulled often, so this line item is real
  • VPC endpoints — traffic stays on the AWS network, and it is cheaper

The catch is that ECR needs three endpoints, not one, and missing the third is the usual cause of a half-working setup: interface endpoints for ecr.api and ecr.dkr, plus an S3 gateway endpoint — because the image layers themselves are stored in S3 and are fetched separately from the registry metadata.

Add CloudWatch Logs to that list if your tasks log anywhere, for the same reason.