Provision AWS Networks and Security Boundaries

This lesson teaches how to use Terraform to create AWS network foundations and the security boundaries around them. The outcome is not merely a VPC that exists. The useful outcome is a network whose address space, subnet tiers, route tables, gateways, and security groups express a clear application boundary that can be reviewed before it is changed.

In this course, Terraform is the control plane for repeatable infrastructure. AWS networking is a good test of that discipline because a small configuration mistake can make workloads unreachable, overly exposed, or expensive to operate. The goal is to model the network in a way that makes those mistakes visible in the plan.

What Terraform Builds in AWS Networking

An AWS VPC is an isolated regional network with one or more CIDR blocks. Subnets are zonal slices of that address space. Route tables decide where packets go next. An internet gateway provides a target for public internet routing. NAT gateways, VPC endpoints, network ACLs, and security groups add different kinds of access control or egress paths.

Terraform does not send one large VPC creation request. It reads configuration, asks the AWS provider schema which attributes are required or computed, refreshes state, builds a dependency graph, and then calls EC2 APIs in an order that satisfies references. A subnet referring to aws_vpc.main.id depends on the VPC. A route that uses aws_internet_gateway.main.id depends on the gateway. Route table associations depend on both the subnet and the table.

The Terraform state file stores the binding between resource addresses such as aws_subnet.private["us-east-1a"] and remote AWS object IDs such as subnet-.... That binding is security-sensitive because it describes the network and often exposes account structure. Use remote state with locking for shared work, and grant CI only the AWS permissions needed to create and update the selected network resources.

Configuration Anatomy

The main Terraform vocabulary for this lesson is resource, variable, for_each, cidrsubnet, references, tags, and outputs. The AWS vocabulary is VPC CIDR, availability zone, public subnet, private subnet, route table, route target, ingress rule, egress rule, and security group reference.

cidrsubnet(prefix, newbits, netnum) is especially useful. Given a VPC CIDR such as 10.40.0.0/16, adding eight bits creates /24 subnets. Different netnum values select different subnets within the parent range. This avoids hand-entered subnet ranges that accidentally overlap.

Security groups are stateful instance or interface boundaries. If inbound traffic is allowed, the response is automatically allowed. Network ACLs are stateless subnet boundaries and require matching inbound and outbound rules. Most application tiering starts with security groups because they can reference other security groups, which means the rule can say app port 8080 is allowed from the web tier rather than from a fragile list of web server IP addresses.

Example 1: VPC and Private Subnets

The first example creates a VPC and two private subnets. The important design choice is that the subnet keys are availability zone names, so Terraform keeps stable addresses when it plans changes.

variable "name" {
  type    = string
  default = "training"
}

variable "azs" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b"]
}

resource "aws_vpc" "main" {
  cidr_block           = "10.40.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.name}-vpc"
  }
}

resource "aws_subnet" "private" {
  for_each                = toset(var.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, index(var.azs, each.value))
  availability_zone       = each.value
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.name}-private-${each.value}"
    Tier = "private"
  }
}

The expected plan includes one VPC and two private subnets. The first private subnet receives 10.40.0.0/24; the second receives 10.40.1.0/24. Because map_public_ip_on_launch is false and there is no default route to an internet gateway here, resources launched into these subnets are not automatically public.

Example 2: Public Subnets and Internet Routing

The second example adds an internet gateway, public subnets, a route table with a default route, and associations from each public subnet to that table.

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.name}-igw"
  }
}

resource "aws_subnet" "public" {
  for_each                = toset(var.azs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, 100 + index(var.azs, each.value))
  availability_zone       = each.value
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.name}-public-${each.value}"
    Tier = "public"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public" {
  for_each       = aws_subnet.public
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

The public subnet CIDRs start at net numbers 100 and 101, producing 10.40.100.0/24 and 10.40.101.0/24. That separation leaves lower numbers for private tiers and makes route-table review easier. The expected AWS behavior is that an instance with a public IP in a public subnet can use the route 0.0.0.0/0 to reach the internet through the internet gateway, assuming its security group permits the traffic.

Example 3: Tiered Security Groups

The third example creates a web security group and an app security group. The boundary is expressed by referencing security groups instead of copying CIDR ranges between tiers.

resource "aws_security_group" "web" {
  name        = "${var.name}-web"
  description = "Allow HTTP from the internet and egress to application tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from clients"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description     = "App traffic only"
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }
}

resource "aws_security_group" "app" {
  name        = "${var.name}-app"
  description = "Accept application traffic from web tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "From web tier"
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]
  }
}

resource "aws_vpc_security_group_egress_rule" "app_https" {
  security_group_id = aws_security_group.app.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "tcp"
  from_port         = 443
  to_port           = 443
}

The deterministic behavior is clear from the rules. Port 80 is open from the internet to the web tier. Port 8080 is allowed from the web tier to the app tier. The app tier can initiate HTTPS egress. There is no inbound SSH rule, no database port from the internet, and no unrestricted egress from the web tier. In a plan review, those omissions are as important as the rules that exist.

Design Choices and Trade-offs

Choose CIDR ranges with future peering, Transit Gateway, VPN, and Kubernetes pod or service ranges in mind. A small /24 VPC may work for a demo but leaves no room for multiple tiers across multiple availability zones. A very large block can collide with other networks and make routing integration harder later.

Use for_each with stable semantic keys for repeated subnets and associations. Using count can be acceptable for disposable examples, but inserting a new item in the middle of a list can shift indexes and create unnecessary replacements. Stable keys make plans easier to understand and reduce accidental churn.

Public subnets are for load balancers, bastion alternatives, NAT gateways, and explicitly public endpoints. Application and data tiers normally belong in private subnets. Private subnets still need outbound paths for patches and package downloads; the trade-off is usually NAT gateway cost and centralization versus VPC endpoints that keep traffic on AWS private connectivity for supported services.

Inline security group rules are compact, but separate rule resources can reduce replacement pressure and make ownership clearer in larger modules. Avoid mixing inline rules and separate rule resources for the same security group because competing definitions can cause confusing drift.

Failure Modes and Troubleshooting

Symptom: Terraform plans to replace subnets after adding a new availability zone. Cause: the module used count and list indexes shifted. Diagnostics: inspect resource addresses in the plan and compare old indexes with the intended AZ names. Correction: migrate to for_each with keys such as us-east-1a, using moved blocks or state moves where appropriate.

Symptom: instances in a public subnet cannot reach the internet. Cause: one of three pieces is missing: public IP assignment, a subnet association to a route table containing 0.0.0.0/0, or a route target to an attached internet gateway. Diagnostics: check subnet attributes, route table associations, route state, and security group egress. Correction: associate the intended table, attach the gateway, or place the workload behind a load balancer if direct public IPs are not desired.

Symptom: the app tier rejects traffic from the web tier even though both instances are healthy. Cause: the app security group allows the wrong source, protocol, or port, or the workload listens on a different port. Diagnostics: inspect security group rules, VPC Flow Logs, target group health checks, and the process listener. Correction: use a security group source reference and align the listener, health check, and rule port.

Symptom: terraform apply fails with an AWS dependency violation while destroying the VPC. Cause: an interface, endpoint, NAT gateway, load balancer, or route table association still exists. Diagnostics: query dependent EC2 resources by VPC ID and compare them with Terraform state. Correction: import unmanaged dependents, remove manually created objects, or destroy dependent modules before the base network.

Security, Reliability, and Performance Implications

Network Terraform code is security policy. Review it with the same care as IAM. Favor named tiers, narrow ports, security group references, and explicit egress. Do not rely on subnet names alone as a boundary; the boundary is the route table, gateway path, ACL behavior, and security group policy actually attached to the workload.

Reliability depends on spreading subnets across availability zones and avoiding hidden single points of failure. A single NAT gateway is cheaper, but private workloads in another AZ depend on cross-zone routing to that gateway. Multiple NAT gateways cost more but reduce the blast radius of an AZ problem. Performance depends on address capacity, route simplicity, endpoint placement, and avoiding unnecessary public internet paths for AWS service calls.

Hands-On Lab

Prerequisites: Terraform, AWS CLI, credentials for a sandbox AWS account, permission to manage VPC resources, and a region with at least two availability zones. Work in an empty directory and use a backend appropriate for your team if more than one person can apply changes.

  1. Create provider configuration for AWS and add the VPC, private subnet, public subnet, route table, and security group examples from this lesson.
  2. Add outputs for vpc_id, private subnet IDs, public subnet IDs, and security group IDs.
  3. Run the following commands from the lab directory.
terraform init
terraform validate
terraform plan -out network.tfplan
terraform apply network.tfplan
aws ec2 describe-subnets --filters Name=vpc-id,Values=$(terraform output -raw vpc_id) --query 'Subnets[].{SubnetId:SubnetId,Public:MapPublicIpOnLaunch,Az:AvailabilityZone}'
terraform destroy

Verification: terraform validate should report that the configuration is valid. The saved plan should show VPC, subnet, route table, internet gateway, association, and security group creations. The AWS CLI subnet query should show public subnets with public IP launch enabled and private subnets without it. Cleanup: run terraform destroy from the same state. If destroy fails with dependencies, query remaining resources by VPC ID, remove or import unmanaged dependencies, and rerun destroy.

Assessment Exercises

  1. Given 10.40.0.0/16, explain what CIDR blocks are produced by cidrsubnet("10.40.0.0/16", 8, 5) and cidrsubnet("10.40.0.0/16", 8, 100), and why reserving ranges by tier helps reviews.
  2. Modify the examples so private subnets can access Amazon S3 without using public internet routing. Explain which Terraform resources you would add and what route table change you expect.
  3. A plan shows a route from a private subnet route table to an internet gateway. Describe the risk, how you would confirm which subnets are affected, and the correction.
  4. Design security group rules for a load balancer, web tier, app tier, and database tier. State which rules should use CIDR sources and which should use security group references.
  5. Explain how you would split this network into reusable Terraform modules without hiding the route and security decisions reviewers need to see.

Summary

Provisioning AWS networks with Terraform means encoding address allocation, subnet placement, route targets, gateway attachments, and security group boundaries as reviewed configuration. Strong designs use stable keys, non-overlapping CIDRs, explicit public and private tiers, narrow security rules, and verification that inspects AWS behavior after apply. The plan should make exposure, reachability, and replacement risk obvious before the network changes.