Secure-by-default Amazon VPC foundation — VPC + public / private / database subnets, route tables, Internet Gateway, configurable NAT topology, DHCP options, VPC Flow Logs (ON by default), and a deny-all default security group, all from a single composite call. Built for the AWS provider v6.x.
- 🏗️ Provisions an
aws_vpckeystone plus its full network fabric in one call — no hand-wiring a dozen resources. - 🌐 Three opinionated subnet tiers — public, private (application), and database (data) — each a
map(object(...))keyed by a stable name for cleanfor_eachplacement across AZs. - 🚪 Internet Gateway auto-created when public subnets exist; NAT gateways with Elastic IPs in
single,per_az, ornonetopology — a documented cost/HA tradeoff. - 🧭 Per-tier route tables: public → IGW, private → NAT, database isolated (no default route) unless explicitly opted in.
- 🔒 Default security group adopted and locked to deny-all — the secure invariant; workloads attach to purpose-built SGs from
tf-mod-aws-security-group. - 📊 VPC Flow Logs enabled by default (
ALLtraffic) to a caller-supplied CloudWatch Log Group, S3 bucket, or Firehose stream — observability the regulated-industry posture expects. - 🏷️ Universal tagging —
var.tagsflows to every taggable resource; per-subnet and per-DHCP tags merge on top;tags_allsurfaced as an output. - 🧱 Optional secondary IPv4 CIDRs, Amazon-provided IPv6 /56, and custom DHCP option sets.
💡 Why it matters: Nearly every networking, compute, and database module in the library wires into this one. A secure, routable, observable VPC delivered from a single module call means the blast radius of a misconfigured network — large in a regulated FI — is closed off at the foundation.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
tf-mod-aws-vpc is a foundation module: it consumes no sibling module and originates the vpc_id, subnet_ids, and route-table ids that the rest of the suite depends on. Its only optional inputs are by-reference Flow Log targets.
flowchart LR
vpc["tf-mod-aws-vpc"]
kms["tf-mod-aws-kms"]
iam["tf-mod-aws-iam-role"]
cwlg["tf-mod-aws-cloudwatch-log-group"]
s3["tf-mod-aws-s3-bucket"]
sg["tf-mod-aws-security-group"]
nacl["tf-mod-aws-network-acl"]
ep["tf-mod-aws-vpc-endpoint"]
lb["tf-mod-aws-lb"]
rds["tf-mod-aws-rds"]
eks["tf-mod-aws-eks"]
tgw["tf-mod-aws-transit-gateway"]
kms -. "flow_log_kms_key_arn (on target)".-> vpc
iam -. "flow_log_iam_role_arn".-> vpc
cwlg -. "flow_log_destination_arn".-> vpc
s3 -. "flow_log_destination_arn".-> vpc
vpc -- "vpc_id" --> sg
vpc -- "vpc_id" --> nacl
vpc -- "vpc_id / route_table_ids" --> ep
vpc -- "public_subnet_ids" --> lb
vpc -- "database_subnet_ids" --> rds
vpc -- "private_subnet_ids" --> eks
vpc -- "route_table_ids" --> tgw
style vpc fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
flowchart TB
subgraph VPCMOD["tf-mod-aws-vpc"]
vpc["aws_vpc.this<br/>(keystone)"]
dsg["aws_default_security_group.this<br/>deny-all"]
sec["aws_vpc_ipv4_cidr_block_association.secondary<br/>(secondary CIDRs)"]
dhcp["aws_vpc_dhcp_options.this<br/>(+ aws_vpc_dhcp_options_association.this)"]
fl["aws_flow_log.this<br/>(ON by default)"]
subgraph PUB["Public tier"]
psub["aws_subnet.public"]
igw["aws_internet_gateway.this"]
eip["aws_eip.nat"]
nat["aws_nat_gateway.this"]
prt["aws_route_table.public<br/>0.0.0.0/0 -> IGW"]
rpi["aws_route.public_internet<br/>0.0.0.0/0 -> IGW"]
rta_pub["aws_route_table_association.public"]
end
subgraph PRIV["Private tier"]
vsub["aws_subnet.private"]
vrt["aws_route_table.private<br/>0.0.0.0/0 -> NAT"]
rpn["aws_route.private_nat<br/>0.0.0.0/0 -> NAT"]
rta_priv["aws_route_table_association.private"]
end
subgraph DB["Database tier"]
dsub["aws_subnet.database"]
drt["aws_route_table.database<br/>isolated (no default route)"]
rdn["aws_route.database_nat<br/>0.0.0.0/0 -> NAT (optional)"]
rta_db["aws_route_table_association.database"]
end
vpc --> psub & vsub & dsub
vpc --> dsg & sec & dhcp & fl & igw
igw --> prt
prt --> rpi
psub --> eip --> nat
nat --> vrt
vrt --> rpn
psub --> rta_pub
prt --- rta_pub
vsub --> rta_priv
vrt --- rta_priv
dsub --> rta_db
drt --- rta_db
drt --> rdn
end
style vpc fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
| Resource | Role | Cardinality |
|---|---|---|
aws_vpc.this |
Keystone VPC | 1 |
aws_vpc_ipv4_cidr_block_association.secondary |
Secondary IPv4 CIDRs | per secondary_cidr_blocks entry |
aws_default_security_group.this |
Adopt + lock default SG to deny-all | 1 |
aws_internet_gateway.this |
Public ingress/egress | 1 (when public subnets exist) |
aws_subnet.public / .private / .database |
Three subnet tiers | per subnet-map entry |
aws_eip.nat |
Elastic IPs backing NAT | per NAT gateway |
aws_nat_gateway.this |
Private-subnet egress | per nat_gateway_mode |
aws_route_table.public / .private / .database |
Per-tier routing | shared / per-AZ |
aws_route.public_internet / .private_nat / .database_nat |
Default routes | per route table |
aws_route_table_association.* |
Subnet ↔ route-table bindings | per subnet |
aws_vpc_dhcp_options.this (+ association) |
Custom DHCP option set | 0–1 |
aws_flow_log.this |
VPC Flow Logs | 0–1 |
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/aws |
>= 6.0, < 7.0 |
No provider {} block is declared inside the module — the caller's configured provider (and its region/credentials) is inherited.
Least-privilege actions the Terraform identity needs to create, read, update, and delete everything this module manages:
| Action | Required for | Notes |
|---|---|---|
ec2:CreateVpc, ec2:DeleteVpc, ec2:DescribeVpcs, ec2:ModifyVpcAttribute |
VPC lifecycle + DNS / NAU attributes | ModifyVpcAttribute sets DNS support/hostnames |
ec2:AssociateVpcCidrBlock, ec2:DisassociateVpcCidrBlock |
Secondary IPv4 CIDRs, Amazon IPv6 /56 | Only when used |
ec2:CreateSubnet, ec2:DeleteSubnet, ec2:DescribeSubnets, ec2:ModifySubnetAttribute |
Subnet tiers | ModifySubnetAttribute sets map_public_ip_on_launch, DNS64, etc. |
ec2:CreateRouteTable, ec2:DeleteRouteTable, ec2:CreateRoute, ec2:DeleteRoute, ec2:AssociateRouteTable, ec2:DisassociateRouteTable, ec2:DescribeRouteTables |
Routing | — |
ec2:CreateInternetGateway, ec2:AttachInternetGateway, ec2:DetachInternetGateway, ec2:DeleteInternetGateway, ec2:DescribeInternetGateways |
Internet Gateway | — |
ec2:CreateNatGateway, ec2:DeleteNatGateway, ec2:DescribeNatGateways |
NAT gateways | Skipped when nat_gateway_mode = "none" |
ec2:AllocateAddress, ec2:ReleaseAddress, ec2:DescribeAddresses |
NAT Elastic IPs | EIP release lags NAT delete |
ec2:CreateDhcpOptions, ec2:DeleteDhcpOptions, ec2:AssociateDhcpOptions, ec2:DescribeDhcpOptions |
Custom DHCP option set | Only when dhcp_options set |
ec2:CreateFlowLogs, ec2:DeleteFlowLogs, ec2:DescribeFlowLogs |
VPC Flow Logs | Default ON |
ec2:RevokeSecurityGroupIngress, ec2:RevokeSecurityGroupEgress, ec2:DescribeSecurityGroups |
Lock the default SG to deny-all | aws_default_security_group strips rules |
ec2:CreateTags, ec2:DeleteTags |
Tagging every resource | — |
iam:PassRole |
Pass the Flow Log delivery role to vpc-flow-logs.amazonaws.com |
CloudWatch Logs destination only |
ℹ️ The module itself creates no IAM role and no service-linked role. Flow Log delivery permissions (
logs:CreateLogDelivery/s3:PutObjecton the target) live on the destination, not on the Terraform identity — they are granted via the delivery role or the bucket/log-group resource policy in sibling modules.
- Service-linked roles: None required for core VPC resources.
- Flow Logs (default ON): A destination must already exist —
- CloudWatch Logs: a Log Group plus an IAM role assumable by
vpc-flow-logs.amazonaws.com(wireflow_log_destination_arn+flow_log_iam_role_arn). - S3: a bucket with a delivery policy permitting
delivery.logs.amazonaws.com(wireflow_log_destination_arn; no IAM role needed). - Firehose: an Amazon Data Firehose delivery stream.
- If
enable_flow_logs = truebutflow_log_destination_arnisnull, no flow log is created (no error) — wire a destination to activate it. - CMK (optional): SSE-KMS on the Flow Log target requires a KMS key whose key policy grants the delivery service (
delivery.logs.amazonaws.comfor CloudWatch/S3) use of the key. Encryption is configured on the destination, not in this module. - CIDR planning: Use an RFC 1918 block. Subnet
cidr_blockandavailability_zoneare force-new — plan the layout up front. - Region: No
us-east-1constraint — VPC is a regional service; the module inherits the caller's provider region. (No global-service gotcha applies here.) - Service quotas (per Region, soft unless noted): 5 VPCs · 5 Elastic IPs (NAT gateways consume EIPs) · 5 NAT gateways per AZ · 200 subnets per VPC · 5 IPv4 CIDR blocks per VPC. Request increases via Service Quotas where needed.
tf-mod-aws-vpc/
├── providers.tf # terraform{} + required_providers (aws >= 6.0, < 7.0); no provider block
├── variables.tf # name, cidr_block, subnet tiers, NAT topology, DHCP, flow logs, tags
├── main.tf # locals (topology) + aws_vpc.this and full network fabric
├── outputs.tf # id + arn (+ aliases), subnet/route-table maps, gateways, tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM, prerequisites, emits, gotchas
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "casey-core"
cidr_block = "10.0.0.0/16"
public_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.0.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.1.0/24" }
}
private_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.10.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.11.0/24" }
}
database_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.20.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.21.0/24" }
}
nat_gateway_mode = "per_az" # production HA
tags = {
Environment = "prod"
CostCenter = "infra"
}
}
⚠️ Pin the source with?ref=v1.0.0— never a branch. To activate the secure-by-default Flow Logs, wireflow_log_destination_arn(andflow_log_iam_role_arnfor the CloudWatch path) from sibling modules — see the Example Library.
This is a foundation module — it consumes no sibling module. Its only optional inputs are by-reference Flow Log targets:
| Input | Type | Source module |
|---|---|---|
flow_log_destination_arn |
string (ARN) |
tf-mod-aws-cloudwatch-log-group or tf-mod-aws-s3-bucket |
flow_log_iam_role_arn |
string (ARN) |
tf-mod-aws-iam-role (CloudWatch destination only) |
| Output | Description | Consumed by |
|---|---|---|
id / vpc_id |
VPC id | nearly every networking / compute / db module |
arn / vpc_arn |
VPC ARN | IAM / SCP / policy conditions scoped to the VPC |
cidr_block |
Primary IPv4 CIDR | peering, route, SG-rule design |
public_subnet_ids |
Map of public subnet ids | tf-mod-aws-lb, NAT placement |
private_subnet_ids |
Map of private subnet ids | tf-mod-aws-ecs-service, tf-mod-aws-eks, tf-mod-aws-rds, Lambda |
database_subnet_ids |
Map of database subnet ids | tf-mod-aws-rds / tf-mod-aws-rds-aurora subnet groups |
public_route_table_ids / private_route_table_ids / database_route_table_ids |
Route-table id maps | tf-mod-aws-vpc-endpoint, tf-mod-aws-transit-gateway, peering routes |
default_security_group_id |
Locked deny-all default SG id | audit / reference (do not attach workloads) |
internet_gateway_id / nat_gateway_ids |
Gateway ids | route targets |
nat_gateway_public_ips |
Map of AZ → NAT public IP | partner egress allow-listing |
tags_all |
All tags incl. provider default_tags |
governance / audit |
1 · Minimal — single public subnet
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "sandbox"
cidr_block = "10.20.0.0/16"
public_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.20.0.0/24" }
}
}2 · Three tiers across two AZs (cost-optimized single NAT)
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "nonprod"
cidr_block = "10.0.0.0/16"
public_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.0.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.1.0/24" }
}
private_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.10.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.11.0/24" }
}
database_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.20.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.21.0/24" }
}
nat_gateway_mode = "single" # one shared NAT — lowest cost
}3 · Production HA — one NAT gateway per AZ
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "prod"
cidr_block = "10.30.0.0/16"
public_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.30.0.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.30.1.0/24" }
c = { availability_zone = "us-east-1c", cidr_block = "10.30.2.0/24" }
}
private_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.30.10.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.30.11.0/24" }
c = { availability_zone = "us-east-1c", cidr_block = "10.30.12.0/24" }
}
nat_gateway_mode = "per_az" # per-AZ private route tables, full HA
}4 · Fully isolated — no NAT (VPC-endpoint-only egress)
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "isolated"
cidr_block = "10.40.0.0/16"
private_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.40.10.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.40.11.0/24" }
}
nat_gateway_mode = "none" # private subnets have no default route; reach AWS via endpoints
}5 · Flow Logs to CloudWatch Logs (secure-default activation)
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "prod"
cidr_block = "10.0.0.0/16"
private_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.10.0/24" }
}
enable_flow_logs = true
flow_log_destination_type = "cloud-watch-logs"
flow_log_destination_arn = module.vpc_flow_log_group.arn # tf-mod-aws-cloudwatch-log-group
flow_log_iam_role_arn = module.flow_log_role.arn # tf-mod-aws-iam-role
flow_log_traffic_type = "ALL"
}6 · Flow Logs to S3 in Parquet, hourly partitions
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "audit"
cidr_block = "10.0.0.0/16"
private_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.10.0/24" }
}
enable_flow_logs = true
flow_log_destination_type = "s3"
flow_log_destination_arn = module.flow_log_bucket.arn # tf-mod-aws-s3-bucket (SSE-KMS on the bucket)
flow_log_destination_options = {
file_format = "parquet"
per_hour_partition = true
}
}💡 SSE-KMS for the logs is configured on the bucket (
tf-mod-aws-s3-bucketwith a customer-managed key), not here. The bucket's KMS key policy must grantdelivery.logs.amazonaws.com.
7 · Customer-managed KMS on the Flow Log destination
# This module references the destination by ARN; encryption lives on the destination.
module "flow_log_group" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-cloudwatch-log-group?ref=v1.0.0"
name = "/casey/vpc/flow-logs"
kms_key_arn = module.kms.arn # tf-mod-aws-kms — CMK whose policy allows delivery.logs.amazonaws.com
}
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "prod"
cidr_block = "10.0.0.0/16"
private_subnets = { a = { availability_zone = "us-east-1a", cidr_block = "10.0.10.0/24" } }
flow_log_destination_arn = module.flow_log_group.arn
flow_log_iam_role_arn = module.flow_log_role.arn
}8 · Disabling Flow Logs (secure-default opt-out — requires exception)
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "ephemeral-ci"
cidr_block = "10.99.0.0/16"
public_subnets = { a = { availability_zone = "us-east-1a", cidr_block = "10.99.0.0/24" } }
enable_flow_logs = false # opt-out of the secure baseline — document the exception
}9 · Tags — merge with provider default_tags
# Caller's provider block owns default_tags; resource tags win on key conflict.
provider "aws" {
region = "us-east-1"
default_tags {
tags = { Owner = "platform", ManagedBy = "terraform" }
}
}
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "tagged"
cidr_block = "10.0.0.0/16"
tags = {
Environment = "prod"
Owner = "network-team" # overrides default_tags Owner on the VPC and children
}
public_subnets = {
a = {
availability_zone = "us-east-1a"
cidr_block = "10.0.0.0/24"
tags = { Tier = "public-dmz" } # merged on top of module tags for this subnet
}
}
}
# module.vpc.tags_all => { Owner="network-team", ManagedBy="terraform", Environment="prod", Name="tagged" }10 · Secondary IPv4 CIDRs + Amazon IPv6 /56
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "dual-stack"
cidr_block = "10.0.0.0/16"
secondary_cidr_blocks = ["10.1.0.0/16", "10.2.0.0/16"]
assign_generated_ipv6_cidr_block = true
public_subnets = {
a = {
availability_zone = "us-east-1a"
cidr_block = "10.0.0.0/24"
ipv6_cidr_block = "2600:1f18:abcd:0::/64" # carve from the assigned /56
assign_ipv6_address_on_creation = true
}
}
}11 · Custom DHCP option set
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "corp"
cidr_block = "10.0.0.0/16"
dhcp_options = {
domain_name = "corp.financialpartners.internal"
domain_name_servers = ["10.0.0.2", "AmazonProvidedDNS"]
ntp_servers = ["10.0.0.10"]
netbios_node_type = "2"
}
}12 · Database tier with deliberate NAT egress (patching)
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "data"
cidr_block = "10.0.0.0/16"
public_subnets = { a = { availability_zone = "us-east-1a", cidr_block = "10.0.0.0/24" } }
database_subnets = { a = { availability_zone = "us-east-1a", cidr_block = "10.0.20.0/24" } }
nat_gateway_mode = "single"
database_subnet_nat_route = true # opt the data tier into outbound egress — non-default
}13 · Private-only VPC (no public DNS hostnames)
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "private-only"
cidr_block = "10.50.0.0/16"
enable_dns_support = true
enable_dns_hostnames = false
private_subnets = { a = { availability_zone = "us-east-1a", cidr_block = "10.50.10.0/24" } }
nat_gateway_mode = "none"
}14 · End-to-end composition — VPC feeding the rest of the stack (finale)
module "kms" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-kms?ref=v1.0.0"
name = "casey-app"
}
module "flow_log_group" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-cloudwatch-log-group?ref=v1.0.0"
name = "/casey/vpc/flow-logs"
kms_key_arn = module.kms.arn
}
module "flow_log_role" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-iam-role?ref=v1.0.0"
name = "casey-vpc-flow-logs"
# trust policy: vpc-flow-logs.amazonaws.com
}
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
name = "casey-app"
cidr_block = "10.0.0.0/16"
public_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.0.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.1.0/24" }
}
private_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.10.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.11.0/24" }
}
database_subnets = {
a = { availability_zone = "us-east-1a", cidr_block = "10.0.20.0/24" }
b = { availability_zone = "us-east-1b", cidr_block = "10.0.21.0/24" }
}
nat_gateway_mode = "per_az"
flow_log_destination_arn = module.flow_log_group.arn
flow_log_iam_role_arn = module.flow_log_role.arn
tags = { Environment = "prod", App = "lending-portal" }
}
module "app_sg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-security-group?ref=v1.0.0"
name = "app"
vpc_id = module.vpc.vpc_id
}
module "alb" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-lb?ref=v1.0.0"
name = "app"
vpc_id = module.vpc.vpc_id
subnet_ids = values(module.vpc.public_subnet_ids)
}
module "db" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-rds?ref=v1.0.0"
identifier = "app"
subnet_ids = values(module.vpc.database_subnet_ids)
kms_key_id = module.kms.arn
}ℹ️ High-level grouping:
- Core:
name,cidr_block,instance_tenancy,secondary_cidr_blocks - DNS / addressing:
enable_dns_support,enable_dns_hostnames,enable_network_address_usage_metrics,assign_generated_ipv6_cidr_block - Subnet tiers:
public_subnets,private_subnets,database_subnets— eachmap(object({ availability_zone, cidr_block, ipv6_cidr_block?, map_public_ip_on_launch?, assign_ipv6_address_on_creation?, enable_dns64?, private_dns_hostname_type_on_launch?, tags? })) - NAT topology:
nat_gateway_mode(single|per_az|none),database_subnet_nat_route - DHCP:
dhcp_options(object, defaultnull) - Flow Logs (secure default):
enable_flow_logs,flow_log_destination_type,flow_log_destination_arn,flow_log_iam_role_arn,flow_log_traffic_type,flow_log_max_aggregation_interval,flow_log_log_format,flow_log_destination_options - Universal:
tags
- Primary:
id/vpc_id,arn/vpc_arn - VPC attributes:
cidr_block,secondary_cidr_block_associations,ipv6_cidr_block,main_route_table_id,default_network_acl_id,default_security_group_id - Subnets:
public_subnet_ids/private_subnet_ids/database_subnet_ids,*_subnet_arns,*_subnet_cidr_blocks(maps keyed by subnet name) - Route tables:
public_route_table_ids,private_route_table_ids,database_route_table_ids - Gateways:
internet_gateway_id(null when no public subnets),nat_gateway_ids,nat_gateway_public_ips,nat_eip_allocation_ids - Conditional (
try(...) → null):dhcp_options_id,flow_log_id,flow_log_arn,internet_gateway_id - Tags:
tags_all
ℹ️ No outputs are marked
sensitive— this module emits no secrets.
- ARN / ID formats:
id→vpc-0a1b2c3d4e5f...arn→arn:aws:ec2:<region>:<account-id>:vpc/<vpc-id>— the cross-resource reference type used by IAM/SCP conditions.- Subnets →
subnet-...; route tables →rtb-...; IGW →igw-...; NAT →nat-...; flow log →fl-.... - Force-new / immutable fields: VPC
cidr_blockandinstance_tenancy; subnetcidr_blockandavailability_zone. Changing any of these destroys and recreates the resource (and everything downstream of a recreated VPC). Secondary CIDRs attach non-destructively. tags↔tags_all↔default_tags:var.tagsis written to every resource'stagsargument (subnets/DHCP additionally merge their per-itemtagson top).tags_allis the provider-computed union of resource tags over the provider'sdefault_tags; resource tags win on key conflict.default_tagsis the caller's provider-block concern and is never set inside the module.- Eventual consistency: NAT gateway creation and deletion are slow; EIP release lags the NAT delete. A too-quick re-apply after destroy can hit
AddressLimitExceeded/ "address still in use." Allow time to settle. - Destroy ordering (the #1 failure mode): A VPC will not delete while ENIs remain attached — NAT gateways, endpoints, and ENIs created by RDS / ECS / EKS / Lambda in sibling modules. Tear down consumers first → NAT gateways (EIPs release) → IGW detach → subnets → route tables → VPC. Terraform handles intra-module ordering; cross-module ENIs are the caller's responsibility.
- Default SG is adopted, not created.
aws_default_security_group.thistakes over the VPC's existing default SG and strips its rules to deny-all. Onterraform destroyit is not deleted — control reverts to AWS, which restores the standard default rules. - IGW is conditional. Created only when
public_subnetsis non-empty;internet_gateway_idisnullotherwise. NAT gateways require it anddepends_onit for a clean apply. - No
us-east-1constraint. VPC is a regional service — the module inherits the caller's provider region and declares noregionvariable.
Secure-by-default posture and the explicit opt-out for each:
| Hardened default | Behavior | Opt-out / control |
|---|---|---|
| VPC Flow Logs | enable_flow_logs = true, ALL traffic |
enable_flow_logs = false (document the exception); a destination must be wired to activate |
| Default security group | Adopted and locked to deny-all ingress + egress | No opt-out — secure invariant; attach workloads to named SGs (tf-mod-aws-security-group) |
| Database tier isolation | No default route — no outbound Internet | database_subnet_nat_route = true |
| Private subnet egress | Via NAT only (no direct IGW route) | nat_gateway_mode / route configuration |
| Public IP auto-assign | false on private/database tiers, true on public |
per-subnet map_public_ip_on_launch |
| Tenancy | default (low-cost, secure baseline) |
instance_tenancy = "dedicated" |
| Flow Log encryption | SSE on the destination; SSE-KMS when the target uses a CMK | configured on the destination module, not here |
Other principles: one composite owns the full network fabric; three subnet tiers modeled as map(object(...)) for for_each placement (no count); SGs / NACLs / endpoints / peering / TGW deliberately excluded to sibling modules to keep blast radius on the fabric itself.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan # requires valid AWS credentials (profile / SSO / OIDC) + a region
terraform apply
terraform output
⚠️ plan/applyrequire valid AWS credentials and a configured region (provider block /AWS_PROFILE/ SSO / OIDC). Always pin the module source with?ref=v1.0.0, never a branch.
terraform init -backend=false && terraform validate— schema + reference integrity.terraform fmt -check— formatting.terraform planagainst a sandbox account — confirm subnet/route/NAT counts match the chosennat_gateway_modeand that the default SG plans to deny-all.- Verify Flow Logs: after
apply, confirmflow_log_idis non-null and records appear at the destination. - Destroy test in a throwaway account to validate teardown ordering before relying on it in shared environments.
Apply complete! Resources: 17 added, 0 changed, 0 destroyed.
Outputs:
arn = "arn:aws:ec2:us-east-1:123456789012:vpc/vpc-0a1b2c3d4e5f67890"
id = "vpc-0a1b2c3d4e5f67890"
cidr_block = "10.0.0.0/16"
public_subnet_ids = { "a" = "subnet-0aaa...", "b" = "subnet-0bbb..." }
private_subnet_ids = { "a" = "subnet-0ccc...", "b" = "subnet-0ddd..." }
database_subnet_ids = { "a" = "subnet-0eee...", "b" = "subnet-0fff..." }
nat_gateway_public_ips = { "us-east-1a" = "52.x.x.x", "us-east-1b" = "54.x.x.x" }
default_security_group_id = "sg-0deadbeef..." # deny-all
tags_all = { "Environment" = "prod", "Name" = "casey-app",... }
- Tag drift / unexpected tags: Caused by
default_tagsoverlap.tags_allmerges resource tags over providerdefault_tagswith resource tags winning — if a tag value differs from what you set, adefault_tagsentry is colliding. Set the value explicitly invar.tags. - Credential-chain failures (
NoCredentialProviders/ExpiredToken): No valid credentials resolved. SetAWS_PROFILE, refresh SSO, or confirm the OIDC role assumption in CI. The module never takes credentials as variables. UnauthorizedOperationon create: An IAM action from the Required IAM Permissions table is missing — commonlyec2:CreateFlowLogsoriam:PassRole(CloudWatch flow-log path).- Flow log "created" but no records: Wrong destination wiring. For CloudWatch you must supply
flow_log_iam_role_arn(a role trustingvpc-flow-logs.amazonaws.com); for S3 the bucket policy must allowdelivery.logs.amazonaws.com. Also confirmflow_log_destination_arnis non-null — otherwise no flow log is created at all. DependencyViolationon destroy: ENIs still attached — NAT gateways, endpoints, or ENIs from RDS/ECS/EKS/Lambda in other modules. Destroy consumers first; the VPC deletes last.AddressLimitExceeded/ "address still in use" on re-apply: EIP release lags NAT deletion (eventual consistency). Wait and retry, or request an EIP quota increase.- Subnet recreates on a CIDR/AZ edit: Expected —
cidr_blockandavailability_zoneare force-new. Plan the layout up front; usesecondary_cidr_blocksto grow address space non-destructively. - No Internet Gateway created: IGW is conditional on
public_subnetsbeing non-empty;internet_gateway_idreturnsnullwhen you define only private/database tiers.
- Terraform Registry —
hashicorp/awsprovider:aws_vpc,aws_subnet,aws_route_table,aws_nat_gateway,aws_flow_log,aws_default_security_group - AWS — What is Amazon VPC? (Amazon Virtual Private Cloud User Guide)
- AWS — Amazon VPC quotas (per-Region limits for VPCs, subnets, EIPs, NAT gateways)
- AWS — NAT gateways and Internet gateways (User Guide)
- AWS — Publish flow logs to CloudWatch Logs / S3 (delivery roles and bucket policies)
- —
tf-mod-aws-kms,tf-mod-aws-iam-role,tf-mod-aws-cloudwatch-log-group,tf-mod-aws-s3-bucket,tf-mod-aws-security-group(sibling modules)
🧡 "Infrastructure as Code should be standardized, consistent, and secure."