Enabling ECR pull-through for Root Container Registry

A step by step guide on setting up a customer-side “mirror cache” into your own Amazon ECR for images you pull from.

What you will achieve

  • A copy of selected cr.root.io images stored in your AWS account in your ECR.
  • Your workloads pull from your ECR, reducing repeated external downloads from cr.root.io.
  • A safe approach for mutable tags (e.g., :prod, :latest) where the mirroring job detects tag changes and updates your cache accordingly.
  • Optional architecture pinning (e.g., mirror only linux/amd64) to avoid cross-platform surprises.

Prerequisites

You’ll need:

  • Your AWS account ID and AWS region(s) where you want the cache (ECR is regional).
  • Credentials for pulling from cr.root.io (username/password/token you already use).
  • A place to run a small scheduled job (anything that can run a script on a timer).
  • Docker installed (the mirroring flow uses docker pull/tag/push).

AWS authentication (required)

The mirroring job needs AWS credentials. Use either:

Option A: AWS profile

export AWS_PROFILE="<profile-name>"

Option B: Access keys

export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."

optional if you use it:

export AWS_SESSION_TOKEN="..."

(And also set AWS_REGION in either case.)

Step 1 — Choose a destination naming convention in ECR

Pick a prefix in your ECR, for example: root-mirror/ Example mapping:

  • Source: cr.root.io/team/app:prod
  • Destination: <ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/root-mirror/team/app:prod

Step 2 — Create the mirror repositories in ECR

You can either:

  • Pre-create the repositories you plan to mirror into (recommended for tighter IAM + predictability), or
  • Allow the mirroring job to auto-create repositories (simpler, requires additional permission).

Option A (recommended): Pre-create repositories

For each source repo path (everything after cr.root.io/ up to :tag), create a matching ECR repo under your chosen prefix.

Example:

  • Source: cr.root.io/team/app:prod
  • Create ECR repo: root-mirror/team/app

Create via AWS CLI:

aws ecr create-repository \
  --region <REGION> \
  --repository-name root-mirror/team/app

Optional (recommended): add a lifecycle policy to limit storage growth (example keeps last 30 images):

aws ecr put-lifecycle-policy \
  --region <REGION> \
  --repository-name root-mirror/team/app \
  --lifecycle-policy-text '{
    "rules": [
      {
        "rulePriority": 1,
        "description": "Keep last 30 images",
        "selection": {
          "tagStatus": "any",
          "countType": "imageCountMoreThan",
          "countNumber": 30
        },
        "action": { "type": "expire" }
      }
    ]
  }'

Option B: Auto-create repositories from the script

If you prefer not to pre-create repos, the script can create them as needed. If you choose this, ensure the job role has:

  • ecr:CreateRepository
  • ecr:DescribeRepositories

Step 3 — Ensure your mirroring job can push to ECR

Your job needs permission to push images into ECR. Minimum AWS permissions commonly required:

  • ecr:GetAuthorizationToken
  • ecr:BatchCheckLayerAvailability
  • ecr:InitiateLayerUpload
  • ecr:UploadLayerPart
  • ecr:CompleteLayerUpload
  • ecr:PutImage
  • (optional) ecr:CreateRepository + ecr:DescribeRepositories (if auto-creating repos)

You can scope these permissions to root-mirror/* repositories for least privilege.

Step 4 — Define what to mirror (images.txt format)

Create an images.txt file.

Format

Each line is: <image>[:tag] [arch]

  • If provided, the script treats it as linux/<arch> (it prepends linux/ internally)

    • If provided, the script treats it as linux/<arch> (it prepends linux/ internally)

Example images.txt

# <image>:<tag> [arch]

cr.root.io/team/app:prod amd64
cr.root.io/team/worker:prod amd64
cr.root.io/team/app:1.2.3 amd64

# Without arch = no override (script uses default platform behavior)
cr.root.io/team/dev-tool:latest

Why the optional arch column exists:

  • If you run the job from macOS (darwin/arm64) or a different architecture, you often still want to mirror linux/amd64.
  • Explicit arch avoids pulling the “wrong” platform variant (or failing to resolve).

Step 5 — Mirror images (with tag-change detection and retries)

What the script does (behavior)

The mirroring script is designed to be safe for **mutable tags **and reliable across environments:

  • Cross-platform logging: uses a portable UTC timestamp format (works on Linux + macOS).
  • Retries: wraps network-sensitive operations (especially docker push) with retries (configurable attempts + delay).
  • Architecture support: reads optional arch from images.txt and mirrors linux/<arch> when specified.
  • Change detection: avoids re-copying images unless the content changed:
    • It compares image config digests (SHA256 of the image config JSON), which is stable across OCI vs Docker v2 manifest format differences.
    • This prevents false-positive “changed” loops that can happen when only manifest formatting differs.

How tag-change detection works (in plain terms)

For each <image>:<tag> [arch]:

  1. Resolve the current config digest for the source tag in cr.root.io (optionally for a specific platform).
  2. Resolve the current config digest for the same tag in your ECR mirror repo (same platform).
  3. If digests match → skip (already cached).
  4. If digests differ or destination tag is missing → pull, tag, and push to ECR.

5.1 Create this mirroring script

Save as mirror_to_ecr.sh:

#!/usr/bin/env bash
set -euo pipefail

# Required env vars:
#   AWS_REGION, AWS_ACCOUNT_ID, ROOT_USER, ROOT_PASS
AWS_REGION="${AWS_REGION:?}"
AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID:?}"
ROOT_USER="${ROOT_USER:?}"
ROOT_PASS="${ROOT_PASS:?}"

ROOT_REGISTRY="cr.root.io"
DST_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
PREFIX="root-mirror"
IMAGES_FILE="${IMAGES_FILE:-images.txt}"

# Set to "true" to auto-create ECR repos if they don't exist
AUTO_CREATE_REPOS="${AUTO_CREATE_REPOS:-false}"

log() { echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] $*"; }

# Retry a command up to N times with a delay between attempts
retry() {
  local attempts="${RETRY_ATTEMPTS:-3}"
  local delay="${RETRY_DELAY:-10}"
  local count=0
  until "$@"; do
    count=$((count + 1))
    if [[ ${count} -ge ${attempts} ]]; then
      log "ERROR: Command failed after ${attempts} attempts: $*"
      return 1
    fi
    log "Attempt ${count}/${attempts} failed, retrying in ${delay}s..."
    sleep "${delay}"
  done
}

# Extract repo path and tag from a fully qualified image reference.
# Input:  cr.root.io/team/app:prod
# Output: repo_path="team/app", tag="prod"
parse_image() {
  local img="$1"
  local no_registry="${img#${ROOT_REGISTRY}/}"
  local repo_path="${no_registry%:*}"
  local tag="${no_registry##*:}"
  echo "${repo_path}" "${tag}"
}

ecr_repo_exists() {
  local repo_name="$1"
  aws ecr describe-repositories --region "${AWS_REGION}" --repository-names "${repo_name}" >/dev/null 2>&1
}

ecr_create_repo() {
  local repo_name="$1"
  aws ecr create-repository --region "${AWS_REGION}" --repository-name "${repo_name}" >/dev/null
  log "Created ECR repository: ${repo_name}"
}
# Return the image config digest (sha256 of the image config JSON).
# This is format-independent (same whether manifest is OCI or Docker v2),
# making it a stable comparison key across registries.
#
# For manifest list images, resolves linux/<arch> to the platform manifest first.
# Usage: image_config_digest <image> [arch]
image_config_digest() {
  local image="$1"
  local arch="${2:-}"

  local raw
  raw=$(docker manifest inspect "${image}" 2>/dev/null) || { echo ""; return 0; }

  # If it's a manifest list, resolve to the platform-specific manifest first
  if echo "${raw}" | python3 -c "import json,sys; sys.exit(0 if 'manifests' in json.load(sys.stdin) else 1)" 2>/dev/null; then
    local platform_ref
    platform_ref=$(echo "${raw}" | python3 -c "
import json, sys
data = json.load(sys.stdin)
arch, image = sys.argv[1], sys.argv[2]
repo = image.rsplit(':', 1)[0]
for m in data.get('manifests', []):
    p = m.get('platform', {})
    if p.get('os') == 'linux' and (not arch or p.get('architecture') == arch):
        print(repo + '@' + m['digest'])
        break
" "${arch}" "${image}" 2>/dev/null) || true

    [[ -z "${platform_ref}" ]] && { echo ""; return 0; }
    raw=$(docker manifest inspect "${platform_ref}" 2>/dev/null) || { echo ""; return 0; }
  fi

  echo "${raw}" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('config', {}).get('digest', ''))
" 2>/dev/null || echo ""
}

log "Logging into ${ROOT_REGISTRY}..."

echo "${ROOT_PASS}" | docker login "${ROOT_REGISTRY}" -u "${ROOT_USER}" --password-stdin >/dev/null

log "Logging into ECR ${DST_REGISTRY}..."
aws ecr get-login-password --region "${AWS_REGION}" \
  | docker login --username AWS --password-stdin "${DST_REGISTRY}" >/dev/null

log "Starting mirroring from ${IMAGES_FILE}..."
while IFS= read -r LINE; do
  [[ -z "${LINE}" ]] && continue
  [[ "${LINE}" =~ ^# ]] && continue

  SRC_IMAGE="${LINE%% *}"
  ARCH=""
  if [[ "${LINE}" == *" "* ]]; then
    ARCH="${LINE##* }"
  fi

  if [[ "${SRC_IMAGE}" != ${ROOT_REGISTRY}/*:* ]]; then
    log "Skipping invalid line (expected ${ROOT_REGISTRY}/path:tag): ${SRC_IMAGE}"
    continue
  fi

  read -r REPO_PATH TAG < <(parse_image "${SRC_IMAGE}")
  TARGET_REPO="${PREFIX}/${REPO_PATH}"
  DST_IMAGE="${DST_REGISTRY}/${TARGET_REPO}:${TAG}"

  if [[ "${AUTO_CREATE_REPOS}" == "true" ]]; then
    if ! ecr_repo_exists "${TARGET_REPO}"; then
      ecr_create_repo "${TARGET_REPO}"
    fi
  fi

  # Resolve source config digest
  log "Inspecting source digest: ${SRC_IMAGE}"
  SRC_DIGEST="$(image_config_digest "${SRC_IMAGE}" "${ARCH}")"
  if [[ -z "${SRC_DIGEST}" ]]; then
    log "ERROR: Could not resolve source digest for ${SRC_IMAGE}"
    continue
  fi

  # Compare with ECR config digest (if image exists)
  CURRENT_ECR_DIGEST="$(image_config_digest "${DST_IMAGE}" "${ARCH}" || true)"

  if [[ -n "${CURRENT_ECR_DIGEST}" && "${CURRENT_ECR_DIGEST}" == "${SRC_DIGEST}" ]]; then
    log "Up-to-date (no copy needed): ${SRC_IMAGE} -> ${DST_IMAGE} (digest ${SRC_DIGEST})"
    continue
  fi

  if [[ -z "${CURRENT_ECR_DIGEST}" ]]; then
    log "Not present in ECR: ${DST_IMAGE} (will copy digest ${SRC_DIGEST})"
  else
    log "Changed tag detected: ${DST_IMAGE}"
    log "  ECR digest:  ${CURRENT_ECR_DIGEST}"
    log "  SRC digest:  ${SRC_DIGEST}"
  fi

  # Pull, tag, push
  log "Copying ${SRC_IMAGE} -> ${DST_IMAGE}${ARCH:+ (linux/${ARCH})}"

  if [[ -n "${ARCH}" ]]; then
    docker pull --platform "linux/${ARCH}" "${SRC_IMAGE}"
  else
    docker pull "${SRC_IMAGE}"
  fi

  docker tag "${SRC_IMAGE}" "${DST_IMAGE}"
  retry docker push "${DST_IMAGE}"

  # Cleanup local images
  docker rmi "${SRC_IMAGE}" "${DST_IMAGE}" 2>/dev/null || true

  log "Copied ${SRC_IMAGE} -> ${DST_IMAGE}"
done < "${IMAGES_FILE}"

log "Mirroring complete."


5.2 Running the script

Export required environment variables:

export AWS_REGION="<REGION>"
export AWS_ACCOUNT_ID="<ACCOUNT_ID>"

# Choose ONE AWS auth method:
export AWS_PROFILE="<profile-name>"
# OR:
# export AWS_ACCESS_KEY_ID="..."
# export AWS_SECRET_ACCESS_KEY="..."
# export AWS_SESSION_TOKEN="..."   # if applicable

export ROOT_USER="<YOUR_CR_ROOT_IO_USERNAME>"
export ROOT_PASS="<YOUR_CR_ROOT_IO_PASSWORD_OR_TOKEN>"

# optional: create ECR repos automatically if missing
export AUTO_CREATE_REPOS=true

# list of images to mirror
export IMAGES_FILE=images.txt

Then run:

chmod +x mirror_to_ecr.sh
./mirror_to_ecr.sh

Step 6 — Update workloads to pull from your ECR

Change pulls from:

cr.root.io/team/app:prod to:
<ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/root-mirror/team/app:prod

With mutable tags, your workloads will see updates after the mirror job runs and pushes the updated tag to your ECR.

If you want the strongest “never change unexpectedly” guarantee, you can still deploy by digest, but it is not required for caching—your ECR tag will track the mirrored tag based on the script’s change detection.

Step 7 - Suggested scheduling (platform-agnostic)

The mirroring script is designed to be run repeatedly. Pick a cadence based on how often your mutable tags change (for example: every 15–60 minutes for frequently-changing tags, less often for stable version tags).

You can schedule it in any environment that can run a command on a timer:

  • Cron on a VM/bastion/utility host
    • Install Docker + AWS CLI, store images.txt + mirror_to_ecr.sh, then add a cron entry.
    • Example (runs every 30 minutes):
      • */30 * * * * /path/to/mirror_to_ecr.sh >> 
        /var/log/root-mirror.log 2>&1
    • CI scheduler
      • Most CI systems support scheduled workflows/pipelines.
      • Store images.txt in the repo, inject secrets (ROOT_USER/ROOT_PASS, AWS creds/profile) via the CI secret manager, run ./mirror_to_ecr.sh.
    • A scheduled container/job runner
      • Package the script + images.txt into a small container image (or mount them), then run it periodically using whatever scheduler you already use (Kubernetes CronJob, a container task scheduler, etc.).
      • Make sure Docker is available (either inside the job image, or via a Docker-enabled runner).

Tip: Send stdout/stderr to centralized logs (or at least a file) so you can alert on failures, and keep images.txt focused on the specific images/tags your production workloads actually use.

Networking note (optional)

If your workloads run in private subnets, consider configuring ECR VPC endpoints / PrivateLink so pulls can stay private and reduce NAT usage. The exact endpoint setup depends on your VPC architecture.

Troubleshooting

  • 401/403 pulling from cr.root.io: credentials/token expired or not authorized
  • Denied pushing to ECR: IAM role missing ecr:PutImage or upload permissions
  • Repository not found: either pre-create repos (Step 2 Option A) or set AUTO_CREATE_REPOS=true
  • Wrong architecture mirrored: add the second column (amd64 / arm64) in images.txt
  • Intermittent push failures: retries are built-in; if persistent, check network throttling / ECR limits and increase retry settings (attempts/delay)