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.ioimages 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:CreateRepositoryecr: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:GetAuthorizationTokenecr:BatchCheckLayerAvailabilityecr:InitiateLayerUploadecr:UploadLayerPartecr:CompleteLayerUploadecr: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 prependslinux/internally)- If provided, the script treats it as
linux/<arch>(it prepends linux/ internally)
- If provided, the script treats it as
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
archfromimages.txtand mirrorslinux/<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]:
- Resolve the current config digest for the source tag in
cr.root.io(optionally for a specific platform). - Resolve the current config digest for the same tag in your ECR mirror repo (same platform).
- If digests match → skip (already cached).
- 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.txtin 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.txtinto 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).
- Package the script +
- Install Docker + AWS CLI, store
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:PutImageor 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) inimages.txt - Intermittent push failures: retries are built-in; if persistent, check network throttling / ECR limits and increase retry settings (attempts/delay)
Updated about 2 hours ago
