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
- You need a general-purpose, hardened bucket — app uploads, data-lake zones, build artifacts, backups, log targets — and want the security baseline applied identically every time.
- You want encryption, versioning, and public-access blocking enforced as code so they show up in plan reviews and cannot silently drift.
- You manage many buckets across many accounts/environments and want one audited module instead of copy-pasted bucket blocks.
- You want lifecycle transitions (Standard → Standard-IA → Glacier) and expiry to control cost on log/archive buckets without hand-writing JSON-like rule blocks.
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 config — live/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 config — live/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
- Never rely on default encryption alone for sensitive data — pass a
kms_key_arnso access is gated by both the bucket policy and the KMS key policy, and enable a key rotation schedule on that CMK. - Keep
force_destroy = falsein production. Combined withversioning_enabled = true, this prevents an accidentalterraform destroyfrom wiping recoverable data; use lifecyclenoncurrent_version_expiration_daysto stop old versions from accumulating cost forever. - Let lifecycle rules drive cost, not manual cleanup — tier cold data to
STANDARD_IA/GLACIER/DEEP_ARCHIVEand set anexpiration_daysthat matches your real retention requirement; for unpredictable access patterns, transition toINTELLIGENT_TIERINGinstead. - Treat the public-access block and TLS-only policy as non-negotiable. This module enforces all four public-access switches and denies non-HTTPS requests by default; grant access only via narrowly-scoped IAM/bucket policies referencing the
arnoutput. - Name buckets deterministically and globally — prefix with org + environment + purpose (e.g.
kloudvin-prod-app-logs) since names are global and immutable; the module’s validation rejects malformed or reserved names beforeapply. - Use
bucket_regional_domain_namefor origins and VPC endpoints, not the legacy globalbucket_domain_name, to avoid cross-region request routing and to keep traffic on your gateway/interface endpoints.