Copy-pasting S3 bucket resources between projects is how buckets end up unencrypted, public, or unversioned. A small module turns your team's standards into the default. In this post we'll build one, keep Terraform state in S3 with locking, and add a GitHub Actions workflow that runs terraform plan on every pull request.
Tested with Terraform 1.10+ and AWS provider 5.x.
Project layout
.
├── modules/
│ └── secure-bucket/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── envs/
│ └── prod/
│ ├── backend.tf
│ └── main.tf
└── .github/workflows/terraform-plan.yml
The module
variables.tf
variable "bucket_name" {
description = "Globally unique bucket name."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
error_message = "Bucket name must be 3-63 chars of lowercase letters, digits, dots or hyphens."
}
}
variable "kms_key_arn" {
description = "Optional KMS key ARN. If null, SSE-S3 (AES256) is used."
type = string
default = null
}
variable "noncurrent_version_days" {
description = "Days to keep noncurrent object versions."
type = number
default = 90
}
variable "force_destroy" {
description = "Allow destroying a non-empty bucket. Keep false in production."
type = bool
default = false
}
variable "tags" {
description = "Tags applied to the bucket."
type = map(string)
default = {}
}
main.tf
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
force_destroy = var.force_destroy
tags = var.tags
}
resource "aws_s3_bucket_ownership_controls" "this" {
bucket = aws_s3_bucket.this.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
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_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = var.kms_key_arn == null ? "AES256" : "aws:kms"
kms_master_key_id = var.kms_key_arn
}
bucket_key_enabled = var.kms_key_arn != null
}
}
resource "aws_s3_bucket_lifecycle_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
id = "expire-noncurrent-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = var.noncurrent_version_days
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
depends_on = [aws_s3_bucket_versioning.this]
}
data "aws_iam_policy_document" "tls_only" {
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" "tls_only" {
bucket = aws_s3_bucket.this.id
policy = data.aws_iam_policy_document.tls_only.json
depends_on = [aws_s3_bucket_public_access_block.this]
}
outputs.tf
output "bucket_id" {
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
A few design choices worth noting. BucketOwnerEnforced disables ACLs entirely, so access is controlled only by policies. The TLS-only policy rejects plain-HTTP requests. The lifecycle rule stops versioning from growing storage costs forever.
Remote state with locking
Local state files get lost, and two engineers applying at once can corrupt them. Store state in S3. Since Terraform 1.10, the S3 backend can lock with a lock file in the bucket itself (use_lockfile), so a DynamoDB table is no longer required.
Create the state bucket once, outside the configuration that uses it (a small bootstrap stack, or the CLI):
#!/usr/bin/env bash
set -euo pipefail
BUCKET="acme-tfstate-prod"
REGION="us-east-1"
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION"
aws s3api put-bucket-versioning --bucket "$BUCKET" \
--versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
(For regions other than us-east-1, add --create-bucket-configuration LocationConstraint=$REGION.)
envs/prod/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "storage/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
envs/prod/main.tf
provider "aws" {
region = "us-east-1"
}
module "reports_bucket" {
source = "../../modules/secure-bucket"
bucket_name = "acme-prod-reports"
tags = {
env = "prod"
owner = "data-platform"
}
}
output "reports_bucket_arn" {
value = module.reports_bucket.bucket_arn
}
If you're on an older Terraform version, use dynamodb_table = "terraform-locks" with a table whose hash key is LockID (string) instead of use_lockfile.
CI: terraform plan on every pull request
The workflow below checks formatting, validates, and runs a plan. It authenticates to AWS with GitHub's OIDC provider, so no long-lived access keys live in repository secrets.
name: terraform-plan
on:
pull_request:
paths:
- "modules/**"
- "envs/**"
permissions:
contents: read
id-token: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: envs/prod
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.10.5
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-terraform-plan
aws-region: us-east-1
- name: Format check
run: terraform fmt -check -recursive ../..
- name: Init
run: terraform init -input=false
- name: Validate
run: terraform validate -no-color
- name: Plan
run: terraform plan -input=false -no-color -lock-timeout=120s -detailed-exitcode
continue-on-error: true
id: plan
- name: Fail on plan error
if: steps.plan.outputs.exitcode == '1'
run: exit 1
-detailed-exitcode returns 0 for no changes, 2 for changes, and 1 for errors. Only an actual error fails the job. Give the plan role read-only permissions plus access to the state bucket. Applies should use a separate, more privileged role that runs only on the main branch.
Common pitfalls
-
Inline bucket arguments. In AWS provider 4+,
versioning,server_side_encryption_configuration, and similar blocks onaws_s3_bucketare deprecated. Use the standalone resources shown above. -
State bucket managed by the same stack it stores. One
destroydeletes your state. Bootstrap it separately and protect it. - Forgetting state contains secrets. Outputs and some resource attributes land in state in plain text. Restrict who can read the state bucket.
-
force_destroy = truein production. Fine for test buckets, dangerous for anything with data. -
Lifecycle filter omitted. Newer provider versions warn or fail without
filter {}. Include it explicitly. - Plans with long-lived keys in CI. Prefer OIDC roles scoped per environment.
Wrap-up
A module encodes the security defaults once, remote state with locking keeps the team in sync, and a plan check on every pull request makes infrastructure changes reviewable like any other code. Start with one bucket module, then apply the same pattern to VPCs, IAM roles, and databases.
Written by the ByteBridge Talent LLC software engineering team.
ByteBridge Talent LLC is an E-Verify enrolled tech staffing firm in Grand Island, Nebraska: bytebridgetalent.com
Top comments (0)