AWS Containers

Amazon ECR Hands-On: Push, Pull, Scan & Lifecycle Container Images

You have a container image — a small web app, an API, a batch worker — and something has to run it: ECS, EKS, App Runner, Lambda, an EC2 box. None of those will pull an image from your laptop. They pull from a registry, and if you are on AWS the default, private, deeply-integrated registry is Amazon Elastic Container Registry (ECR). It is the warehouse between “I built an image” and “AWS is running it”: you docker push a tagged image into an ECR repository, AWS stores the layers in S3 behind the scenes, scans them for vulnerabilities, ages out the old ones, and hands them back — authenticated — to whatever compute pulls them.

ECR looks trivial for about ten minutes (create repo, log in, push, pull) and then quietly hands you a stack of decisions that decide whether your supply chain is safe, cheap and reproducible or a slow leak of money and CVEs. Should tags be mutable (so v1.2 can be overwritten) or immutable (so a tag pins one exact image forever)? Should you even trust tags, or pin by digest? How do you stop a build pipeline pushing 4,000 images and racking up storage — a lifecycle policy? Basic scan-on-push or continuous enhanced scanning with Amazon Inspector? How does an ECS task in another account pull your image without you emailing a password around — a repository policy plus IAM? And why does the same image that pulls fine from your laptop fail with CannotPullContainerError the moment it runs in a private subnet?

This article installs the whole mental model by building it. You will create a repository with immutable tags and scan-on-push, authenticate with the 12-hour token (and understand why it is not a static credential), build a tiny image, tag it with the registry URI and push, read the scan findings, attach a lifecycle policy that expires untagged and old images, add a repository policy that lets another account pull, and then pull — all with aws and docker CLI first, then the identical setup as Terraform, then a clean teardown. After that comes the part you will actually return to: a symptom-to-fix troubleshooting playbook for denied: not authorized, expired logins, immutable-tag conflicts, CannotPullContainerError from ECS, cross-account denials, stuck scans, and Docker Hub rate limits (the case for pull-through cache). Read the prose once; keep the tables open when your own push breaks.

What problem this solves

Without a registry, a container image is stuck wherever you built it. Compute platforms are pull-based: the ECS agent, the EKS kubelet, the Lambda control plane and App Runner all fetch the image from a registry URL at launch, using an identity, over the network. So you need a place that is (a) reachable by that compute, (b) access-controlled by IAM rather than a shared Docker Hub password, © durable and versioned, and (d) fast and cheap in-region. That is the job ECR exists to do, and doing it yourself on Docker Hub or a self-hosted registry means running the registry, paying egress, hitting anonymous pull rate limits, and bolting on your own auth and scanning.

What breaks without ECR done right is rarely “the registry is down” — it is subtler and more expensive. A team pushes myapp:latest on every CI run into a mutable repo, so the tag latest silently means a different image every hour; a rollback pulls “the version that was live” and gets something else, because nobody pinned a digest. Storage creeps to hundreds of gigabytes because no lifecycle policy ever deletes the 3,900 dead images from failed builds. A t3 fleet in a private subnet with no NAT and no VPC endpoints can’t reach ECR at all, so every task dies with CannotPullContainerError and the on-call engineer debugs the application for an hour before realising it never started. A partner account can’t pull the shared base image because the repository policy was set but the partner’s IAM wasn’t (cross-account pull needs both). And a Docker Hub base image throttles the whole cluster during a deploy because there is no pull-through cache.

Who hits this: every developer who containerises anything on AWS, every platform team wiring ECS/EKS pipelines, and every security engineer who has to answer “is this image scanned and is that tag reproducible?” ECR is a CLF-C02 foundational service, a DVA-C02 developer staple (build/push/scan in CI), and a SAA-C03 architecture piece (cross-account/cross-Region image distribution). Here is the whole field on one screen — the pieces you meet and the classic trap on each:

Piece What it is You configure it as The classic trap
Registry The per-account, per-Region container store Implicit: <acct>.dkr.ecr.<region>.amazonaws.com Pushing to the wrong Region’s registry
Repository A named bucket of image versions create-repository One giant repo for everything
Image / tag A manifest + layers, labelled by a tag docker tag … <uri>:<tag> latest meaning something different hourly
Digest The immutable sha256: content address <uri>@sha256:… Trusting a tag instead of a digest for rollbacks
Auth token A 12-hour password from get-login-password docker login Treating it as permanent; scripting a stale one
Tag mutability Whether a tag can be overwritten IMMUTABLE / MUTABLE Immutable repo + re-push same tag → hard fail
Lifecycle policy Rules that expire old/untagged images JSON rules[] No policy → unbounded storage cost
Scanning CVE scan: basic on-push or enhanced/Inspector scanOnPush / registry scan config Assuming “scanned” without checking findings
Repository policy Resource policy: who may pull/push JSON on the repo Cross-account pull without the puller’s IAM
Pull-through cache A cache proxy of Docker Hub/Quay/public ECR create-pull-through-cache-rule Direct Docker Hub pulls hitting rate limits

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You need an AWS account with permission to create ECR repositories and IAM roles (a personal or dev/sandbox account — never straight into production), the AWS CLI v2 configured (aws configure or aws sso login), a working Docker (or Finch/nerdctl) engine to build and push images, and for the IaC half Terraform ≥ 1.5. You should be comfortable with basic Docker (build, tag, push) and reading JSON. You do not need prior ECR experience. Everything here is effectively free: private ECR storage is billed per GB-month and this lab stores a few megabytes; the only paise-level line items are storage and (if you enable it) enhanced scanning, both torn down at the end.

Where this sits: ECR is the registry layer beneath every AWS container runtime. Deciding which runtime pulls from it is the upstream question answered in AWS ECS vs EKS vs Fargate: Choose Your Container Path and, one level up, AWS Compute: EC2, Lambda, ECS and EKS — Which One to Choose?. Once you can push an image, the wave sibling AWS ECS on Fargate: Your First Service Hands-On runs one of these images as a service (it references the repo you build here). If you ship functions rather than long-running services, container-image Lambdas pull from ECR too — the zip-vs-layer alternative is covered in AWS Lambda Layers & Packaging: Managing Dependencies the Right Way. A quick map of who owns what, so when a pull fails you look in the right place first:

Layer What lives here Who “owns” it What it can cause
Authentication The 12-h token from GetAuthorizationToken You (IAM) denied / no basic auth credentials
Repository + tags The images, tag mutability, scan config You Immutable-tag conflict, name unknown
Repository policy Resource policy: who may pull/push You (per repo) Cross-account pull denied
IAM identity policy What your principal may do in ECR You (per principal) not authorized to perform: ecr:…
Network path Route to ECR API/DKR + S3 for layers You (VPC) CannotPullContainerError
Consuming runtime ECS/EKS/Lambda execution/node role pull You Task/pod stuck in PENDING

Core concepts

Six ideas make everything later obvious. Read them once; the deep sections expand each.

A registry holds repositories; a repository holds image versions. Your account has exactly one private registry per Region, addressed as <aws_account_id>.dkr.ecr.<region>.amazonaws.com. Inside it you create repositories (kv-web, kv-api) and each repository holds many images, distinguished by tag (v1.4.2, latest) or by immutable digest (sha256:…). You never “create the registry” — it exists the moment your account does; you create repositories in it.

An image is a manifest plus content-addressed layers. A Docker/OCI image is a small JSON manifest that lists layers (the filesystem diffs) by their sha256 digest, plus a config blob. ECR stores each layer once and deduplicates — if two images share a base layer, it is uploaded and stored a single time. This is why the second push of a similar image is fast: only the changed layers upload. Under the hood ECR keeps layer blobs in an AWS-managed S3 bucket, which is why the network path to pull needs S3 reachability too.

Authentication is a short-lived token, not a stored password. You do not put a permanent secret in docker login. You call aws ecr get-login-password, which uses your existing IAM credentials to mint a token valid for 12 hours, and pipe it into docker login with the fixed username AWS. Docker caches that token; when it expires you re-mint it. This is the single biggest conceptual jump for people coming from Docker Hub: ECR access is your IAM identity, wrapped in a temporary token.

Two IAM directions govern every action. Your IAM identity policy (on your user/role) says what you may do — ecr:GetAuthorizationToken, ecr:BatchGetImage, ecr:PutImage. The repository’s resource-based policy says who else (which principals, often in other accounts) may act on this repo. Same-account access usually needs only the identity policy; cross-account access needs the repository policy on the owning side and IAM on the consuming side. Confusing these is the number-one cross-account failure.

Tags move; digests don’t. A tag is a mutable label unless you make the repository immutable. A digest (sha256:…) is the content hash of the exact manifest and never changes. Production systems that care about reproducibility pull by digest (or use immutable tags), because pulling myapp:latest twice can legally return two different images.

ECR governs the image after you push it. Once an image lands, ECR can scan it for CVEs (basic on-push, or enhanced/continuous via Inspector), expire it on a lifecycle policy schedule, replicate it to other Regions/accounts, and serve it — authenticated — to compute. Push is the start of the image’s life in ECR, not the end. Pin the vocabulary before the deep dive:

Term One-line definition Where you set/see it Why it matters early
Private registry Your per-account, per-Region store <acct>.dkr.ecr.<region>.amazonaws.com The URI you tag against
ECR Public The public gallery registry public.ecr.aws/<alias>/<repo> Anonymous, world-readable images
Repository A named set of image versions create-repository Your unit of access + lifecycle
Manifest JSON listing an image’s layers Stored per tag/digest The thing a tag/digest points at
Layer A content-addressed filesystem diff In S3, deduped Why re-pushes are fast
Tag A mutable (or pinned) label docker tag/push Human-friendly version pointer
Digest The sha256: content address describe-images, @sha256: Reproducible, immutable pull
Auth token 12-h password from IAM get-login-password Not a stored secret
Repository policy Resource policy on a repo set-repository-policy Cross-account pull/push
Lifecycle policy Rules that expire images put-lifecycle-policy Controls storage cost
Scan finding A CVE detected in an image describe-image-scan-findings Your vulnerability report
Pull-through cache A cache proxy of an upstream create-pull-through-cache-rule Beats Docker Hub rate limits

Private vs public registries and repositories

ECR comes in two flavours that share a name but differ in almost every property: the private registry you push app images into, and ECR Public, a world-readable gallery like Docker Hub.

Private registry

Every AWS account automatically has one private registry per Region. It is not a resource you create; it is addressed by your account id and Region: 111122223333.dkr.ecr.ap-south-1.amazonaws.com. Images in it are private by default — only principals your IAM/repository policies allow can pull. Repositories are scoped to that registry and Region, so kv-web in ap-south-1 and kv-web in us-east-1 are different repositories in different registries (this is exactly why “it works in Mumbai but not N. Virginia” happens).

ECR Public

ECR Public is a separate service with a single global registry reachable at public.ecr.aws, and a browsable Amazon ECR Public Gallery (gallery.ecr.aws) where AWS, verified vendors and anyone else publish images anyone can pull anonymously (no AWS account needed to pull). You publish to it under an alias: public.ecr.aws/<your-alias>/<repo>:<tag>. Its API lives only in us-east-1 (you authenticate against us-east-1 even when pushing from elsewhere). It exists so you can distribute images publicly without paying Docker Hub or hitting its limits.

Dimension Private ECR ECR Public
Address <acct>.dkr.ecr.<region>.amazonaws.com public.ecr.aws/<alias>/<repo>
Registry scope One per account per Region One global registry
Who can pull Only IAM/repo-policy-allowed principals Anyone, anonymously
Auth to pull docker login with 12-h token None needed (anonymous)
Auth to push aws ecr get-login-password aws ecr-public get-login-password (us-east-1)
Scanning Basic + enhanced (Inspector) Not offered
Lifecycle policies Yes No
Replication Yes (cross-Region/account) N/A (already global)
Typical use Your app images, private base images Open-source distribution, public tools
Free tier 500 MB-month storage (12 mo) 50 GB storage + generous egress always

Repository settings you choose at creation

A repository is more than a name — several settings are easiest to get right at creation time (some can’t be changed later cheaply):

Setting Values Default When to change Gotcha
repositoryName e.g. team/kv-web Namespacing with / Name is permanent; rename = new repo
imageTagMutability MUTABLE / IMMUTABLE MUTABLE Immutable for reproducible tags Immutable rejects re-pushing a tag
imageScanningConfiguration.scanOnPush true / false false On for security gates Basic scan only; enhanced is registry-level
encryptionConfiguration AES256 / KMS AES256 KMS for CMK control/audit KMS choice is set at creation only
tags Key/value none Cost allocation, ownership Repo tags ≠ image tags

Naming with slashes (platform/base-node, apps/kv-web) is purely organisational — ECR has no real folders, but the / in the name lets you scope IAM and lifecycle by prefix, which is genuinely useful at scale.

Authentication: the 12-hour token, not static creds

The mechanic that trips up newcomers most is that you never store an ECR password. Access to ECR is your IAM identity; docker login just needs a short-lived token derived from it.

The get-login-password flow

The canonical one-liner mints a token and pipes it straight into Docker so it never touches your shell history:

aws ecr get-login-password --region ap-south-1 \
  | docker login --username AWS --password-stdin \
    111122223333.dkr.ecr.ap-south-1.amazonaws.com
# Login Succeeded

Three facts to internalise, because each is a troubleshooting row later:

Fact Detail Consequence
Username is always AWS Not your IAM user name Wrong username → auth fails
Token lasts 12 hours Then Docker’s cached creds are stale Long CI jobs / next-day pushes must re-login
It needs ecr:GetAuthorizationToken A registry-level action (resource *) Missing it → can’t even log in
The endpoint is the registry URI <acct>.dkr.ecr.<region>.amazonaws.com Logging into the wrong Region’s registry
It uses your current IAM creds SSO/role/keys behind the scenes Expired SSO session → token mint fails

Under the hood, get-login-password is a thin wrapper over aws ecr get-authorization-token, which returns a base64 blob that decodes to AWS:<password>. You rarely call the lower-level API by hand, but knowing it exists explains what the credential helper automates.

The credential helper — never type login again

The Amazon ECR Docker Credential Helper (docker-credential-ecr-login) plugs into Docker’s credential system and fetches (and refreshes) tokens automatically using your ambient AWS credentials. You install the binary and point Docker’s config at it:

// ~/.docker/config.json
{
  "credHelpers": {
    "111122223333.dkr.ecr.ap-south-1.amazonaws.com": "ecr-login",
    "public.ecr.aws": "ecr-login"
  }
}

After that, a plain docker pull 111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web:v1 just works — no docker login, no 12-hour expiry to babysit, because the helper mints a fresh token per operation. This is the right setup for developer laptops and CI runners.

Auth method How it works Best for Downside
get-login-password | docker login Manual 12-h token Quick start, scripts Expires; must re-run
Credential helper Auto-fetch per op Laptops, long-lived CI One-time install
get-authorization-token (raw API) Base64 AWS:pwd Custom tooling You handle decoding/expiry
Task/instance role (ECS/EKS) Agent/kubelet pulls with its role Runtime pulls Not for interactive docker
CI OIDC → assumed role GitHub/GitLab OIDC → STS → ECR Keyless CI push Trust policy setup

The takeaway: on a developer machine, install the credential helper once and forget authentication exists. In CI, prefer an OIDC-assumed role (no long-lived keys) whose IAM grants ecr:GetAuthorizationToken plus the repo push actions.

Pushing and pulling images

Push and pull are just Docker verbs — the ECR-specific part is that the image name is the registry URI, and the identity behind it is IAM.

The registry URI anatomy

Every push and pull targets a fully-qualified name. Read it left to right:

Segment Example Meaning
Account id 111122223333 Your AWS account (the registry owner)
Service + region dkr.ecr.ap-south-1 ECR Docker endpoint in that Region
Domain amazonaws.com Fixed
Repository /kv-web The repo (may contain /)
Tag or digest :v1.4.2 or @sha256:9b2c… The version

Full form: 111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web:v1.4.2 (by tag) or …/kv-web@sha256:9b2c… (by digest).

Tag, push, pull

You build locally with a short name, then re-tag it with the registry URI before pushing:

# build a local image
docker build -t kv-web:local .

# re-tag with the full ECR URI
docker tag kv-web:local \
  111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web:v1.0.0

# push (after docker login) — only changed layers upload
docker push 111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web:v1.0.0

Pull is symmetric — by tag or, for reproducibility, by digest:

docker pull 111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web:v1.0.0
# or pin the exact bytes:
docker pull 111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web@sha256:9b2c…

The IAM/repo-policy actions behind each verb are worth knowing, because a denied message names the exact action that is missing:

Operation ECR actions required Managed policy that grants it
docker login ecr:GetAuthorizationToken All ECR managed policies
docker pull ecr:BatchGetImage, ecr:GetDownloadUrlForLayer, ecr:BatchCheckLayerAvailability AmazonEC2ContainerRegistryReadOnly
docker push pull actions + ecr:PutImage, ecr:InitiateLayerUpload, ecr:UploadLayerPart, ecr:CompleteLayerUpload AmazonEC2ContainerRegistryPowerUser
Create/delete repo, policies ecr:CreateRepository, ecr:SetRepositoryPolicy, ecr:DeleteRepository, … AmazonEC2ContainerRegistryFullAccess

The three built-in managed policies map neatly onto roles: ReadOnly for anything that only pulls (ECS tasks, EKS nodes), PowerUser for CI that pushes but shouldn’t delete repos, FullAccess for platform admins.

Image tags: mutable, immutable, and pinning by digest

A tag is a human-friendly pointer to a manifest. Whether that pointer can move is the single most consequential repo setting for reproducibility.

Mutable vs immutable

In a MUTABLE repository (the default), pushing kv-web:v1 again simply re-points the tag v1 at the new image; the old one becomes untagged. In an IMMUTABLE repository, once v1 exists you cannot push another image with tag v1 — the push fails, forcing every build to use a unique tag (a git SHA, a build number, a semantic version).

Property Mutable tags Immutable tags
Re-push same tag Overwrites (tag moves) Rejected — push fails
Reproducibility Weak — v1 can change Strong — a tag pins one image forever
latest workflow Works (and is dangerous) Impossible for a fixed tag
Rollback safety Must pin a digest to be sure Tag alone is safe
CI pattern Tag by env (staging) Tag by git SHA / build id
Set via imageTagMutability=MUTABLE imageTagMutability=IMMUTABLE
Error on conflict none (silent overwrite) ImageTagAlreadyExistsException

The failure you will see when you re-push a tag into an immutable repo looks like this — recognise it, because it is a good failure telling you your CI is trying to overwrite history:

tag invalid: v1.0.0: The image tag 'v1.0.0' already exists in the
'kv-web' repository and cannot be overwritten because the repository
is configured to be immutable.

The latest anti-pattern and digest pinning

latest is just a tag with no special meaning to ECR — it does not auto-track the newest push; it points at whatever you last tagged latest. In a mutable repo that makes latest a moving target: two docker pull …:latest a minute apart can return different images, so deployments become non-deterministic and rollbacks unreliable. The professional fixes:

Strategy How Reproducible? Use when
Tag by git SHA …:git-9f3a1c2 Yes (unique per commit) The default CI tag
Tag by build id …:build-4821 Yes CI without clean SHA
Immutable repo Reject re-pushed tags Yes (enforced) Belt-and-braces on the above
Pin by digest in deploy …@sha256:… Yes (exact bytes) ECS task defs, K8s manifests, IaC
latest for humans only Convenience pointer No Never for automated deploys

Find an image’s digest to pin it:

aws ecr describe-images --repository-name kv-web \
  --image-ids imageTag=v1.0.0 \
  --query 'imageDetails[0].imageDigest' --output text --region ap-south-1
# sha256:9b2c8e1f…    ← put THIS in your ECS task definition / K8s manifest

The rule of thumb: tag by SHA, pull by digest for anything a machine deploys, and keep latest only as a convenience for humans. Immutable repos enforce it so a tired engineer can’t overwrite v1.0.0 at 2 a.m.

Lifecycle policies: controlling image sprawl and cost

A CI pipeline that pushes on every commit will, left alone, accumulate thousands of images — most of them untagged intermediate manifests and superseded builds — and you pay storage on all of them. A lifecycle policy is a JSON document of prioritised rules that ECR evaluates to automatically expire (delete) images.

Rule anatomy

Each rule has a priority, a selection (which images it matches), and an action (always expire today):

Field Values Meaning
rulePriority 1…N (unique, low = first) Evaluation order
selection.tagStatus tagged / untagged / any Which images the rule sees
selection.tagPrefixList e.g. ["prod","v"] For tagged: only these prefixes
selection.countType imageCountMoreThan / sinceImagePushed Keep-newest-N vs age-based
selection.countNumber integer The N, or the age quantity
selection.countUnit days (age only) Unit for sinceImagePushed
action.type expire Delete matched images

Two selection styles cover almost everything: imageCountMoreThan keeps the newest N and expires the rest; sinceImagePushed expires anything older than N days. A very common, safe policy is “delete untagged images after 1 day, keep only the last 20 tagged production images”:

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Expire untagged images after 1 day",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 1
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "Keep only the 20 newest prod-tagged images",
      "selection": {
        "tagStatus": "tagged",
        "tagPrefixList": ["prod"],
        "countType": "imageCountMoreThan",
        "countNumber": 20
      },
      "action": { "type": "expire" }
    }
  ]
}
aws ecr put-lifecycle-policy --repository-name kv-web \
  --lifecycle-policy-text file://lifecycle.json --region ap-south-1

Preview before you delete

Lifecycle rules delete real images, so ECR gives you a dry-run preview that shows exactly which images would be expired without touching anything — always run it first:

aws ecr start-lifecycle-policy-preview --repository-name kv-web \
  --lifecycle-policy-text file://lifecycle.json --region ap-south-1
aws ecr get-lifecycle-policy-preview --repository-name kv-web --region ap-south-1
Behaviour Detail Gotcha
Evaluation order Ascending rulePriority An early broad rule can shadow later ones
Untagged handling tagStatus: untagged ignores tagPrefixList Prefixes only apply to tagged
Expiry timing Runs periodically, not instantly “Deleted image is still there” — wait
Deletion is permanent No recycle bin Preview first; keep enough countNumber
In-use images ECR does not know an image is running A too-aggressive rule can delete a live tag

That last row is the dangerous one: ECR has no idea an image is currently running in ECS/EKS. If a lifecycle rule expires the exact digest a running task was launched from, a task that later needs to re-pull (scale-out, node replacement) fails with CannotPullContainerError: … not found. Keep countNumber comfortably above your rollback window, and prefer pinning deploys by digest so you always know which ones must survive.

Image scanning: basic vs enhanced

ECR can scan images for known vulnerabilities (CVEs) in two distinct modes. They differ in depth, cadence, coverage and cost, and you pick one per registry (basic can also be per-repo scan-on-push).

Basic vs enhanced

Dimension Basic scanning Enhanced scanning (Amazon Inspector)
Engine ECR-managed CVE database Amazon Inspector
Trigger On push and/or manual On push and continuous re-scan
Coverage OS packages only OS packages + language/app dependencies
Re-scan on new CVE No Yes, automatically
Cost Free Per-image (Inspector pricing)
Config level Per-repo scanOnPush or registry Registry scanning configuration
Findings surface ECR describe-image-scan-findings ECR + Inspector console/findings
Best for Quick CVE gate, cost-sensitive Production security posture, compliance

Basic is the free, on-push CVE check most teams start with: it flags known vulnerabilities in the operating-system packages of your image. Enhanced hands scanning to Amazon Inspector, which additionally inspects application dependencies (your package.json, pip, go.mod, etc.), and — crucially — continuously re-scans stored images when new CVEs are published, so an image that was clean yesterday can raise a finding today without a re-push.

Turning it on

Per-repository basic scan-on-push (set at or after creation):

aws ecr put-image-scanning-configuration \
  --repository-name kv-web \
  --image-scanning-configuration scanOnPush=true --region ap-south-1

Registry-wide (choose the scan type and rules for the whole registry):

# Enhanced (Inspector), continuous, for all repos:
aws ecr put-registry-scanning-configuration \
  --scan-type ENHANCED \
  --rules '[{"scanFrequency":"CONTINUOUS_SCAN","repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}]}]' \
  --region ap-south-1

Reading findings and severities

After a push, poll the scan status and read the findings:

aws ecr describe-image-scan-findings \
  --repository-name kv-web --image-id imageTag=v1.0.0 \
  --region ap-south-1 \
  --query 'imageScanFindingsSummary.findingSeverityCounts'
# { "CRITICAL": 1, "HIGH": 3, "MEDIUM": 12, "LOW": 40 }
Severity Meaning Typical action
CRITICAL Remotely exploitable, severe Block/fix before deploy
HIGH Serious vulnerability Fix in the next build
MEDIUM Moderate risk Track and patch
LOW Minor Batch with base-image updates
INFORMATIONAL Note, no fix Ignore or record
UNDEFINED Severity not yet rated Re-check later

Scan status matters too, because a scan can legitimately not run:

Scan status Meaning What to do
IN_PROGRESS Still scanning Poll again shortly
COMPLETE Findings available Read findingSeverityCounts
FAILED Scan errored Re-trigger; check image validity
UNSUPPORTED_IMAGE OS/base not supported by basic scan Use enhanced, or a supported base (Amazon Linux, Debian, Ubuntu, Alpine via enhanced)
SCAN_ELIGIBILITY_EXPIRED Basic re-scan window passed Re-push or switch to enhanced

The most common “scan is stuck” is actually UNSUPPORTED_IMAGE: basic scanning only understands certain OS package databases, so a scratch, distroless or unusual base returns no findings. Enhanced (Inspector) covers far more, including language packages, and is the answer when basic reports nothing useful.

Replication, repository policies and cross-account access

Once an image is in one repo in one Region, real systems need it elsewhere — in another Region for latency/DR, or in another account for a shared platform. Two mechanisms handle this: replication (copy the bits) and repository policies + IAM (authorise access to the bits).

Replication

A registry-level replication configuration automatically copies images (and future pushes) to other Regions and/or accounts. You define destinations and optional repository filters:

aws ecr put-replication-configuration --replication-configuration '{
  "rules": [{
    "destinations": [
      {"region": "us-east-1", "registryId": "111122223333"},
      {"region": "ap-south-1", "registryId": "444455556666"}
    ],
    "repositoryFilters": [{"filter": "prod/", "filterType": "PREFIX_MATCH"}]
  }]
}' --region ap-south-1
Replication aspect Detail Gotcha
Scope Cross-Region and/or cross-account Set at the registry level, not per-repo
What replicates New pushes matching the filter Existing images may need a re-push/refresh
Cross-account target Needs a repo policy allowing replication ECR can’t write to another account without permission
Filters PREFIX_MATCH on repo name No filter = replicate everything
Cost Storage in each destination + cross-Region transfer You pay per destination copy

Repository policy vs IAM — the two-key rule

A repository policy is a resource-based policy attached to a single repository saying which principals may perform which ECR actions on it. For same-account access, an identity policy on your role is usually enough. For cross-account access, you need both keys: the repository policy in the owning account allowing the other account’s principal, and an IAM policy in the consuming account allowing that principal to call ECR (including the registry-level ecr:GetAuthorizationToken).

Access scenario Owning-account repo policy Consuming-account IAM Both needed?
Same-account pull Optional Required (ReadOnly) IAM alone
Same-account push (CI) Optional Required (PowerUser) IAM alone
Cross-account pull Required (allow their principal) Required (allow ECR + GetAuthToken) Yes — both
Cross-account push Required (allow PutImage) Required Yes — both
ECS/EKS in another account pulls Required (allow the exec/node role) Required in that account Yes — both

A minimal cross-account pull repository policy (owning account attaches this to kv-web):

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCrossAccountPull",
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::444455556666:root" },
    "Action": [
      "ecr:GetDownloadUrlForLayer",
      "ecr:BatchGetImage",
      "ecr:BatchCheckLayerAvailability"
    ]
  }]
}
aws ecr set-repository-policy --repository-name kv-web \
  --policy-text file://repo-policy.json --region ap-south-1

The other account (444455556666) also needs an IAM policy granting those same actions plus ecr:GetAuthorizationToken (which is registry-level and can’t live in a repo policy). Omit that IAM half and the pull fails with denied even though the repository policy is perfect — the classic “I set the repo policy, why is it still denied?” trap.

Pull-through cache and image signing

Two features round out a production registry: a pull-through cache so you stop hammering public registries, and image signing so you can prove an image is the one you built.

Pull-through cache

A pull-through cache rule turns your private ECR into a caching proxy for an upstream public registry. The first pull of an upstream image fetches and caches it into a repo under a prefix you choose; subsequent pulls serve from ECR — faster, in-region, and immune to the upstream’s rate limits (the reason this exists: Docker Hub throttles anonymous/free pulls, which can brown out a whole cluster during a deploy).

# Proxy Docker Hub under the prefix "dockerhub"
aws ecr create-pull-through-cache-rule \
  --ecr-repository-prefix dockerhub \
  --upstream-registry-url registry-1.docker.io \
  --region ap-south-1

# Now pull Docker Hub's library/nginx THROUGH your ECR:
docker pull 111122223333.dkr.ecr.ap-south-1.amazonaws.com/dockerhub/library/nginx:latest
Upstream Typical upstream-registry-url Auth needed?
Docker Hub registry-1.docker.io Optional (creds in Secrets Manager to raise limits)
Amazon ECR Public public.ecr.aws No
Quay quay.io Optional
GitHub Container Registry ghcr.io For private images
Kubernetes registry registry.k8s.io No
Microsoft (MCR) mcr.microsoft.com No

For authenticated upstreams (e.g. a Docker Hub account to lift rate limits), store the credentials in Secrets Manager and reference the secret ARN with --credential-arn. Cached images are ordinary ECR images — they get your lifecycle policies and (with enhanced scanning) get scanned too.

Image signing

ECR itself does not sign images, but it can store signatures as OCI referrer artifacts alongside an image, so you can adopt supply-chain signing:

Approach Signs with Verify at Note
AWS Signer + Notation AWS-managed signing profile Deploy (admission controller) AWS-native, integrates with IAM
cosign (Sigstore) Keyless (OIDC) or key pair Deploy (Kyverno/policy) Popular in the K8s ecosystem
No signing Trust the registry + IAM only

Signing lets a deployment gate (an EKS admission controller such as Kyverno, or an ECS deploy check) refuse to run an image that isn’t signed by your pipeline — closing the gap where a valid-but-unauthorised image sits in the repo. It is optional for a first push, but the moment images cross trust boundaries it becomes the mechanism that proves provenance.

Consuming images from ECS, EKS and Lambda

Pushing is half the story; the point is compute pulling the image at launch. Each runtime pulls with a specific identity over a specific network path, and both must be right.

The pull identity per runtime

Runtime Who pulls Identity that needs ECR read Grant it
ECS (EC2 or Fargate) The ECS agent / Fargate Task execution role AmazonECSTaskExecutionRolePolicy (includes ECR pull)
EKS The kubelet on the node Node instance role (or IRSA setup) AmazonEC2ContainerRegistryReadOnly
Lambda (container image) The Lambda service Same-account: automatic; cross-account: repo policy Repo policy allowing the Lambda service/account
App Runner App Runner service Access role for private ECR ECR read on the access role
EC2 (docker pull) The instance Instance profile role AmazonEC2ContainerRegistryReadOnly

The recurring beginner error is attaching ECR permissions to the task role (your app’s identity) instead of the task execution role (the agent that pulls the image and sets the task up). ECR pull belongs on the execution role; the task role is for what your code calls at runtime.

The network path — why private subnets fail

ECR pulls touch three endpoints: the ECR API (ecr.api), the Docker registry (ecr.dkr), and S3 (layer blobs are downloaded via a pre-signed S3 URL). A task in a private subnet with no route to any of these can’t pull. You have two options:

Networking option What it provides Cost/consideration
NAT Gateway Private subnets reach ECR + S3 over the internet Hourly + per-GB NAT cost
VPC endpoints (recommended) Private, in-VPC path to ECR + S3 Per-endpoint hourly; no NAT egress
com.amazonaws.<region>.ecr.api ECR control-plane calls Interface endpoint
com.amazonaws.<region>.ecr.dkr Docker pull/push Interface endpoint
com.amazonaws.<region>.s3 Layer download (mandatory!) Gateway endpoint (free)
com.amazonaws.<region>.logs If tasks log to CloudWatch Interface endpoint

The single most-forgotten piece is the S3 gateway endpoint: people add the two ECR interface endpoints, watch the pull still fail, and don’t realise the layers themselves come from S3. Add all three (api, dkr, s3) and a fully private, no-NAT task pulls cleanly — which is both cheaper and more secure than routing image pulls through a NAT Gateway.

Architecture at a glance

The diagram below is the exact lifecycle you build in the lab, drawn left to right. On the left, a build (your laptop or CI) produces an image made of content-addressed layers. To ship it you authenticateget-login-password mints a 12-hour token that docker login consumes, gated by your IAM ecr:* permissions — and then docker push uploads only the changed layers into an ECR private repository. Inside ECR the image is governed: it is scanned (basic on-push or enhanced/Inspector continuous), aged out by a lifecycle policy, and — if the repo is immutable — protected from tag overwrites. To the right, ECR distributes: a replication config copies it cross-Region/account and a repository policy authorises who may pull. Finally the runtimes — ECS/Fargate, EKS nodes and container-image Lambda — pull the image with their execution/node roles over VPC endpoints.

The six numbered badges mark the six places an ECR workflow fails, and the legend narrates each as symptom · confirm · fix — the same map as the troubleshooting playbook, drawn onto the architecture so you can see where each failure lives.

Amazon ECR image lifecycle: a docker build in CI or on a laptop produces a layered image; the developer authenticates with a 12-hour get-login-password token gated by IAM ecr permissions and docker-pushes into a private ECR repository configured with immutable tags, basic or enhanced Inspector scan-on-push, and a lifecycle policy that expires untagged and aged images; the repository replicates cross-Region and cross-account and a repository policy plus IAM authorises pulls; ECS or Fargate tasks via their execution role, EKS nodes via the ReadOnly policy, and container-image Lambdas pull the image over ecr.api, ecr.dkr and S3 VPC endpoints, with six numbered badges marking auth-token-expired, repository-not-found, immutable-tag-conflict, unsupported-scan, cross-account-pull-denied, and CannotPullContainerError failure points.

Badge Failure class Lives at Playbook row
1 Auth token expired / no basic auth The docker login step rows 1, 2
2 Repository not found (wrong Region/account) The repo URI row 5
3 Immutable-tag conflict Push into an immutable repo row 4
4 Scan stuck / UNSUPPORTED_IMAGE The scan step row 9
5 Cross-account pull denied Repo policy + IAM rows 7, 8
6 CannotPullContainerError ECS/EKS pull path rows 10, 11, 12

Real-world scenario

Finch Freight, a logistics startup in Pune, ran three microservices on ECS Fargate and shipped them from a single CI pipeline that pushed to ECR on every merge. It worked for months, then three things went wrong in the same quarter — each a lesson from this article.

First, the bill for ECR storage quietly climbed to a noticeable line item. Their pipeline tagged every build latest and a git SHA in a mutable repo with no lifecycle policy, so ~140 images accumulated per service — most of them untagged manifests from latest being repeatedly overwritten. A platform engineer, Rohan, ran a lifecycle-policy preview and saw that 380 images across the three repos were dead. He applied a two-rule policy — expire untagged after 1 day, keep the newest 30 SHA-tagged images — and reclaimed the bulk of the storage in the next eval cycle, with zero risk because the preview showed exactly what would go.

Second, a rollback went wrong. An incident required reverting to “the version that was live yesterday,” but the team had deployed by the latest tag, and latest had been overwritten six times since. They pulled latest expecting yesterday’s image and got today’s broken one. The fix was structural: they switched the repos to immutable tags, changed CI to tag only by git SHA, and rewrote the ECS task definitions to reference the image by digest. Now every deploy pins exact bytes and a rollback is “point the task def at the previous digest” — deterministic.

Third — and most disruptive — they migrated the tasks into private subnets for compliance, removed the public subnets’ route, and every new task deployment died with CannotPullContainerError: … i/o timeout. The app hadn’t changed; the network path to ECR had vanished. Rohan added the two ECR interface endpoints (ecr.api, ecr.dkr) and, after the pull still failed, remembered the missing piece from this very topic — the S3 gateway endpoint, because layers download from S3. With all three endpoints in place, the fully private tasks pulled cleanly, and they saved money by dropping the NAT Gateway they’d been about to add.

Rohan’s write-up for the team wiki was the thesis of this article: “ECR never failed. We paid for storage because we never set a lifecycle policy, we couldn’t roll back because we trusted a moving tag, and pulls broke because we forgot layers live in S3. Immutable tags, a lifecycle policy, digest-pinned deploys, and the three VPC endpoints — that’s the whole game.” Their steady-state ECR bill after the cleanup dropped by more than half, and no deploy has failed to pull since.

Advantages and disadvantages

Advantages Disadvantages
Deep IAM integration — no shared registry password Auth token expires every 12 hours (must refresh)
Private, in-Region, low-latency pulls One registry per Region — cross-Region needs replication
Layer dedup + S3-backed durability Pull path needs S3 reachability (surprises private subnets)
Built-in scanning (basic free, enhanced continuous) Enhanced scanning (Inspector) costs per image
Lifecycle policies control storage cost automatically No recycle bin — expired images are gone
Immutable tags enforce reproducibility Cross-account access needs repo policy and IAM
Replication cross-Region/account Replication is registry-level, not per-repo granular
Pull-through cache beats Docker Hub rate limits Public distribution is a separate service (ECR Public)

When each side matters: ECR is the obvious right choice for any AWS-hosted container workload — the IAM integration, in-region speed, and native scanning/lifecycle remove work you’d otherwise do by hand. The friction shows up at boundaries: cross-Region (you must configure replication), cross-account (two-key IAM + repo policy), private networking (VPC endpoints), and public distribution (a different service). None are blockers; they are just the specific things you must remember, and each maps to a troubleshooting row below.

Choose ECR when… Look wider when…
Your compute is ECS/EKS/Lambda/App Runner on AWS You need one registry serving many clouds equally
You want IAM-native, password-free access Your team standardises on a third-party registry (Harbor, Artifactory)
You need built-in CVE scanning + lifecycle You already run a mature registry with its own scanning
You distribute private images in-org You publish widely to anonymous users (→ ECR Public / Docker Hub)

Hands-on lab

You will build the diagram: create an immutable, scan-on-push repository, authenticate, build a tiny image, tag and push, read the scan findings, attach a lifecycle policy, add a cross-account repository policy, then pull — first with aws/docker CLI, then the identical setup as Terraform, then a clean teardown. This lab uses ap-south-1 (Mumbai); pick one Region and stick to it.

⚠️ Cost note: Private ECR storage is billed per GB-month; this lab stores a few MB (negligible). Basic scanning is free. If you enable enhanced scanning, Amazon Inspector bills per image scanned — the lab keeps it optional and tears everything down at the end.

What you’ll create

Resource Purpose Cost at lab volume
ECR repository kv-web Immutable, scan-on-push image store ~₹0 (few MB storage)
A tiny Docker image Something to push and pull Free (local build)
Lifecycle policy Expire untagged / old images Free
Repository policy Cross-account pull grant (example) Free
Basic scan findings The CVE report on push Free

Part A — the CLI + Docker path

Step 1 — Create the repository (immutable + scan-on-push). Get the two most important settings right at creation:

aws ecr create-repository \
  --repository-name kv-web \
  --image-tag-mutability IMMUTABLE \
  --image-scanning-configuration scanOnPush=true \
  --region ap-south-1

Expected: JSON with "repositoryUri": "111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web", "imageTagMutability": "IMMUTABLE", and scanOnPush: true. Capture your account id and URI:

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REPO_URI=${ACCOUNT_ID}.dkr.ecr.ap-south-1.amazonaws.com/kv-web

Step 2 — Build a tiny image. Create a minimal Dockerfile (a supported base so basic scanning has something to inspect):

FROM public.ecr.aws/docker/library/python:3.12-slim
RUN pip install --no-cache-dir flask==3.0.3
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
docker build -t kv-web:local .
# ... successfully built

Step 3 — Authenticate (the 12-hour token). Mint a token and log Docker in to your registry:

aws ecr get-login-password --region ap-south-1 \
  | docker login --username AWS --password-stdin \
    ${ACCOUNT_ID}.dkr.ecr.ap-south-1.amazonaws.com
# Login Succeeded

If this errors with no basic auth credentials later, your token expired — just re-run this one command.

Step 4 — Tag with the registry URI and push. Use a real version tag (immutable repos reject re-pushes of the same tag):

docker tag kv-web:local ${REPO_URI}:v1.0.0
docker push ${REPO_URI}:v1.0.0
# The push refers to repository [111122223333.dkr.ecr.ap-south-1.amazonaws.com/kv-web]
# v1.0.0: digest: sha256:9b2c… size: 1789

Note the digest in the output — that sha256:… is the immutable address you’d pin in a deploy. Verify the image landed:

aws ecr describe-images --repository-name kv-web --region ap-south-1 \
  --query 'imageDetails[].{tag:imageTags[0],pushed:imagePushedAt,size:imageSizeInBytes}'

Step 5 — Read the scan findings. Because scanOnPush=true, a basic scan started automatically. Poll it, then read the summary:

aws ecr wait image-scan-complete \
  --repository-name kv-web --image-id imageTag=v1.0.0 --region ap-south-1

aws ecr describe-image-scan-findings \
  --repository-name kv-web --image-id imageTag=v1.0.0 --region ap-south-1 \
  --query 'imageScanFindingsSummary.findingSeverityCounts'
# { "HIGH": 2, "MEDIUM": 7, "LOW": 15 }   (numbers vary with the base image)

If you see status UNSUPPORTED_IMAGE, the base OS isn’t covered by basic scanning — switch to enhanced (Step 5b) or use a supported base.

Step 5b (optional) — Enhanced scanning. ⚠️ This enables Amazon Inspector (billable per image). Turn it on registry-wide, then off in teardown:

aws ecr put-registry-scanning-configuration --scan-type ENHANCED \
  --rules '[{"scanFrequency":"CONTINUOUS_SCAN","repositoryFilters":[{"filter":"kv-*","filterType":"WILDCARD"}]}]' \
  --region ap-south-1

Step 6 — Attach a lifecycle policy (preview first). Save the two-rule policy from earlier as lifecycle.json, preview it, then apply:

aws ecr start-lifecycle-policy-preview --repository-name kv-web \
  --lifecycle-policy-text file://lifecycle.json --region ap-south-1
aws ecr get-lifecycle-policy-preview --repository-name kv-web --region ap-south-1 \
  --query 'previewResults[].{tag:imageTags[0],action:action.type}'

aws ecr put-lifecycle-policy --repository-name kv-web \
  --lifecycle-policy-text file://lifecycle.json --region ap-south-1

Step 7 — Add a cross-account repository policy. Grant a partner account (444455556666) pull access. Save the pull policy from earlier as repo-policy.json:

aws ecr set-repository-policy --repository-name kv-web \
  --policy-text file://repo-policy.json --region ap-south-1
# Reminder: the OTHER account also needs IAM allowing ecr:GetAuthorizationToken + pull.

Step 8 — Pull it back (verify). Prove the round-trip — remove the local image, then pull from ECR:

docker rmi ${REPO_URI}:v1.0.0 kv-web:local
docker pull ${REPO_URI}:v1.0.0
# v1.0.0: Pulling from kv-web ... Status: Downloaded newer image

# and prove immutability — re-pushing the SAME tag must fail:
docker tag $(docker images -q ${REPO_URI}:v1.0.0) ${REPO_URI}:v1.0.0
docker push ${REPO_URI}:v1.0.0
# tag invalid: 'v1.0.0' already exists ... repository is configured to be immutable.  ✅ expected

Step 9 — Tear down. Delete the repository (--force also deletes the images inside it) and revert enhanced scanning if you enabled it:

aws ecr delete-repository --repository-name kv-web --force --region ap-south-1
# revert to free basic scanning if you enabled enhanced:
aws ecr put-registry-scanning-configuration --scan-type BASIC --rules '[]' --region ap-south-1
Teardown step Command Why it matters
Delete repo + images delete-repository --force Without --force, a non-empty repo won’t delete
Revert scan type put-registry-scanning-configuration --scan-type BASIC Stops Inspector billing
Remove local images docker rmi … Frees local disk (no cloud cost)
Delete pull-through rules delete-pull-through-cache-rule If you created any

Part B — the same thing as Terraform

The CLI is great for learning; Terraform is how you keep it. This main.tf reproduces the repository, its scan config, lifecycle policy and repository policy declaratively:

terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = "ap-south-1"
}

# 1. The repository: immutable tags + scan-on-push
resource "aws_ecr_repository" "kv_web" {
  name                 = "kv-web"
  image_tag_mutability = "IMMUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }

  encryption_configuration {
    encryption_type = "AES256"   # or "KMS" with a kms_key
  }
}

# 2. Lifecycle policy: expire untagged after 1 day; keep newest 20 prod images
resource "aws_ecr_lifecycle_policy" "kv_web" {
  repository = aws_ecr_repository.kv_web.name
  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Expire untagged after 1 day"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 1
        }
        action = { type = "expire" }
      },
      {
        rulePriority = 2
        description  = "Keep 20 newest prod images"
        selection = {
          tagStatus     = "tagged"
          tagPrefixList = ["prod"]
          countType     = "imageCountMoreThan"
          countNumber   = 20
        }
        action = { type = "expire" }
      }
    ]
  })
}

# 3. Cross-account pull repository policy
data "aws_iam_policy_document" "cross_account_pull" {
  statement {
    sid    = "AllowCrossAccountPull"
    effect = "Allow"
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::444455556666:root"]
    }
    actions = [
      "ecr:GetDownloadUrlForLayer",
      "ecr:BatchGetImage",
      "ecr:BatchCheckLayerAvailability",
    ]
  }
}

resource "aws_ecr_repository_policy" "kv_web" {
  repository = aws_ecr_repository.kv_web.name
  policy     = data.aws_iam_policy_document.cross_account_pull.json
}

# 4. Registry-wide enhanced scanning (optional; billable)
resource "aws_ecr_registry_scanning_configuration" "this" {
  scan_type = "ENHANCED"
  rule {
    scan_frequency = "CONTINUOUS_SCAN"
    repository_filter {
      filter      = "kv-*"
      filter_type = "WILDCARD"
    }
  }
}

output "repository_url" {
  value = aws_ecr_repository.kv_web.repository_url
}
terraform init
terraform apply     # review the plan, type yes
# push into it exactly as in Part A, then:
terraform destroy   # note: destroy fails if images remain unless force_delete=true
Terraform detail Why it’s there If you omit it
image_tag_mutability = "IMMUTABLE" Enforces reproducible tags Tags can be silently overwritten
scan_on_push = true Free CVE gate on every push Images land unscanned
aws_ecr_lifecycle_policy Caps storage automatically Unbounded image sprawl + cost
aws_ecr_repository_policy Cross-account/pull authorisation Partners/other accounts can’t pull
force_delete = true (add to repo) Lets destroy remove a non-empty repo destroy errors on remaining images
aws_ecr_registry_scanning_configuration Enhanced/Inspector, registry-wide Only basic scanning available

Common mistakes & troubleshooting

This is the section you’ll return to. Match your symptom, run the confirm command, apply the fix.

# Symptom Root cause Confirm (exact command / console path) Fix
1 no basic auth credentials on push/pull Never logged in, or Docker creds cleared docker system info → no ECR in credentials; try docker login Re-run get-login-password | docker login <registry-uri>
2 denied: Your authorization token has expired The 12-h token lapsed (long job / next day) Re-run any ECR op; the message names expiry Re-authenticate; on laptops install the credential helper
3 denied: User: arn:… is not authorized to perform: ecr:InitiateLayerUpload IAM lacks push actions (or repo policy blocks) aws iam simulate-principal-policy / read the exact action in the error Attach AmazonEC2ContainerRegistryPowerUser or add the action
4 Push fails: tag 'v1' already exists … immutable Re-pushing an existing tag into an immutable repo aws ecr describe-repositories --query '…imageTagMutability' Push a new unique tag (git SHA/build id); this is working as designed
5 name unknown: The repository … does not exist Wrong Region or account in the URI, or repo not created aws ecr describe-repositories --region <r> — is it there? Create the repo, or fix the Region/account in the URI
6 denied: requested access to the resource is denied on pull IAM/repo policy lacks pull actions Error names the denied context; check ReadOnly policy Grant BatchGetImage/GetDownloadUrlForLayer/BatchCheckLayerAvailability
7 Cross-account pull denied despite repo policy Consuming account’s IAM missing (needs both keys) In the other account: aws ecr get-login-password fails? check IAM Add ECR pull + ecr:GetAuthorizationToken to that account’s IAM
8 Cross-account pull denied despite IAM Repository policy missing/incomplete in owning account aws ecr get-repository-policy --repository-name <r> set-repository-policy allowing the other account’s principal
9 Scan “stuck” / no findings ever Status UNSUPPORTED_IMAGE (base OS not covered by basic) aws ecr describe-image-scan-findings … --query imageScanStatus Switch to enhanced scanning, or use a supported base image
10 ECS: CannotPullContainerError: … i/o timeout No network route to ECR/S3 (private subnet, no NAT/endpoints) Task events; check subnet route table + endpoints Add ecr.api, ecr.dkr and s3 (gateway) VPC endpoints, or a NAT GW
11 ECS: CannotPullContainerError: … 403 Forbidden Task execution role lacks ECR pull perms Task def → executionRoleArn; check attached policy Attach AmazonECSTaskExecutionRolePolicy to the execution role
12 ECS: CannotPullContainerError: … not found Tag/digest doesn’t exist (lifecycle deleted it, or typo) aws ecr describe-images --repository-name <r> Re-push the image; loosen lifecycle countNumber; pin surviving digests
13 Docker Hub base image: toomanyrequests: … rate limit Anonymous Docker Hub pull throttled during a deploy The pull log shows toomanyrequests Configure a pull-through cache for Docker Hub; pull via ECR
14 Lifecycle deleted an image you still needed Rule countNumber too low / matched a live tag get-lifecycle-policy → re-read the rules Raise countNumber; scope with tagPrefixList; always preview first
15 KMS repo: AccessDenied on pull Puller can’t kms:Decrypt the CMK the repo uses Repo encryptionConfiguration; the CMK key policy Grant the pulling principal kms:Decrypt on that key
16 EKS pod ImagePullBackOff from ECR Node role lacks ECR read, or private-subnet path missing kubectl describe pod → events; node IAM role Attach AmazonEC2ContainerRegistryReadOnly; add VPC endpoints

The error / status reference

The strings ECR and the container runtimes emit, decoded:

Message / status Where you see it Meaning Fix
no basic auth credentials docker push/pull Not logged in to this registry docker login with a fresh token
denied: … token has expired Any ECR op 12-h token lapsed Re-authenticate / credential helper
not authorized to perform: ecr:X Push/pull IAM missing action X Grant X (or a managed policy)
tag invalid: … immutable Push Re-pushing a tag into an immutable repo Use a unique tag
name unknown: … does not exist Push/pull Repo/Region/account wrong Create repo / fix URI
CannotPullContainerError ECS task event Network, auth, or missing image Rows 10–12
ImagePullBackOff / ErrImagePull EKS kubectl kubelet can’t pull Row 16
UNSUPPORTED_IMAGE Scan status Basic scan can’t parse the base Enhanced scan / supported base
toomanyrequests Docker Hub pull Rate-limited upstream Pull-through cache
LayersNotFoundException API A referenced layer is gone (lifecycle) Re-push the image

The three nastiest, explained

CannotPullContainerError in a private subnet (rows 10–12) burns the most hours, because the application looks broken when the truth is the task never started — it couldn’t fetch the image. The instinct is to read app logs, but there are none; the task died at the pull. Confirm with the task’s Events/stopped-reason, not app logs, and remember the pull touches three things: the ECR API endpoint, the ECR Docker endpoint, and S3 (layers). The most common private-subnet miss is adding the two ECR interface endpoints and forgetting the S3 gateway endpoint — add all three and a no-NAT task pulls. If the network is fine and you get 403, it’s the execution role (not the task role) missing ECR read.

Cross-account pull needs two keys (rows 7–8), and people fix only one. The owning account’s repository policy must name the consuming principal, and the consuming account’s IAM must allow ECR pull plus ecr:GetAuthorizationToken (a registry-level action that can’t live in a repo policy). Set the repo policy but forget the puller’s IAM and you get denied; set the puller’s IAM but forget the repo policy and you also get denied. Confirm both sides — get-repository-policy in the owner, and an IAM check in the consumer — before assuming ECR is broken.

A too-aggressive lifecycle policy deletes a live image (row 14). ECR has no idea an image is currently running; a rule like “keep only the 5 newest” will happily expire the digest a long-running ECS service or an autoscaling EKS node still needs to re-pull, and the next scale-out fails with … not found. Always run start-lifecycle-policy-preview before applying, keep countNumber well above your rollback/retention window, scope rules with tagPrefixList, and pin production deploys by digest so you always know which images must survive.

Best practices

Security notes

ECR’s security surface is small but load-bearing:

Control What to do Why
Least-privilege IAM ReadOnly/PowerUser/FullAccess by role; scope by repo ARN/prefix A leaked pull role shouldn’t be able to push or delete
Repository policies scoped tight Name exact principals; avoid "AWS":"*" on private repos Prevents unintended cross-account access
Encryption AES256 by default; KMS CMK for audit/control Control who can decrypt; CloudTrail the key use
Immutable tags Enforce on prod repos Stops silent overwrite of a released image
Scan-on-push + enhanced Gate CVEs before deploy; continuously re-scan Catch vulns at push and as new CVEs land
Private pulls via VPC endpoints ecr.api/ecr.dkr/s3 endpoints; no public egress Image traffic never leaves the AWS network
No credentials in images Never bake secrets into layers Layers are pullable by anyone with repo read
Image signing AWS Signer/cosign + verify at deploy Proves provenance; blocks unauthorised images
CloudTrail on ECR Audit PutImage, SetRepositoryPolicy, BatchGetImage Detect tampering and unexpected pullers

The two people get wrong first: opening a repository policy to "Principal": "*" “just to make the pull work” (that makes a private image world-pullable to any AWS account — scope it to exact ARNs), and baking a secret or API key into an image layer (anyone who can pull the image can extract it — use Secrets Manager/SSM at runtime instead).

Cost & sizing

ECR cost has three drivers, and the free tier covers most learning:

Cost driver How it’s charged Lever to pull
Storage ~$0.10 per GB-month (private) Lifecycle policies; smaller/multi-stage images
Data transfer out Standard AWS egress; in-Region to ECS/EC2 is free Keep compute in the image’s Region
Enhanced scanning Amazon Inspector: per image scanned + per re-scan Use basic where enhanced isn’t required
Replication Storage in each destination + cross-Region transfer Filter to prod/; replicate only what you need
Pull-through cache Storage of cached images Lifecycle-expire cached images too
Free tier Amount Note
Private ECR storage 500 MB-month for 12 months Then ~$0.10/GB-month
ECR Public storage 50 GB always free For public repos
ECR Public data transfer Generous free egress to anonymous/AWS For public distribution
Basic scanning Free Any volume

A worked example to make it concrete:

Scenario Images × size Storage/mo Est. monthly cost
This lab 1 × ~150 MB 0.15 GB ₹0 (free tier)
Small team, no lifecycle 400 × 200 MB ~80 GB ~$8 storage + Inspector if enhanced
Same team, with lifecycle ~40 × 200 MB ~8 GB ~$0.80 storage — 10× cheaper
Cross-Region replicated 8 GB × 2 Regions 16 GB + transfer ~$1.60 storage + cross-Region egress

The single biggest lever is a lifecycle policy — the “no lifecycle” vs “with lifecycle” rows above are the same team, an order of magnitude apart on storage. Enhanced scanning is the other conscious spend: it’s worth it for production security posture, but enabling it registry-wide on hundreds of images (each re-scanned continuously) is a line item to size deliberately, not a default to leave on everywhere.

Interview & exam questions

1. What is Amazon ECR and how does it authenticate? ECR is AWS’s managed container registry (private per-account/Region, plus a public gallery). It authenticates via IAM: aws ecr get-login-password mints a 12-hour token from your IAM credentials that docker login consumes with username AWS — not a stored password. (CLF-C02, DVA-C02)

2. Mutable vs immutable tags — why does it matter? In a mutable repo a tag can be overwritten, so v1 (or latest) can silently point at different images over time, breaking reproducibility and rollbacks. Immutable repos reject re-pushing an existing tag, forcing unique tags (git SHA) and guaranteeing a tag pins one image. (DVA-C02)

3. Why pin by digest instead of tag? A digest (sha256:…) is the immutable content address of the exact manifest; a tag is a movable pointer. Machine deploys (ECS task defs, K8s manifests) should pin digests so the deployed bytes are certain and rollbacks are deterministic. (DVA-C02, SAA-C03)

4. What does a lifecycle policy do and what’s the risk? It’s prioritised JSON rules that automatically expire images by count (imageCountMoreThan) or age (sinceImagePushed) to control storage cost. The risk: ECR doesn’t know an image is running, so an over-aggressive rule can delete a live image — always preview first and keep the count above your rollback window. (DVA-C02)

5. Basic vs enhanced scanning? Basic is free, on-push, OS-packages-only. Enhanced uses Amazon Inspector: it also scans application/language dependencies and continuously re-scans stored images as new CVEs are published, at a per-image cost. (SCS, DVA-C02)

6. How does a container in account B pull an image from account A’s ECR? You need both: a repository policy in account A allowing account B’s principal the pull actions, and an IAM policy in account B granting ECR pull plus ecr:GetAuthorizationToken. Missing either yields denied. (SAA-C03, SCS)

7. An ECS task fails with CannotPullContainerError in a private subnet. Diagnose. The task has no network path to ECR. Pulls need the ECR API endpoint, the ECR Docker endpoint, and S3 (layers live in S3). Add ecr.api, ecr.dkr interface endpoints and the S3 gateway endpoint, or a NAT Gateway; if it’s a 403, the task execution role lacks ECR read. (SAA-C03, DVA-C02)

8. What is a pull-through cache and why use it? A rule that makes your private ECR a caching proxy for an upstream public registry (Docker Hub, Quay, public ECR). The first pull caches the image; later pulls serve from ECR — faster, in-region, and immune to Docker Hub’s rate limits. (DVA-C02, SAA-C03)

9. Which IAM identity pulls the image for ECS vs EKS? ECS: the task execution role (AmazonECSTaskExecutionRolePolicy). EKS: the node instance role (AmazonEC2ContainerRegistryReadOnly). ECR pull belongs on the pulling identity, not the app’s task/pod role. (DVA-C02)

10. How does ECR store images, and why does the S3 endpoint matter? ECR stores image layers in an AWS-managed S3 bucket and dedupes them. Because layer downloads are served from S3, a private-subnet pull needs an S3 gateway endpoint in addition to the ECR endpoints. (SAA-C03)

11. How do you distribute an image to anonymous users? Publish to ECR Public (public.ecr.aws/<alias>/<repo>), a separate global registry that anyone can pull without an AWS account — you authenticate to push via aws ecr-public get-login-password in us-east-1. (CLF-C02)

12. What does replication give you and where’s it configured? Cross-Region and cross-account automatic copying of images, configured at the registry level (not per repo) with repository filters. Cross-account destinations require a repository policy permitting the replication. (SAA-C03, ANS)

Quick check

  1. You run aws ecr get-login-password | docker login … and it succeeds. Roughly how long until you must re-authenticate, and why isn’t this a stored password?
  2. Your CI pushes kv-web:v1.0.0 to an immutable repo, then re-runs and pushes v1.0.0 again. What happens?
  3. Name the three network endpoints a private-subnet ECS task needs to pull an ECR image — and which one people forget.
  4. A partner account’s ECS task can’t pull your image even though you set the repository policy. What’s the most likely missing piece?
  5. Your basic scan returns UNSUPPORTED_IMAGE. What does it mean and what’s the fix?

Answers

  1. About 12 hoursget-login-password mints a short-lived token from your IAM credentials, so access is your IAM identity wrapped in a temporary token, not a permanent secret. (On laptops, the credential helper refreshes it automatically.)
  2. The second push fails with tag 'v1.0.0' already exists … cannot be overwritten because the repository is immutable. That’s working as designed — immutable repos force unique tags (tag by git SHA).
  3. com.amazonaws.<region>.ecr.api, com.amazonaws.<region>.ecr.dkr, and the S3 gateway endpoint (layers download from S3). The S3 gateway endpoint is the one everyone forgets.
  4. The consuming account’s IAM — cross-account pull needs both the repository policy (owner side) and IAM in the puller’s account granting ECR pull plus ecr:GetAuthorizationToken.
  5. Basic scanning can’t parse that image’s base OS packages, so it produces no findings. Switch to enhanced (Inspector) scanning, or use a base image basic scanning supports.

Glossary

Term Definition
ECR Amazon Elastic Container Registry: AWS’s managed private/public container image registry.
Registry The per-account, per-Region store addressed as <acct>.dkr.ecr.<region>.amazonaws.com; ECR Public is a single global registry.
Repository A named collection of image versions within a registry.
Image manifest The JSON document listing an image’s layers and config by digest.
Layer A content-addressed filesystem diff, stored (deduplicated) in S3.
Tag A human-friendly, mutable-by-default pointer to a manifest.
Digest The immutable sha256: content address of a manifest; reproducible pull target.
Immutable tags A repo setting that rejects re-pushing an existing tag.
Authorization token The 12-hour credential from get-login-password, derived from IAM.
Credential helper docker-credential-ecr-login; auto-fetches/refreshes ECR tokens for Docker.
Lifecycle policy Prioritised JSON rules that expire images by count or age.
Basic scanning Free, on-push CVE scan of OS packages.
Enhanced scanning Amazon Inspector continuous scan of OS + application dependencies (billable).
Repository policy A resource-based policy on a repo controlling who may pull/push (key for cross-account).
Replication Registry-level automatic copying of images cross-Region/cross-account.
Pull-through cache A rule that makes ECR a caching proxy of an upstream public registry.
Task execution role The ECS role that pulls the image and sets up the task (needs ECR read).
VPC endpoints (ECR) Private in-VPC paths (ecr.api, ecr.dkr, plus S3 gateway) for pulls without NAT.

Next steps

AWSECRContainersDockerImage ScanningLifecycle PoliciesTerraformECS
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading