IaC AWS

Terraform Module: AWS VPC — a flow-logged, DNS-ready network foundation

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

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 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/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

TerraformAWSVPCModuleIaC
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