AWS S3 Buckets with Terraform and OpenTofu: Secure Configuration
A secure S3 bucket with Terraform #
AWS S3 is object storage. Since version 4 of the AWS provider, the settings of a bucket are separate resources instead of arguments of aws_s3_bucket. This guide creates a private, encrypted and versioned bucket, which is the right default.
The bucket #
resource "aws_s3_bucket" "data" {
bucket = "ditwl-pro-data-${data.aws_caller_identity.current.account_id}"
tags = local.common_tags
}
data "aws_caller_identity" "current" {}Bucket names are global across all AWS accounts, so adding the account ID helps to make it unique. Never use force_destroy = true in production: it deletes all objects when the bucket is destroyed.
Block all public access #
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}Enable this by default. For public websites do not open the bucket: serve it through CloudFront.
Ownership and ACLs #
New buckets disable ACLs by default. Make it explicit:
resource "aws_s3_bucket_ownership_controls" "data" {
bucket = aws_s3_bucket.data.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}Encryption with KMS #
S3 encrypts every object with SSE-S3 by default. To use your own KMS key:
resource "aws_kms_key" "s3" {
description = "Key for the data bucket"
enable_key_rotation = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.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 # reduces KMS request costs
}
}Versioning and lifecycle #
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "data" {
bucket = aws_s3_bucket.data.id
depends_on = [aws_s3_bucket_versioning.data]
rule {
id = "archive-and-expire"
status = "Enabled"
filter {}
transition {
days = 30
storage_class = "STANDARD_IA"
}
noncurrent_version_expiration {
noncurrent_days = 90
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}Lifecycle rules control costs by moving old data to cheaper classes and deleting old versions.
Bucket policy: force HTTPS #
data "aws_iam_policy_document" "data_bucket" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
actions = ["s3:*"]
resources = [aws_s3_bucket.data.arn, "${aws_s3_bucket.data.arn}/*"]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "data" {
bucket = aws_s3_bucket.data.id
policy = data.aws_iam_policy_document.data_bucket.json
depends_on = [aws_s3_bucket_public_access_block.data]
}Grant access to applications with IAM roles rather than by opening the bucket.
Several buckets #
Use for_each or a module when you need the same configuration more than once.
Using S3 as a Terraform backend #
The same service can store your state: see Terraform backends. Use a separate bucket for it, with versioning and no public access.
Cost #
S3 charges for storage by class, requests and data transferred out. Versioning and incomplete uploads add storage cost silently, hence the lifecycle rule. Estimate with Infracost.
Verify #
$ tofu apply
$ aws s3 cp hello.txt s3://ditwl-pro-data-111111111111/hello.txt
$ aws s3api get-public-access-block --bucket ditwl-pro-data-111111111111