Terraform AWS Modules vs Custom: VPC and IAM Done Right

devops-terraform

When you face this choice

terraform aws modules vpc iam illustration

Every new AWS account starts the same way. You spin up the account, get IAM access sorted, and someone on the platform team opens a terminal and types terraform init for the first time in that account. Then comes the question that quietly shapes everything for the next two years: do you pull terraform aws modules vpc iam setups straight from the Registry, or do you write your own?

This isn’t a throwaway decision. VPC, IAM, and S3 are almost always the first modules a platform team writes or adopts, because everything else — EKS clusters, RDS instances, Lambda functions — depends on them. Whatever pattern you pick here becomes the template every other module in the repo copies. Get it wrong and you’re refactoring twelve downstream modules eighteen months later.

The trigger points I see most often: onboarding a new AWS account into an existing landing zone, standing up a multi-account Terraform Cloud or Enterprise workspace structure, or — my personal favorite — a security audit that suddenly demands every IAM role in the org has a permissions boundary attached. That last one has a way of surfacing exactly how much you trusted upstream module defaults without reading them.

I’ve built this decision three times now for three different companies, and I land on a different answer each time depending on the resource type. That’s actually the point of this post — it’s not “Registry vs custom,” it’s “Registry and custom, applied deliberately per module.”

Option A — terraform-aws-modules (Registry modules)

The terraform-aws-modules org on the Terraform Registry is genuinely excellent engineering. The VPC module handles NAT gateway HA, VPC endpoints, subnet CIDR math, and Flow Logs — all the stuff that’s tedious to get right and easy to get subtly wrong. Pros are real: battle-tested edge cases, weekly-ish release cadence, and enough GitHub stars and production usage that “did I miss a flag” risk drops dramatically. You write twenty lines of HCL and get a fully wired 3-tier VPC.

Speed is the headline benefit. A junior engineer can stand up a compliant-looking VPC in an afternoon. But there are real costs that show up later.

Version drift is the first one. Bumping terraform-aws-modules/vpc/aws from v3 to v5 renames variables and changes default behavior around enable_nat_gateway — upgrade without reading CHANGELOG.md and your plan silently changes what it’s provisioning. I’ve watched a terraform plan show zero changes when it should have shown forty, because a variable rename meant the new setting was silently ignored and the old default kicked back in.

Second cost: bloated state. A “simple” 3-AZ VPC module run can create 40-60 resources — subnets × three types × three AZs, route tables, associations, NACLs you never touch. That inflates plan time on large state files and increases blast radius if something in that module block goes wrong. Run this before any upgrade:

terraform state list | grep module.vpc | wc -l

Third: enforcing org tagging and naming conventions on a Registry module means either passing every tag through every variable, or wrapping it. There’s no “org mode” switch. And pinning to an exact version is mandatory — version = ">= 5.0" in a prod root module is how you inherit an unreviewed upstream change on a Tuesday afternoon.

Option B — Hand-rolled internal modules

The alternative is writing your own thin modules from raw aws_vpc, aws_subnet, aws_iam_role resources. I’ve done this for IAM at every company I’ve worked with, and I’d do it again.

Full control is the big win. You decide exactly which resources get created — no default NACLs, no Flow Logs you didn’t ask for, no surprise route table associations. State stays small, blast radius stays small, and a new hire reading terraform plan output can actually reason about what’s about to happen. You also control tagging and naming at the resource level instead of threading it through a wrapper’s tag-merging logic.

Security-sensitive logic benefits the most from this. A hand-rolled IAM module can enforce permissions_boundary on every role as a required variable — no default, no null shortcut, compile fails if someone forgets it. Registry IAM modules almost always leave that as optional, which means it gets skipped under deadline pressure, which is exactly how you end up with an over-permissioned role six months later.

The cons are just as real, though. You own every edge case: multi-AZ NAT failover logic, S3 cross-region replication configs, the CIDR math for subnetting. That’s reinventing wheels that terraform-aws-modules already spent years getting right. Initial delivery is slower — budget for it. And as your team grows and AWS’s API surface changes, you’re the one maintaining that logic, not a community of maintainers pushing weekly releases.

My rule of thumb from production: modules under ~10 resources are cheap to hand-roll. Anything with cross-resource dependency math — like VPC subnetting or NAT routing — is expensive to hand-roll correctly and rarely worth it.

Decision matrix

Here’s how I score the two approaches across the criteria that actually matter once you’re running this in production, not just in a demo.

Criteria                     | Registry | Hand-rolled
------------------------------|----------|------------
Setup speed                  |    5     |     2
Customization / tag control  |    2     |     5
Maintenance burden           |    3     |     4
Security posture out-of-box  |    3     |     4  (only if you enforce it)
Blast radius / state size    |    2     |     5
Learning curve for new hires |    4     |     3
Cost of ongoing upgrades      |    2     |     4

Registry wins on speed and, honestly, on baseline security defaults — someone already thought about VPC endpoints and S3 lifecycle rules so you don’t have to. Hand-rolled wins on control and state size, which matters more than people expect once your state file crosses a few hundred resources and every terraform plan takes ninety seconds.

The pattern I see repeatedly: teams start 100% Registry, then around the third or fourth AWS account they start wrapping the VPC module and fully replacing the IAM module. That’s not indecision — that’s the natural maturity curve. If you’re a one-account startup, just use the Registry modules and move on. If you’re building a landing zone for a company with a compliance team, start hand-rolling IAM now.

My pick

I use terraform-aws-modules for VPC. I hand-roll IAM and S3. That split isn’t arbitrary — it maps directly to variance.

VPC networking logic — route tables, NAT HA, subnet CIDR math — has low variance across organizations. A VPC in fintech and a VPC in a gaming startup look almost identical structurally. Reusing a well-maintained module here is just good engineering. Fighting that instinct to “own everything” wastes engineering time on a solved problem.

IAM and S3 policy logic has high variance. Least-privilege requirements, permissions boundaries, compliance frameworks (SOC 2, PCI, whatever your auditors care about this quarter) differ wildly between orgs. A generic Registry module optimizes for flexibility, which in practice means permissive defaults. That’s exactly the wrong direction for security-sensitive infra. Reuse here invites over-permissioning, not because the module authors did anything wrong, but because “works for everyone” and “least privilege for you” are opposing goals.

The practical compromise — and what I actually ship — is a thin wrapper around the Registry VPC module that pins the exact version and injects org tagging/naming, giving you Registry speed without losing control:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1" # pin exact version — never a range in prod

  name = "${var.org}-${var.env}-vpc"
  cidr = var.vpc_cidr

  azs             = var.azs
  private_subnets = [for k, az in var.azs : cidrsubnet(var.vpc_cidr, 4, k)]
  public_subnets  = [for k, az in var.azs : cidrsubnet(var.vpc_cidr, 4, k + 10)]

  # HA NAT instead of single_nat_gateway to avoid AZ-wide outage risk
  enable_nat_gateway     = true
  one_nat_gateway_per_az = var.env == "prod" ? true : false
  single_nat_gateway     = var.env != "prod"

  enable_dns_hostnames = true
  enable_dns_support   = true

  # Org-mandated tagging — this is why we wrap instead of calling raw module
  tags = merge(var.default_tags, {
    Module     = "vpc-wrapper"
    ManagedBy  = "terraform"
    CostCenter = var.cost_center
  })
}

# --- Hand-rolled IAM role module instead of Registry iam-assumable-role ---
# Full control over permissions_boundary enforcement (security requirement)
resource "aws_iam_role" "app_role" {
  name                 = "${var.org}-${var.env}-${var.role_name}"
  assume_role_policy   = data.aws_iam_policy_document.trust.json
  permissions_boundary = var.permissions_boundary_arn # non-negotiable, no default = null shortcut

  tags = var.default_tags
}

resource "aws_iam_role_policy_attachment" "app_role_policies" {
  for_each   = toset(var.managed_policy_arns)
  role       = aws_iam_role.app_role.name
  policy_arn = each.value
}

Watch out: single_nat_gateway = true in production is a cost-saving move that trades money for reliability — if that one NAT’s AZ goes down, every private subnet loses egress at once. one_nat_gateway_per_az = true costs more (multi-AZ NAT gateways run ~$0.045/hr plus ~$0.045/GB processed each, easily $300+/month across three AZs before real traffic), but it’s the difference between an AZ blip and a full outage. Dev environments should stay on single-NAT — that keeps costs around $32/month instead.

Second watch out: the S3 module. The Registry’s terraform-aws-modules/s3-bucket/aws module has acl = "private" as a default, but it does not automatically set block_public_acls, restrict_public_buckets, or attach_deny_insecure_transport_policy. I’ve seen this exact gap in a prod logs bucket:

# --- WRONG: relies on module defaults, misses two security controls ---
module "s3_bucket_wrong" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "4.2.1"
  bucket  = "kuryzhev-logs-prod"
  acl     = "private"
  # versioning left unset — object deletion is unrecoverable
  # attach_deny_insecure_transport_policy left unset — HTTP allowed
}

# --- CORRECT: explicit security controls ---
module "s3_bucket_correct" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "4.2.1"
  bucket  = "kuryzhev-logs-prod"
  acl     = "private"

  versioning = {
    enabled = true
  }

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  attach_deny_insecure_transport_policy = true

  tags = {
    Environment = "prod"
    Owner       = "platform-team"
  }
}

# Audit before any module version upgrade:
# terraform state list | grep module.s3_bucket_correct | wc -l

Notice both blocks use the Registry module — my “hand-roll S3” preference in practice often means hardening the Registry module with explicit security blocks rather than writing raw aws_s3_bucket resources from scratch. Same principle, less boilerplate. What matters is that nothing security-relevant is left to an implicit default.

If you’re just starting a landing zone, don’t overthink this: use terraform aws modules vpc iam patterns for networking, wrap or replace them for IAM and S3, pin every version, and run terraform-docs (v0.17+) against your hand-rolled modules so the README stays honest about what variables actually exist. We cover more of this workspace structure over in our Terraform category if you want the multi-account variant of this setup. For the canonical module docs, the Terraform module sources documentation is worth bookmarking before you fork anything to a private Git repo — the source syntax changes completely once you do.

Related

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank