S3 Bucket with Versioning, Encryption and Lifecycle Rules
Scenario
Your company stores customer-uploaded documents in S3. The security team requires that all objects be encrypted at rest and that older versions of documents are automatically moved to cheaper storage after 30 days, and permanently deleted after 90 days.
You need to provision an S3 bucket that enforces these policies through Terraform — no manual console configuration allowed.
- Create an aws_s3_bucket with a globally unique name using a random suffix.
- Enable versioning using aws_s3_bucket_versioning.
- Enable server-side encryption (AES-256) using aws_s3_bucket_server_side_encryption_configuration.
- Block all public access using aws_s3_bucket_public_access_block.
- Add a lifecycle rule: transition noncurrent versions to STANDARD_IA after 30 days and expire them after 90 days.
- Output the bucket name and ARN.
Additional Context
S3 bucket names must be globally unique. Use the random provider's random_id resource to generate an 8-character hex suffix appended to a base name variable. This is a common pattern to avoid naming conflicts in multi-account environments.
In Terraform, S3 bucket settings like versioning, encryption, and public access block are now separate resources (not nested attributes in the bucket itself). This is because AWS split these into separate API calls, and the Terraform AWS provider follows suit to avoid drift.
versions.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
# The random provider lets us generate unique suffixes for S3 bucket names.
random = {
source = "hashicorp/random"
version = "~> 3.5"
}
}
}variables.tf
# variables.tf
variable "aws_region" {
type = string
default = "us-east-1"
}
variable "bucket_base_name" {
description = "Base name for the S3 bucket. A random suffix is appended."
type = string
default = "company-documents"
}
variable "environment" {
type = string
default = "prod"
}main.tf
# main.tf
provider "aws" {
region = var.aws_region
}
# Generate a random 8-character hex suffix so the bucket name is globally unique.
resource "random_id" "bucket_suffix" {
byte_length = 4 # 4 bytes = 8 hex characters
}
# The primary S3 bucket resource.
# Note: keep the bucket resource minimal — all settings are managed as
# separate resources below, each with its own lifecycle tracking.
resource "aws_s3_bucket" "documents" {
bucket = "${var.bucket_base_name}-${random_id.bucket_suffix.hex}"
tags = {
Name = var.bucket_base_name
Environment = var.environment
ManagedBy = "terraform"
}
}
# Enable versioning — keeps all historical object versions.
# Versioning is required for the lifecycle noncurrent version rules below.
resource "aws_s3_bucket_versioning" "documents" {
bucket = aws_s3_bucket.documents.id
versioning_configuration {
status = "Enabled"
}
}
# Enable server-side encryption using AES-256 (managed by Amazon S3).
# For stricter compliance, swap SSE-S3 for SSE-KMS using a customer CMK.
resource "aws_s3_bucket_server_side_encryption_configuration" "documents" {
bucket = aws_s3_bucket.documents.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
# Prevent uploads that explicitly disable encryption.
bucket_key_enabled = true
}
}
# Block ALL public access — this is a defence-in-depth control.
# Even if a bucket policy accidentally grants public access,
# this setting overrides it and keeps the bucket private.
resource "aws_s3_bucket_public_access_block" "documents" {
bucket = aws_s3_bucket.documents.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Lifecycle rule — automatically manage storage costs and data retention.
resource "aws_s3_bucket_lifecycle_configuration" "documents" {
bucket = aws_s3_bucket.documents.id
# Versioning must be enabled before lifecycle rules can use noncurrent actions.
depends_on = [aws_s3_bucket_versioning.documents]
rule {
id = "noncurrent-version-management"
status = "Enabled"
# Move older (noncurrent) versions to STANDARD_IA after 30 days.
# STANDARD_IA is ~40% cheaper and suitable for infrequently accessed data.
noncurrent_version_transition {
noncurrent_days = 30
storage_class = "STANDARD_IA"
}
# Permanently delete noncurrent versions after 90 days total.
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}outputs.tf
# outputs.tf
output "bucket_name" {
description = "Full S3 bucket name (including random suffix)."
value = aws_s3_bucket.documents.id
}
output "bucket_arn" {
description = "ARN — use this when writing IAM policies that grant access to this bucket."
value = aws_s3_bucket.documents.arn
}
output "bucket_region" {
description = "AWS region the bucket was created in."
value = aws_s3_bucket.documents.region
}Workflow Commands
terraform init
terraform plan
terraform apply
# Verify versioning is enabled
aws s3api get-bucket-versioning \
--bucket $(terraform output -raw bucket_name)
# Verify encryption is active
aws s3api get-bucket-encryption \
--bucket $(terraform output -raw bucket_name)
# Upload a test file and check it has a version ID
echo "hello" | aws s3 cp - s3://$(terraform output -raw bucket_name)/test.txt
aws s3api list-object-versions \
--bucket $(terraform output -raw bucket_name)
terraform destroyApply complete! Resources: 5 added, 0 changed, 0 destroyed. Outputs: bucket_arn = "arn:aws:s3:::company-documents-a1b2c3d4" bucket_name = "company-documents-a1b2c3d4" bucket_region = "us-east-1"

