Build AWS VPC Using Terraform
- OS: Windows 11
- Terraform version: 1.10.5
After reading the previous article, you should now have a basic understanding of Terraform. In this post, we’ll reinforce that knowledge through a hands-on example.
To avoid jumping into overly complex tasks too soon, this article will guide you step by step in using basic resource
blocks to create an AWS VPC. In the next article, we’ll refactor the setup using Modules.
VPC (Virtual Private Cloud) is a networking service provided by AWS that allows you to create an isolated network environment in the cloud. For readers interested in a deeper dive, you can refer to the official documentation.
The main goal of this article is to use Terraform to create the following resources:
- 1 VPC
- 1 Public Subnet
- 1 Private Subnet
- 2 Route Tables
- 2 Network ACLs
- 1 Internet Gateway
Prerequisites
Before we start writing Terraform configurations, we need to set up the local environment. This guide focuses on the Windows operating system.
Install Terraform
If you’re not using Windows, refer to the official Install Terraform guide.
First, open PowerShell and run the following command to create a folder for installing Terraform:
mkdir "$env:LOCALAPPDATA\Programs\Terraform\bin"
Download the appropriate version of Terraform for your CPU architecture. In this example, we’ll download the Windows AMD64 v1.10.5 Terraform zip file, then extract it to the folder you just created.
After extracting the files, go to User Environment Variables
, and add the following path to your Path
variable:
%LOCALAPPDATA%\Programs\Terraform\bin
Once that’s done, restart PowerShell and run the following command to verify the installation:
terraform -version
If the output shows something like Terraform v1.10.5 on windows_amd64, the installation was successful.
AWS Setup
Install AWS CLI
Follow the official documentation to install the AWS CLI according to your operating system.
Configure AWS Profile
The config and credentials files are stored under $env:UserProfile\.aws
. To set up a default profile, run the following command:
aws configure
Then enter the following information when prompted:
- Access Key
- Secret Access Key
- Default Region (e.g., ap-northeast-1)
- Output Format (e.g, json)
HCL
According to the official recommendation, we’ll start by creating the following files: main.tf, variables.tf, outputs.tf, terraform.tf, providers.tf, backend.tf, and locals.tf. After creating these files, the project structure should look like this:
terraform-demo/
├── backend.tf # Backend configuration
├── locals.tf # Local variable definitions
├── main.tf # Main resource definitions
├── outputs.tf # Output values
├── providers.tf # Provider configurations
├── terraform.tf # Core Terraform settings (version and required providers)
└── variables.tf # Input variable declarations
Define Terraform and Provider Versions
Before defining resources such as the VPC, we need to specify the required Terraform version and the provider to use. In this example, we’ll use Terraform version 1.0 or later, along with AWS Provider version 6.3.X:
# terraform.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.3.0"
}
}
}
Use Local Backend
By default, Terraform uses the local backend, which stores the state file in the project directory. However, we’ll define it explicitly here to make it clear which part to modify later when switching to a remote backend:
# backend.tf
terraform {
backend "local" {
path = "terraform.tfstate"
}
}
💡 If you omit the backend block, Terraform will still default to using a local state file. Therefore, this configuration is not strictly required, but it helps make the structure more explicit.
VPC
Let’s start with the most basic resource: the VPC. First, define the VPC name prefix and a CIDR block in the variables.tf file:
# variables.tf
variable "name" {
description = "Name of the VPC"
type = string
default = "demo"
}
Next, in locals.tf, construct the full VPC name and select a single Availability Zone to simplify the architecture. For this example, we’ll use just one AZ:
# locals.tf
data "aws_availability_zones" "available" {}
locals {
vpc_name = "${var.name}-vpc"
azs = slice(data.aws_availability_zones.available.names, 0, 1)
}
Finally, define a VPC in main.tf, and enable DNS support:
# main.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = local.vpc_name
}
}
Subnet
Next, we’ll create one Public Subnet and one Private Subnet for the VPC. To avoid overlapping CIDR blocks, we’ll use Terraform’s cidrsubnet function to automatically calculate the CIDR for each subnet.
Add the following to locals.tf to compute the CIDR blocks for the public and private subnets:
# locals.tf
locals {
public_subnets = [for k, v in local.azs : cidrsubnet(aws_vpc.main.cidr_block, 4, k)]
private_subnets = [for k, v in local.azs : cidrsubnet(aws_vpc.main.cidr_block, 4, k + 8)]
}
💡 The cidrsubnet(base, newbits, netnum) function is used to split an existing CIDR block into smaller subnets. In this case, we divide a /16 block into multiple /20 subnets (since 4 = 20 - 16), and use different netnum values to avoid overlap.
Next, define the Public and Private Subnets in main.tf:
# main.tf
resource "aws_subnet" "public" {
count = length(local.azs)
availability_zone = element(local.azs, count.index)
cidr_block = element(local.public_subnets, count.index)
map_public_ip_on_launch = true
vpc_id = aws_vpc.main.id
tags = {
Name = format("${var.name}-subnet-public%s-%s", count.index + 1, element(local.azs, count.index))
Type = "Public"
}
}
resource "aws_subnet" "private" {
count = length(local.azs)
availability_zone = element(local.azs, count.index)
cidr_block = element(local.private_subnets, count.index)
vpc_id = aws_vpc.main.id
tags = {
Name = format("${var.name}-subnet-private%s-%s", count.index + 1, element(local.azs, count.index))
Type = "Private"
}
}
Internet Gateway
Since we’ve created a Public Subnet, we now need to allow it to connect to the Internet. To do this, we’ll attach an Internet Gateway to the VPC.
# main.tf
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.name}-igw"
}
}
💡 An Internet Gateway acts as the gateway between a VPC and the public Internet. A subnet is only considered truly “public” if it’s properly routed to the Internet Gateway and has map_public_ip_on_launch enabled.
Route Table
Next, we’ll configure a Route Table to enable the subnets to communicate externally. The Public Subnet needs a route through the Internet Gateway to access the Internet, while the Private Subnet remains isolated for internal communication only.
First, create a Route Table for the Public Subnet and add a route that directs outbound traffic through the IGW:
# main.tf
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.name}-rtb-public"
}
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = element(aws_subnet.public[*].id, count.index)
route_table_id = aws_route_table.public.id
}
resource "aws_route" "public_internet_gateway" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
timeouts {
create = "5m"
}
}
The Private Subnet doesn’t need to connect to the Internet Gateway, but it should still be able to communicate with other subnets within the VPC.
# main.tf
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.name}-rtb-private"
}
}
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = element(aws_subnet.private[*].id, count.index)
route_table_id = aws_route_table.private.id
}
NACL
Next, we’ll create corresponding Network ACLs (NACLs) for both the Public and Private Subnets. NACLs are subnet-level network access control mechanisms that allow you to define rules for allowing or denying traffic.
For now, we’ll allow all traffic to simplify testing and development. You can tighten the rules later based on your security requirements.
Here is the NACL configuration for the Public Subnet:
# main.tf
resource "aws_network_acl" "public" {
vpc_id = aws_vpc.main.id
subnet_ids = aws_subnet.public[*].id
tags = {
Name = "${var.name}-nacl-public"
}
}
resource "aws_network_acl_rule" "public_inbound" {
network_acl_id = aws_network_acl.public.id
egress = false
rule_number = 100
rule_action = "allow"
from_port = 0
to_port = 0
protocol = "-1"
cidr_block = "0.0.0.0/0"
}
resource "aws_network_acl_rule" "public_outbound" {
network_acl_id = aws_network_acl.public.id
egress = true
rule_number = 100
rule_action = "allow"
from_port = 0
to_port = 0
protocol = "-1"
cidr_block = "0.0.0.0/0"
}
And for the Private Subnet:
# main.tf
resource "aws_network_acl" "private" {
vpc_id = aws_vpc.main.id
subnet_ids = aws_subnet.private[*].id
tags = {
Name = "${var.name}-nacl-private"
}
}
resource "aws_network_acl_rule" "private_inbound" {
network_acl_id = aws_network_acl.private.id
egress = false
rule_number = 100
rule_action = "allow"
from_port = 0
to_port = 0
protocol = "-1"
cidr_block = "0.0.0.0/0"
}
resource "aws_network_acl_rule" "private_outbound" {
network_acl_id = aws_network_acl.private.id
egress = true
rule_number = 100
rule_action = "allow"
from_port = 0
to_port = 0
protocol = "-1"
cidr_block = "0.0.0.0/0"
}
Deployment
Once all the configurations above are complete, you’re ready to deploy.
Format the Code
First, format the .tf files to ensure consistent and readable syntax:
terraform fmt
Initialize the Project
Initialize the Terraform project, download the required providers, and set up the working directory:
terraform init
After initialization, you’ll notice a .terraform/
directory and a .terraform.lock.hcl
file are created:
.terraform/
: Contains provider binaries, Terraform version information, and backend initialization metadata..terraform.lock.hcl
: Records the exact versions of the providers used to ensure consistent results across teams and future runs, avoiding unexpected changes due to version upgrades.
Apply the Configuration
Now you can deploy the defined resources to AWS:
terraform apply
Type yes
when prompted to begin execution.
Once completed, you’ll see the VPC, subnets, NACLs, and other resources created in your AWS account. A terraform.tfstate
file will also appear in the project root, recording the current state of all resources managed by Terraform.
Destroy the Resources
If the resources were only for testing or if you want to clean up the environment, you can destroy all Terraform-managed resources with the following command:
terraform destroy
Just like apply, type yes
to confirm and begin the destruction process.
After destruction, the terraform.tfstate
file will show an empty resources array. You’ll also find a terraform.tfstate.backup
file, which holds the last known successful state.