Multi-Region S3 with Replication and Encryption: A Terraform Guide
S3 is deceptively simple to start with — create a bucket, upload objects, done. But a production-grade S3 setup for compliance or disaster recovery looks very different: versioning enabled, server-side encryption with KMS, cross-region replication with its own IAM role and destination bucket configuration, lifecycle policies to manage storage costs, public access blocked at every level, and access logging for audit trails.
Writing this in Terraform involves more moving parts than most people expect. IaC Genius generates the complete configuration with every piece connected correctly.
What Makes Multi-Region S3 Complex
Cross-Region Replication Prerequisites
CRR (Cross-Region Replication) has strict prerequisites that must all be in place before replication works:
- Versioning must be enabled on both buckets — source and destination. Enable it on just one and replication silently does nothing.
- Both buckets must exist before you configure replication — Terraform's dependency resolution handles this, but only if you write the
depends_oncorrectly. - An IAM role with specific S3 permissions must exist and be referenced in the replication configuration. The trust policy must allow
s3.amazonaws.comto assume the role. - The destination bucket's region must be different from the source — AWS will return an error if you accidentally point replication at the same region.
KMS Encryption Across Regions
If you encrypt the source bucket with a KMS key (as you should), replication needs permission to decrypt objects from the source key AND encrypt them with a destination key. This means:
- Two KMS keys (one per region)
- The replication IAM role needs
kms:Decrypton the source key andkms:GenerateDataKeyon the destination key - The KMS key policies need to allow the replication role access
Miss any of these and encrypted objects silently fail to replicate with no obvious error in the S3 console.
Lifecycle Policies and Cost Management
Without lifecycle policies, versioned buckets accumulate non-current versions indefinitely. A bucket that receives frequent writes can balloon in storage costs within weeks. Lifecycle rules need to be attached to the right prefixes, apply to non-current versions specifically, and have transition rules (to Glacier) before expiration rules — in the wrong order, Glacier transition rules conflict with expiration rules and one silently wins.
Public Access Block
S3 has four separate public access block settings at both the bucket and account level. All four should be true for private buckets. AWS has been adding defaults here over time, but Terraform configurations that don't explicitly set these leave you dependent on account-level defaults that may change.
What IaC Genius Generates
Source Bucket with Versioning and Encryption
resource "aws_s3_bucket" "source" {
bucket = "${var.project}-${var.environment}-primary"
force_destroy = var.force_destroy
tags = var.tags
}
resource "aws_s3_bucket_versioning" "source" {
bucket = aws_s3_bucket.source.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "source" {
bucket = aws_s3_bucket.source.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.source.arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "source" {
bucket = aws_s3_bucket.source.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
bucket_key_enabled = true is an easy win that reduces KMS API calls by up to 99%, significantly cutting KMS costs on high-traffic buckets.
Replication IAM Role
resource "aws_iam_role" "replication" {
name = "${var.project}-s3-replication"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "s3.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "replication" {
name = "replication-policy"
role = aws_iam_role.replication.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetReplicationConfiguration",
"s3:ListBucket"
]
Resource = aws_s3_bucket.source.arn
},
{
Effect = "Allow"
Action = [
"s3:GetObjectVersionForReplication",
"s3:GetObjectVersionAcl",
"s3:GetObjectVersionTagging"
]
Resource = "${aws_s3_bucket.source.arn}/*"
},
{
Effect = "Allow"
Action = [
"s3:ReplicateObject",
"s3:ReplicateDelete",
"s3:ReplicateTagging"
]
Resource = "${aws_s3_bucket.destination.arn}/*"
},
{
Effect = "Allow"
Action = ["kms:Decrypt"]
Resource = aws_kms_key.source.arn
},
{
Effect = "Allow"
Action = ["kms:GenerateDataKey"]
Resource = aws_kms_key.destination.arn
}
]
})
}
This is the complete IAM policy for KMS-encrypted cross-region replication. Every permission is present, every resource ARN is correct, and the KMS decrypt/encrypt permissions are both included.
Replication Configuration
resource "aws_s3_bucket_replication_configuration" "main" {
role = aws_iam_role.replication.arn
bucket = aws_s3_bucket.source.id
rule {
id = "replicate-all"
status = "Enabled"
destination {
bucket = aws_s3_bucket.destination.arn
storage_class = var.replica_storage_class
encryption_configuration {
replica_kms_key_id = aws_kms_key.destination.arn
}
}
source_selection_criteria {
sse_kms_encrypted_objects {
status = "Enabled"
}
}
}
depends_on = [
aws_s3_bucket_versioning.source,
aws_s3_bucket_versioning.destination
]
}
The depends_on block ensures versioning is active on both buckets before replication is configured — the most common source of silent replication failures.
Lifecycle Policy
resource "aws_s3_bucket_lifecycle_configuration" "source" {
bucket = aws_s3_bucket.source.id
rule {
id = "manage-versions"
status = "Enabled"
noncurrent_version_transition {
noncurrent_days = var.glacier_transition_days
storage_class = "GLACIER"
}
noncurrent_version_expiration {
noncurrent_days = var.expiration_days
}
}
}
Old versions move to Glacier for cheap long-term retention, then expire after your configured retention period. Storage costs stay predictable.
Security Best Practices Built In
- KMS encryption on both buckets: Customer-managed KMS keys give you full control over key rotation and access policies
- Public access blocked at all four levels: No accidental public exposure
- Access logging: S3 server access logs written to a dedicated logging bucket for audit trails
- Versioning on both buckets: Enables point-in-time recovery and is required for replication
- Delete marker replication: Deletes propagate to the replica, preventing replica drift
Customization Options
- Storage class for replicas: STANDARD, STANDARD_IA, or GLACIER depending on access patterns
- Replication scope: All objects or specific prefixes
- Lifecycle retention periods: Glacier transition and expiration days
- KMS key rotation: Automatic annual rotation enabled by default
- Access logging: Enable/disable with dedicated logging bucket
Who Uses This
Multi-region S3 replication is most commonly needed for:
- Compliance requirements: GDPR, HIPAA, and PCI-DSS often require data redundancy in separate geographic regions
- Disaster recovery: RPO close to zero for critical data assets
- Multi-region application architectures: Serving data to users from the region closest to them
- Backup strategy: A separate region means infrastructure events in one region don't affect your backup
Try the Multi-Region S3 template for free at app.iacgenius.com. Generate a complete, validated S3 replication setup in minutes.