# AWS S3 Buckets with Terraform and OpenTofu: Secure Configuration

> Create secure AWS S3 buckets with Terraform or OpenTofu: public access block, encryption with KMS, versioning, lifecycle rules and bucket policies.

- Source: https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/
- Published: 2026-08-12
- Updated: 2026-08-12
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## A secure S3 bucket with Terraform

[AWS S3](https://www.itwonderlab.com/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

```hcl title="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

```hcl title="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](https://www.itwonderlab.com/terraform-s3-static-website-cloudfront/).

### Ownership and ACLs

New buckets disable ACLs by default. Make it explicit:

```hcl title="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](https://www.itwonderlab.com/aws-kms/) key:

```hcl title="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

```hcl title="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

```hcl title="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](https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/) rather than by opening the bucket.

### Several buckets

Use [`for_each`](https://www.itwonderlab.com/terraform-for-each-vs-count/) or a [module](https://www.itwonderlab.com/terraform-module/) when you need the same configuration more than once.

### Using S3 as a Terraform backend

The same service can store your [state](https://www.itwonderlab.com/terraform-state/): see [Terraform backends](https://www.itwonderlab.com/aws-terraform-tutorial-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](https://www.itwonderlab.com/terraform-cost-estimation-infracost/).

### Verify

```shell
$ 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
```
