11 min read

    Cross-Account, Cross-Region Replication for ECR and S3, Including the Data You Already Have

    Hardik Shah

    Hardik Shah

    Cloud Architect & AWS Expert

    AWS
    ECR
    S3
    Replication
    Disaster Recovery
    IAM
    DevOps
    Cross-Account, Cross-Region Replication for ECR and S3, Including the Data You Already Have

    A disaster-recovery copy in a second AWS account and region sounds like a checkbox: turn on replication and move on. Then someone opens the destination bucket and finds it empty. ECR replication and S3 replication only copy data written after the rule exists. This post sets up both rules and the one-time backfill that copies everything already there.

    Why doesn't replication copy existing data?

    Both services treat replication as a reaction to a write. ECR copies an image when it is pushed, and S3 copies an object when it is put. Nothing is pushed or put again when you create the rule, so every image and object from before that moment stays in the source until you copy it yourself.

    That splits the work into two jobs for each service:

    • New data: a native replication rule for ECR and one for each S3 bucket.
    • Existing data: a one-time backfill, with crane for container images and S3 Batch Replication for objects.

    The setup

    SideAccountRegionAWS CLI profile
    Source111111111111us-east-1src
    Destination222222222222us-west-2dst

    The goals are to replicate every ECR repository and image, and to replicate a set of S3 buckets (16 in our case) to matching buckets in the destination. Existing objects and their older versions count too. Every command below names its profile and region explicitly, so nothing depends on whatever your shell happens to default to.

    Part 1: ECR

    Step 1: Let the source replicate into the destination

    In the destination account, add a registry policy that trusts the source account to create repositories and replicate images. Save it as dest-registry-policy.json:

    json
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "AllowReplicationFromSourceAccount",
          "Effect": "Allow",
          "Principal": { "AWS": "arn:aws:iam::111111111111:root" },
          "Action": ["ecr:CreateRepository", "ecr:ReplicateImage"],
          "Resource": "arn:aws:ecr:us-west-2:222222222222:repository/*"
        }
      ]
    }
    bash
    aws ecr put-registry-policy \
      --policy-text file://dest-registry-policy.json \
      --region us-west-2 --profile dst

    Replication creates any missing destination repository with default settings: AES256 encryption, mutable tags and no lifecycle policy. If you want something else, create a repository creation template before you turn replication on. This one makes every replicated repository use immutable tags:

    bash
    aws ecr create-repository-creation-template \
      --prefix ROOT \
      --applied-for REPLICATION \
      --image-tag-mutability IMMUTABLE \
      --region us-west-2 --profile dst

    Step 2: Create the replication rule in the source

    Save the rule as replication-config.json and apply it in the source region:

    json
    {
      "rules": [
        {
          "destinations": [
            { "region": "us-west-2", "registryId": "222222222222" }
          ]
        }
      ]
    }
    bash
    aws ecr put-replication-configuration \
      --replication-configuration file://replication-config.json \
      --region us-east-1 --profile src

    With no repositoryFilters, the rule covers every repository. ECR creates the AWSServiceRoleForECRReplication service-linked role on first use, so whoever runs this needs iam:CreateServiceLinkedRole. From here on, every push in us-east-1 is copied to the destination.

    Step 3: Backfill the images that already exist

    For the images pushed before the rule, I use crane. It copies manifests and layers straight from one registry to the other without pulling to local disk, and it keeps multi-arch images and their digests intact. A docker pull and docker push loop only handles your own machine's platform, so an arm64 variant would quietly go missing.

    Install it on x86_64 (use the Linux_arm64 tarball on Graviton):

    bash
    VERSION=$(curl -s https://api.github.com/repos/google/go-containerregistry/releases/latest \
      | grep '"tag_name"' | cut -d'"' -f4)
    curl -sL "https://github.com/google/go-containerregistry/releases/download/${VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
      | tar -xz -C /usr/local/bin crane
    crane version

    Build hosts in private subnets often have no route to GitHub. If that is yours, download the tarball somewhere else and copy it over with scp.

    Then save this as backfill-ecr.sh. It logs crane in to both registries, creates any destination repository that does not exist yet, and copies every tagged image:

    bash
    #!/usr/bin/env bash
    set -euo pipefail
    
    SRC_ACCT=111111111111; SRC_REGION=us-east-1; SRC_PROFILE=src
    DST_ACCT=222222222222; DST_REGION=us-west-2; DST_PROFILE=dst
    SRC_REG=$SRC_ACCT.dkr.ecr.$SRC_REGION.amazonaws.com
    DST_REG=$DST_ACCT.dkr.ecr.$DST_REGION.amazonaws.com
    
    aws ecr get-login-password --region $SRC_REGION --profile $SRC_PROFILE \
      | crane auth login $SRC_REG -u AWS --password-stdin
    aws ecr get-login-password --region $DST_REGION --profile $DST_PROFILE \
      | crane auth login $DST_REG -u AWS --password-stdin
    
    for repo in $(aws ecr describe-repositories --region $SRC_REGION --profile $SRC_PROFILE \
                  --query 'repositories[].repositoryName' --output text); do
    
      aws ecr describe-repositories --repository-names "$repo" \
          --region $DST_REGION --profile $DST_PROFILE >/dev/null 2>&1 \
        || aws ecr create-repository --repository-name "$repo" \
             --region $DST_REGION --profile $DST_PROFILE >/dev/null
    
      for tag in $(aws ecr list-images --repository-name "$repo" --filter tagStatus=TAGGED \
                   --region $SRC_REGION --profile $SRC_PROFILE \
                   --query 'imageIds[].imageTag' --output text); do
        echo "Copying $repo:$tag"
        crane copy "$SRC_REG/$repo:$tag" "$DST_REG/$repo:$tag" || echo "WARN: failed $repo:$tag"
      done
    done
    bash
    bash backfill-ecr.sh 2>&1 | tee backfill.log

    Run it with bash, not sh. On many distributions sh is dash, and set -o pipefail fails there before the script copies anything. The script only copies tagged images. Untagged manifests are usually build leftovers, and a repository creation template or lifecycle policy in the destination is the place to decide what to keep.

    Step 4: Verify ECR

    bash
    aws ecr describe-image-replication-status \
      --repository-name <repo> --image-id imageTag=<tag> \
      --region us-east-1 --profile src

    You want "status": "COMPLETE" for the destination. Then push a new test tag to any repository and confirm it shows up in us-west-2 within a minute or two. That proves the rule; the backfill log proves the history.

    Part 2: S3

    S3 has more moving parts than ECR:

    PieceLives inPurpose
    Replication IAM roleSourceS3 assumes it to read source objects and write replicas
    Destination bucket policyDestinationLets that role write into the bucket
    VersioningBothRequired on both sides before replication will run
    Replication ruleSource bucketCopies new objects
    Batch Operations role and jobSourceCopies existing objects

    With 16 buckets, clicking through the console is how one bucket ends up with versioning off and nobody notices for a month. I scripted it instead, with every setting in one config file. Why the copy belongs in a separate account and region at all is covered in S3 cross-account, cross-region replication for a DR site.

    bash
    # common.conf
    SRC_ACCT=111111111111;  SRC_REGION=us-east-1; SRC_PROFILE=src
    DST_ACCT=222222222222;  DST_REGION=us-west-2; DST_PROFILE=dst
    
    REPL_ROLE=s3-crr-to-222222222222
    BATCH_ROLE=s3-batch-replication-role
    REPORT_BUCKET=""                    # optional: failure reports for batch jobs
    DELETE_MARKER_REPLICATION=Disabled  # don't propagate deletes (safer for DR)
    
    BUCKET_PAIRS=(
      "app-data-src:app-data-dst"
      "reports-src:reports-dst"
      # ...one line per bucket pair
    )

    The scripts need jq (dnf install -y jq or apt install -y jq).

    Step 1: Create the IAM roles in the source

    The replication role is trusted by s3.amazonaws.com. It reads from the source bucket and writes replicas into the destination:

    json
    {
      "Version": "2012-10-17",
      "Statement": [
        { "Effect": "Allow",
          "Action": ["s3:GetReplicationConfiguration", "s3:ListBucket"],
          "Resource": ["arn:aws:s3:::app-data-src"] },
        { "Effect": "Allow",
          "Action": ["s3:GetObjectVersionForReplication", "s3:GetObjectVersionAcl", "s3:GetObjectVersionTagging"],
          "Resource": ["arn:aws:s3:::app-data-src/*"] },
        { "Effect": "Allow",
          "Action": ["s3:ReplicateObject", "s3:ReplicateDelete", "s3:ReplicateTags", "s3:ObjectOwnerOverrideToBucketOwner"],
          "Resource": ["arn:aws:s3:::app-data-dst/*"] }
      ]
    }

    The Batch Operations role is trusted by batchoperations.s3.amazonaws.com:

    json
    {
      "Version": "2012-10-17",
      "Statement": [
        { "Effect": "Allow", "Action": ["s3:InitiateReplication"],
          "Resource": ["arn:aws:s3:::app-data-src/*"] },
        { "Effect": "Allow", "Action": ["s3:GetReplicationConfiguration", "s3:PutInventoryConfiguration"],
          "Resource": ["arn:aws:s3:::app-data-src"] }
      ]
    }

    In the script, the resource lists are generated from BUCKET_PAIRS, so one role of each kind covers every bucket.

    Order matters here. The destination bucket policy in the next step names the replication role as its principal. If the role does not exist yet, put-bucket-policy fails with Invalid principal. Create the roles first.

    Step 2: Turn on versioning and extend the policy in the destination

    For each destination bucket, turn on versioning:

    bash
    aws s3api put-bucket-versioning --bucket app-data-dst \
      --versioning-configuration Status=Enabled \
      --region us-west-2 --profile dst

    Then add these two statements to its bucket policy. Add, not replace: the bucket may already carry statements someone depends on.

    json
    [
      {
        "Sid": "S3ReplicationFromSourceObjects",
        "Effect": "Allow",
        "Principal": { "AWS": "arn:aws:iam::111111111111:role/s3-crr-to-222222222222" },
        "Action": ["s3:ReplicateObject", "s3:ReplicateDelete", "s3:ReplicateTags", "s3:ObjectOwnerOverrideToBucketOwner"],
        "Resource": "arn:aws:s3:::app-data-dst/*"
      },
      {
        "Sid": "S3ReplicationFromSourceBucket",
        "Effect": "Allow",
        "Principal": { "AWS": "arn:aws:iam::111111111111:role/s3-crr-to-222222222222" },
        "Action": ["s3:GetBucketVersioning", "s3:PutBucketVersioning"],
        "Resource": "arn:aws:s3:::app-data-dst"
      }
    ]

    put-bucket-policy replaces the whole policy in one call. So the script reads the existing policy, backs it up to a file, merges the two statements in with jq and writes the result back:

    bash
    existing=$(aws s3api get-bucket-policy --bucket "$dst" --query Policy --output text 2>/dev/null \
               || echo '{"Version":"2012-10-17","Statement":[]}')
    echo "$existing" > "backups/${dst}-policy.json"
    
    merged=$(echo "$existing" | jq -c --argjson new "$NEW_STATEMENTS" '
      .Statement |= ((if type=="object" then [.] else . end)
        | map(select(.Sid != "S3ReplicationFromSourceObjects" and .Sid != "S3ReplicationFromSourceBucket"))
        + $new)')
    
    aws s3api put-bucket-policy --bucket "$dst" --policy "$merged"

    Filtering on Sid makes the step safe to re-run. The two statements are replaced each time instead of piling up as duplicates, and the backup file is what you restore from if you roll back.

    Step 3: Add the replication rule for new objects in the source

    Turn on versioning on each source bucket:

    bash
    aws s3api put-bucket-versioning --bucket app-data-src \
      --versioning-configuration Status=Enabled \
      --region us-east-1 --profile src

    Then write the rule as replication.json:

    json
    {
      "Role": "arn:aws:iam::111111111111:role/s3-crr-to-222222222222",
      "Rules": [{
        "ID": "crr-to-app-data-dst",
        "Priority": 1,
        "Status": "Enabled",
        "Filter": { "Prefix": "" },
        "DeleteMarkerReplication": { "Status": "Disabled" },
        "Destination": {
          "Bucket": "arn:aws:s3:::app-data-dst",
          "Account": "222222222222",
          "AccessControlTranslation": { "Owner": "Destination" }
        }
      }]
    }
    bash
    aws s3api put-bucket-replication --bucket app-data-src \
      --replication-configuration file://replication.json \
      --region us-east-1 --profile src

    Three choices in that rule are worth explaining:

    • AccessControlTranslation set to Destination makes the destination account the owner of the replicas. Without it, the source account owns objects sitting in someone else's bucket, and the destination team cannot manage them cleanly.
    • DeleteMarkerReplication set to Disabled means a delete in the source does not hide the object in the destination. For a recovery copy that is usually what you want. An accidental or malicious delete should not take out both copies.
    • put-bucket-replication overwrites any replication configuration already on the bucket. The script skips buckets that already have one unless you set FORCE=1, and it also skips buckets with SSE-KMS default encryption, which need the extra settings covered below.

    Step 4: Copy existing objects with S3 Batch Replication

    This is the S3 version of the crane backfill, except you do not write the copy loop. Batch Replication builds its own manifest of every object that has never been replicated, then replicates each one through the rule you just created. All versions, metadata and tags come across, and the copies are marked REPLICA exactly like newly written objects.

    bash
    aws s3control create-job \
      --account-id 111111111111 \
      --operation '{"S3ReplicateObject":{}}' \
      --report '{"Enabled":false}' \
      --manifest-generator '{
        "S3JobManifestGenerator": {
          "ExpectedBucketOwner": "111111111111",
          "SourceBucket": "arn:aws:s3:::app-data-src",
          "EnableManifestOutput": false,
          "Filter": { "EligibleForReplication": true,
                      "ObjectReplicationStatuses": ["NONE", "FAILED"] }
        }}' \
      --priority 10 \
      --role-arn arn:aws:iam::111111111111:role/s3-batch-replication-role \
      --no-confirmation-required \
      --region us-east-1 --profile src

    The ObjectReplicationStatuses filter does the real work. NONE picks up everything that predates the rule, and FAILED retries anything that failed before. That makes the job safe to run again later: objects already replicated are left alone.

    The script loops over every bucket pair, starts one job per bucket and records the job IDs, so a second script can print a status table:

    bash
    BUCKET                     STATUS       TOTAL       OK   FAILED
    app-data-src               Complete     48213    48213        0
    reports-src                Active      120554    90310        0

    A job on an empty bucket can end in Failed because its manifest has nothing in it. That is harmless. If Batch Replication is not an option for you, aws s3 sync between the two buckets copies the current versions. It does not carry older versions or tags, though, and the copies are not marked as replicas.

    Step 5: Verify S3

    bash
    # Write a test object
    echo test > repl-test.txt
    aws s3 cp repl-test.txt s3://app-data-src/repl-test.txt --profile src --region us-east-1
    
    # Source: PENDING, then COMPLETED
    aws s3api head-object --bucket app-data-src --key repl-test.txt \
      --query ReplicationStatus --profile src --region us-east-1
    
    # Destination: REPLICA
    aws s3api head-object --bucket app-data-dst --key repl-test.txt \
      --query ReplicationStatus --profile dst --region us-west-2
    
    # Compare object counts and total size
    aws s3 ls s3://app-data-src --recursive --summarize --profile src | tail -2
    aws s3 ls s3://app-data-dst --recursive --summarize --profile dst | tail -2

    The counts will not match while a batch job is still running. Compare them once every job in the status table reads Complete.

    What about SSE-KMS buckets?

    Replication skips objects encrypted with SSE-KMS unless you opt in, and it does so without an error. The rule has to select KMS-encrypted objects and name a destination key. The replication role needs decrypt on the source key and encrypt on the destination key, and the destination key policy must allow that role.

    The rule change looks like this:

    json
    "SourceSelectionCriteria": { "SseKmsEncryptedObjects": { "Status": "Enabled" } },
    "Destination": {
      "Bucket": "arn:aws:s3:::app-data-dst",
      "Account": "222222222222",
      "AccessControlTranslation": { "Owner": "Destination" },
      "EncryptionConfiguration": { "ReplicaKmsKeyID": "<destination KMS key ARN>" }
    }

    Buckets on SSE-S3 (AES256) need none of this, which is why the script handles them first and leaves KMS buckets for a deliberate second pass.

    What does replication not copy?

    Neither service copies data that existed before the rule, which is why the backfill exists. ECR also leaves repository settings and deletes behind. S3 leaves permanent deletes, lifecycle expirations and bucket settings behind, and skips KMS-encrypted objects unless configured. Plan for each of these explicitly instead of finding them during a recovery.

    ServiceNot replicatedWhat to do
    ECRImages pushed before the ruleBackfill with crane
    ECRLifecycle policies, repository policies, scan settingsRepository creation template, or set them per repository
    ECRPull-through cache repositoriesConfigure pull-through cache rules in the destination too
    ECRDeletes in the sourceNothing; the destination keeps the image, which suits a recovery copy
    S3Objects written before the ruleBatch Replication
    S3Permanent deletes by version ID, lifecycle expirationsGive the destination its own lifecycle rules
    S3Lifecycle rules, CORS, notifications, website configurationApply them to the destination bucket yourself
    S3SSE-KMS objectsOpt in with SseKmsEncryptedObjects and key permissions

    Troubleshooting

    SymptomCause and fix
    ECR replication status FAILEDDestination registry policy missing, or it names the wrong principal
    crane: command not found, then BrokenPipeErrorcrane is not installed, so the AWS CLI was piping its password into nothing
    syntax error near ( or pipefail errorsScript run with sh; run it with bash
    S3 Invalid principal on the bucket policyReplication role not created yet, or IAM is still propagating; wait and retry
    S3 ReplicationStatus FAILEDDestination bucket policy missing, or destination versioning is off
    KMS objects never arriveAdd SseKmsEncryptedObjects to the rule and grant the key permissions
    Batch job AccessDeniedBatch role lacks s3:InitiateReplication, or the caller lacks iam:PassRole
    Old data missing in the destinationExpected until you run the crane backfill or the batch job

    Rollback

    bash
    # ECR: turn replication off and remove the trust
    echo '{"rules":[]}' > empty.json
    aws ecr put-replication-configuration --replication-configuration file://empty.json \
      --region us-east-1 --profile src
    aws ecr delete-registry-policy --region us-west-2 --profile dst
    
    # S3: remove the rule, restore the original destination policy from backup
    aws s3api delete-bucket-replication --bucket app-data-src --region us-east-1 --profile src
    aws s3api put-bucket-policy --bucket app-data-dst \
      --policy file://backups/app-data-dst-policy.json --region us-west-2 --profile dst

    S3 versioning can be suspended but never turned off again, and anything already replicated stays in the destination. Rolling back stops new copies; it does not undo old ones.

    Wrapping up

    Replication is really two jobs: a rule that copies everything from now on, and a one-time backfill for everything that already exists. For ECR the backfill is a short crane copy loop. For S3, Batch Replication with the NONE and FAILED filter does it natively, versions and all.

    Run the steps in order: roles, then destination policy, then source rule, then backfill. Verify each side with a test write and a count. At the end you have a complete copy of your registry and buckets in a second account and region that you can prove is complete.

    Hardik Shah

    About Hardik Shah

    Hardik has built disaster-recovery copies of container registries and S3 buckets across AWS accounts. He checks the destination with a count, not with the rule's status.