IaC AWS

Terraform Module: AWS S3 Bucket — secure, encrypted buckets with sane defaults

Quick take — A production-ready Terraform module for AWS S3 buckets on hashicorp/aws ~> 5.0: enforced encryption, versioning, lifecycle tiering, full public-access blocking, and TLS-only bucket policies. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "s3" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-s3?ref=v1.0.0"

  bucket_name = "..."  # Globally unique bucket name; validated for length and D…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon S3 is AWS’s object storage service — the default home for everything from application assets and data-lake landing zones to log archives, Terraform remote state, and static-site content. The catch is that a bare aws_s3_bucket in the AWS provider 5.x is now a deliberately empty shell: encryption, versioning, public-access blocking, lifecycle rules, and ownership controls each live in their own separate resources (aws_s3_bucket_server_side_encryption_configuration, aws_s3_bucket_versioning, aws_s3_bucket_public_access_block, and so on). Wire them up by hand on every bucket and it is only a matter of time before someone ships a bucket with no encryption, public ACLs left on, or no TLS enforcement — three of the most common ways S3 data gets exposed.

This module wraps aws_s3_bucket together with the sub-resources that almost every production bucket needs, and ships them secure by default: SSE enabled, all four public-access-block switches on, bucket-owner-enforced object ownership (ACLs disabled), and an attached bucket policy that denies any request not made over TLS. You opt into riskier choices (like enabling a website endpoint), you do not have to remember to opt out of insecure ones.

When to use it

Reach for something heavier (or compose additional resources) when you need cross-region replication, S3 Object Lock / WORM compliance retention, event notifications to Lambda/SQS, or access points — those are intentionally outside this module’s baseline scope.

Module structure

terraform-module-aws-s3/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # KMS encryption requires a key ARN; otherwise fall back to SSE-S3 (AES256).
  sse_algorithm = var.kms_key_arn != null ? "aws:kms" : "AES256"
}

resource "aws_s3_bucket" "this" {
  bucket        = var.bucket_name
  force_destroy = var.force_destroy

  tags = merge(
    var.tags,
    {
      Name      = var.bucket_name
      ManagedBy = "terraform"
    },
  )
}

# Disable ACLs entirely — the bucket owner owns every object.
resource "aws_s3_bucket_ownership_controls" "this" {
  bucket = aws_s3_bucket.this.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

# Block ALL forms of public access at the bucket level.
resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id

  block_public_acls       = true
  block_public_policy      = true
  ignore_public_acls       = true
  restrict_public_buckets   = true
}

# Server-side encryption: SSE-KMS when a key is supplied, else SSE-S3.
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = local.sse_algorithm
      kms_master_key_id = var.kms_key_arn
    }
    bucket_key_enabled = var.kms_key_arn != null ? true : null
  }
}

# Object versioning (recommended on for data-bearing buckets).
resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.versioning_enabled ? "Enabled" : "Disabled"
  }
}

# Optional lifecycle rules: tier old objects to cheaper storage and expire them.
resource "aws_s3_bucket_lifecycle_configuration" "this" {
  count  = length(var.lifecycle_rules) > 0 ? 1 : 0
  bucket = aws_s3_bucket.this.id

  # A lifecycle config depends on versioning being applied first.
  depends_on = [aws_s3_bucket_versioning.this]

  dynamic "rule" {
    for_each = var.lifecycle_rules
    content {
      id     = rule.value.id
      status = rule.value.enabled ? "Enabled" : "Disabled"

      filter {
        prefix = rule.value.prefix
      }

      dynamic "transition" {
        for_each = rule.value.transitions
        content {
          days          = transition.value.days
          storage_class = transition.value.storage_class
        }
      }

      dynamic "expiration" {
        for_each = rule.value.expiration_days != null ? [1] : []
        content {
          days = rule.value.expiration_days
        }
      }

      dynamic "noncurrent_version_expiration" {
        for_each = rule.value.noncurrent_version_expiration_days != null ? [1] : []
        content {
          noncurrent_days = rule.value.noncurrent_version_expiration_days
        }
      }
    }
  }
}

# Deny any request not made over TLS (aws:SecureTransport = false).
data "aws_iam_policy_document" "this" {
  # Merge in caller-supplied policy statements if provided.
  source_policy_documents = var.bucket_policy_json != null ? [var.bucket_policy_json] : []

  statement {
    sid    = "DenyInsecureTransport"
    effect = "Deny"

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    actions   = ["s3:*"]
    resources = [
      aws_s3_bucket.this.arn,
      "${aws_s3_bucket.this.arn}/*",
    ]

    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}

resource "aws_s3_bucket_policy" "this" {
  bucket = aws_s3_bucket.this.id
  policy = data.aws_iam_policy_document.this.json

  # The policy can only be applied after public access is blocked,
  # otherwise block_public_policy can reject it.
  depends_on = [aws_s3_bucket_public_access_block.this]
}
# variables.tf

variable "bucket_name" {
  description = "Globally unique S3 bucket name (3-63 chars, lowercase, DNS-compliant)."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
    error_message = "bucket_name must be 3-63 chars, lowercase, and start/end with a letter or digit."
  }

  validation {
    condition     = !can(regex("\\.\\.|^xn--|^sthree-|-s3alias$|--ol-s3$", var.bucket_name))
    error_message = "bucket_name must not contain '..' or use reserved S3 prefixes/suffixes."
  }
}

variable "force_destroy" {
  description = "Allow Terraform to delete a non-empty bucket (and all objects). Keep false in prod."
  type        = bool
  default     = false
}

variable "versioning_enabled" {
  description = "Enable S3 object versioning to protect against accidental overwrite/delete."
  type        = bool
  default     = true
}

variable "kms_key_arn" {
  description = "KMS key ARN for SSE-KMS encryption. When null, the module uses SSE-S3 (AES256)."
  type        = string
  default     = null

  validation {
    condition     = var.kms_key_arn == null || can(regex("^arn:aws[a-z-]*:kms:", var.kms_key_arn))
    error_message = "kms_key_arn must be null or a valid KMS key ARN."
  }
}

variable "bucket_policy_json" {
  description = "Optional additional bucket policy JSON, merged with the enforced TLS-only deny statement."
  type        = string
  default     = null
}

variable "lifecycle_rules" {
  description = "List of lifecycle rules for tiering and expiring objects."
  type = list(object({
    id                                 = string
    enabled                            = optional(bool, true)
    prefix                             = optional(string, "")
    expiration_days                    = optional(number)
    noncurrent_version_expiration_days = optional(number)
    transitions = optional(list(object({
      days          = number
      storage_class = string
    })), [])
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.lifecycle_rules : alltrue([
        for t in r.transitions : contains(
          ["STANDARD_IA", "ONEZONE_IA", "INTELLIGENT_TIERING", "GLACIER_IR", "GLACIER", "DEEP_ARCHIVE"],
          t.storage_class
        )
      ])
    ])
    error_message = "Each transition storage_class must be a valid S3 storage class (e.g. STANDARD_IA, GLACIER, DEEP_ARCHIVE)."
  }
}

variable "tags" {
  description = "Tags applied to the bucket."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "id" {
  description = "The name (ID) of the bucket."
  value       = aws_s3_bucket.this.id
}

output "bucket_name" {
  description = "The name of the bucket."
  value       = aws_s3_bucket.this.bucket
}

output "arn" {
  description = "The ARN of the bucket, for use in IAM/resource policies."
  value       = aws_s3_bucket.this.arn
}

output "bucket_domain_name" {
  description = "The bucket domain name (bucket.s3.amazonaws.com)."
  value       = aws_s3_bucket.this.bucket_domain_name
}

output "bucket_regional_domain_name" {
  description = "The region-specific bucket domain name (use for S3 origins / VPC endpoints)."
  value       = aws_s3_bucket.this.bucket_regional_domain_name
}

output "hosted_zone_id" {
  description = "The Route 53 hosted zone ID for this bucket's region (for alias records)."
  value       = aws_s3_bucket.this.hosted_zone_id
}

How to use it

# A versioned, KMS-encrypted log archive bucket that tiers old logs to Glacier.
module "s3_bucket" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-s3?ref=v1.0.0"

  bucket_name        = "kloudvin-prod-app-logs"
  versioning_enabled = true
  kms_key_arn        = aws_kms_key.logs.arn

  lifecycle_rules = [
    {
      id     = "archive-and-expire-logs"
      prefix = "app/"
      transitions = [
        { days = 30, storage_class = "STANDARD_IA" },
        { days = 90, storage_class = "GLACIER" },
      ]
      expiration_days                    = 365
      noncurrent_version_expiration_days = 30
    }
  ]

  tags = {
    Environment = "prod"
    Owner       = "platform-team"
    CostCenter  = "logging"
  }
}

# Downstream: grant a delivery role write access using the bucket ARN output.
data "aws_iam_policy_document" "log_writer" {
  statement {
    effect    = "Allow"
    actions   = ["s3:PutObject"]
    resources = ["${module.s3_bucket.arn}/*"]
  }
}

resource "aws_iam_role_policy" "log_writer" {
  name   = "write-app-logs"
  role   = aws_iam_role.log_delivery.id
  policy = data.aws_iam_policy_document.log_writer.json
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/s3/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-s3?ref=v1.0.0"
}

inputs = {
  bucket_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/s3 && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
bucket_name string Yes Globally unique bucket name; validated for length and DNS-compliant characters.
force_destroy bool false No Allow Terraform to delete a non-empty bucket. Keep false in production.
versioning_enabled bool true No Enable object versioning to guard against overwrite/delete.
kms_key_arn string null No KMS key ARN for SSE-KMS. When null, the bucket uses SSE-S3 (AES256).
bucket_policy_json string null No Extra bucket policy JSON, merged with the enforced TLS-only deny statement.
lifecycle_rules list(object) [] No Tiering/expiry rules (transitions, object expiration, noncurrent-version expiration).
tags map(string) {} No Tags applied to the bucket (module also adds Name and ManagedBy).

Outputs

Name Description
id The name (ID) of the bucket.
bucket_name The name of the bucket.
arn The bucket ARN, for IAM and resource policies.
bucket_domain_name The bucket domain name (bucket.s3.amazonaws.com).
bucket_regional_domain_name The region-specific domain name (use for CloudFront/S3 origins and VPC endpoints).
hosted_zone_id The Route 53 hosted zone ID for the bucket’s region (for alias records).

Enterprise scenario

A fintech platform centralizes all microservice and ALB access logs into a dedicated logging account. Each application team consumes this module to provision its own log bucket — KMS-encrypted with the account’s central CMK, versioned, and configured with a lifecycle rule that moves objects to Standard-IA after 30 days, to Glacier after 90, and expires them at 365 days to satisfy the firm’s data-retention policy at minimum storage cost. Because every bucket comes pre-hardened (public access blocked, TLS-only, ACLs disabled), the security team’s Config rules and the quarterly audit pass without per-bucket remediation tickets.

Best practices

TerraformAWSS3 BucketModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading