AWS ECR: Container Registry, Image Scanning and Lifecycle Policies (2026)

AWS ECR — Container Registry and Image Scanning

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.

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.

FeatureAWS ECR (private)Docker HubGitHub Container Registry
Storage pricing$0.10/GB/monthFree (1 private), paid plans for moreFree for public; included in GitHub plan for private
Data transfer to AWS computeFree (same region)Egress charges applyEgress charges apply
Pull rate limitsNone100/6 h anon, 200/6 h free authNone (authenticated)
IAM integrationNative — IAM roles, resource policiesUsername/password or PAT onlyGitHub PAT or OIDC token
Image scanningBuilt-in (Basic + Enhanced/Inspector)Paid add-on (Scout)Dependabot alerts only
Lifecycle policiesNative, rule-based JSONManual or 3rd-partyManual only
Private networkingVPC endpoint (no public internet)Public onlyPublic only
Best forAWS-first, production workloadsOpen source, cross-cloudGitHub Actions-first teams
Key insight: For teams running on AWS, ECR's zero data-transfer cost within the same region is a decisive advantage. A single high-traffic EKS cluster can pull images tens of thousands of times per day. On Docker Hub, those pulls would hit rate limits and carry egress costs. On ECR, they are free.

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"
        ]
      }
    ]
  })
}
Immutable tags: Set 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}
Graviton tip: ARM64 images on AWS Graviton3 (c7g, m7g) give ~20-40% better price-performance than equivalent x86 for CPU-bound workloads. Multi-arch manifests let you run the same image tag on both, eliminating separate build pipelines.

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.

FeatureBasic ScanningEnhanced Scanning (Inspector)
EngineClair (open source)Amazon Inspector v2
OS packagesYesYes
Application packages (npm, pip, Maven, Go)NoYes
Continuous rescanningNo (on-push only)Yes (on new CVE)
EventBridge integrationYesYes (richer payload)
SBOM exportNoYes (CycloneDX/SPDX)
CostFree$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."
False positives: Basic scanning can flag CVEs that are not exploitable in your specific runtime. Triage findings carefully before creating a hard block. Enhanced scanning provides package-level accuracy, significantly reducing false positives on OS-level findings that are patched in your base image but reported as present in metadata.

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" }
      }
    ]
  })
}
Gotcha: Lifecycle policies consider the pushed date, not the last-pulled date. An image actively used in production can be expired if it is older than the policy threshold. Always structure policies by tag prefix so that production-tagged images (e.g. 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"
      }
    ]
  })
}
VPC endpoints for cross-account: If both accounts are connected via VPC peering or AWS Transit Gateway, configure ECR VPC endpoints (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.

Bandwidth pricing for ECR Public: Pulls from within AWS (any region) are always free. Pulls from outside AWS (e.g. your developer laptops, external CI runners) are free up to 500 GB/month per account; beyond that, standard AWS data transfer rates apply. This is still far more generous than Docker Hub's rate limits.

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"]
First-pull latency: The first pull of a new image tag triggers a fetch from the upstream registry and caches it in ECR. This is slower than a regular ECR pull. Subsequent pulls of the same tag are served from ECR at full speed. Pre-warm critical base images during off-peak hours if cold-start latency matters.

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 buildx layer 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

Q: How long does the ECR authorization token last?

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.

Q: What happens to images in ECR if I delete a repository?

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.

Q: Can Lambda pull images from ECR?

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.

Q: Does ECR support OCI (Open Container Initiative) artifacts beyond Docker images?

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.

Q: How do I sign images in ECR for supply chain security?

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.