Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_vpc that ships DNS resolution, IPv6, tenancy controls, and VPC Flow Logs to CloudWatch as a clean, var-driven network foundation. 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 "vpc" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc?ref=v1.0.0"
name = "..." # VPC name; drives the Name tag and Flow Log group/role n…
environment = "..." # Environment tag; must be `dev`, `staging`, `prod`, or `…
cidr_block = "..." # Primary IPv4 CIDR (prefix `/16`–`/28`); immutable after…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Amazon VPC (Virtual Private Cloud) is the logically isolated network boundary inside an AWS account: it owns the IPv4/IPv6 CIDR space, the DHCP and DNS behaviour, the default route table, the default network ACL, and the default security group that every subnet, ENI, and EC2 instance ultimately hangs off. Everything else in your account’s networking — subnets, NAT gateways, route tables, endpoints, peering, Transit Gateway attachments — references a VPC by ID. Because it is the root of the dependency tree, getting its CIDR, DNS settings, and observability right at creation time matters: you cannot resize a primary CIDR after the fact, and retro-fitting Flow Logs across dozens of accounts is painful.
This module wraps the aws_vpc resource into a single, opinionated, var-driven unit. It exposes the handful of attributes you actually tune per environment (CIDR, DNS hostnames/support, instance tenancy, optional Amazon-provided IPv6 block) and bakes in two things teams almost always bolt on later: a consistent tagging contract and VPC Flow Logs delivered to a CloudWatch Logs group with its own IAM role. The result is that “create a network” becomes a three-line module call that is identical in dev, staging, and prod, instead of a copy-pasted block that drifts between accounts.
When to use it
- You are standing up the network foundation for a new account, landing-zone spoke, or workload and need a VPC that other modules (subnets, NAT, endpoints, EKS) can consume by ID.
- You want DNS hostnames and DNS support on by default so private hosted zones, VPC endpoints, and EKS DNS resolution work without surprises.
- Your security or compliance baseline requires Flow Logs on every VPC, and you would rather have that guaranteed by the module than enforced after the fact by a Config rule that flags non-compliance.
- You need dual-stack (IPv6) networking and want the Amazon-provided /56 attached at create time.
- You operate many accounts and want one canonical, version-pinned VPC definition rather than per-team variants.
If you only need a throwaway default VPC for a quick experiment, the AWS-managed default VPC is fine and this module is overkill. Reach for it when the network is meant to last.
Module structure
terraform-module-aws-vpc/
├── versions.tf # provider + Terraform version constraints
├── main.tf # aws_vpc + optional Flow Logs (CW log group, IAM role/policy, flow log)
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/arn/cidr + DNS + default-resource outputs
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Flow Logs land in a per-VPC, environment-scoped log group name.
flow_log_group_name = "/aws/vpc/flow-logs/${var.name}"
base_tags = merge(
var.tags,
{
Name = var.name
Environment = var.environment
ManagedBy = "terraform"
Module = "terraform-module-aws-vpc"
}
)
}
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
instance_tenancy = var.instance_tenancy
enable_dns_support = var.enable_dns_support
enable_dns_hostnames = var.enable_dns_hostnames
# Request an Amazon-provided /56 IPv6 block when dual-stack is enabled.
assign_generated_ipv6_cidr_block = var.enable_ipv6
tags = local.base_tags
}
# ---------------------------------------------------------------------------
# VPC Flow Logs -> CloudWatch Logs (optional, on by default)
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = local.flow_log_group_name
retention_in_days = var.flow_logs_retention_in_days
kms_key_id = var.flow_logs_kms_key_arn
tags = local.base_tags
}
data "aws_iam_policy_document" "flow_logs_assume" {
count = var.enable_flow_logs ? 1 : 0
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["vpc-flow-logs.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "flow_logs_permissions" {
count = var.enable_flow_logs ? 1 : 0
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
]
resources = [
aws_cloudwatch_log_group.flow_logs[0].arn,
"${aws_cloudwatch_log_group.flow_logs[0].arn}:*",
]
}
}
resource "aws_iam_role" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "${var.name}-vpc-flow-logs"
assume_role_policy = data.aws_iam_policy_document.flow_logs_assume[0].json
tags = local.base_tags
}
resource "aws_iam_role_policy" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "${var.name}-vpc-flow-logs"
role = aws_iam_role.flow_logs[0].id
policy = data.aws_iam_policy_document.flow_logs_permissions[0].json
}
resource "aws_flow_log" "this" {
count = var.enable_flow_logs ? 1 : 0
vpc_id = aws_vpc.this.id
traffic_type = var.flow_logs_traffic_type
iam_role_arn = aws_iam_role.flow_logs[0].arn
log_destination = aws_cloudwatch_log_group.flow_logs[0].arn
log_destination_type = "cloud-watch-logs"
max_aggregation_interval = var.flow_logs_max_aggregation_interval
tags = local.base_tags
}
variables.tf
variable "name" {
description = "Name of the VPC. Used for the Name tag and to derive Flow Log group/role names."
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 64
error_message = "name must be between 1 and 64 characters."
}
}
variable "environment" {
description = "Environment this VPC belongs to (e.g. dev, staging, prod). Applied as a tag."
type = string
validation {
condition = contains(["dev", "staging", "prod", "shared"], var.environment)
error_message = "environment must be one of: dev, staging, prod, shared."
}
}
variable "cidr_block" {
description = "Primary IPv4 CIDR block for the VPC (e.g. 10.0.0.0/16). Cannot be changed after creation."
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "cidr_block must be a valid IPv4 CIDR (e.g. 10.0.0.0/16)."
}
validation {
condition = tonumber(split("/", var.cidr_block)[1]) >= 16 && tonumber(split("/", var.cidr_block)[1]) <= 28
error_message = "VPC CIDR prefix must be between /16 and /28 per AWS limits."
}
}
variable "instance_tenancy" {
description = "Default tenancy for instances launched in the VPC. 'default' (shared) or 'dedicated'."
type = string
default = "default"
validation {
condition = contains(["default", "dedicated"], var.instance_tenancy)
error_message = "instance_tenancy must be 'default' or 'dedicated'."
}
}
variable "enable_dns_support" {
description = "Enable DNS resolution via the Amazon-provided resolver (.2 address) in the VPC."
type = bool
default = true
}
variable "enable_dns_hostnames" {
description = "Assign public DNS hostnames to instances with public IPs. Required for many endpoints/EKS."
type = bool
default = true
}
variable "enable_ipv6" {
description = "Request an Amazon-provided /56 IPv6 CIDR block for dual-stack networking."
type = bool
default = false
}
variable "enable_flow_logs" {
description = "Create VPC Flow Logs delivered to a dedicated CloudWatch Logs group."
type = bool
default = true
}
variable "flow_logs_traffic_type" {
description = "Which traffic Flow Logs capture: ACCEPT, REJECT, or ALL."
type = string
default = "ALL"
validation {
condition = contains(["ACCEPT", "REJECT", "ALL"], var.flow_logs_traffic_type)
error_message = "flow_logs_traffic_type must be ACCEPT, REJECT, or ALL."
}
}
variable "flow_logs_retention_in_days" {
description = "Retention for the Flow Logs CloudWatch Logs group."
type = number
default = 90
validation {
condition = contains(
[1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653],
var.flow_logs_retention_in_days
)
error_message = "flow_logs_retention_in_days must be a value accepted by CloudWatch Logs."
}
}
variable "flow_logs_max_aggregation_interval" {
description = "Max interval (seconds) during which a flow is captured and aggregated: 60 or 600."
type = number
default = 600
validation {
condition = contains([60, 600], var.flow_logs_max_aggregation_interval)
error_message = "flow_logs_max_aggregation_interval must be 60 or 600."
}
}
variable "flow_logs_kms_key_arn" {
description = "Optional KMS key ARN to encrypt the Flow Logs CloudWatch Logs group. Null = AWS-owned key."
type = string
default = null
}
variable "tags" {
description = "Additional tags merged onto every resource the module creates."
type = map(string)
default = {}
}
outputs.tf
output "vpc_id" {
description = "ID of the VPC."
value = aws_vpc.this.id
}
output "vpc_arn" {
description = "ARN of the VPC."
value = aws_vpc.this.arn
}
output "vpc_name" {
description = "Name tag of the VPC."
value = var.name
}
output "cidr_block" {
description = "Primary IPv4 CIDR block of the VPC."
value = aws_vpc.this.cidr_block
}
output "ipv6_cidr_block" {
description = "Amazon-provided IPv6 CIDR block, or null when IPv6 is disabled."
value = aws_vpc.this.ipv6_cidr_block
}
output "default_route_table_id" {
description = "ID of the default (main) route table created with the VPC."
value = aws_vpc.this.default_route_table_id
}
output "default_security_group_id" {
description = "ID of the default security group created with the VPC."
value = aws_vpc.this.default_security_group_id
}
output "default_network_acl_id" {
description = "ID of the default network ACL created with the VPC."
value = aws_vpc.this.default_network_acl_id
}
output "main_route_table_id" {
description = "ID of the route table associated by default with the VPC."
value = aws_vpc.this.main_route_table_id
}
output "flow_log_id" {
description = "ID of the VPC Flow Log, or null when Flow Logs are disabled."
value = try(aws_flow_log.this[0].id, null)
}
output "flow_log_group_name" {
description = "CloudWatch Logs group name receiving Flow Logs, or null when disabled."
value = try(aws_cloudwatch_log_group.flow_logs[0].name, null)
}
How to use it
module "vpc" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc?ref=v1.0.0"
name = "platform-prod"
environment = "prod"
cidr_block = "10.20.0.0/16"
# Dual-stack with DNS on (defaults, shown for clarity).
enable_dns_support = true
enable_dns_hostnames = true
enable_ipv6 = true
# Flow Logs: capture everything, keep for a year, 1-minute aggregation.
enable_flow_logs = true
flow_logs_traffic_type = "ALL"
flow_logs_retention_in_days = 365
flow_logs_max_aggregation_interval = 60
tags = {
CostCenter = "platform-network"
Owner = "cloud-platform-team"
}
}
# Downstream: a public subnet that references the module's VPC id output.
resource "aws_subnet" "public_a" {
vpc_id = module.vpc.vpc_id
cidr_block = cidrsubnet(module.vpc.cidr_block, 8, 0) # 10.20.0.0/24
availability_zone = "ap-south-1a"
map_public_ip_on_launch = true
tags = {
Name = "platform-prod-public-a"
Tier = "public"
}
}
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/vpc/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc?ref=v1.0.0"
}
inputs = {
name = "..."
environment = "..."
cidr_block = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/vpc && 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 |
|---|---|---|---|---|
name |
string |
— | Yes | VPC name; drives the Name tag and Flow Log group/role names. |
environment |
string |
— | Yes | Environment tag; must be dev, staging, prod, or shared. |
cidr_block |
string |
— | Yes | Primary IPv4 CIDR (prefix /16–/28); immutable after creation. |
instance_tenancy |
string |
"default" |
No | Instance tenancy: default or dedicated. |
enable_dns_support |
bool |
true |
No | Enable the Amazon-provided DNS resolver in the VPC. |
enable_dns_hostnames |
bool |
true |
No | Assign public DNS hostnames; required for many endpoints and EKS. |
enable_ipv6 |
bool |
false |
No | Attach an Amazon-provided /56 IPv6 CIDR for dual-stack. |
enable_flow_logs |
bool |
true |
No | Create VPC Flow Logs to a dedicated CloudWatch Logs group. |
flow_logs_traffic_type |
string |
"ALL" |
No | Captured traffic: ACCEPT, REJECT, or ALL. |
flow_logs_retention_in_days |
number |
90 |
No | Retention for the Flow Logs log group (CloudWatch-valid values). |
flow_logs_max_aggregation_interval |
number |
600 |
No | Flow aggregation interval in seconds: 60 or 600. |
flow_logs_kms_key_arn |
string |
null |
No | KMS key ARN to encrypt the Flow Logs log group; null uses an AWS-owned key. |
tags |
map(string) |
{} |
No | Extra tags merged onto every resource the module creates. |
Outputs
| Name | Description |
|---|---|
vpc_id |
ID of the VPC. |
vpc_arn |
ARN of the VPC. |
vpc_name |
Name tag of the VPC. |
cidr_block |
Primary IPv4 CIDR block of the VPC. |
ipv6_cidr_block |
Amazon-provided IPv6 CIDR block, or null when IPv6 is disabled. |
default_route_table_id |
ID of the default (main) route table. |
default_security_group_id |
ID of the default security group. |
default_network_acl_id |
ID of the default network ACL. |
main_route_table_id |
ID of the route table associated by default with the VPC. |
flow_log_id |
ID of the VPC Flow Log, or null when disabled. |
flow_log_group_name |
CloudWatch Logs group receiving Flow Logs, or null when disabled. |
Enterprise scenario
A regulated fintech runs an AWS Organizations landing zone where every workload account gets one spoke VPC vended by this module through Account Factory. Because enable_flow_logs defaults to true with a 365-day retention, every account is born compliant with the firm’s network-traffic logging control — auditors query a single, predictably-named log group (/aws/vpc/flow-logs/<name>) per account instead of chasing ad-hoc setups. When the platform team later adds Transit Gateway attachments, they consume module.vpc.vpc_id and module.vpc.cidr_block directly, so non-overlapping CIDR planning and routing stay consistent across all 40+ accounts.
Best practices
- Plan CIDRs before you apply. The primary
cidr_blockis immutable; use distinct, non-overlapping/16s per account/region so future peering and Transit Gateway routing never collide. The module’s/16–/28validation catches obvious mistakes but not overlap — keep an IPAM allocation sheet (or AWS IPAM) as the source of truth. - Keep DNS support and hostnames on. Interface VPC endpoints, PrivateLink, Route 53 private hosted zones, and EKS all rely on in-VPC DNS resolution; disabling these is a common cause of silent name-resolution failures.
- Lock down the default security group. The default SG that ships with every VPC allows all intra-SG traffic; treat it as deny-all by never attaching it to workloads, and reference
default_security_group_idto manage/lock it explicitly downstream. - Right-size Flow Logs for cost.
ALLtraffic at a 60-second interval to CloudWatch is the most expensive combination; in cost-sensitive or high-throughput accounts use600-second aggregation, or route to S3 for cheap long-term storage and reserve CloudWatch for short, queryable retention. - Encrypt and retain deliberately. Set
flow_logs_kms_key_arnto a customer-managed key in regulated environments, and alignflow_logs_retention_in_dayswith your compliance window rather than leaving logs indefinitely. - Tag consistently for ownership and cost. Pass
CostCenter,Owner, and any data-classification tags viatags; the module merges them onto the VPC, log group, and IAM role so cost allocation and resource discovery work uniformly across accounts.