DEV Community

How to Migrate ECR Docker Images Between Repositories (with Automation)

Every now and then, developers find themselves in a situation where they need to migrate container images between Amazon ECR repositories.

Maybe the naming convention changed.
Maybe a new repo structure was introduced.
Maybe you just want to clean things up.

Whatever the reason, doing this manually — one image at a time — is slow and painful. And in fast-moving teams, anything that slows us down affects our velocity and robustness.

That’s why I built an automation script to migrate images quickly and safely. It uses skopeo under the hood to copy images between repositories without pulling and pushing them manually.

Let’s dive in. 👇

🛠️ Prerequisites

  • AWS CLI configured with a profile that has access to the ECR repositories
  • jq for JSON parsing
  • skopeo for copying container images

Installing skopeo

  • macOS (Homebrew)
  brew install skopeo
Enter fullscreen mode Exit fullscreen mode
  • Ubuntu/Debian
  sudo apt-get update
  sudo apt-get -y install skopeo
Enter fullscreen mode Exit fullscreen mode
  • Windows (via WSL2 or Chocolatey)

    • If you’re using WSL2 (Ubuntu), just use the Ubuntu instructions.
    • If you’re on Windows with Chocolatey:
    choco install skopeo
    

📜 The Migration Script

Below is the script I use on macOS (works with GNU/Linux and WSL as well, only date syntax differs).

It migrates all images pushed within the last N days from a source ECR repo to a destination ECR repo.

👉 Save it as migrate-ecr-images.sh and run it.

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

# --- check for skopeo and install if missing (macOS Homebrew) ---
if ! command -v skopeo >/dev/null 2>&1; then
  echo "skopeo not found, installing..."
  if ! command -v brew >/dev/null 2>&1; then
    echo "Homebrew is required but not found. Please install Homebrew first: https://brew.sh/"
    exit 1
  fi
  brew install skopeo
fi

SRC_REPO="java-micro-repo"
DST_REPO="repo"
WINDOW_DAYS=90
DRY_RUN=false

# --- detect env from AWS profile ---
REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-$(aws configure get region)}}"
: "${REGION:?Region not set in environment or AWS config}"
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
REG_HOST="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"

# --- BSD/GNU date helpers ---
is_bsd_date=false
if date -v-1d +%s >/dev/null 2>&1; then is_bsd_date=true; fi
cutoff_epoch() {
  if $is_bsd_date; then date -u -v-"${WINDOW_DAYS}"d +%s; else date -u -d "${WINDOW_DAYS} days ago" +%s; fi
}
to_epoch() {
  ts="$1"
  if $is_bsd_date; then
    ts_norm=$(printf "%s" "$ts" \
      | sed -E 's/Z$/+0000/' \
      | sed -E 's/([+-][0-9]{2}):([0-9]{2})$/\1\2/' \
      | sed -E 's/([0-9]{2}:[0-9]{2}:[0-9]{2})\.[0-9]+/\1/')
    case "$ts_norm" in *+????|*-[0-9][0-9][0-9][0-9]) : ;; *) ts_norm="${ts_norm}+0000" ;; esac
    date -j -u -f "%Y-%m-%dT%H:%M:%S%z" "$ts_norm" +%s
  else
    date -u -d "$ts" +%s
  fi
}

CUTOFF_EPOCH="$(cutoff_epoch)"

echo "Source:      ${REG_HOST}/${SRC_REPO}"
echo "Destination: ${REG_HOST}/${DST_REPO}"
echo "Window:      last ${WINDOW_DAYS} days"
echo "Dry run:     ${DRY_RUN}"
echo

# --- ensure destination repo exists ---
if ! aws ecr describe-repositories --repository-names "$DST_REPO" >/dev/null 2>&1; then
  echo "Creating destination repo: $DST_REPO"
  aws ecr create-repository --repository-name "$DST_REPO" >/dev/null
  aws ecr put-image-tag-mutability --repository-name "$DST_REPO" --image-tag-mutability MUTABLE >/dev/null || true
fi

# --- get ECR password for skopeo auth ---
ECR_PASS="$(aws ecr get-login-password --region "$REGION")"

# --- collect recent tags ---
TMP_LIST="$(mktemp)"; TMP_TAGS="$(mktemp)"
aws ecr describe-images --repository-name "$SRC_REPO" --output json \
| jq -r '.imageDetails[] | select(.imageTags!=null) | .imagePushedAt as $t | .imageTags[] | "\($t)\t\(.)"' > "$TMP_LIST"

while IFS=$'\t' read -r pushed tag; do
  epoch="$(to_epoch "$pushed")" || continue
  if [ "$epoch" -ge "$CUTOFF_EPOCH" ]; then printf "%s\n" "$tag"; fi
done < "$TMP_LIST" | sort -u > "$TMP_TAGS"

if ! [ -s "$TMP_TAGS" ]; then
  echo "No images pushed in the last ${WINDOW_DAYS} days. Nothing to do."
  rm -f "$TMP_LIST" "$TMP_TAGS"; exit 0
fi

echo "Found $(wc -l < "$TMP_TAGS" | tr -d ' ') tag(s):"
sed 's/^/  - /' "$TMP_TAGS"
echo

FAILED=()
while IFS= read -r TAG; do
  [ -z "$TAG" ] && continue
  SRC="docker://${REG_HOST}/${SRC_REPO}:${TAG}"
  DST="docker://${REG_HOST}/${DST_REPO}:${TAG}"
  echo "Copying ${TAG} …"
  if [ "$DRY_RUN" = true ]; then
    echo "  [dry-run] skopeo copy --all ${SRC} -> ${DST}"
    continue
  fi
  if ! skopeo copy --all \
        --src-creds "AWS:${ECR_PASS}" \
        --dest-creds "AWS:${ECR_PASS}" \
        "$SRC" "$DST"; then
    echo "  ❌ Failed: ${TAG}"
    FAILED+=("$TAG")
  else
    echo "  ✅ Done: ${TAG}"
  fi
done < "$TMP_TAGS"

rm -f "$TMP_LIST" "$TMP_TAGS"

echo
if [ "${#FAILED[@]}" -gt 0 ]; then
  echo "Completed with failures:"
  printf '  - %s\n' "${FAILED[@]}"
  exit 1
else
  echo "All requested tags copied successfully."
fi
Enter fullscreen mode Exit fullscreen mode

⚡ How It Works

  1. Finds recent images
    Using aws ecr describe-images, it filters tags by their imagePushedAt date within the last WINDOW_DAYS.

  2. Creates the destination repo if missing
    So you don’t have to do it manually.

  3. Copies images with skopeo
    No need to pull and re-push—skopeo copies directly between registries.

  4. Supports dry-run mode
    Set DRY_RUN=true to preview what will happen.

🔧 Example Usage

Copy the last 90 days of images from java-micro-repo to repo:

SRC_REPO="java-micro-repo" DST_REPO="repo" WINDOW_DAYS=90 ./migrate-ecr-images.sh
Enter fullscreen mode Exit fullscreen mode

Run a dry run (no changes made):

DRY_RUN=true ./migrate-ecr-images.sh
Enter fullscreen mode Exit fullscreen mode

✅ Why This Helps

  • Saves hours compared to manual copying
  • Automates repo creation if missing
  • Respects velocity → lets you focus on coding, not housekeeping
  • Cross-platform: works on macOS, Ubuntu, and Windows

🎯 Final Thoughts

This script became a huge time-saver in my team. Anytime we need to rename or restructure ECR repositories, it’s just one command away.

If you work with AWS ECR often, I recommend keeping this script handy in your toolbox. 🔧

👉 What about you — have you run into ECR image migration headaches?
Drop your thoughts in the comments—I’d love to hear how you’ve solved it.

Top comments (1)

Collapse
 
cloud-sky-ops profile image
cloud-sky-ops

Thanks, I'll try building something similar for Azure. This reference is helpful.