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=trueBoth 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.comThe 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.0The 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 IMMUTABLEWith 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 tableScanning
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.