Find S3 Cross-Region Replication Rules and What They Cost

Rows of network cables and patch panels connecting server racks in a data center

Photo by Valentin Lacoste on Unsplash

S3 cross region replication cost is the sum of inter-Region data transfer (from $0.01 to $0.02 per GB out of us-east-1), $0.015 per GB for Replication Time Control if it’s on, one PUT request per replicated object, and storage for the second copy in the destination Region. To see which rules you pay for, call GetBucketReplication on every bucket and price each rule’s route.

Replication rules are set once and billed forever. A disaster-recovery copy set up for an audit, a log bucket replicated to another account, a rule pointing at a bucket someone deleted: none of them show up as a line item called “replication”. This example is for engineers and FinOps-minded leads who want a list of every rule, where it sends data, what S3 cross region replication costs for each, and which rules are broken.

The script is report only. It reads replication configurations and bucket locations and changes nothing.

What makes up S3 cross region replication cost?

Replication itself has no separate fee. You pay for the pieces it uses. As of September 2026, the AWS Price List files (S3 published 28 September 2026, data transfer 16 September 2026) give these list prices; the Amazon S3 pricing page has the full tables for every Region.

Cost driver Price (as of September 2026) When it applies
Inter-Region data transfer $0.02 per GB from us-east-1 to us-west-2 or eu-west-1; $0.01 per GB from us-east-1 to us-east-2 Cross-Region Replication only. Same-Region Replication has no transfer charge.
S3 Replication Time Control $0.015 per GB replicated Rules with ReplicationTime enabled
Replication PUT requests $0.005 per 1,000 (S3 Standard in us-east-1, us-west-2, eu-west-1) One PUT per replicated object at the destination
Destination storage $0.023 per GB-month S3 Standard (first 50 TB), $0.0125 Standard-IA, $0.004 Glacier Instant Retrieval, $0.0036 Glacier Flexible Retrieval Every replica, every month, in the destination Region
Replication metrics Billed as CloudWatch custom metrics Turned on automatically with RTC, or on their own

S3 documents that you pay for only one PUT per replicated object, even though replication also makes GET and HEAD requests against the source. Objects encrypted with SSE-KMS add KMS requests on both sides. Transfer prices differ by Region pair, so check yours if the source isn’t us-east-1.

Worked example: 500 GB a month to Oregon with RTC

A bucket in us-east-1 receives 500 GB and 200,000 new objects a month, replicated to us-west-2 in S3 Standard with RTC on:

  • Transfer: 500 GB × $0.02 = $10.00
  • RTC: 500 GB × $0.015 = $7.50
  • PUTs: 200,000 ÷ 1,000 × $0.005 = $1.00
  • New storage: 500 GB × $0.023 = $11.50 for the first month

That’s $30.00 in the first month. The storage line grows every month because replicas accumulate: after a year, the destination holds 6,000 GB, which costs 6,000 × $0.023 = $138.00 a month on its own. If versioning keeps old versions on both sides, you pay for those twice as well; the script to measure S3 storage used by noncurrent object versions shows how big that part is.

Replication traffic is billed as inter-Region transfer, not internet egress. For downloads to the internet, see how to estimate S3 data transfer out cost, and for request charges in general, how S3 GET and PUT request costs add up.

What does the script do?

  1. Lists every bucketpaginateListBuckets, using each bucket’s BucketRegion so the replication call goes to the right endpoint.
  2. Reads replication rulesGetBucketReplication per bucket. The error code ReplicationConfigurationNotFoundError means the bucket has no replication, so it’s skipped.
  3. Resolves each destinationGetBucketLocation on the destination bucket. NoSuchBucket means it was deleted; access denied (typical for another account) leaves the Region as unknown.
  4. Describes the ruleStatus, filter (prefix, tags), destination account, storage class, RTC and delete marker replication.
  5. Estimates costFor the volume you pass with --gb-per-month and --objects-per-month, using the September 2026 us-east-1 prices above.

Replication needs versioning on both buckets, so every source here is versioned; the script to find S3 buckets without versioning enabled covers the rest of the account.

Prerequisites

Which IAM permissions does it need?

s3-replication-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBuckets",
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Sid": "ReadReplicationAndLocation",
      "Effect": "Allow",
      "Action": ["s3:GetReplicationConfiguration", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::*"
    }
  ]
}

Everything is read-only. To resolve destinations in another account, that account’s bucket policy has to allow s3:GetBucketLocation for your role; otherwise the route shows “unknown”, which isn’t an error.

The script to find S3 replication rules and estimate their cost

find-s3-replication-rules.ts

// find-s3-replication-rules.ts
// Lists every S3 replication rule in the account: source and destination Region, storage class,
// Replication Time Control, delete marker replication and filters, flags destinations that no longer
// exist, and estimates the monthly cost of replicating a given volume. Report only.
// Usage: npx tsx find-s3-replication-rules.ts [--gb-per-month 500] [--objects-per-month 200000]
import {
  S3Client,
  GetBucketLocationCommand,
  GetBucketReplicationCommand,
  paginateListBuckets,
  type ReplicationRule,
} from "@aws-sdk/client-s3";

const args = process.argv.slice(2);
const flag = (name: string, fallback: number): number => {
  const i = args.indexOf(name);
  return i >= 0 ? Number(args[i + 1]) : fallback;
};
const gbPerMonth = flag("--gb-per-month", 100);
const objectsPerMonth = flag("--objects-per-month", 100_000);

// List prices from the AWS Price List (September 2026). Transfer rates are for us-east-1 as the source;
// other source Regions charge different rates, so check the S3 pricing page for yours.
const RTC_PER_GB = 0.015;
const PUT_PER_1000 = 0.005;
const STORAGE_PER_GB_MONTH: Record<string, number> = {
  STANDARD: 0.023,
  INTELLIGENT_TIERING: 0.023, // Frequent Access tier; objects move to cheaper tiers later
  STANDARD_IA: 0.0125,
  ONEZONE_IA: 0.01,
  GLACIER_IR: 0.004,
  GLACIER: 0.0036,
};
function transferPerGb(from: string, to: string): number {
  if (from === to) return 0; // Same-Region Replication has no inter-Region transfer charge
  if (from === "us-east-1" && to === "us-east-2") return 0.01;
  return 0.02; // us-east-1 to other US and EU Regions; confirm the rate for other pairs
}

interface Row {
  Source: string;
  Rule: string;
  Destination: string;
  Route: string;
  Class: string;
  RTC: string;
  DeleteMarkers: string;
  Filter: string;
  PerMonth: string;
  Problem: string;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const clients = new Map<string, S3Client>();
const s3 = (region: string): S3Client => {
  if (!clients.has(region)) clients.set(region, new S3Client({ region }));
  return clients.get(region)!;
};

/** Region of a bucket, "missing" if it no longer exists, "unknown" if we can't tell (for example another account). */
const regionCache = new Map<string, string>();
async function bucketRegion(bucket: string): Promise<string> {
  const cached = regionCache.get(bucket);
  if (cached) return cached;
  let region = "unknown";
  try {
    const res = await s3("us-east-1").send(new GetBucketLocationCommand({ Bucket: bucket }));
    const loc = res.LocationConstraint as string | undefined;
    region = !loc ? "us-east-1" : loc === "EU" ? "eu-west-1" : loc;
  } catch (err) {
    if (err instanceof Error && err.name === "NoSuchBucket") region = "missing";
  }
  regionCache.set(bucket, region);
  return region;
}

function describeFilter(rule: ReplicationRule): string {
  const f = rule.Filter;
  const parts: string[] = [];
  const prefix = f?.Prefix ?? f?.And?.Prefix ?? rule.Prefix;
  if (prefix) parts.push(`prefix ${prefix}`);
  for (const t of [...(f?.Tag ? [f.Tag] : []), ...(f?.And?.Tags ?? [])]) parts.push(`tag ${t.Key}=${t.Value}`);
  return parts.length ? parts.join(", ") : "whole bucket";
}

async function rulesFor(source: string, sourceRegion: string): Promise<Row[]> {
  let rules: ReplicationRule[];
  try {
    const res = await s3(sourceRegion).send(new GetBucketReplicationCommand({ Bucket: source }));
    rules = res.ReplicationConfiguration?.Rules ?? [];
  } catch (err) {
    if (err instanceof Error && err.name === "ReplicationConfigurationNotFoundError") return []; // no replication
    throw err;
  }
  const rows: Row[] = [];
  for (const rule of rules) {
    const destArn = rule.Destination?.Bucket ?? "";
    const destBucket = destArn.replace(/^arn:aws[a-z-]*:s3:::/, "");
    const destRegion = await bucketRegion(destBucket);
    const storageClass = rule.Destination?.StorageClass ?? "STANDARD"; // unset means the source object's class
    const rtc = rule.Destination?.ReplicationTime?.Status === "Enabled";
    const problems: string[] = [];
    if (rule.Status !== "Enabled") problems.push("rule disabled");
    if (destRegion === "missing") problems.push("destination bucket deleted");
    if (rule.Destination?.Account && !rule.Destination.AccessControlTranslation) problems.push("cross-account, no owner override");

    let perMonth = "n/a";
    if (rule.Status === "Enabled" && destRegion !== "missing" && destRegion !== "unknown") {
      const storage = STORAGE_PER_GB_MONTH[storageClass];
      const cost =
        gbPerMonth * (transferPerGb(sourceRegion, destRegion) + (rtc ? RTC_PER_GB : 0)) +
        (objectsPerMonth / 1000) * PUT_PER_1000 +
        (storage === undefined ? 0 : gbPerMonth * storage);
      perMonth = `$${cost.toFixed(2)}${storage === undefined ? " + storage" : ""}`;
    }
    rows.push({
      Source: source,
      Rule: rule.ID ?? "(no id)",
      Destination: destBucket + (rule.Destination?.Account ? ` (${rule.Destination.Account})` : ""),
      Route: `${sourceRegion} -> ${destRegion}`,
      Class: rule.Destination?.StorageClass ?? "same as source",
      RTC: rtc ? "on" : "off",
      DeleteMarkers: rule.DeleteMarkerReplication?.Status ?? "n/a (V1 rule)",
      Filter: describeFilter(rule),
      PerMonth: perMonth,
      Problem: problems.join("; "),
    });
  }
  return rows;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for await (const page of paginateListBuckets({ client: s3("us-east-1") }, {})) {
    for (const b of page.Buckets ?? []) {
      if (!b.Name) continue;
      const region = b.BucketRegion ?? (await bucketRegion(b.Name));
      try {
        rows.push(...(await rulesFor(b.Name, region)));
      } catch (err) {
        console.error(`${b.Name}: ${errorText(err)}`);
      }
    }
  }
  if (rows.length === 0) {
    console.log("No bucket in this account has a replication configuration.");
    return;
  }
  console.table(rows);
  console.log(
    `Estimate for ${gbPerMonth} GB and ${objectsPerMonth.toLocaleString("en-US")} new objects per rule per month: ` +
      "inter-Region transfer + RTC + replication PUTs + one month of destination storage (us-east-1 list prices).",
  );
}

main().catch((err) => {
  console.error(errorText(err));
  process.exit(1);
});

When a rule doesn’t set a destination storage class, S3 uses the source object’s class; the estimate prices that case as S3 Standard. The bucket listing uses an SDK v3 paginator, explained in the guide to paginating any AWS API with SDK v3.

How do you run it?

Terminal

npm install @aws-sdk/client-s3
npm install --save-dev tsx typescript @types/node

AWS_PROFILE=readonly npx tsx find-s3-replication-rules.ts --gb-per-month 500 --objects-per-month 200000

Sample output

Output

┌─────────┬────────────────────┬─────────────────────┬───────────────────────────────────┬──────────────────────────┬──────────────────┬───────┬───────────────┬──────────────────────────────┬──────────┬────────────────────────────────────┐
│ (index) │ Source             │ Rule                │ Destination                       │ Route                    │ Class            │ RTC   │ DeleteMarkers │ Filter                       │ PerMonth │ Problem                            │
├─────────┼────────────────────┼─────────────────────┼───────────────────────────────────┼──────────────────────────┼──────────────────┼───────┼───────────────┼──────────────────────────────┼──────────┼────────────────────────────────────┤
│ 0       │ 'acme-app-uploads' │ 'uploads-to-oregon' │ 'acme-app-uploads-usw2'           │ 'us-east-1 -> us-west-2' │ 'same as source' │ 'on'  │ 'Disabled'    │ 'whole bucket'               │ '$30.00' │ ''                                 │
│ 1       │ 'acme-logs'        │ 'logs-to-audit'     │ 'audit-archive-eu (210987654321)' │ 'us-east-1 -> unknown'   │ 'GLACIER_IR'     │ 'off' │ 'Disabled'    │ 'prefix cloudtrail/'         │ 'n/a'    │ 'cross-account, no owner override' │
│ 2       │ 'acme-logs'        │ 'logs-to-ohio'      │ 'acme-logs-use2'                  │ 'us-east-1 -> us-east-2' │ 'STANDARD_IA'    │ 'off' │ 'Disabled'    │ 'prefix app/, tag keep=true' │ '$12.25' │ ''                                 │
│ 3       │ 'acme-backups'     │ 'backups-dr'        │ 'acme-backups-dr-old'             │ 'us-east-1 -> missing'   │ 'GLACIER'        │ 'off' │ 'Enabled'     │ 'whole bucket'               │ 'n/a'    │ 'destination bucket deleted'       │
└─────────┴────────────────────┴─────────────────────┴───────────────────────────────────┴──────────────────────────┴──────────────────┴───────┴───────────────┴──────────────────────────────┴──────────┴────────────────────────────────────┘
Estimate for 500 GB and 200,000 new objects per rule per month: inter-Region transfer + RTC + replication PUTs + one month of destination storage (us-east-1 list prices).

This run used mocked S3 responses. uploads-to-oregon matches the worked example at $30.00 for the first month. logs-to-ohio is cheaper because us-east-1 to us-east-2 transfer is $0.01 per GB and the replicas land in Standard-IA. backups-dr replicates the whole backup bucket to a destination that no longer exists, so every new object fails to replicate. logs-to-audit sends CloudTrail logs to another account without an owner override; the report can’t see that Region, so the cost stays blank.

What should you do with each rule?

  • Destination bucket deleted. Remove the rule, or recreate the destination if the copy is still required. Replication can’t succeed while it points at nothing.
  • RTC on where nobody needs a 15-minute target. RTC adds $0.015 per GB on top of transfer. Keep it for compliance or failover workloads; turn it off for archives and logs.
  • Standard at the destination for a copy nobody reads. Set the rule’s destination storage class to Standard-IA or a Glacier class. Colder classes have minimum storage durations and retrieval charges, so compare them first with S3 storage class costs for backups.
  • Whole-bucket rules. A prefix or tag filter can cut volume sharply. Note that delete marker replication isn’t supported for tag-based filters.
  • Growing destinations. Add a lifecycle rule on the destination bucket; the report to find S3 buckets without lifecycle rules lists the gaps.

Live replication copies only new objects. Copying existing ones takes S3 Batch Replication, which runs as an S3 Batch Operations job. The price list shows Batch Operations at $0.25 per job and $1.00 per million objects processed, on top of the replication charges above.

Troubleshooting

  • A bucket you know has rules isn’t listed. S3 documents that a get soon after a put or delete of the configuration can return a stale result. Run the report again in a few minutes.
  • Every destination shows “unknown”. The role lacks s3:GetBucketLocation, or the destinations are in other accounts.
  • Errors for one bucket only. A bucket policy can deny s3:GetReplicationConfiguration even when your IAM policy allows it. See troubleshooting AWS IAM access denied errors.
  • The estimate doesn’t match your bill. It assumes us-east-1 source prices and one month of new data. Check the actual replication and transfer lines with the script that shows your most expensive AWS service in Cost Explorer.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “Which S3 buckets have replication rules, and where do they replicate to?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and summarizes the answer; the explanation of how ChatWithCloud works shows what’s sent where. It can be wrong and runs changes without confirmation, so use a read-only profile. For size questions, try asking AI which S3 buckets are largest, and see the collection of AWS practical examples for more reports.

Frequently asked questions

How much does S3 cross-region replication cost per GB?

From us-east-1, inter-Region transfer is $0.02 per GB to most US and EU Regions ($0.01 to us-east-2), plus $0.015 per GB with RTC, plus destination storage and one PUT per object. Prices as of September 2026.

Is Same-Region Replication cheaper?

Yes. SRR has no inter-Region transfer charge, but you still pay for PUT requests, destination storage and RTC if enabled.

Does S3 replication copy existing objects?

No. Live replication copies objects written after the rule exists. Use S3 Batch Replication for existing objects.

How do I check a bucket’s replication rules from the CLI?

Run aws s3api get-bucket-replication --bucket my-bucket. The error code ReplicationConfigurationNotFoundError means no rules are configured.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud