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:
- Tier intent isn’t a first-class field. “Public” really means route table has an IGW route +
map_public_ip_on_launch = true. The module encodes that intent once and wires the route-table association for you. - AZ pinning is fragile. Hardcoding
us-east-1ais non-portable; using a raw index againstdata.aws_availability_zonesis non-deterministic if AWS reorders the list. The module accepts an explicit AZ name so plans stay stable. - Dual-stack is fiddly. IPv6 requires
ipv6_cidr_blockcarved from the VPC’s/56, plusassign_ipv6_address_on_creationand (for IPv6-only egress)enable_dns64. The module exposes these as clean toggles. - Tagging discipline. EKS, ALBs, and many controllers key off subnet tags (
kubernetes.io/role/elb,Tier,Name). The module merges a consistent tag set so those integrations just work.
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:
- Building a tiered VPC (public / app / data) and want each subnet’s routing intent declared, not implied.
- Spreading workloads across AZs for high availability and need stable, explicit AZ pinning that survives
terraform plandiffs. - Running EKS, ALB/NLB, or RDS where subnet tags (
kubernetes.io/role/*,Tier) drive controller behavior. - Adopting dual-stack networking and want IPv4 + IPv6 CIDRs managed together with DNS64/NAT64 toggles.
- Standardizing across many accounts/environments so subnet naming, tagging, and public-IP behavior are identical everywhere.
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 config — live/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 config — live/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; null ⇒ true 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
- Pin to AZ names for the resource but track AZ IDs in audits. Use the explicit
availability_zoneinput for stable plans, but rely on theavailability_zone_idoutput when correlating across accounts — AZ names likeus-east-1amap to different physical zones per account, whileuse1-az1does not. - Let the route table define “public,” never just the flag. A subnet with
map_public_ip_on_launch = truebut no IGW route is a trap that hands out unreachable public IPs. Always passroute_table_id; for thedatatier, point it at a table with no0.0.0.0/0route at all. - Right-size CIDRs and reserve headroom. AWS reserves 5 addresses per subnet, so a
/24yields 251 usable IPs. For EKS, where each pod consumes a VPC IP, prefer/22or larger app subnets and keep contiguous space free between tiers for future growth. - Keep public IPv4 off by default — it now costs money. Since the public-IPv4 charge took effect, leaving
map_public_ip_on_launchon for private tiers quietly bills you per address-hour. The module already defaults it tofalsefor non-public tiers; don’t override it without reason, and prefer dual-stack/IPv6 egress where you can. - Tag for the consumers, not just for humans. EKS, ALB/NLB auto-discovery, and Aurora subnet groups all key off tags. Drive
kubernetes.io/role/elb(public) andkubernetes.io/role/internal-elb(private) plus a consistentTierthrough thetagsinput so controllers don’t need hardcoded subnet IDs. - Standardize naming with
name_prefix. Let the derived<name_prefix>-<tier>-<az>pattern name subnets sokv-prod-private-eu-west-1ais instantly readable in the console and in cost reports; only set an explicitnamefor legacy resources you’re importing.