Build a VPC with Terraform

Provision a complete AWS network and EC2 instance using Terraform.

Introduction

30 Second Summary

Every website, app, and cloud service you use runs on a network. In AWS, you design that network yourself using a Virtual Private Cloud (VPC).

In this project, you will build a complete AWS network from scratch using Terraform. You will create a VPC, subnets, an internet gateway, a security group, and an EC2 instance, then migrate your Terraform state to a remote S3 backend.

What You'll Build

A fully functional AWS network defined entirely as code, with a running EC2 instance you can SSH into and remote state management in S3.

By the end of this project, you'll have:

  • A custom VPC with a public subnet, internet gateway, and route table wired for internet access.
  • A security group and SSH key pair generated entirely through Terraform.
  • A running EC2 instance inside your VPC that you can connect to via SSH.
  • Your Terraform state stored remotely in an S3 backend with native state locking enabled.
  • Secret Mission: Add a private subnet to your VPC and understand the architectural difference between public and private subnets.

Are there any prerequisites?

This project assumes you already have an AWS account with the AWS CLI configured and Terraform installed. If you completed the "Create S3 Buckets with Terraform" project, you are all set.

Set Up Your Project

Every AWS resource you build lives inside a network. That network is called a VPC (Virtual Private Cloud), and it controls how your resources talk to each other and to the internet.

In this step, you will create your project folder, write the initial Terraform configuration, and confirm your tools are connected to AWS.

In this step, get ready to:
  • Create a project folder and initialize Terraform with the AWS provider.
  • Verify that Terraform and the AWS CLI are working.
  • Create an outputs file for displaying resource IDs later.
Create your project folder and Terraform configuration

Terraform needs a working directory with at least one .tf file to operate. You will create that folder and write your first configuration file inside it.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir terraform-vpc
cd terraform-vpc
  • Confirm the folder exists by running:
ls

You should see terraform-vpc listed in the output (or you will already be inside it).

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the terraform-vpc folder, then click Open.

Why VS Code for Terraform?

VS Code gives you a file explorer, integrated terminal, and syntax highlighting for .tf files all in one window. You will write your Terraform code in the editor and run commands in the built-in terminal without switching apps.

  • Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Create main.tf by clicking the new file icon in the Explorer sidebar (or right-click and select New File).
  • Name the file main.tf and press Enter.
  • Paste the following code into main.tf:
# Define which providers Terraform needs to download
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  # Minimum Terraform version for this project
  required_version = ">= 1.10.0"
}

# Set the AWS region where resources will be created
provider "aws" {
  region = "us-east-1"
}

What does this code do?

The terraform block tells Terraform which provider plugins to download. Here you are requiring the AWS provider (version ~> 6.0) and setting the minimum Terraform version to 1.10.0.

The provider block configures that AWS plugin. Setting region = "us-east-1" means all resources you create will live in the US East (N. Virginia) region by default.

  • Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Now you need to initialize Terraform so it downloads the AWS provider plugin.

  • In VS Code's integrated terminal, initialize Terraform by running:
terraform init

You should see output ending with Terraform has been successfully initialized! and a note that the AWS provider was installed.

Seeing an error during init?

  • If you see a provider-related error, double-check that your main.tf has the correct source value of hashicorp/aws. A typo here prevents Terraform from finding the plugin.
  • If you see a network error, confirm your internet connection is active. Terraform downloads the provider from the Terraform Registry.

help me fix my terraform init error

Verify your tools are ready

Before building infrastructure, confirm that Terraform and the AWS CLI are both responding correctly.

  • Check your Terraform version by running:
terraform version

You should see a version number of 1.10.0 or higher in the output.

  • Confirm your AWS CLI is connected to your account by running:
aws sts get-caller-identity

You should see JSON output containing your Account ID, Arn, and UserId. This confirms the CLI can authenticate with AWS.

Command not found or unable to locate credentials?

  • If terraform version returns an error, Terraform may not be on your system PATH. Revisit the installation steps from the "Create S3 Buckets with Terraform" project.
  • If aws sts get-caller-identity says it cannot locate credentials, run aws configure and re-enter your Access Key ID and Secret Access Key.

help me fix my AWS CLI or Terraform setup

Create an outputs file for later use

Terraform uses output blocks to display useful values (like resource IDs) after an apply. You will populate this file as you build resources in the next steps.

  • In VS Code's Explorer sidebar, click the new file icon.
  • Name the file outputs.tf and press Enter.
  • Leave the file empty for now. Save it with Cmd+S (macOS) or Ctrl+S (Windows).

Why create an empty file now?

Separating outputs into their own file keeps your configuration organized. As your Terraform project grows, having main.tf for resources and outputs.tf for outputs makes each file easier to read and maintain.

Confirm your project folder now contains main.tf, outputs.tf, a .terraform directory, and a .terraform.lock.hcl file in the Explorer sidebar.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before continuing.

ⓧ I'd like to double check the full code

Here is the complete main.tf for this step:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  required_version = ">= 1.10.0"
}

provider "aws" {
  region = "us-east-1"
}

Your outputs.tf should be empty at this point.

Your project is initialized and your tools are confirmed working. Next up, you will create the VPC and networking resources that form the backbone of your AWS environment.

Create Your VPC and Networking

Your Terraform project is initialized and ready to go. Now it is time to build the network that your cloud resources will live inside.

Every resource in AWS needs a network to communicate. A VPC (Virtual Private Cloud) is your own isolated section of the AWS cloud where you control the IP addresses, subnets, and routing. In this step, you will define a VPC, carve out a public subnet, and wire it to the internet.

In this step, get ready to:
  • Create a VPC with a public subnet.
  • Wire internet access through an internet gateway and route table.
  • Display your resource IDs with Terraform outputs.
Define the VPC and public subnet

A VPC defines the overall network boundary. Inside it, you create subnets to organize resources into smaller IP ranges.

  • In VS Code, open main.tf.
  • Below the provider "aws" block, add the VPC resource by pasting this code:
# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "my-terraform-vpc"
  }
}

What does this code do?

The aws_vpc resource creates a Virtual Private Cloud with a 10.0.0.0/16 CIDR block. That gives you over 65,000 possible IP addresses to work with.

The enable_dns_support and enable_dns_hostnames settings allow resources inside the VPC to resolve domain names and receive public DNS hostnames.

You will not see a change yet. The terraform plan command at the end of this substep confirms both resources.

  • Directly below the VPC resource, add the public subnet by pasting this code:
# Public Subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "my-public-subnet"
  }
}

What does this code do?

The aws_subnet resource carves out a smaller network (10.0.1.0/24, 256 addresses) inside your VPC.

Setting map_public_ip_on_launch = true means any instance launched in this subnet automatically gets a public IP address. That is what makes it a "public" subnet.

  • Save main.tf.
  • Preview what Terraform will create by running this command:
terraform plan
  • You should see a summary ending with Plan: 2 to add, 0 to change, 0 to destroy. confirming both the VPC and subnet will be created.

Seeing an error instead of a plan?

  • Check for typos in cidr_block values. The VPC must be "10.0.0.0/16" and the subnet must be "10.0.1.0/24".
  • Confirm that vpc_id = aws_vpc.main.id in your subnet references aws_vpc.main exactly. A mismatch means Terraform cannot find the VPC.
  • Make sure you saved main.tf before running the command.

Help me debug my terraform plan error

Add the internet gateway and route table

A subnet alone cannot reach the internet. You need an Internet Gateway to connect your VPC to the public internet, and a Route Table to tell traffic where to go.

  • In main.tf, below the subnet resource, add the internet gateway by pasting this code:
# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "my-igw"
  }
}

What is an internet gateway?

An internet gateway is the door between your VPC and the public internet. Without it, nothing inside your VPC can send or receive traffic from outside AWS.

You will not see a change yet. The terraform apply command after the next chunk creates everything together.

  • Below the internet gateway, add the route table and route table association by pasting this code:
# Route Table
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
  }

  tags = {
    Name = "my-public-rt"
  }
}

# Route Table Association
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

What does this code do?

The route table contains a rule that sends all outbound traffic (0.0.0.0/0) to the internet gateway. Think of it as a signpost telling packets "go this way to reach the internet."

The route table association links this route table to your public subnet. Without it, the subnet would use the VPC's default route table, which has no internet route.

  • Save main.tf.
  • Create all networking resources by running this command:
terraform apply

What should I see?

Terraform displays the plan and asks "Do you want to perform these actions?" Type yes and press Enter to confirm.

After a few seconds, you should see Apply complete! Resources: 5 added, 0 changed, 0 destroyed. confirming your VPC, subnet, internet gateway, route table, and route table association are live.

Apply failed with an error?

  • Check that gateway_id = aws_internet_gateway.main.id in your route table matches the internet gateway resource name exactly.
  • Verify your AWS credentials are still valid by running aws sts get-caller-identity in a separate terminal.
  • If you see a "VPC limit" error, your account may have hit the default VPC limit for the region. Delete unused VPCs in the AWS Console.

Help me debug my terraform apply error for VPC networking resources

Add outputs to display your VPC and subnet IDs

Terraform outputs let you extract specific values from your infrastructure after it is created. You will use these IDs in the next step when attaching a security group and instance to your VPC.

  • Open outputs.tf in VS Code.
  • Add the following output definitions:
output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "subnet_id" {
  description = "The ID of the public subnet"
  value       = aws_subnet.public.id
}

What does this code do?

Each output block exposes a value from your infrastructure so you can reference it later. After running terraform apply, Terraform prints these values to your terminal.

The description field is documentation for anyone reading your code. The value field references the actual resource attribute you want to display.

  • Save outputs.tf.
  • Apply your changes to register the outputs by running:
terraform apply
  • Type yes when prompted.
  • Verify the outputs are displayed by running:
terraform output
  • You should see your VPC ID and subnet ID printed, similar to:
subnet_id = "subnet-0abc123def456ghi7"
vpc_id = "vpc-0abc123def456ghi7"

Why do the IDs look random?

AWS generates unique IDs for every resource. Your values will differ from the example above, and that is expected. What matters is that both vpc_id and subnet_id display a value.

✔️ Awesome, I've got everything!

Great. Make sure both main.tf and outputs.tf are saved.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  required_version = ">= 1.10.0"
}

provider "aws" {
  region = "us-east-1"
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "my-terraform-vpc"
  }
}

# Public Subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "my-public-subnet"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "my-igw"
  }
}

# Route Table
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
  }

  tags = {
    Name = "my-public-rt"
  }
}

# Route Table Association
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

Here is the complete outputs.tf:

output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "subnet_id" {
  description = "The ID of the public subnet"
  value       = aws_subnet.public.id
}

Your network foundation is live. Next up, you will add a security group to control who can access your network and generate an SSH key pair so you can connect to an instance.

Add Security Group and Key Pair

Your VPC has a public subnet with internet access, but there is no way to control who can connect to resources inside it. Before launching an EC2 instance, you need two things: a firewall rule that allows SSH traffic in, and a key pair that proves your identity when connecting.

In this step, you will create a Security Group that acts as a virtual firewall and generate an SSH key pair entirely through Terraform.

In this step, get ready to:
  • Create a security group that allows SSH access to your VPC.
  • Generate an SSH key pair using Terraform's TLS provider.
  • Apply the configuration and verify your new resources in state.
Create a security group allowing SSH access

A security group defines which network traffic is allowed in and out of your resources. You need one that permits inbound SSH connections on port 22 so you can log into your EC2 instance later.

  • In VS Code, open main.tf.
  • Scroll to the bottom of the file, below the aws_route_table_association resource.
  • Add the security group resource by pasting this code at the end of the file:
# Security Group
resource "aws_security_group" "ssh" {
  name        = "allow-ssh"
  description = "Allow SSH inbound traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "allow-ssh"
  }
}

What does this code do?

The ingress block allows inbound TCP traffic on port 22 (SSH) from any IP address (0.0.0.0/0).

The egress block allows all outbound traffic. Protocol -1 means "all protocols" and port 0 means "all ports."

The vpc_id places this security group inside your VPC so it can be attached to resources there.

Why allow SSH from 0.0.0.0/0?

Allowing SSH from any IP is acceptable for a short-lived learning project. In production, you would restrict this to your own IP address or a corporate VPN range to reduce attack surface.

  • Save main.tf.
  • Preview the change by running this command in your terminal:
terraform plan
  • You should see Plan: 1 to add, 0 to change, 0 to destroy confirming the security group will be created.

Seeing an error instead of a plan?

  • Check that the security group block is placed outside and below the aws_route_table_association closing brace, not nested inside another resource.
  • Make sure all curly braces {} are balanced. Each opening brace needs a matching closing brace.

Why is my terraform plan failing?

Generate an SSH key pair using Terraform

To SSH into an EC2 instance, you need a key pair. The public key goes to AWS and the private key stays on your machine. You will use Terraform's TLS provider to generate both keys without leaving your terminal.

First, you need to register the TLS provider so Terraform can download it.

  • In main.tf, find the required_providers block at the top of the file.
  • Find the closing brace of the aws provider entry:
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  • Add the TLS provider directly below the aws block, so the required_providers section looks like this:
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }

What is the TLS provider?

The TLS provider lets Terraform generate cryptographic keys and certificates. Here you will use it to create an RSA key pair without needing external tools like ssh-keygen.

  • Save main.tf.
  • Download the new TLS provider by running:
terraform init
  • You should see a message that includes Installing hashicorp/tls followed by Terraform has been successfully initialized! confirming the provider was downloaded.

Seeing a provider error?

  • Make sure the tls block is inside the required_providers braces, not outside them.
  • Check that source is "hashicorp/tls" and version is "~> 4.0" with proper quotes.

Help me fix my terraform init provider error.

Now add the key pair resources. These three resources work together: one generates the key, one registers it with AWS, and one saves the private key to your machine.

  • In main.tf, scroll to the bottom of the file (below the security group you just added).
  • Add the key pair resources by pasting this code at the end:
# TLS Private Key
resource "tls_private_key" "this" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

# AWS Key Pair
resource "aws_key_pair" "this" {
  key_name   = "my-vpc-key"
  public_key = trimspace(tls_private_key.this.public_key_openssh)
}

# Save Private Key Locally
resource "local_file" "private_key" {
  content         = tls_private_key.this.private_key_pem
  filename        = "${path.module}/my-vpc-key.pem"
  file_permission = "0400"
}

What do these three resources do?

  • tls_private_key generates a 4096-bit RSA key pair in memory.
  • aws_key_pair registers the public half of that key with AWS so EC2 instances can accept connections from anyone holding the private half.
  • local_file writes the private key to a .pem file on your machine with read-only permissions (0400).

The trimspace() function removes trailing whitespace from the public key output, which the TLS provider appends by default.

  • Save main.tf.
Apply and verify
  • Create all the new resources by running:
terraform apply
  • Terraform will show a plan with resources to add. Type yes and press Enter to confirm.
  • Confirm the private key file was created by running:
ls my-vpc-key.pem
  • You should see my-vpc-key.pem in the output.

Now add outputs so you can easily reference the security group and key pair later.

  • Open outputs.tf and add these two outputs below the existing ones:
output "security_group_id" {
  description = "The ID of the SSH security group"
  value       = aws_security_group.ssh.id
}

output "key_pair_name" {
  description = "The name of the key pair"
  value       = aws_key_pair.this.key_name
}

Why add outputs?

Outputs make it easy to reference resource IDs without digging through the AWS Console. In the next step, you will use the security group and key pair when launching your EC2 instance.

  • Save outputs.tf.
  • Verify all your resources are tracked in state by running:
terraform state list
  • You should see all your resources listed, including aws_security_group.ssh, tls_private_key.this, aws_key_pair.this, and local_file.private_key.

Don't see all resources in the state list?

  • Make sure you ran terraform apply (not just terraform plan) and typed yes to confirm.
  • If you see an error about the TLS provider, re-run terraform init first, then try terraform apply again.

Why are my resources missing from terraform state list?

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.tf and outputs.tf.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }

  required_version = ">= 1.10.0"
}

provider "aws" {
  region = "us-east-1"
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "my-terraform-vpc"
  }
}

# Public Subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "my-public-subnet"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "my-igw"
  }
}

# Route Table
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
  }

  tags = {
    Name = "my-public-rt"
  }
}

# Route Table Association
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

# Security Group
resource "aws_security_group" "ssh" {
  name        = "allow-ssh"
  description = "Allow SSH inbound traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "allow-ssh"
  }
}

# TLS Private Key
resource "tls_private_key" "this" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

# AWS Key Pair
resource "aws_key_pair" "this" {
  key_name   = "my-vpc-key"
  public_key = trimspace(tls_private_key.this.public_key_openssh)
}

# Save Private Key Locally
resource "local_file" "private_key" {
  content         = tls_private_key.this.private_key_pem
  filename        = "${path.module}/my-vpc-key.pem"
  file_permission = "0400"
}

And here is the complete outputs.tf:

output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "subnet_id" {
  description = "The ID of the public subnet"
  value       = aws_subnet.public.id
}

output "security_group_id" {
  description = "The ID of the SSH security group"
  value       = aws_security_group.ssh.id
}

output "key_pair_name" {
  description = "The name of the key pair"
  value       = aws_key_pair.this.key_name
}

Your VPC now has a firewall and an SSH key ready to go. Next up, you will launch an EC2 instance inside your VPC and connect to it using the key pair you just created.

Launch an EC2 Instance

Your VPC has all the networking and security pieces in place. The VPC, subnet, internet gateway, route table, and security group form a complete network ready to host compute resources.

Now you will launch an EC2 instance inside your public subnet. This is the moment where all the networking you built becomes real. You will connect to the instance over SSH and prove that traffic flows through your internet gateway to the public internet.

In this step, get ready to:
  • Add an EC2 instance resource to your Terraform configuration.
  • Apply the configuration and SSH into the running instance.
  • Verify the instance has internet access through your VPC networking.
Add the EC2 instance resource

To launch an EC2 instance, Terraform needs to know which operating system image (AMI) to use. Instead of hardcoding an AMI ID that changes per region and over time, you will use a data source that automatically finds the latest Amazon Linux 2023 AMI.

  • In main.tf, scroll to the bottom of the file (below the local_file resource) and add the AMI data source by pasting the following code:
# Data Source: Latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

What does this code do?

A data source reads information from AWS without creating anything. This one queries AWS for the latest AMI matching your filters.

  • most_recent = true picks the newest AMI if multiple match.
  • owners = ["amazon"] ensures you only get official Amazon-published images.
  • The filters narrow results to Amazon Linux 2023 on x86_64 with HVM virtualization.

Now add the EC2 instance resource directly below the data source you just pasted:

  • Add the EC2 instance that ties together your subnet, security group, and key pair by pasting this code below the data source:
# EC2 Instance
resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.ssh.id]
  key_name               = aws_key_pair.this.key_name

  depends_on = [aws_internet_gateway.main]

  tags = {
    Name = "my-vpc-instance"
  }
}

What does this code do?

This resource launches an EC2 instance using all the infrastructure you built in previous steps.

  • ami references the AMI ID found by the data source above.
  • instance_type = "t2.micro" is free tier eligible (750 hours/month for 12 months).
  • subnet_id places the instance in your public subnet so it gets a public IP.
  • vpc_security_group_ids attaches your SSH security group.
  • key_name associates the key pair so you can SSH in with your .pem file.
  • depends_on ensures the internet gateway exists before the instance launches. Without it, the instance could start before the route to the internet is ready.
  • Save main.tf.

Seeing a syntax error?

Make sure the data source block and the resource block are separate top-level blocks. They should not be nested inside any other resource. Check that all opening braces have matching closing braces.

help me fix a syntax error in my Terraform main.tf file for the aws_ami data source or aws_instance resource

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf file as it should look now:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }

  required_version = ">= 1.10.0"
}

provider "aws" {
  region = "us-east-1"
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "my-terraform-vpc"
  }
}

# Public Subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "my-public-subnet"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "my-igw"
  }
}

# Route Table
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
  }

  tags = {
    Name = "my-public-rt"
  }
}

# Route Table Association
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

# Security Group
resource "aws_security_group" "ssh" {
  name        = "allow-ssh"
  description = "Allow SSH inbound traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "allow-ssh"
  }
}

# TLS Private Key
resource "tls_private_key" "this" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

# AWS Key Pair
resource "aws_key_pair" "this" {
  key_name   = "my-vpc-key"
  public_key = trimspace(tls_private_key.this.public_key_openssh)
}

# Save Private Key Locally
resource "local_file" "private_key" {
  content         = tls_private_key.this.private_key_pem
  filename        = "${path.module}/my-vpc-key.pem"
  file_permission = "0400"
}

# Data Source: Latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# EC2 Instance
resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.ssh.id]
  key_name               = aws_key_pair.this.key_name

  depends_on = [aws_internet_gateway.main]

  tags = {
    Name = "my-vpc-instance"
  }
}
Apply and connect

Before you apply, you also need an output to display the instance's public IP address. This saves you from digging through the AWS Console to find it.

  • Open outputs.tf and add this output block at the bottom of the file:
output "instance_public_ip" {
  description = "The public IP of the EC2 instance"
  value       = aws_instance.web.public_ip
}

What does this output do?

After Terraform creates the instance, AWS assigns it a public IP. This output prints that IP directly in your terminal so you can use it for the SSH connection.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved outputs.tf before applying.

ⓧ I'd like to double check the full code

Here is the complete outputs.tf file as it should look now:

output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "subnet_id" {
  description = "The ID of the public subnet"
  value       = aws_subnet.public.id
}

output "security_group_id" {
  description = "The ID of the SSH security group"
  value       = aws_security_group.ssh.id
}

output "key_pair_name" {
  description = "The name of the key pair"
  value       = aws_key_pair.this.key_name
}

output "instance_public_ip" {
  description = "The public IP of the EC2 instance"
  value       = aws_instance.web.public_ip
}
  • Apply your Terraform configuration to launch the EC2 instance by running:
terraform apply

What will Terraform ask?

Terraform will show you a plan with the new resources it wants to create (the EC2 instance and the AMI data source lookup). Type yes and press Enter to confirm.

  • Type yes when Terraform asks you to confirm.
  • Wait for the apply to complete. This typically takes 30-60 seconds while AWS launches the instance.

You should see output ending with the instance's public IP address.

Apply failed with an error?

If you see an error about an invalid AMI or no matching AMI found, make sure your provider block is set to us-east-1 and your filter values match exactly: al2023-ami-*-x86_64.

help me debug a terraform apply error when launching my EC2 instance in my VPC project

  • Get the instance's public IP by running:
terraform output instance_public_ip

You should see an IP address like 54.xx.xx.xx printed in your terminal. Copy this value for the next step.

  • Connect to your EC2 instance via SSH by running this command (replace the IP with your own):
ssh -i my-vpc-key.pem ec2-user@[[EC2_PUBLIC_IP="your EC2 instance public IP"]]

What is this SSH command doing?

  • -i my-vpc-key.pem tells SSH to use your private key file for authentication.
  • ec2-user is the default username for Amazon Linux AMIs.
  • The IP address is the public IP of your instance from the output above.
  • When SSH asks if you want to continue connecting (the host authenticity message), type yes and press Enter.

Your terminal prompt should change to something like [ec2-user@ip-10-0-1-xxx ~]$. You are now inside your EC2 instance.

Getting a permissions error on the .pem file?

On macOS/Linux, the file_permission = "0400" setting from Terraform should have already set the correct permissions. If SSH still complains, run:

chmod 400 my-vpc-key.pem

On Windows, file permissions work differently. Run these three commands in PowerShell from your project folder:

icacls.exe my-vpc-key.pem /reset
icacls.exe my-vpc-key.pem /grant:r "$($env:USERNAME):(R)"
icacls.exe my-vpc-key.pem /inheritance:r

help me fix SSH permission denied when connecting to my EC2 instance with my .pem key file

Verify the network

You are connected to the instance, but can it actually reach the internet? The internet gateway and route table you built should be routing traffic outbound. Time to prove it.

  • From inside the EC2 instance, confirm internet access by running:
curl http://checkip.amazonaws.com

You should see your instance's public IP address printed back to you. This confirms traffic is flowing from your instance through the internet gateway to the public internet and back.

  • Disconnect from the EC2 instance by running:
exit

Your terminal prompt returns to your local machine.

  • Open the AWS Console in your browser.
  • Navigate to the EC2 service.
  • Click Instances in the left sidebar.
  • Confirm you see an instance named my-vpc-instance with a Running state.

Cost reminder

The t2.micro instance is free tier eligible for 750 hours per month for 12 months. You will destroy this instance at the end of the project to avoid any charges beyond the free tier.

Instance not showing as Running?

If the instance shows Pending, wait 1-2 minutes and refresh the page. If it shows Terminated, check the Terraform output for errors and run terraform apply again.

help me troubleshoot why my EC2 instance is not running in the AWS Console after terraform apply

Your EC2 instance is live inside the VPC you built from scratch. Next up, you will migrate your Terraform state to an S3 remote backend so your infrastructure is managed like a professional team would.

Migrate State to S3 Remote Backend

You have a running EC2 instance inside a custom VPC, all provisioned through Terraform. The next step is making sure your infrastructure state is stored safely and ready for team collaboration.

Right now, Terraform tracks all your resources in a local file called terraform.tfstate. If that file gets deleted or corrupted, Terraform loses track of what it manages. In this step, you'll migrate your state to an S3 remote backend with native state locking.

In this step, get ready to:
  • Create an S3 bucket to store your Terraform state.
  • Configure the S3 backend in your Terraform configuration.
  • Verify the migration was successful and your state lives in the cloud.
Create an S3 bucket for state storage

Before Terraform can store its state remotely, the destination bucket needs to exist. You will create this bucket using the AWS CLI rather than Terraform itself.

Why use AWS CLI instead of Terraform here?

This is called the bootstrap problem. You cannot use Terraform to create the bucket that stores Terraform's own state in the same configuration. The bucket must exist before Terraform can write state to it.

The solution is to create the state bucket once with the AWS CLI, then point Terraform at it.

  • Create your state bucket by running this command:
aws s3api create-bucket --bucket [[STATE_BUCKET="your-unique-terraform-state-bucket"]] --region us-east-1

What does this command do?

This creates a new S3 bucket in the us-east-1 region. S3 bucket names are globally unique across all AWS accounts, so you need to pick a name nobody else has used.

A good pattern is to include your name and a purpose, like jane-terraform-state-2026.

  • Enable versioning on your state bucket by running:
aws s3api put-bucket-versioning --bucket [[STATE_BUCKET="your-unique-terraform-state-bucket"]] --versioning-configuration Status=Enabled

Why enable versioning?

Versioning keeps a history of every state file write. If your state ever gets corrupted or overwritten incorrectly, you can roll back to a previous version.

  • Verify the bucket was created by running:
aws s3 ls | grep [[STATE_BUCKET="your-unique-terraform-state-bucket"]]

You should see your bucket name listed with a creation date.

Seeing a BucketAlreadyExists error?

S3 bucket names are globally unique. If someone else already has that name, choose a different one. Try adding random numbers or your initials to make it unique.

help me fix my S3 bucket creation error

Configure the S3 backend in Terraform

Now that the bucket exists, you'll tell Terraform to use it for state storage. This requires adding a backend block to your configuration.

  • In main.tf, find the closing line of your required_version declaration inside the terraform block:
  required_version = ">= 1.10.0"
  • Add the following backend "s3" block directly below that line, still inside the terraform {} block:
  backend "s3" {
    bucket       = "[[STATE_BUCKET=your-unique-terraform-state-bucket]]"
    key          = "vpc/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
  }

What does this backend block do?

This tells Terraform where to store its state file remotely:

  • bucket is the S3 bucket you just created.
  • key is the path inside the bucket where the state file lives.
  • region tells Terraform which AWS region the bucket is in.
  • use_lockfile = true enables native S3 state locking to prevent two people from writing state at the same time.

What is state locking?

State locking prevents concurrent writes to the same state file. If two team members run terraform apply at the same time without locking, the state can become corrupted. Native S3 locking is the recommended approach since Terraform 1.10.0.

  • Save main.tf.
  • Migrate your state to the new backend by running:
terraform init

What will Terraform ask you?

Terraform detects that you already have local state and that you've added a new backend. It will ask if you want to copy your existing state to the new S3 backend. Type yes and press Enter to confirm the migration.

  • When Terraform asks to migrate your existing state, type yes and press Enter.

You should see a success message confirming that Terraform has been successfully initialized with the S3 backend.

Seeing an error about the backend configuration?

Make sure the backend "s3" block is inside the terraform {} block, not outside it. It should be at the same indentation level as required_providers and required_version.

Also confirm your bucket name in the backend block matches the exact bucket name you created with the AWS CLI.

help me fix my Terraform backend configuration error

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf and that the migration completed successfully.

ⓧ I'd like to double check the full code

Here is the complete main.tf file at this point:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }

  required_version = ">= 1.10.0"

  backend "s3" {
    bucket       = "your-unique-terraform-state-bucket"
    key          = "vpc/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
  }
}

provider "aws" {
  region = "us-east-1"
}

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "my-terraform-vpc"
  }
}

# Public Subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "my-public-subnet"
  }
}

# Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "my-igw"
  }
}

# Route Table
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
  }

  tags = {
    Name = "my-public-rt"
  }
}

# Route Table Association
resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

# Security Group
resource "aws_security_group" "ssh" {
  name        = "allow-ssh"
  description = "Allow SSH inbound traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "allow-ssh"
  }
}

# TLS Private Key
resource "tls_private_key" "this" {
  algorithm = "RSA"
  rsa_bits  = 4096
}

# AWS Key Pair
resource "aws_key_pair" "this" {
  key_name   = "my-vpc-key"
  public_key = trimspace(tls_private_key.this.public_key_openssh)
}

# Save Private Key Locally
resource "local_file" "private_key" {
  content         = tls_private_key.this.private_key_pem
  filename        = "${path.module}/my-vpc-key.pem"
  file_permission = "0400"
}

# Data Source: Latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# EC2 Instance
resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.ssh.id]
  key_name               = aws_key_pair.this.key_name

  depends_on = [aws_internet_gateway.main]

  tags = {
    Name = "my-vpc-instance"
  }
}
Verify remote state

The migration is complete, but you should confirm that Terraform is reading state from S3 correctly and that nothing has drifted.

  • Confirm the migration was successful by running:
terraform plan

You should see a message saying there are no changes needed. This proves Terraform can read the remote state and it matches your actual infrastructure.

Why does no changes mean success?

If the state migration worked correctly, Terraform still knows about all the resources it created. A plan with no changes means the remote state file has the same information as the local one did.

If you saw resources being re-created, it would mean the state was lost during migration.

  • Verify the state file in S3 by opening the AWS Console.
  • Navigate to the S3 service.
  • Click on your state bucket.
  • Open the vpc/ folder. You should see a terraform.tfstate file inside.
  • Delete the now-empty local state file by running:
rm terraform.tfstate

Why delete the local state file?

After migration, the local terraform.tfstate file is empty or contains only a backend reference. Terraform now reads and writes state directly from S3. The local file is no longer needed and removing it avoids confusion.

Terraform plan shows changes after migration?

If terraform plan shows resources being created or destroyed, the state did not migrate correctly. Run terraform init again and make sure you type yes when prompted to copy existing state.

help me fix my Terraform state migration

Secret mission

Add a Private Subnet

Your VPC has a public subnet where your EC2 instance lives. But in production, not every resource should be exposed to the internet. Can you extend your VPC with a private subnet that has no internet access?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The EC2 instance and S3 bucket are both free tier eligible, but free tier limits apply (750 hours/month for EC2, 5 GB for S3, both for 12 months).

Cost warning

Your t2.micro EC2 instance is free tier eligible for 750 hours per month during your first 12 months of AWS usage. If you have exceeded your free tier period or are running other EC2 instances, charges will accrue. Destroy your resources when you are done to avoid unexpected charges.

Resources you used:

  • VPC, public subnet, internet gateway, route tables, route table associations (free).
  • Security group (free).
  • EC2 instance (t2.micro, free tier eligible).
  • TLS private key, AWS key pair, local .pem file.
  • S3 bucket for Terraform state (free tier: 5 GB storage).
  • If Secret Mission completed: private subnet and private route table (free).
  • Local project folder terraform-vpc on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend this project soon.

  • Your EC2 instance will continue consuming free tier hours. Monitor usage in the AWS Billing console.
  • The S3 state bucket will remain with your terraform.tfstate file stored inside.
  • Your local terraform-vpc folder remains on your Desktop.

Pause - I'll come back to this later

Stop the EC2 instance to avoid compute charges while keeping all your infrastructure and configuration intact.

  • Open the AWS Console and navigate to the EC2 service.
  • Select your my-vpc-instance instance.
  • Click Instance state in the top right, then click Stop instance.
  • Confirm by clicking Stop.

While stopped, you will not be charged for compute. A small EBS storage charge applies for the root volume. To resume, select the instance and choose Start instance.

Delete - I don't want to use this again

Remove all project resources permanently. This cannot be undone.

  • In your terminal, make sure you are inside the terraform-vpc folder on your Desktop.
  • Destroy all Terraform-managed AWS resources by running:
terraform destroy
  • Terraform will show a plan of everything it will destroy. Type yes and press Enter to confirm.
  • Wait for the output to show Destroy complete! before continuing.

The S3 state bucket was created outside of Terraform (via AWS CLI), so it is not destroyed by the command above. Delete it manually.

  • Delete all object versions in the bucket by running:
aws s3api delete-objects --bucket [[STATE_BUCKET="your-unique-terraform-state-bucket"]] --delete "$(aws s3api list-object-versions --bucket [[STATE_BUCKET="your-unique-terraform-state-bucket"]] --output json --query='{Objects: Versions[].{Key:Key,VersionId:VersionId}}')"
  • Delete any delete markers left behind by running:
aws s3api delete-objects --bucket [[STATE_BUCKET="your-unique-terraform-state-bucket"]] --delete "$(aws s3api list-object-versions --bucket [[STATE_BUCKET="your-unique-terraform-state-bucket"]] --output json --query='{Objects: DeleteMarkers[].{Key:Key,VersionId:VersionId}}')"
  • Now delete the empty bucket by running:
aws s3 rb s3://[[STATE_BUCKET="your-unique-terraform-state-bucket"]] --force
  • Finally, delete your local project folder by running:

macOS

rm -rf ~/Desktop/terraform-vpc

Windows

rmdir /s /q %USERPROFILE%\Desktop\terraform-vpc

Why three commands to delete the bucket?

You enabled versioning on the state bucket earlier in the project. Versioned buckets retain old copies of every object, even after deletion. The aws s3 rb --force command only removes non-versioned objects.

You need to explicitly delete all object versions and delete markers before the bucket can be removed. The three commands handle versions, markers, and then the bucket itself.

Nice Work!

Nice Work!

You just built a complete AWS network from scratch using Terraform. From an empty folder to a fully functioning VPC with an EC2 instance you can SSH into, all managed with remote state.

You've learned how to:

  • Build a custom VPC with a public subnet, internet gateway, and route table to give your resources internet access.
  • Launch an EC2 instance inside your VPC with a Terraform-managed SSH key pair and security group, then connect to it via SSH.
  • Migrate Terraform state to an S3 remote backend with native state locking so your infrastructure is managed like a professional team would.
  • Secret Mission: Added a private subnet to understand the architectural difference between public and private subnets in production AWS environments.

Ready to quiz yourself?