DEV Community

ByteBridge Talent LLC Team
ByteBridge Talent LLC Team

Posted on

A Reusable Terraform Module for Secure S3 Buckets, with Remote State and CI Plan Checks

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
Enter fullscreen mode Exit fullscreen mode

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     = {}
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

outputs.tf

output "bucket_id" {
  value = aws_s3_bucket.this.id
}

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

-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 on aws_s3_bucket are deprecated. Use the standalone resources shown above.
  • State bucket managed by the same stack it stores. One destroy deletes 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 = true in 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)