Last reviewed: May 2026
Build the AWS services on the ANS-C01 exam with plain Terraform — one block at a time, each tied back to an exam domain. The same code works on OpenTofu.
By the end of this lab you'll have provisioned, with plain Terraform, the canonical AWS multi-VPC backbone — two dual-stack (IPv4 + IPv6) VPCs in the same region, connected through a Transit Gateway with explicit route-table associations, and CloudWatch-backed VPC Flow Logs on the primary VPC so you can actually see what's traversing your network. This is the architecture every ANS-C01 multi-VPC question assumes.
Every resource is plain Terraform. Drop the snippets into a single main.tf, run terraform init, then terraform apply step-by-step.
>= 1.5 or OpenTofu >= 1.6.us-east-1.Most of this lab is cheap or free, but Transit Gateway is not:
The TGW hourly fee is the bill for this lab. Two attachments × $0.05/hour × 24 hours × 30 days ≈ $72/month while running. Destroy as soon as you're done exploring — TGW is the single biggest cost trap in any ANS-C01 lab.
Standard opener. Transit Gateway is regional — all attachments must be in the region the TGW lives in. For cross-region connectivity you'd add TGW peering (out of scope for v1). We default to us-east-1.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Project = "certlabpro-ans-c01"
ManagedBy = "terraform"
}
}
}
data "aws_availability_zones" "available" {
state = "available"
}ANS-C01 specifically tests dual-stack VPCs — AWS now defaults to assigning an IPv6 /56 to every new VPC if you set assign_generated_ipv6_cidr_block = true. The exam's Network Design domain tests both the IPv4 CIDR planning math (non-overlapping /16s if you want to peer or TGW-attach) and the IPv6 subnet derivation (each subnet gets a /64 carved from the VPC's /56).
We assign IPv4 10.0.0.0/16 and an AWS-auto-generated IPv6 /56. The single subnet here uses a /24 IPv4 block and a /64 IPv6 block carved from the VPC's /56 — cidrsubnet is the Terraform built-in for that math. The internet gateway is dual-stack by default in modern AWS regions.
resource "aws_vpc" "primary" {
cidr_block = "10.0.0.0/16"
assign_generated_ipv6_cidr_block = true
enable_dns_hostnames = true
tags = { Name = "certlabpro-ans-c01-primary" }
}
resource "aws_subnet" "primary_a" {
vpc_id = aws_vpc.primary.id
cidr_block = "10.0.1.0/24"
ipv6_cidr_block = cidrsubnet(aws_vpc.primary.ipv6_cidr_block, 8, 1)
availability_zone = data.aws_availability_zones.available.names[0]
assign_ipv6_address_on_creation = true
tags = { Name = "certlabpro-ans-c01-primary-a" }
}
resource "aws_internet_gateway" "primary" {
vpc_id = aws_vpc.primary.id
}We build a second VPC at 10.1.0.0/16 — deliberately non-overlapping with VPC #1's 10.0.0.0/16. ANS-C01 hammers on this design rule because the moment you want to connect two VPCs (peering, TGW, anything), overlapping CIDRs forbid it at the AWS API layer. You can't route to a CIDR that's also claimed locally.
VPC #2 also gets its own AWS-auto-generated IPv6 /56 (each VPC gets a globally-unique block, so there's no IPv6 overlap risk by default). The subnet carving math is the same shape as Step 2.
resource "aws_vpc" "secondary" {
cidr_block = "10.1.0.0/16"
assign_generated_ipv6_cidr_block = true
enable_dns_hostnames = true
tags = { Name = "certlabpro-ans-c01-secondary" }
}
resource "aws_subnet" "secondary_a" {
vpc_id = aws_vpc.secondary.id
cidr_block = "10.1.1.0/24"
ipv6_cidr_block = cidrsubnet(aws_vpc.secondary.ipv6_cidr_block, 8, 1)
availability_zone = data.aws_availability_zones.available.names[0]
assign_ipv6_address_on_creation = true
tags = { Name = "certlabpro-ans-c01-secondary-a" }
}The Transit Gateway is the hub. It scales to thousands of VPC attachments, supports cross-account sharing via AWS Resource Access Manager (RAM), and gives you a single route table to reason about instead of N² peering connections. ANS-C01 tests this hub-and-spoke choice over VPC peering whenever the question mentions "more than three VPCs" or "as our network grows".
We create the TGW with default route-table association and propagation disabled — that's the more-tested ANS-C01 pattern because it forces you to be explicit about which attachments populate which route tables. Auto-association is the convenience setting; manual is the audit-friendly setting.
The attachment-resource is the per-VPC bridge. With both VPCs attached, the TGW knows about both their CIDRs; we still need to add routes from each VPC's main route table pointing at the TGW (Step 5).
resource "aws_ec2_transit_gateway" "hub" {
description = "certlabpro-ans-c01 hub"
default_route_table_association = "disable"
default_route_table_propagation = "disable"
multicast_support = "disable"
tags = { Name = "certlabpro-ans-c01-tgw" }
}
resource "aws_ec2_transit_gateway_route_table" "main" {
transit_gateway_id = aws_ec2_transit_gateway.hub.id
tags = { Name = "certlabpro-ans-c01-tgw-rt" }
}
resource "aws_ec2_transit_gateway_vpc_attachment" "primary" {
transit_gateway_id = aws_ec2_transit_gateway.hub.id
vpc_id = aws_vpc.primary.id
subnet_ids = [aws_subnet.primary_a.id]
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
}
resource "aws_ec2_transit_gateway_vpc_attachment" "secondary" {
transit_gateway_id = aws_ec2_transit_gateway.hub.id
vpc_id = aws_vpc.secondary.id
subnet_ids = [aws_subnet.secondary_a.id]
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
}
resource "aws_ec2_transit_gateway_route_table_association" "primary" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.primary.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.main.id
}
resource "aws_ec2_transit_gateway_route_table_association" "secondary" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.secondary.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.main.id
}
resource "aws_ec2_transit_gateway_route_table_propagation" "primary" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.primary.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.main.id
}
resource "aws_ec2_transit_gateway_route_table_propagation" "secondary" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.secondary.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.main.id
}The TGW from Step 4 knows about both VPCs, but each VPC's own route table still doesn't know how to reach the other. We add explicit routes in both directions: VPC #1's route table sends 10.1.0.0/16 traffic to the TGW, and vice versa. The exam tests this dual-side requirement repeatedly — "I attached both to the TGW, why can't they ping?" is almost always a missing return-path route.
Finally, VPC Flow Logs. ANS-C01's Network Monitoring domain tests these as the only way to see what packets are actually traversing your network — accepted, rejected, and metadata-only (5-tuple + bytes + packets + result). We turn them on for the primary VPC, with logs flowing to a dedicated CloudWatch log group. The IAM role we attach is the one Flow Logs assumes to write into the log group.
With routes in place and Flow Logs running, the backbone is complete. Two dual-stack VPCs, connected through a TGW with explicit (audit-friendly) routing, with packet-level visibility on the primary side. Every additional ANS-C01 pattern (Direct Connect Gateway, TGW peering, NAT, PrivateLink, VPC endpoints) attaches to this base.
resource "aws_route" "primary_to_secondary" {
route_table_id = aws_vpc.primary.main_route_table_id
destination_cidr_block = "10.1.0.0/16"
transit_gateway_id = aws_ec2_transit_gateway.hub.id
depends_on = [aws_ec2_transit_gateway_vpc_attachment.primary]
}
resource "aws_route" "secondary_to_primary" {
route_table_id = aws_vpc.secondary.main_route_table_id
destination_cidr_block = "10.0.0.0/16"
transit_gateway_id = aws_ec2_transit_gateway.hub.id
depends_on = [aws_ec2_transit_gateway_vpc_attachment.secondary]
}
# ── VPC Flow Logs ─────────────────────────────────────────────
resource "aws_cloudwatch_log_group" "vpc_flow" {
name = "/aws/vpc-flow/certlabpro-ans-c01-primary"
retention_in_days = 30
}
resource "aws_iam_role" "vpc_flow" {
name = "certlabpro-ans-c01-vpc-flow"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "vpc-flow-logs.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "vpc_flow" {
name = "write-flow-logs"
role = aws_iam_role.vpc_flow.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams",
]
Resource = aws_cloudwatch_log_group.vpc_flow.arn
}]
})
}
resource "aws_flow_log" "primary" {
vpc_id = aws_vpc.primary.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.vpc_flow.arn
iam_role_arn = aws_iam_role.vpc_flow.arn
max_aggregation_interval = 60
}terraform destroy tears down everything in this lab, but be careful with ordering — Transit Gateway attachments take 5–10 minutes to detach, and the TGW itself can't be deleted until both attachments are fully gone. terraform destroy handles the dependency graph correctly, but the operation isn't fast. Don't Ctrl-C mid-destroy or you'll end up with half-detached attachments you have to clean up via the console.
The biggest reason to destroy promptly is the TGW hourly fee — two attachments at $0.05/hour each = $72/month combined. The lab adds up fast if you forget it's running.
ANS-C01 covers networking surfaces this lab can't fit — Direct Connect (no test environment without a physical port), AWS Site-to-Site VPN (needs a customer-side endpoint), Route 53 Resolver inbound/outbound endpoints for hybrid DNS, PrivateLink + VPC endpoint services, AWS Network Firewall, AWS Global Accelerator, Route 53 health checks + failover, Network Access Analyzer, Reachability Analyzer, Transit Gateway peering (cross-region), Cloud WAN, AWS App Mesh, and BGP-specific scenarios (route filtering, prepend, AS-PATH manipulation).
We stick to the multi-VPC backbone shape because it's the single most-tested architecture on the exam — Direct Connect attachments, VPN tunnels, hybrid DNS, and PrivateLink all anchor to this kind of TGW hub. Get the hub-and-spoke right; the spoke-types are extensions of it.
For the surfaces not provisioned, the Browse, Playbook, and Editorial sections of this cert page have conceptual coverage. A follow-up lab adding Route 53 Resolver + a VPN attachment + PrivateLink would round out the hybrid-network story.