AWS ECR: Container Registry, Image Scanning and Lifecycle Policies (2026)
Amazon Elastic Container Registry (ECR) is a fully managed, private Docker-compatible container registry integrated deeply into the AWS ecosystem. It is where your container images live before they are pulled by ECS, EKS, Lambda, App Runner, or any other compute layer. Getting ECR right — authentication, scanning, lifecycle management, cross-account access, and cost controls — is foundational work that pays dividends every time a new service ships. This guide covers every production-relevant aspect of ECR in 2026, from your first docker push to a hardened multi-account image supply chain.
Table of Contents
- ECR vs Docker Hub vs GitHub Container Registry
- Creating Repositories — CLI & Terraform
- Docker Auth, Push & Pull Workflow
- ECR in CI/CD — GitHub Actions & CodeBuild
- Image Scanning — Basic vs Enhanced (Inspector)
- Lifecycle Policies — Expiry and Retention Rules
- Cross-Account Access
- ECR Public Gallery
- Pull-Through Cache
- Cost Optimisation
- FAQ
ECR vs Docker Hub vs GitHub Container Registry
Choosing where to store container images is a real decision, not a default. The three most common registries in 2026 are AWS ECR, Docker Hub, and GitHub Container Registry (GHCR). Each has a distinct cost model, authentication story, and integration surface.
| Feature | AWS ECR (private) | Docker Hub | GitHub Container Registry |
|---|---|---|---|
| Storage pricing | $0.10/GB/month | Free (1 private), paid plans for more | Free for public; included in GitHub plan for private |
| Data transfer to AWS compute | Free (same region) | Egress charges apply | Egress charges apply |
| Pull rate limits | None | 100/6 h anon, 200/6 h free auth | None (authenticated) |
| IAM integration | Native — IAM roles, resource policies | Username/password or PAT only | GitHub PAT or OIDC token |
| Image scanning | Built-in (Basic + Enhanced/Inspector) | Paid add-on (Scout) | Dependabot alerts only |
| Lifecycle policies | Native, rule-based JSON | Manual or 3rd-party | Manual only |
| Private networking | VPC endpoint (no public internet) | Public only | Public only |
| Best for | AWS-first, production workloads | Open source, cross-cloud | GitHub Actions-first teams |
ECR public (gallery.ecr.aws) is a separate product discussed in section 8. It provides unlimited pulls of public images globally at no cost — making it the right home for open-source base images that need to scale without Docker Hub throttling.
One area where Docker Hub and GHCR still win: cross-cloud portability. If your image needs to be pulled by Azure AKS and AWS ECS in the same deployment, a neutral registry avoids the complexity of cross-cloud IAM. For purely AWS workloads, ECR is the right default.
Creating Repositories — CLI & Terraform
An ECR repository is a namespace for a single image family (e.g. my-company/api). One repository holds multiple tags (versions) of the same image. You can have private repositories (account-scoped) and public repositories (gallery.ecr.aws).
Creating via AWS CLI
# Create a private repository
aws ecr create-repository \
--repository-name my-company/api \
--region us-east-1 \
--image-scanning-configuration scanOnPush=true \
--encryption-configuration encryptionType=KMS,kmsKey=arn:aws:kms:us-east-1:123456789012:key/mrk-abc123 \
--tags Key=env,Value=prod Key=team,Value=backend
# List your repositories
aws ecr describe-repositories --region us-east-1
# Get the repository URI (needed for docker tag/push)
aws ecr describe-repositories \
--repository-names my-company/api \
--query 'repositories[0].repositoryUri' \
--output text
# Output: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-company/api
Creating via Terraform
resource "aws_ecr_repository" "api" {
name = "my-company/api"
image_tag_mutability = "IMMUTABLE" # Prevent tag overwrite — use for prod
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "KMS"
kms_key = aws_kms_key.ecr.arn
}
tags = {
Environment = "production"
Team = "backend"
}
}
# Output the full repository URL
output "ecr_repository_url" {
value = aws_ecr_repository.api.repository_url
}
# ECR Repository Policy — allow specific IAM roles to pull
resource "aws_ecr_repository_policy" "api_pull_policy" {
repository = aws_ecr_repository.api.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowECSTaskPull"
Effect = "Allow"
Principal = {
AWS = [
"arn:aws:iam::123456789012:role/ecs-task-execution-role"
]
}
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
]
}
]
})
}
image_tag_mutability = "IMMUTABLE" in production. This prevents :latest from being silently overwritten mid-deployment, which is a common cause of hard-to-debug production incidents. Use semantic version tags (v1.4.2) or commit SHA tags (git rev-parse --short HEAD) with immutability enabled.
Private vs Public Repositories
Private repositories are created in a specific AWS region and account. All pulls require AWS authentication. Public repositories live in us-east-1 globally and are accessible without credentials. You cannot convert a private repository to a public one — they are separate products with separate APIs.
Docker Auth, Push & Pull Workflow
ECR does not use a static username/password. Authentication is handled via short-lived tokens generated by the AWS Security Token Service (STS). The aws ecr get-login-password command retrieves a 12-hour token and pipes it directly into docker login.
Authenticate Docker to ECR
AWS_ACCOUNT_ID=123456789012
AWS_REGION=us-east-1
aws ecr get-login-password --region $AWS_REGION \
| docker login \
--username AWS \
--password-stdin \
${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
Full Build, Tag, and Push Workflow
REPO_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/my-company/api"
IMAGE_TAG=$(git rev-parse --short HEAD) # e.g. a3f1b9c
# Build
docker build -t my-company/api:${IMAGE_TAG} .
# Tag with full ECR URI
docker tag my-company/api:${IMAGE_TAG} ${REPO_URI}:${IMAGE_TAG}
docker tag my-company/api:${IMAGE_TAG} ${REPO_URI}:latest
# Push both tags
docker push ${REPO_URI}:${IMAGE_TAG}
docker push ${REPO_URI}:latest
Multi-Architecture Images with buildx
Running EKS clusters with a mix of x86 Graviton nodes, or deploying to both Intel laptops and Apple Silicon CI runners, requires multi-arch images. Docker Buildx creates a manifest list that Docker/containerd automatically resolves to the correct architecture.
# One-time setup: create a buildx builder that supports multi-arch
docker buildx create --name multiarch --use --bootstrap
# Build and push linux/amd64 + linux/arm64 in one step
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag ${REPO_URI}:${IMAGE_TAG} \
--tag ${REPO_URI}:latest \
--push \
.
# Verify the manifest list
docker manifest inspect ${REPO_URI}:${IMAGE_TAG}
Pulling Images
# Pull with auth
aws ecr get-login-password --region $AWS_REGION \
| docker login --username AWS --password-stdin \
${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
docker pull ${REPO_URI}:${IMAGE_TAG}
# In Kubernetes (EKS on same account), IAM node role handles auth — no imagePullSecret needed
# In ECS, the task execution role handles auth automatically
ECR in CI/CD — GitHub Actions & CodeBuild
Pushing to ECR from CI should never involve long-lived AWS access keys stored as secrets. The correct approach in 2026 is OIDC (OpenID Connect) federation, which lets GitHub Actions or other CI providers assume an IAM role without any stored credentials.
GitHub Actions with OIDC — No Stored Keys
# .github/workflows/build-push.yml
name: Build and Push to ECR
on:
push:
branches: [main]
permissions:
id-token: write # Required for OIDC token
contents: read
env:
AWS_REGION: us-east-1
ECR_REPOSITORY: my-company/api
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker tag $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
$REGISTRY/$ECR_REPOSITORY:latest
docker push $REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$ECR_REPOSITORY:latest
echo "image=$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Block deploy on critical CVE
run: |
sleep 30 # Allow scan to complete
FINDINGS=$(aws ecr describe-image-scan-findings \
--repository-name $ECR_REPOSITORY \
--image-id imageTag=${{ github.sha }} \
--query 'imageScanFindings.findingSeverityCounts.CRITICAL' \
--output text)
if [ "$FINDINGS" != "None" ] && [ "$FINDINGS" -gt 0 ]; then
echo "CRITICAL CVEs found: $FINDINGS — blocking deployment"
exit 1
fi
IAM Role for GitHub Actions OIDC
resource "aws_iam_role" "github_actions_ecr" {
name = "github-actions-ecr-push"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# Restrict to specific repo and branch
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "ecr_push" {
role = aws_iam_role.github_actions_ecr.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"
}
AWS CodeBuild buildspec.yml with ECR
# buildspec.yml
version: 0.2
env:
variables:
AWS_DEFAULT_REGION: us-east-1
ECR_REPOSITORY: my-company/api
phases:
pre_build:
commands:
- echo Logging in to Amazon ECR...
- AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
- ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com"
- IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c1-8)
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY
build:
commands:
- echo Building the Docker image...
- docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
- docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
post_build:
commands:
- echo Pushing to ECR...
- docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- echo Writing imagedefinitions.json for ECS rolling deploy...
- printf '[{"name":"api","imageUri":"%s"}]' \
"$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" > imagedefinitions.json
artifacts:
files:
- imagedefinitions.json
Image Scanning — Basic vs Enhanced (Inspector)
Container image scanning is one of the most important security controls in a container supply chain. ECR offers two scanning modes with very different capabilities, coverage, and pricing.
| Feature | Basic Scanning | Enhanced Scanning (Inspector) |
|---|---|---|
| Engine | Clair (open source) | Amazon Inspector v2 |
| OS packages | Yes | Yes |
| Application packages (npm, pip, Maven, Go) | No | Yes |
| Continuous rescanning | No (on-push only) | Yes (on new CVE) |
| EventBridge integration | Yes | Yes (richer payload) |
| SBOM export | No | Yes (CycloneDX/SPDX) |
| Cost | Free | $0.09/image/month |
Enable Enhanced Scanning via Terraform
resource "aws_ecr_registry_scanning_configuration" "main" {
scan_type = "ENHANCED"
rule {
scan_frequency = "CONTINUOUS_SCAN" # or SCAN_ON_PUSH
repository_filter {
filter = "*" # All repositories
filter_type = "WILDCARD"
}
}
}
# Optionally scope to specific repos
resource "aws_ecr_registry_scanning_configuration" "prod_only" {
scan_type = "ENHANCED"
rule {
scan_frequency = "CONTINUOUS_SCAN"
repository_filter {
filter = "my-company/prod-*"
filter_type = "WILDCARD"
}
}
}
Interpreting Vulnerability Reports
# View scan findings for a specific image
aws ecr describe-image-scan-findings \
--repository-name my-company/api \
--image-id imageTag=v1.4.2 \
--region us-east-1
# Count findings by severity
aws ecr describe-image-scan-findings \
--repository-name my-company/api \
--image-id imageTag=v1.4.2 \
--query 'imageScanFindings.findingSeverityCounts' \
--output json
# Example output:
# {
# "CRITICAL": 2,
# "HIGH": 14,
# "MEDIUM": 31,
# "LOW": 8,
# "INFORMATIONAL": 3,
# "UNDEFINED": 0
# }
# List specific critical CVEs
aws ecr describe-image-scan-findings \
--repository-name my-company/api \
--image-id imageTag=v1.4.2 \
--query 'imageScanFindings.findings[?severity==`CRITICAL`].[name,uri,description]' \
--output table
Blocking Critical CVEs in CI Pipeline
The most common gate is to fail the CI pipeline when CRITICAL vulnerabilities are detected. With Enhanced Scanning and EventBridge you can also create automated remediation workflows, but the simplest production guard is a post-push check in your build pipeline.
#!/bin/bash
# check-scan.sh — call after docker push, poll until scan completes
REPO=$1
TAG=$2
REGION=${AWS_REGION:-us-east-1}
MAX_WAIT=120 # seconds
SLEEP_INTERVAL=10
echo "Waiting for ECR scan on ${REPO}:${TAG}..."
elapsed=0
while [ $elapsed -lt $MAX_WAIT ]; do
STATUS=$(aws ecr describe-image-scan-findings \
--repository-name "$REPO" \
--image-id imageTag="$TAG" \
--region "$REGION" \
--query 'imageScanStatus.status' \
--output text 2>/dev/null || echo "PENDING")
if [ "$STATUS" = "COMPLETE" ]; then
break
fi
echo "Scan status: $STATUS — waiting ${SLEEP_INTERVAL}s..."
sleep $SLEEP_INTERVAL
elapsed=$((elapsed + SLEEP_INTERVAL))
done
CRITICAL=$(aws ecr describe-image-scan-findings \
--repository-name "$REPO" \
--image-id imageTag="$TAG" \
--region "$REGION" \
--query 'imageScanFindings.findingSeverityCounts.CRITICAL' \
--output text)
HIGH=$(aws ecr describe-image-scan-findings \
--repository-name "$REPO" \
--image-id imageTag="$TAG" \
--region "$REGION" \
--query 'imageScanFindings.findingSeverityCounts.HIGH' \
--output text)
echo "Critical: ${CRITICAL:-0}, High: ${HIGH:-0}"
if [ "${CRITICAL:-0}" != "None" ] && [ "${CRITICAL:-0}" -gt 0 ]; then
echo "FAIL: ${CRITICAL} critical CVE(s) found. Blocking deployment."
exit 1
fi
echo "Scan passed."
Lifecycle Policies — Expiry and Retention Rules
ECR charges $0.10/GB/month for stored images. Without lifecycle policies, old image layers accumulate indefinitely. A busy team pushing 10 images per day can accumulate thousands of images within a year. Lifecycle policies are ECR's built-in housekeeping mechanism — they run daily and expire images according to rules you define.
Policy Structure
A lifecycle policy is a JSON document with an array of rules. Each rule selects images by tag status (tagged/untagged/any) and expiration criteria (age or count), and applies an expire action. Rules are evaluated in priority order (lower number = higher priority).
Example 1: Keep Last 10 Tagged + Expire Old Untagged
{
"rules": [
{
"rulePriority": 1,
"description": "Expire untagged images older than 7 days",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 7
},
"action": { "type": "expire" }
},
{
"rulePriority": 2,
"description": "Keep only last 10 images tagged with 'v*'",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["v"],
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": { "type": "expire" }
},
{
"rulePriority": 3,
"description": "Keep only last 5 images tagged with commit SHA (short hex)",
"selection": {
"tagStatus": "tagged",
"tagPatternList": ["[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]"],
"countType": "imageCountMoreThan",
"countNumber": 5
},
"action": { "type": "expire" }
}
]
}
Example 2: Dev Repo — Expire Everything Older Than 14 Days
{
"rules": [
{
"rulePriority": 1,
"description": "Expire all images older than 14 days in dev",
"selection": {
"tagStatus": "any",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 14
},
"action": { "type": "expire" }
}
]
}
Apply via CLI and Terraform
# Apply lifecycle policy from a file
aws ecr put-lifecycle-policy \
--repository-name my-company/api \
--lifecycle-policy-text file://lifecycle-policy.json
# Preview what would be deleted (dry run)
aws ecr start-lifecycle-policy-preview \
--repository-name my-company/api \
--lifecycle-policy-text file://lifecycle-policy.json
# Check preview results
aws ecr get-lifecycle-policy-preview \
--repository-name my-company/api \
--query 'previewResults[*].[imageTags,action]'
resource "aws_ecr_lifecycle_policy" "api" {
repository = aws_ecr_repository.api.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Expire untagged images older than 7 days"
selection = {
tagStatus = "untagged"
countType = "sinceImagePushed"
countUnit = "days"
countNumber = 7
}
action = { type = "expire" }
},
{
rulePriority = 2
description = "Keep last 10 versioned releases"
selection = {
tagStatus = "tagged"
tagPrefixList = ["v"]
countType = "imageCountMoreThan"
countNumber = 10
}
action = { type = "expire" }
}
]
})
}
v1.x.x) are protected with a count-based rule, while unversioned/untagged images are expired aggressively.
Cross-Account Access
In organisations with separate dev, staging, and prod AWS accounts, container images are typically built in a central tooling account and pulled by workload accounts. ECR cross-account access uses resource-based policies on the repository itself, not IAM policies in the consumer account.
Repository Policy for Cross-Account Pull
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCrossAccountPull",
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::PROD_ACCOUNT_ID:root",
"arn:aws:iam::STAGING_ACCOUNT_ID:root"
]
},
"Action": [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
"ecr:DescribeImages",
"ecr:DescribeRepositories",
"ecr:GetRepositoryPolicy",
"ecr:ListImages"
]
}
]
}
# Apply repository policy from the tooling/central account
aws ecr set-repository-policy \
--repository-name my-company/api \
--policy-text file://cross-account-policy.json \
--region us-east-1
IAM Policy in the Consumer Account
The consumer account's IAM role (e.g. ECS task execution role or EKS node role) also needs the pull permissions, plus the ecr:GetAuthorizationToken action targeting the registry in the source account.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowECRTokenFromToolingAccount",
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
},
{
"Sid": "AllowPullFromToolingAccount",
"Effect": "Allow",
"Action": [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
],
"Resource": "arn:aws:ecr:us-east-1:TOOLING_ACCOUNT_ID:repository/my-company/api"
}
]
}
Cross-Account Pull from ECS Fargate
# Task execution role in PROD account — attach cross-account pull policy
resource "aws_iam_role_policy" "cross_account_ecr" {
name = "cross-account-ecr-pull"
role = aws_iam_role.ecs_task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = "ecr:GetAuthorizationToken"
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability"
]
Resource = "arn:aws:ecr:us-east-1:${var.tooling_account_id}:repository/my-company/api"
}
]
})
}
com.amazonaws.us-east-1.ecr.dkr and com.amazonaws.us-east-1.ecr.api) in both accounts. This keeps image traffic off the public internet entirely and eliminates per-image NAT gateway charges.
ECR Public Gallery
ECR Public (gallery.ecr.aws) is Amazon's answer to Docker Hub's rate limits and reliability problems. It provides a globally distributed, unlimited-pull container registry for public images. Any AWS account can publish to ECR Public. Anyone can pull without authentication, at no cost and with no rate limits.
Publishing a Public Image
# ECR Public is only in us-east-1
aws ecr-public get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin public.ecr.aws
# Create a public repository
aws ecr-public create-repository \
--repository-name my-org/my-tool \
--catalog-data description="My open-source container tool",\
operatingSystems="Linux",architectures="x86-64,ARM 64",\
aboutText="Longer description here" \
--region us-east-1
# Tag and push
docker tag my-tool:1.0.0 public.ecr.aws/my-alias/my-org/my-tool:1.0.0
docker push public.ecr.aws/my-alias/my-org/my-tool:1.0.0
Using Public Images in ECS Fargate
{
"containerDefinitions": [
{
"name": "app",
"image": "public.ecr.aws/nginx/nginx:1.25-alpine",
"essential": true,
"portMappings": [{"containerPort": 80}]
}
]
}
AWS-owned public images on ECR Public include popular base images like NGINX, Redis, Fluentbit, and the AWS Distro for OpenTelemetry (ADOT). Using public.ecr.aws base images eliminates Docker Hub pull rate limits in your CI environment and your production nodes simultaneously.
Pull-Through Cache
Pull-through cache is one of ECR's most operationally impactful features for teams that rely on upstream public registries. It transparently proxies and caches images from Docker Hub, Quay.io, Kubernetes Registry (registry.k8s.io), GitHub Container Registry (ghcr.io), and Azure Container Registry into your private ECR. Once cached, subsequent pulls come from ECR — no upstream rate limits, no external dependency at runtime.
Why Use It
- Docker Hub rate limits (100 pulls/6h unauthenticated) cause CI/CD failures in busy teams and EKS nodes
- Upstream registry outages cannot affect your in-flight deployments
- Pulls stay inside AWS — eliminates NAT gateway egress cost for base image pulls
- Lifecycle policies apply to cached images — automatic storage management
Configure via AWS Console or Terraform
# Create pull-through cache rules for Docker Hub and Quay
resource "aws_ecr_pull_through_cache_rule" "dockerhub" {
ecr_repository_prefix = "docker-hub"
upstream_registry_url = "registry-1.docker.io"
credential_arn = aws_secretsmanager_secret.dockerhub_creds.arn
}
resource "aws_ecr_pull_through_cache_rule" "quay" {
ecr_repository_prefix = "quay"
upstream_registry_url = "quay.io"
}
resource "aws_ecr_pull_through_cache_rule" "k8s" {
ecr_repository_prefix = "k8s"
upstream_registry_url = "registry.k8s.io"
}
# Docker Hub credentials stored in Secrets Manager (required for authenticated pulls)
resource "aws_secretsmanager_secret" "dockerhub_creds" {
name = "ecr-pull-through/dockerhub"
}
resource "aws_secretsmanager_secret_version" "dockerhub_creds" {
secret_id = aws_secretsmanager_secret.dockerhub_creds.id
secret_string = jsonencode({
username = var.dockerhub_username
accessToken = var.dockerhub_access_token
})
}
Using Cached Images
# Instead of: docker pull nginx:1.25-alpine
# Use:
docker pull 123456789012.dkr.ecr.us-east-1.amazonaws.com/docker-hub/library/nginx:1.25-alpine
# In Kubernetes (kustomize patch):
# Replace image references in manifests:
# FROM: nginx:1.25-alpine
# TO: 123456789012.dkr.ecr.us-east-1.amazonaws.com/docker-hub/library/nginx:1.25-alpine
# ECS task definition:
# "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/docker-hub/library/redis:7-alpine"
Dockerfile Using Pull-Through Cache
ARG ECR_REGISTRY=123456789012.dkr.ecr.us-east-1.amazonaws.com
# Use pull-through cache for base image
FROM ${ECR_REGISTRY}/docker-hub/library/node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
# Final stage
FROM ${ECR_REGISTRY}/docker-hub/library/node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
Cost Optimisation
ECR pricing is simple: $0.10/GB/month for private repository storage, plus standard AWS data transfer rates for pulls outside the same region. For most teams, the biggest cost driver is accumulated image layers from builds that were never cleaned up.
Understanding Storage Costs
A typical production image is 200–500 MB compressed. A team pushing 20 images per day without lifecycle policies accumulates 600 images per month. At 300 MB average: 600 × 0.3 GB × $0.10 = $18/month just for that one repository — and most teams have dozens of repositories. The fix is lifecycle policies (covered in section 6), but there are additional optimisations worth knowing.
Reduce Image Size
# Bad: large base image, single layer
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y nodejs npm
COPY . .
RUN npm install && npm run build
# Good: distroless or alpine, multi-stage, .dockerignore
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Distroless final image — no shell, no package manager
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["dist/index.js"]
# .dockerignore — keeps build context small, prevents leaking secrets
node_modules
.git
.github
*.md
tests/
.env*
Dockerfile*
docker-compose*
coverage/
.nyc_output/
*.log
Multi-Arch vs Separate Images
A multi-arch manifest list (one tag, two architecture blobs) stores approximately the same total data as two separate images, but the operational benefit of a single image reference outweighs any storage difference. ECR charges based on unique layer storage, and many layers are shared between architectures (application code, config files). In practice, a multi-arch image costs ~10-20% more storage than a single-arch image of the same application.
Cost Summary Checklist
- Apply lifecycle policies to every repository — set untagged expiry to 7 days and keep ≤ 10-20 tagged images
- Use
docker buildxlayer caching in CI — avoid full rebuilds when only application code changes - Use alpine or distroless base images — a 50 MB image costs 5× less to store than a 250 MB image
- Enable pull-through cache — eliminates NAT gateway egress ($0.045/GB) for base image pulls
- Use VPC endpoints for ECR — eliminates NAT gateway costs for image pulls on ECS/EKS inside your VPC
- Review storage monthly with:
aws ecr describe-repositories --query 'repositories[*].[repositoryName,repositorySizeInBytes]'
# List repositories sorted by size
aws ecr describe-repositories \
--query 'repositories[*].{Name:repositoryName,Size:repositorySizeInBytes}' \
--output json | python3 -c "
import json, sys
repos = json.load(sys.stdin)
for r in sorted(repos, key=lambda x: x.get('Size') or 0, reverse=True)[:10]:
size_mb = (r.get('Size') or 0) / (1024*1024)
print(f\"{r['Name']}: {size_mb:.1f} MB\")
"
Frequently Asked Questions
The token returned by aws ecr get-login-password is valid for 12 hours. In CI pipelines, always obtain a fresh token at the start of each job. In long-running ECS tasks or EKS nodes, the kubelet and ECS agent handle token refresh automatically using the node/task IAM role.
Deleting a repository is immediate and permanent. All images and their layers are deleted and cannot be recovered. Always use lifecycle policies to expire old images rather than manually deleting repositories. If you need to delete a repository with many images, use aws ecr delete-repository --force to skip the "repository must be empty" check.
Yes. Lambda container images are stored in ECR. The Lambda service role requires the same ECR pull permissions as ECS. When you deploy a Lambda function using a container image URI, Lambda caches the image in its own internal fleet — you are not charged for ECR data transfer to Lambda in the same region.
Yes. ECR supports OCI artifacts, which means you can store Helm charts, OPA (Open Policy Agent) bundles, and Cosign signatures alongside container images. Use oras push or Helm's OCI support (helm push chart.tgz oci://...) to push non-image artifacts to ECR.
Use AWS Signer (notation-based) or Cosign (sigstore). AWS Signer with Notation integrates natively with ECR and IAM. After signing, deploy a Kubernetes admission controller (like Kyverno or OPA Gatekeeper) to enforce that only signed images from trusted registries can run in your cluster. This is the recommended approach for a hardened container supply chain in 2026.