Skip to content
Infrastructure as Code iac cloud 4 min read

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_on makes 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

ConcernResourceSetting
Recoverabilityaws_s3_bucket_versioningstatus = "Enabled"
Cost controlaws_s3_bucket_lifecycle_configurationnoncurrent expiry, MPU abort
Encryption at restaws_s3_bucket_server_side_encryption_configurationaws:kms + bucket key
No public exposureaws_s3_bucket_public_access_blockall four flags true
Owner-only objectsaws_s3_bucket_ownership_controlsBucketOwnerEnforced
Encryption in transitaws_s3_bucket_policydeny 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_enabled for an audit trail without the per-request KMS premium.
  • Set all four public-access-block flags to true and use BucketOwnerEnforced to retire ACLs entirely.
  • Build policies with aws_iam_policy_document so 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.
Last updated June 14, 2026
Was this helpful?