README.md

s3-bucket

Creates a hardened S3 bucket with encryption, versioning, lifecycle rules, access logging, and optional cross-region replication.

Provisions an S3 bucket with all public access blocked by default, server-side encryption (aws:kms or AES256), object versioning, configurable lifecycle transitions and expirations, access log delivery to a target bucket, and optional cross-region replication with per-rule destination encryption keys.

Usage

Minimal

The smallest valid call. Versioning, SSE-KMS with the AWS-managed key, BucketOwnerEnforced ownership, and a fully-blocking public access block are all applied by default.

module "minimal_bucket" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name = "my-org-minimal-bucket"
}

Customer-managed KMS key

Recommended for most HCA workloads. Passing an explicit kms_key_id also means the module's kms_key_id output actually returns a usable ARN — see the Known limitations note below on why the AWS-managed key case differs.

module "data_bucket" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name = "my-org-data-bucket"
  kms_key_id  = module.kms.key_arn

  lifecycle_rules = {
    archive = {
      transitions = [
        { days = 90, storage_class = "STANDARD_IA" },
        { days = 365, storage_class = "GLACIER" },
      ]
    }
  }
}

Lifecycle rules with expiration

Each transition's days must be less than the rule's expiration_days when both are set — a transition scheduled to happen after the object has already expired is rejected at terraform validate, not silently accepted as dead configuration.

module "logs_bucket" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name = "my-org-app-logs"

  lifecycle_rules = {
    standard_tiering = {
      prefix                                 = "logs/"
      abort_incomplete_multipart_upload_days = 7
      noncurrent_version_expiration_days     = 90
      expiration_days                        = 365
      transitions = [
        { days = 30, storage_class = "STANDARD_IA" },
        { days = 90, storage_class = "GLACIER" },
        { days = 180, storage_class = "DEEP_ARCHIVE" },
      ]
    }
    short_lived_temp = {
      prefix          = "tmp/"
      expiration_days = 14
      # transitions omitted -> object simply expires at day 14, no tiering
    }
  }
}

Access logging

module "app_bucket" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name            = "my-org-app-bucket"
  logging_target_bucket  = "my-org-access-logs"
  logging_target_prefix  = "app-bucket-logs/"
}

logging_target_bucket must already exist and accept log delivery writes — this module does not validate its format, existence, or permissions. See Known limitations.

Cross-region / cross-account replication

Replication requires versioning_enabled = true (enforced at terraform validate) and a pre-existing IAM role with the standard S3 replication trust policy and permissions.

module "replicated_bucket" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name           = "my-org-primary-bucket"
  versioning_enabled    = true
  replication_role_arn  = "arn:aws:iam::123456789012:role/s3-replication-role"

  replication_rules = {
    same_region_dr = {
      destination_bucket_arn = "arn:aws:s3:::my-org-dr-bucket"
      priority                = 1
    }
    cross_account_archive = {
      destination_bucket_arn = "arn:aws:s3:::my-org-archive-bucket"
      destination_account_id = "234567890123"
      destination_kms_key_id = "arn:aws:kms:us-east-1:234567890123:key/abcd1234-ef56-7890-ab12-cd34ef567890"
      priority                = 2
    }
  }
}

Every rule needs a unique priority — duplicate priorities across rules in the same replication_rules map are rejected at terraform validate.

Object Lock (compliance / WORM)

object_lock_enabled can only be set at bucket creation and can never be disabled afterward. It also requires versioning_enabled = true.

module "compliance_bucket" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name         = "my-org-compliance-records"
  versioning_enabled  = true
  object_lock_enabled = true

  object_lock_default_retention = {
    mode = "GOVERNANCE"
    days = 365
  }
}

⚠️ Warning

A bucket with Object Lock enabled and objects written to it cannot be destroyed until each object's retention period expires (GOVERNANCE mode can be bypassed with s3:BypassGovernanceRetention permission and explicit action; COMPLIANCE mode cannot be bypassed at all, by anyone, including the account root). An empty Object Lock bucket can still be destroyed normally. Do not apply this configuration against a bucket you intend to tear down casually — test with a plain bucket first.

Restrictive public access overrides

All four public access block settings default to true (fully blocking). Override only when you have a specific, reviewed reason — for example, a bucket serving public static assets behind CloudFront with OAC, where the bucket policy (not ACLs) governs access.

module "public_assets_bucket" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name          = "my-org-public-assets"
  object_ownership     = "BucketOwnerEnforced"
  block_public_acls    = true
  block_public_policy  = false
  ignore_public_acls   = true
  restrict_public_buckets = false

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "PublicReadGetObject"
      Effect    = "Allow"
      Principal = "*"
      Action    = "s3:GetObject"
      Resource  = "arn:aws:s3:::my-org-public-assets/*"
    }]
  })
}

Full example

Combines encryption, lifecycle, logging, tags, and a custom object ownership mode in one call.

module "full_example" {
  source  = "hcassc.jfrog.io/iac-tf-modules-virtual/storage/s3-bucket/aws"
  version = "0.1.0"

  bucket_name        = "my-org-full-example"
  versioning_enabled = true

  sse_algorithm      = "aws:kms"
  kms_key_id         = module.kms.key_arn
  bucket_key_enabled = true

  object_ownership = "BucketOwnerEnforced"

  logging_target_bucket = "my-org-access-logs"
  logging_target_prefix = "full-example/"

  lifecycle_rules = {
    standard_tiering = {
      prefix                                 = "data/"
      abort_incomplete_multipart_upload_days = 7
      noncurrent_version_expiration_days     = 90
      expiration_days                        = 730
      transitions = [
        { days = 30, storage_class = "STANDARD_IA" },
        { days = 180, storage_class = "GLACIER" },
      ]
    }
  }

  tags = {
    environment = "production"
    owner       = "platform-team"
  }
}

Known limitations

  • kms_key_id output is empty when using the AWS-managed key. When kms_key_id is left unset (the default), AWS automatically encrypts objects with the account's default aws/s3 managed key — but the AWS provider does not read that resolved key ARN back into Terraform state, so the module's kms_key_id output returns an empty string in that case. It only returns a usable value when you supply your own kms_key_id explicitly. If a downstream consumer needs the key ARN (for example, to grant kms:Decrypt to another principal), pass a customer-managed key rather than relying on the AWS-managed default.
  • mfa_delete is not exposed. Enabling MFA Delete requires a live MFA-authenticated session (root or IAM user), which is incompatible with this platform's OIDC/assumed-role pipeline authentication. If MFA Delete is required on a specific bucket, it must be enabled manually, out-of-band, by someone with root or IAM-user credentials and a physical/virtual MFA device — Terraform will not manage that attribute.
  • logging_target_bucket is not format- or existence-validated. The module does not check that the target bucket exists, is in a compatible region, or has the correct log-delivery permissions. These are AWS API-level failures at apply time, not something format validation can catch, so none is applied.
  • Replication priority uniqueness is stricter than AWS technically requires. AWS only needs unique priorities among rules whose prefixes can overlap on the same object. This module enforces uniqueness across all rules in a replication_rules map regardless of prefix overlap, since prefix-overlap analysis isn't validated either. If you have a legitimate case for two non-overlapping rules sharing a priority, this module will currently reject it.

Requirements

Name Version
terraform >= 1.15.0, < 2.0.0
aws >= 6.0, < 7.0

Providers

Name Version
aws >= 6.0, < 7.0

Resources

Name Type
aws_s3_bucket.this resource
aws_s3_bucket_lifecycle_configuration.this resource
aws_s3_bucket_logging.this resource
aws_s3_bucket_object_lock_configuration.this resource
aws_s3_bucket_ownership_controls.this resource
aws_s3_bucket_policy.this resource
aws_s3_bucket_public_access_block.this resource
aws_s3_bucket_replication_configuration.this resource
aws_s3_bucket_server_side_encryption_configuration.this resource
aws_s3_bucket_versioning.this resource

Inputs

Name Description Type Default Required
bucket_name Globally unique S3 bucket name string n/a yes
block_public_acls Block public ACLs bool true no
block_public_policy Block public bucket policies bool true no
bucket_key_enabled Enable S3 bucket key to reduce KMS API calls bool true no
force_destroy Allow destruction of non-empty bucket bool false no
ignore_public_acls Ignore public ACLs bool true no
kms_key_id KMS key ARN for server-side encryption (null uses aws:kms with managed key) string null no
lifecycle_rules Map of lifecycle rules to apply map(object({ enabled = optional(bool, true) prefix = optional(string, null) abort_incomplete_multipart_upload_days = optional(number, 7) expiration_days = optional(number, null) noncurrent_version_expiration_days = optional(number, 90) transitions = optional(list(object({ days = number storage_class = string })), []) })) {} no
logging_target_bucket S3 bucket name to receive access logs (optional) string null no
logging_target_prefix Prefix for access log objects string "logs/" no
object_lock_default_retention Default retention rule applied to objects when Object Lock isenabled. mode must be 'GOVERNANCE' or 'COMPLIANCE'. Only usedwhen object_lock_enabled is true. object({ mode = string days = number }) null no
object_lock_enabled Enable S3 Object Lock on the bucket. Can only be set at bucketcreation, cannot be enabled on an existing bucket. Requiresversioning_enabled = true. bool false no
object_ownership Object ownership string "BucketOwnerEnforced" no
policy JSON bucket policy document (optional) string null no
replication_role_arn IAM role ARN for S3 replication (required when replication_rules is set) string null no
replication_rules Map of replication rules map(object({ destination_bucket_arn = string destination_kms_key_id = optional(string, null) destination_account_id = optional(string, null) prefix = optional(string, "") priority = optional(number, 1) delete_marker_replication = optional(bool, true) })) {} no
restrict_public_buckets Restrict public bucket access bool true no
sse_algorithm SSE algorithm (aws:kms or AES256) string "aws:kms" no
tags Resource tags to apply to all resources map(string) {} no
versioning_enabled Enable object versioning bool true no

Outputs

Name Description
arn S3 bucket ARN
bucket_domain_name Bucket-style domain name
bucket_regional_domain_name Regional domain name (for CloudFront origins)
hosted_zone_id Route 53 hosted zone ID for the bucket's region
id S3 bucket name
kms_key_id KMS key ARN actually applied for SSE-KMS encryption
object_lock_enabled Whether S3 Object Lock is enabled on the bucket
replication_configuration_id Replication configuration ID, if replication is configured
versioning_status Effective versioning status of the bucket