AWS S3 Buckets with Terraform and OpenTofu: Secure Configuration

· 1 min read · Terraform & OpenTofu Tutorials

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 #

s3.tf
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 #

s3.tf
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:

s3.tf
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:

s3.tf
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 #

s3.tf
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 #

s3.tf
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

#AWS #AWS S3 #Terraform #OpenTofu #Security