IaC AWS

Terraform Module: AWS Subnet — Consistent, Tier-Aware Subnets Across AZs

Quick take — A reusable Terraform module for aws_subnet on hashicorp/aws ~> 5.0: tier-aware naming, dual-stack IPv4/IPv6 CIDRs, AZ pinning, route-table associations, and public/private intent baked in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "subnet" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-subnet?ref=v1.0.0"

  vpc_id            = "..."  # VPC the subnet belongs to (validated as `vpc-…`).
  availability_zone = "..."  # Full AZ name to pin to (e.g. `us-east-1a`); explicit fo…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS Subnet is a partition of a VPC’s IP address space pinned to a single Availability Zone. Every ENI — and therefore every EC2 instance, load balancer node, RDS endpoint, NAT gateway, and VPC endpoint — lives inside exactly one subnet, and the subnet is what ties an IP block to a fault domain (the AZ) and a routing intent (public vs. private, via its route table). Subnets are deceptively load-bearing: a wrong availability_zone, a forgotten map_public_ip_on_launch, or an overlapping CIDR shows up as broken cross-AZ failover or instances that silently can’t reach the internet.

Wrapping aws_subnet in a module matters because the raw resource has sharp edges that get copy-pasted wrong across a fleet:

The result: one aws_subnet per module call, fully wired with its route-table association and optional NAT/IGW intent, named and tagged identically everywhere.

When to use it

Use this module when you are:

Reach for something heavier (a full VPC module like terraform-aws-modules/vpc) when you want the VPC, IGW, NAT gateways, and entire subnet matrix generated in one shot. This module is the focused building block for teams that compose their network from explicit, reviewable pieces — call it in a for_each to lay down a tier across AZs.

Module structure

terraform-module-aws-subnet/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # aws_subnet + route_table_association
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # id, arn, cidr, az, route assoc id

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # "Public" intent is the combination of an IGW-routed table plus
  # auto-assigned public IPv4. We only force map_public_ip when the
  # caller hasn't explicitly overridden it.
  map_public_ip = (
    var.map_public_ip_on_launch != null
    ? var.map_public_ip_on_launch
    : var.tier == "public"
  )

  name_tag = coalesce(
    var.name,
    "${var.name_prefix}-${var.tier}-${var.availability_zone}"
  )

  base_tags = {
    Name = local.name_tag
    Tier = var.tier
  }
}

resource "aws_subnet" "this" {
  vpc_id            = var.vpc_id
  availability_zone = var.availability_zone

  # IPv4
  cidr_block = var.cidr_block

  # IPv6 (dual-stack or IPv6-only)
  ipv6_cidr_block                 = var.ipv6_cidr_block
  assign_ipv6_address_on_creation = var.ipv6_cidr_block != null ? var.assign_ipv6_address_on_creation : null
  ipv6_native                     = var.ipv6_native
  enable_dns64                    = var.enable_dns64

  # Public-subnet behavior
  map_public_ip_on_launch = local.map_public_ip

  # Resource-name DNS records (A/AAAA) for instances launched here
  enable_resource_name_dns_a_record_on_launch    = var.enable_resource_name_dns_a_record
  enable_resource_name_dns_aaaa_record_on_launch = var.ipv6_cidr_block != null || var.ipv6_native ? var.enable_resource_name_dns_aaaa_record : null
  private_dns_hostname_type_on_launch            = var.private_dns_hostname_type

  tags = merge(var.tags, local.base_tags)
}

# Associate the subnet with the route table that encodes its intent
# (IGW route for public, NAT/none for private). Optional so callers
# can manage associations elsewhere if they prefer.
resource "aws_route_table_association" "this" {
  count = var.route_table_id != null ? 1 : 0

  subnet_id      = aws_subnet.this.id
  route_table_id = var.route_table_id
}

variables.tf

variable "vpc_id" {
  description = "ID of the VPC this subnet belongs to."
  type        = string

  validation {
    condition     = can(regex("^vpc-[0-9a-f]{8,17}$", var.vpc_id))
    error_message = "vpc_id must be a valid VPC id (vpc-xxxxxxxx)."
  }
}

variable "availability_zone" {
  description = "Full AZ name to pin the subnet to (e.g. us-east-1a). Explicit pinning keeps plans deterministic."
  type        = string

  validation {
    condition     = can(regex("^[a-z]{2}-[a-z]+-[0-9][a-z]$", var.availability_zone))
    error_message = "availability_zone must be a full AZ name like us-east-1a."
  }
}

variable "cidr_block" {
  description = "IPv4 CIDR block for the subnet. Set to null for an IPv6-only subnet."
  type        = string
  default     = null

  validation {
    condition     = var.cidr_block == null || can(cidrhost(var.cidr_block, 0))
    error_message = "cidr_block must be a valid IPv4 CIDR (e.g. 10.0.1.0/24) or null."
  }
}

variable "tier" {
  description = "Routing/intent tier for the subnet. Drives default public-IP behavior, naming, and the Tier tag."
  type        = string
  default     = "private"

  validation {
    condition     = contains(["public", "private", "data", "transit"], var.tier)
    error_message = "tier must be one of: public, private, data, transit."
  }
}

variable "route_table_id" {
  description = "Route table to associate with this subnet. The route table encodes intent (IGW for public, NAT for private). Set null to manage the association elsewhere."
  type        = string
  default     = null
}

variable "map_public_ip_on_launch" {
  description = "Override auto-assign of public IPv4. When null, defaults to true only for tier=public."
  type        = bool
  default     = null
}

variable "ipv6_cidr_block" {
  description = "IPv6 /64 CIDR carved from the VPC's /56. Null disables IPv6."
  type        = string
  default     = null
}

variable "ipv6_native" {
  description = "Create an IPv6-only subnet (no IPv4). Requires cidr_block to be null."
  type        = bool
  default     = false

  validation {
    condition     = !(var.ipv6_native && var.cidr_block != null)
    error_message = "ipv6_native subnets must not set an IPv4 cidr_block."
  }
}

variable "assign_ipv6_address_on_creation" {
  description = "Auto-assign an IPv6 address to ENIs created in this subnet. Only applies when ipv6_cidr_block is set."
  type        = bool
  default     = true
}

variable "enable_dns64" {
  description = "Enable DNS64 so IPv6-only hosts can reach IPv4 destinations via NAT64. Typically true for ipv6_native egress subnets."
  type        = bool
  default     = false
}

variable "enable_resource_name_dns_a_record" {
  description = "Create an A (IPv4) DNS record from the resource name for instances launched in this subnet."
  type        = bool
  default     = false
}

variable "enable_resource_name_dns_aaaa_record" {
  description = "Create an AAAA (IPv6) DNS record from the resource name. Only applies when IPv6 is enabled."
  type        = bool
  default     = false
}

variable "private_dns_hostname_type" {
  description = "Hostname type for instances launched here: ip-name or resource-name. resource-name is required for IPv6-only."
  type        = string
  default     = "ip-name"

  validation {
    condition     = contains(["ip-name", "resource-name"], var.private_dns_hostname_type)
    error_message = "private_dns_hostname_type must be ip-name or resource-name."
  }
}

variable "name" {
  description = "Explicit Name tag. When null, derived as <name_prefix>-<tier>-<availability_zone>."
  type        = string
  default     = null
}

variable "name_prefix" {
  description = "Prefix used to build the Name tag when name is not supplied (e.g. kv-prod)."
  type        = string
  default     = "subnet"
}

variable "tags" {
  description = "Additional tags merged onto the subnet (Name and Tier are added automatically)."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "ID of the subnet."
  value       = aws_subnet.this.id
}

output "arn" {
  description = "ARN of the subnet."
  value       = aws_subnet.this.arn
}

output "name" {
  description = "Resolved Name tag of the subnet."
  value       = local.name_tag
}

output "availability_zone" {
  description = "Availability Zone the subnet is pinned to."
  value       = aws_subnet.this.availability_zone
}

output "availability_zone_id" {
  description = "AZ ID (e.g. use1-az1) — stable across accounts, unlike the AZ name."
  value       = aws_subnet.this.availability_zone_id
}

output "cidr_block" {
  description = "IPv4 CIDR block of the subnet (null for IPv6-only)."
  value       = aws_subnet.this.cidr_block
}

output "ipv6_cidr_block" {
  description = "IPv6 CIDR block of the subnet (null if IPv6 not enabled)."
  value       = aws_subnet.this.ipv6_cidr_block
}

output "tier" {
  description = "Tier of the subnet (public/private/data/transit)."
  value       = var.tier
}

output "route_table_association_id" {
  description = "ID of the route-table association, or null if none was created."
  value       = try(aws_route_table_association.this[0].id, null)
}

How to use it

This example lays down a three-AZ private app tier, pinning each subnet to an explicit AZ and associating it with a NAT-routed private route table. Subnets are then fed into an EKS node group via the module’s id outputs.

locals {
  azs = ["us-east-1a", "us-east-1b", "us-east-1c"]

  # /24 per AZ carved from the VPC's 10.40.0.0/16 app space
  app_cidrs = {
    "us-east-1a" = "10.40.16.0/24"
    "us-east-1b" = "10.40.17.0/24"
    "us-east-1c" = "10.40.18.0/24"
  }
}

module "app_subnet" {
  source   = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-subnet?ref=v1.0.0"
  for_each = toset(local.azs)

  vpc_id            = aws_vpc.main.id
  availability_zone = each.value
  cidr_block        = local.app_cidrs[each.value]

  tier           = "private"
  route_table_id = aws_route_table.private[each.value].id

  name_prefix = "kv-prod"

  tags = {
    Environment                       = "prod"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

# Downstream: feed the subnet IDs into an EKS managed node group
resource "aws_eks_node_group" "app" {
  cluster_name    = aws_eks_cluster.this.name
  node_group_name = "app"
  node_role_arn   = aws_iam_role.node.arn

  # Consume the module outputs across all AZs
  subnet_ids = [for s in module.app_subnet : s.id]

  scaling_config {
    desired_size = 3
    min_size     = 3
    max_size     = 9
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/subnet/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-subnet?ref=v1.0.0"
}

inputs = {
  vpc_id = "..."
  availability_zone = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/subnet && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
vpc_id string Yes VPC the subnet belongs to (validated as vpc-…).
availability_zone string Yes Full AZ name to pin to (e.g. us-east-1a); explicit for deterministic plans.
cidr_block string null No IPv4 CIDR; null for an IPv6-only subnet.
tier string "private" No public / private / data / transit; drives default public-IP, naming, and Tier tag.
route_table_id string null No Route table to associate (encodes IGW/NAT intent); null skips the association.
map_public_ip_on_launch bool null No Override auto public IPv4; nulltrue only when tier = public.
ipv6_cidr_block string null No IPv6 /64 carved from the VPC /56; null disables IPv6.
ipv6_native bool false No IPv6-only subnet (no IPv4); requires cidr_block = null.
assign_ipv6_address_on_creation bool true No Auto-assign IPv6 to ENIs (only when ipv6_cidr_block set).
enable_dns64 bool false No Enable DNS64 for IPv6-only egress via NAT64.
enable_resource_name_dns_a_record bool false No Create an A record from the resource name on launch.
enable_resource_name_dns_aaaa_record bool false No Create an AAAA record from the resource name (IPv6 only).
private_dns_hostname_type string "ip-name" No ip-name or resource-name; resource-name required for IPv6-only.
name string null No Explicit Name tag; defaults to <name_prefix>-<tier>-<az>.
name_prefix string "subnet" No Prefix for the derived Name tag.
tags map(string) {} No Extra tags merged onto the subnet (Name, Tier added automatically).

Outputs

Name Description
id ID of the subnet.
arn ARN of the subnet.
name Resolved Name tag.
availability_zone AZ the subnet is pinned to.
availability_zone_id AZ ID (e.g. use1-az1) — stable across accounts.
cidr_block IPv4 CIDR (null for IPv6-only).
ipv6_cidr_block IPv6 CIDR (null if IPv6 disabled).
tier Tier of the subnet.
route_table_association_id Route-table association ID, or null if none.

Enterprise scenario

A fintech running a regulated workload in eu-west-1 uses this module in a for_each across three AZs to build separate public (ALB), private (app/EKS), and data (RDS, ElastiCache) tiers, with the data tier deliberately given a route table that has no internet path. Because each subnet carries a Tier tag and the kubernetes.io/role/internal-elb tag, the AWS Load Balancer Controller and Aurora subnet groups discover the right subnets automatically, and a quarterly Config rule audits that every Tier = data subnet has map_public_ip_on_launch = false. When they later expand to a fourth AZ for capacity, it’s a one-line addition to the azs list — the module guarantees the new subnet is named, tagged, and routed identically to its peers.

Best practices

TerraformAWSSubnetModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading