DEV Community

Oleksandr Kuryzhev
Oleksandr Kuryzhev

Posted on Originally published at kuryzhev.cloud

Terraform Modules for AWS VPC, IAM, and S3: Structure That Scales

Originally published on kuryzhev.cloud


The scenario

A second team asks for "the same network and storage setup as the first one," and suddenly your single 600-line main.tf needs to become Terraform modules that two environments can share. The VPC, the application bucket, and the role that reads from it are all tangled together, and copying the file means copying every hardcoded CIDR and bucket name with it.

This guide splits that setup into three small modules: vpc, s3, and iam. A thin root configuration then composes them per environment. The design rule is simple. Each module owns one concern, exposes a few typed inputs, and returns only the outputs that other modules need.

The IAM module does not create or look up the bucket. It receives a bucket ARN as an input, which keeps the dependency visible and one-directional. The VPC module stands alone. Nothing here is exotic, but the boundaries are where most module designs go wrong, and they are what make reuse possible later.

Prerequisites

You need Terraform 1.6 or newer (or a compatible OpenTofu release) and the AWS provider on the current 6.x line. Pin both, and check the provider changelog before bumping a major version. You also need an AWS account you can safely experiment in, with credentials supplied through SSO, an assumed role, or environment variables rather than static keys in code.

Use this layout. The modules/ directory holds reusable code, and envs/ holds one small root configuration per environment:

infra/
  modules/
    vpc/   (main.tf, variables.tf, outputs.tf)
    s3/    (main.tf, variables.tf, outputs.tf)
    iam/   (main.tf, variables.tf, outputs.tf)
  envs/
    dev/   (main.tf, providers.tf, backend.tf)

Watch out for: do not put provider blocks inside child modules. Modules inherit the provider from the caller, and embedded provider configuration makes removal and reuse painful. The HashiCorp guidance on module development covers this and the standard file structure.

Also decide on remote state now. An S3 backend with locking is common. Recent Terraform releases support native S3 lock files via use_lockfile = true, and DynamoDB-based locking is deprecated. The backend block belongs in the root configuration, never in a module.

Step 1: Build the VPC module

Keep the first Terraform modules boring. This VPC module takes a name, a CIDR, and a map of availability zones to fixed subnet indexes, then derives subnets with cidrsubnet. It deliberately omits NAT gateways, which cost money per hour and per GB, so you can add them behind an explicit flag later.

# modules/vpc/main.tf
variable "name" { type = string }

variable "cidr_block" {
  type    = string
  default = "10.0.0.0/16"
}

# AZ name => fixed subnet index, e.g. { "eu-central-1a" = 0 }
variable "azs" { type = map(number) }

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags                 = { Name = var.name }
}

# keyed by AZ name with a fixed index: adding or removing a zone
# does not renumber or recreate the others
resource "aws_subnet" "private" {
  for_each          = var.azs
  vpc_id            = aws_vpc.this.id
  availability_zone = each.key
  cidr_block        = cidrsubnet(var.cidr_block, 8, each.value)
  tags              = { Name = "${var.name}-private-${each.key}" }
}

resource "aws_subnet" "public" {
  for_each                = var.azs
  vpc_id                  = aws_vpc.this.id
  availability_zone       = each.key
  cidr_block              = cidrsubnet(var.cidr_block, 8, each.value + 100) # offset avoids overlap
  map_public_ip_on_launch = false
  tags                    = { Name = "${var.name}-public-${each.key}" }
}

output "vpc_id"             { value = aws_vpc.this.id }
output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] }

Gotcha: using count, or for_each with an index derived from list position, ties each subnet's CIDR to its position in the list. Removing the first zone shifts the indexes, changes the computed CIDRs, and proposes replacing real subnets. Keying for_each on the AZ name and giving each zone a fixed index avoids that. Add an internet gateway and route tables when you actually need public routing.

Step 2: Build the S3 module with safe defaults

Since the AWS provider v4 split, bucket settings live in separate resources rather than inline arguments. The module should make the secure choice the default: block public access, enable versioning, and set encryption explicitly even though S3 now encrypts new objects with SSE-S3 by default. Explicit config documents intent and lets you switch to KMS.

# modules/s3/main.tf
variable "bucket_name" { type = string }

variable "force_destroy" {
  type    = bool
  default = false
}

variable "kms_key_arn" {
  description = "KMS key ARN; null means SSE-S3"
  type        = string
  default     = null
}

resource "aws_s3_bucket" "this" {
  bucket        = var.bucket_name
  force_destroy = var.force_destroy
}

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
    }
  }
}

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

Watch out for: bucket names are global across all AWS accounts, so pass a name that includes account or environment context. Leave force_destroy false by default so a careless destroy cannot wipe data. See the AWS documentation on Block Public Access for what each of the four flags does.

Step 3: Build the IAM module around least privilege

The IAM module creates a role that EC2 can assume and attaches a policy scoped to one bucket. It takes the bucket ARN as input rather than reaching into the S3 module, which keeps it reusable for any resource that exposes an ARN. Policy documents built with the data source are easier to review than JSON heredocs.

# modules/iam/main.tf
variable "name"       { type = string }
variable "bucket_arn" { type = string }

data "aws_iam_policy_document" "assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "bucket_read" {
  statement {
    actions   = ["s3:ListBucket"]
    resources = [var.bucket_arn]          # bucket-level action
  }
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${var.bucket_arn}/*"]   # object-level action needs /*
  }
}

resource "aws_iam_role" "this" {
  name               = var.name
  assume_role_policy = data.aws_iam_policy_document.assume.json
}

resource "aws_iam_role_policy" "bucket_read" {
  name   = "${var.name}-bucket-read"
  role   = aws_iam_role.this.id
  policy = data.aws_iam_policy_document.bucket_read.json
}

output "role_arn" { value = aws_iam_role.this.arn }

A typical failure here is granting s3:GetObject on the bucket ARN alone. The call is denied because object actions require the /* resource form, while ListBucket requires the bare bucket ARN. Both statements above are needed.

Step 4: Compose the modules in a root configuration

The root configuration is where environment decisions live. It passes values in and wires outputs to inputs, and Terraform infers the order from those references.

# envs/dev/main.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 6.0" }
  }
}

provider "aws" { region = "eu-central-1" }

module "vpc" {
  source = "../../modules/vpc"
  name   = "dev"
  azs    = { "eu-central-1a" = 0, "eu-central-1b" = 1 }
}

module "data_bucket" {
  source      = "../../modules/s3"
  bucket_name = "example-dev-data-123456789012" # must be globally unique
}

module "app_role" {
  source     = "../../modules/iam"
  name       = "dev-app"
  bucket_arn = module.data_bucket.bucket_arn
}

output "app_role_arn" {
  value = module.app_role.role_arn
}

Local paths are fine while one repository owns everything. When several teams consume the modules, publish them to a registry or a Git tag and pin a version, so a change to modules/s3 cannot silently alter another team's plan.

Verify and test

Run static checks first, then read the plan critically before any apply. In a sandbox account, apply once and confirm the results with the AWS CLI rather than trusting the plan alone.

terraform fmt -check -recursive      # formatting gate for CI
terraform init
terraform validate                   # catches wrong types and missing arguments
terraform plan -out=tfplan           # review: expect only creates on a first run
terraform apply tfplan

# confirm the real bucket settings
aws s3api get-public-access-block --bucket example-dev-data-123456789012
aws s3api get-bucket-versioning   --bucket example-dev-data-123456789012

# confirm the role can read but not write
aws iam simulate-principal-policy \
  --policy-source-arn "$(terraform output -raw app_role_arn)" \
  --action-names s3:GetObject s3:PutObject \
  --resource-arns "arn:aws:s3:::example-dev-data-123456789012/test.txt"

The app_role_arn output defined in the root configuration supplies the role ARN for this check. The simulation should report s3:GetObject as allowed and s3:PutObject as implicitly denied. Verify the exact response fields against the current AWS CLI reference.

For regression checks, Terraform's native terraform test command can run plan-level assertions against each module. Check the HashiCorp documentation for the syntax in your installed version. Finish with terraform destroy in the sandbox so nothing lingers.

Where to go from here

Three small modules with typed inputs, secure defaults, and explicit wiring in the root are enough to stamp out new environments without copying files. Next, add NAT and routing behind a flag, an S3 bucket policy that enforces TLS, and a CI job that runs the checks above on every pull request. For more infrastructure patterns, browse the rest of kuryzhev.cloud, and keep the module interfaces small, because every extra variable is something you have to support later.

Related

Top comments (0)