Recipe: S3 Bucket
Amazon S3 is the default home for state files, build artifacts, logs, and static assets—and it is also the service most often misconfigured into a public data leak. The modern AWS provider splits a bucket’s behavior across several dedicated resources (versioning, encryption, public-access block, ownership controls) rather than cramming everything into one aws_s3_bucket block. This recipe assembles those pieces into a secure-by-default bucket: versioned, encrypted with SSE-KMS, walled off from the public internet, and locked down with an explicit policy that denies any non-TLS request. Everything here runs unchanged on Terraform 1.5+ and OpenTofu.
The bucket and a unique name
S3 bucket names are globally unique across every AWS account, so hard-coding one guarantees a collision sooner or later. The clean pattern is a stable prefix plus a random suffix generated by the random provider. The bucket resource itself is now intentionally minimal—all behavior lives in the companion resources below.
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
resource "random_id" "suffix" {
byte_length = 4
}
locals {
bucket_name = "devcraftly-artifacts-${random_id.suffix.hex}"
tags = {
Project = "devcraftly"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket" "this" {
bucket = local.bucket_name
tags = local.tags
}
Versioning and lifecycle
Versioning keeps every object revision, so an accidental overwrite or delete is recoverable. Without a lifecycle rule, however, old versions accumulate forever and inflate the bill. Pair versioning with a rule that expires noncurrent versions and cleans up incomplete multipart uploads.
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
id = "expire-noncurrent"
status = "Enabled"
noncurrent_version_expiration {
noncurrent_days = 90
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}
Server-side encryption
Every object should be encrypted at rest. SSE-S3 (AES256) is the zero-config baseline; SSE-KMS gives you an audit trail and per-key access control. Enabling bucket_key_enabled reduces KMS request costs by caching a bucket-level data key.
resource "aws_kms_key" "s3" {
description = "KMS key for ${local.bucket_name}"
enable_key_rotation = true
deletion_window_in_days = 14
tags = local.tags
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.s3.arn
}
bucket_key_enabled = true
}
}
Blocking public access and owning every object
Two resources form the core of the secure-by-default posture. The public-access block disables ACLs and public bucket policies at the account-of-record level for this bucket. Ownership controls force BucketOwnerEnforced, which disables ACLs entirely so the bucket owner owns every uploaded object.
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
}
resource "aws_s3_bucket_ownership_controls" "this" {
bucket = aws_s3_bucket.this.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
Apply the public-access block before any bucket policy. If you attach a policy first, a misconfigured statement can briefly expose data until the block lands. Terraform’s dependency graph respects this when the policy references the bucket, but adding
depends_onmakes the ordering explicit.
A least-privilege bucket policy
The final layer is a resource policy that denies any request not using TLS. Build it with aws_iam_policy_document rather than a raw JSON heredoc—it validates structure at plan time and is far easier to extend.
data "aws_iam_policy_document" "this" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
actions = ["s3:*"]
resources = [
aws_s3_bucket.this.arn,
"${aws_s3_bucket.this.arn}/*",
]
principals {
type = "*"
identifiers = ["*"]
}
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
depends_on = [aws_s3_bucket_public_access_block.this]
}
Recipe at a glance
| Concern | Resource | Setting |
|---|---|---|
| Recoverability | aws_s3_bucket_versioning | status = "Enabled" |
| Cost control | aws_s3_bucket_lifecycle_configuration | noncurrent expiry, MPU abort |
| Encryption at rest | aws_s3_bucket_server_side_encryption_configuration | aws:kms + bucket key |
| No public exposure | aws_s3_bucket_public_access_block | all four flags true |
| Owner-only objects | aws_s3_bucket_ownership_controls | BucketOwnerEnforced |
| Encryption in transit | aws_s3_bucket_policy | deny aws:SecureTransport=false |
Applying the recipe
terraform init
terraform plan
terraform apply
Output:
Plan: 9 to add, 0 to change, 0 to destroy.
random_id.suffix: Creation complete after 0s [id=q1w2e3r4]
aws_kms_key.s3: Creation complete after 2s [id=8f0c...]
aws_s3_bucket.this: Creation complete after 1s [id=devcraftly-artifacts-ab57d3f4]
aws_s3_bucket_versioning.this: Creation complete after 1s
aws_s3_bucket_public_access_block.this: Creation complete after 1s
aws_s3_bucket_policy.this: Creation complete after 1s
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.
Verify the public-access block from the CLI:
aws s3api get-public-access-block --bucket devcraftly-artifacts-ab57d3f4
Output:
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}
Best Practices
- Generate bucket names with a random suffix so configurations are portable and never collide in the global namespace.
- Treat versioning and a lifecycle rule as a pair—versioning alone grows unbounded and silently raises storage costs.
- Prefer SSE-KMS with key rotation and
bucket_key_enabledfor an audit trail without the per-request KMS premium. - Set all four public-access-block flags to
trueand useBucketOwnerEnforcedto retire ACLs entirely. - Build policies with
aws_iam_policy_documentso structure is validated at plan time; always include a deny for non-TLS requests. - Order the policy after the public-access block (via reference or
depends_on) so a bucket is never momentarily exposed.