Provision a VPC on AWS with Terraform
Use Terraform to provision a custom VPC and EC2 instance on AWS.
Introduction
30 Second Summary
Imagine rebuilding a house from scratch every single time you move. That is what setting up cloud servers manually feels like. Infrastructure as Code lets you write a blueprint once and rebuild your entire environment in minutes.
In this project, you will use Terraform to provision a complete AWS networking environment from code. You will create a custom VPC, configure networking resources, launch an EC2 instance, and connect to it via SSH.
What You'll Build
A complete AWS VPC environment defined entirely in Terraform configuration files that you can create and destroy with a single command.
By the end of this project, you'll have:
- A custom VPC with a public subnet and internet connectivity, all defined in code.
- A running EC2 instance accessible via SSH from your local machine.
- A reusable Terraform configuration that can create and destroy the entire environment with a single command.
- Secret Mission: Add a private subnet and a second EC2 instance to simulate a two-tier network architecture.
Are there any prerequisites?
You need an AWS account with CLI credentials already configured, the Terraform CLI installed, and a Linux machine with SSH access. This project assumes basic familiarity with AWS services.
Set Up the Terraform Project
Every AWS resource you provision in this project will be defined in Terraform configuration files written in HCL (HashiCorp Configuration Language).
In this step, you'll create a project directory, set up your file structure, and initialize Terraform so it can communicate with AWS.
In this step, get ready to:
- Create a project directory with three Terraform configuration files.
- Configure the AWS and TLS providers in terraform.tf.
- Initialize the Terraform workspace with terraform init.
Create the project directory and files
A Terraform project is just a folder containing .tf files. Terraform reads all .tf files in the current directory and combines them into a single configuration.
- Open your terminal.
- 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
- Create the three Terraform configuration files by running:
touch terraform.tf main.tf outputs.tf
- Confirm the files exist by running:
ls
You should see terraform.tf, main.tf, and outputs.tf in the output.
What are these three files for?
- terraform.tf tells Terraform which providers (like AWS) to use and what versions to download.
- main.tf is where you define the actual cloud resources (VPC, subnets, EC2 instances).
- outputs.tf defines values Terraform should display after provisioning, like a server's IP address.
Don't see the three files?
Make sure you ran cd terraform-vpc before running touch. If you ran touch from the Desktop, the files ended up in the wrong folder.
help me find where my terraform files were created
Write the provider configuration
Terraform needs to know which cloud platform you want to manage. You specify this through providers. Each provider is a plugin that Terraform downloads to communicate with a specific API.
- Open terraform.tf in your text editor and paste in the following code:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
required_version = ">= 1.2"
}
provider "aws" {
region = "us-east-1"
}
What does this code do?
- required_providers declares two providers: hashicorp/aws for managing AWS resources, and hashicorp/tls for generating SSH keys later.
- ~> 6.0 means "any version 6.x but not 7.x". This prevents breaking changes from a future major release.
- required_version = ">= 1.2" ensures anyone running this code has a compatible Terraform CLI version.
- provider "aws" configures which AWS region to target. Terraform automatically picks up your CLI credentials from ~/.aws/credentials.
- Save terraform.tf.
- Initialize the Terraform workspace by running this command:
terraform init
Terraform downloads the AWS and TLS provider plugins into a hidden .terraform directory. You should see output ending with "Terraform has been successfully initialized!".
Seeing a provider error?
If you see "Failed to install provider", check your internet connection. Terraform needs to download plugins from the HashiCorp registry.
If you see "Could not satisfy plugin requirements", make sure your terraform.tf matches the code above exactly. A typo in the version constraint or source path will cause this error.
help me fix my terraform init error
Verify your Terraform setup
- Confirm the provider plugins were downloaded by running:
ls .terraform/providers/registry.terraform.io/hashicorp/
You should see two directories: aws and tls. These contain the provider binaries that Terraform uses to talk to the AWS API.
- Check your Terraform and provider versions by running:
terraform version
You should see your Terraform CLI version followed by the AWS provider (v6.x) and TLS provider (v4.x) versions listed below it.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete terraform.tf file. Your main.tf and outputs.tf files should still be empty at this point.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
required_version = ">= 1.2"
}
provider "aws" {
region = "us-east-1"
}
Your Terraform workspace is ready. Next up, you'll define the VPC and networking resources that will form the foundation of your cloud environment.
Define the VPC and Networking Resources
Your Terraform project is initialized and ready to describe infrastructure. Right now, any EC2 instance you launch on AWS lands in the default VPC that every account comes with. That default VPC is shared, generic, and hard to reason about.
In this step, you will define a custom VPC with its own subnet, internet gateway, and route table. This gives your server a dedicated, isolated network that you fully control.
In this step, get ready to:
- Define a VPC with DNS support and a public subnet.
- Create an internet gateway to connect your VPC to the public internet.
- Set up a route table that directs outbound traffic through the gateway.
Define the AMI data source and VPC
Before creating any resources, you need to tell Terraform which machine image to use for your future EC2 instance. A data source lets Terraform query AWS for information rather than creating something new.
- Open main.tf in your editor. It should be empty.
- Add the AMI data source by pasting this code into main.tf:
# Look up the latest Ubuntu 22.04 AMI from Canonical
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical
}
What does this code do?
This is a data source, not a resource. It queries AWS to find an existing AMI instead of creating something.
- most_recent = true picks the newest matching image.
- The two filters narrow results to Ubuntu 22.04 HVM images.
- owners = ["099720109477"] restricts results to images published by Canonical (Ubuntu's publisher).
You won't see a change yet. The EC2 instance you create in a later step will reference this AMI.
Now add the VPC resource directly below the data source in main.tf. The VPC is the isolated network that all your other resources will live inside.
- Add the VPC resource below the data source you just pasted:
# Create a custom VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "devops-vpc"
}
}
What does this code do?
- cidr_block = "10.0.0.0/16" gives your VPC a private IP range with 65,536 addresses.
- enable_dns_support and enable_dns_hostnames let instances inside the VPC resolve domain names and receive public DNS hostnames.
- The tags block labels the VPC in the AWS console so you can identify it easily.
- Save main.tf.
- Verify the syntax is valid by running this command:
terraform validate
You should see Success! The configuration is valid. in the output.
Seeing a syntax error?
- Check that every opening brace has a matching closing brace. HCL blocks use curly braces, not indentation.
- Make sure string values are wrapped in double quotes. Single quotes are not valid in HCL.
- Confirm the data block is above the resource block. Terraform doesn't care about order, but check you didn't accidentally nest one block inside another.
Help me fix my Terraform validation error.
Add the public subnet and internet gateway
A VPC on its own is just an empty network. You need a subnet to place instances in, and an internet gateway to give that subnet a path to the public internet.
- Add the public subnet and internet gateway below the VPC resource in main.tf:
# Create a 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
availability_zone = "us-east-1a"
tags = {
Name = "devops-public-subnet"
}
}
# Create an internet gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "devops-igw"
}
}
What does this code do?
These two resources work together to make your VPC internet-accessible.
- The subnet carves out a smaller IP range (10.0.1.0/24, 256 addresses) from your VPC.
- map_public_ip_on_launch = true gives every instance in this subnet a public IP address automatically.
- vpc_id = aws_vpc.main.id references the VPC you just defined. Terraform resolves this dependency automatically.
- The internet gateway attaches to your VPC and acts as the door between your private network and the public internet.
- Save main.tf.
You won't see a change yet. The route table in the next section will wire the subnet to the internet gateway.
Create the route table and subnet association
An internet gateway exists, but your subnet doesn't know to use it yet. A route table tells traffic where to go. You need a rule that says "anything destined for the internet goes through the gateway."
- Add the route table and its subnet association below the internet gateway in main.tf:
# Create a route table with a default route to the internet
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 = "devops-public-rt"
}
}
# Associate the route table with the public subnet
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?
- cidr_block = "0.0.0.0/0" is the default route. It matches all traffic that doesn't have a more specific route.
- gateway_id = aws_internet_gateway.main.id sends that traffic to your internet gateway.
- The route table association links this routing logic to your public subnet. Without it, the subnet would use the VPC's default (empty) route table.
- Save main.tf.
- Preview what Terraform will create by running:
terraform plan
You should see Plan: 6 to add, 0 to change, 0 to destroy at the bottom of the output. Terraform lists each resource it will create: the VPC, subnet, internet gateway, route table, route table association, and the AMI data source lookup.
Seeing fewer resources or an error?
- If you see an error about an undeclared resource, check that each vpc_id and subnet_id reference matches the resource name exactly (e.g. aws_vpc.main.id, not aws_vpc.vpc.id).
- If the plan shows fewer than 6 resources, make sure you pasted all code blocks in order below each other in the same file.
- If you see a credentials error, confirm your AWS CLI is configured by running aws sts get-caller-identity in the terminal.
Help me debug my terraform plan output.
Why does Terraform plan before applying?
The plan step is a dry run. It shows exactly what will be created, changed, or destroyed without touching AWS. This gives you a chance to catch mistakes before they cost money or break your environment.
✔️ Awesome, I've got everything!
Great. Double check you've 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 at the end of this step:
# Look up the latest Ubuntu 22.04 AMI from Canonical
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical
}
# Create a custom VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "devops-vpc"
}
}
# Create a 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
availability_zone = "us-east-1a"
tags = {
Name = "devops-public-subnet"
}
}
# Create an internet gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "devops-igw"
}
}
# Create a route table with a default route to the internet
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 = "devops-public-rt"
}
}
# Associate the route table with the public subnet
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
Your networking foundation is in place. Next up, you will add a security group and launch an EC2 instance inside this VPC.
Define Security Group and EC2 Instance
Your VPC network is in place with a public subnet and internet connectivity. Now it's time to add the resources that will actually run your workload and control who can access it.
A security group acts as a virtual firewall for your instance, controlling which traffic is allowed in and out. You'll also generate an SSH key pair entirely within Terraform so the project stays self-contained.
In this step, get ready to:
- Create a security group allowing SSH traffic into the VPC.
- Generate an SSH key pair using the TLS provider.
- Launch an EC2 instance and define Terraform outputs.
Create the security group
Without a security group, your EC2 instance will reject all incoming connections. You need to explicitly allow SSH (port 22) so you can connect to the server after it launches.
- In main.tf, add the following security group resource at the bottom of the file, below the route table association:
# Create a security group allowing SSH access
resource "aws_security_group" "ssh" {
name = "devops-ssh-sg"
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 = "devops-ssh-sg"
}
}
What does this code do?
The ingress block opens port 22 (SSH) for inbound traffic. The egress block uses protocol "-1" which means "all protocols" and allows all outbound traffic.
The vpc_id ties this security group to your VPC. Security groups are VPC-scoped, so an instance in a different VPC cannot use this group.
Why does the ingress allow 0.0.0.0/0?
The CIDR 0.0.0.0/0 means "any IP address." This is fine for a short-lived learning project. In production, you would restrict this to your own IP address to prevent unauthorized access attempts.
- Save main.tf.
- You won't see a change yet. The next chunk adds the SSH key pair that works with this security group.
Generate an SSH key pair with Terraform
To SSH into your EC2 instance, you need a key pair. Instead of creating one manually with ssh-keygen, you'll let Terraform generate it using the TLS provider. This keeps the entire project self-contained in your Terraform configuration.
- Add the following code below the security group in main.tf:
# Generate an SSH key pair
resource "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "deployer" {
key_name = "devops-key"
public_key = tls_private_key.ssh.public_key_openssh
}
What does this code do?
The tls_private_key resource generates a 4096-bit RSA key pair in memory. Terraform stores it in state so you can retrieve the private key later.
The aws_key_pair resource uploads the public half of that key to AWS under the name devops-key. When you launch an instance with this key pair, AWS installs the public key on the server so your private key can authenticate.
- Save main.tf.
- Verify your syntax is still valid by running:
terraform validate
- You should see Success! The configuration is valid. in your terminal output.
Seeing a validation error?
Check that the closing brace } of the security group resource is on its own line before the key pair resources. A missing brace causes Terraform to misparse everything below it.
Also confirm that tls_private_key.ssh.public_key_openssh is spelled exactly as shown. The attribute name is public_key_openssh, not public_key.
Help me debug my Terraform validation error
Define the EC2 instance and outputs
With networking, security, and keys ready, you can now define the actual compute resource. The EC2 instance ties everything together by referencing the subnet, security group, and key pair you just created.
- Add the EC2 instance resource at the bottom of main.tf, below the key pair:
# Launch an EC2 instance
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.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.deployer.key_name
tags = {
Name = "devops-server"
}
}
What does this code do?
- ami references the Ubuntu AMI data source you defined in Step 2. Terraform resolves this to the latest Ubuntu 22.04 image at apply time.
- subnet_id places the instance in your public subnet so it gets a public IP.
- vpc_security_group_ids attaches your SSH security group. This is a list because instances can have multiple security groups.
- key_name tells AWS which key pair to install on the instance for SSH authentication.
- Save main.tf.
- You won't see a change yet. The next chunk adds outputs so Terraform displays the instance's IP after deployment.
Terraform outputs let you extract values from your infrastructure after it's created. You'll output the instance's public IP (for SSH), its ID, the VPC ID, and the private key (so you can save it to a file).
- Open outputs.tf and add the following output definitions:
output "instance_public_ip" {
description = "Public IP address of the EC2 instance"
value = aws_instance.web.public_ip
}
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "private_key" {
description = "Private SSH key (save this to a file)"
value = tls_private_key.ssh.private_key_pem
sensitive = true
}
What does this code do?
Each output block tells Terraform to display a value after terraform apply completes. The sensitive = true flag on private_key hides it from normal output. You'll retrieve it with a special command later.
- Save outputs.tf.
- Validate the complete configuration by running:
terraform validate
- You should see Success! The configuration is valid. confirming all three files work together.
Validation failed on outputs?
Check that you used aws_instance.web.public_ip (not public_ip_address). The correct attribute name for the AWS provider is public_ip.
Also verify that resource names match across files. The instance must be aws_instance.web in both main.tf and outputs.tf.
Help me fix my Terraform output validation error
✔️ Awesome, I've got everything!
Great. Double check you've 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:
# Look up the latest Ubuntu 22.04 AMI from Canonical
data "aws_ami" "ubuntu" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
owners = ["099720109477"] # Canonical
}
# Create a custom VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "devops-vpc"
}
}
# Create a 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
availability_zone = "us-east-1a"
tags = {
Name = "devops-public-subnet"
}
}
# Create an internet gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "devops-igw"
}
}
# Create a route table with a default route to the internet
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 = "devops-public-rt"
}
}
# Associate the route table with the public subnet
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# Create a security group allowing SSH access
resource "aws_security_group" "ssh" {
name = "devops-ssh-sg"
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 = "devops-ssh-sg"
}
}
# Generate an SSH key pair
resource "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "deployer" {
key_name = "devops-key"
public_key = tls_private_key.ssh.public_key_openssh
}
# Launch an EC2 instance
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.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.deployer.key_name
tags = {
Name = "devops-server"
}
}
And here is the complete outputs.tf:
output "instance_public_ip" {
description = "Public IP address of the EC2 instance"
value = aws_instance.web.public_ip
}
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "private_key" {
description = "Private SSH key (save this to a file)"
value = tls_private_key.ssh.private_key_pem
sensitive = true
}
Why generate keys inside Terraform?
Generating the SSH key pair within Terraform keeps the project entirely self-contained. You don't need to run ssh-keygen separately or manage key files before provisioning.
In production, teams typically use pre-existing keys or a secrets manager. For learning, the TLS provider approach lets you focus on the Terraform workflow without extra manual steps.
Your entire infrastructure is now defined in code. Next up, you'll run terraform plan to preview what Terraform will create, then terraform apply to bring it all to life.
Deploy and Verify the Infrastructure
Your infrastructure is fully defined in code. Every resource, from the VPC down to the EC2 instance, exists as a declaration in your .tf files.
Now it's time to bring that code to life. Terraform's workflow gives you a safety net before creating anything. You preview the plan, confirm it looks right, and only then does Terraform provision real resources on AWS.
In this step, get ready to:
- Preview the infrastructure plan with terraform plan.
- Deploy all 9 resources to AWS with terraform apply.
- SSH into the EC2 instance to verify end-to-end connectivity.
Preview the infrastructure plan
Before creating any real resources, Terraform lets you preview exactly what it will do. This dry run catches misconfigurations before they cost time or money.
- Make sure your terminal is inside the terraform-vpc directory.
- Preview the resources Terraform will create by running this command:
terraform plan
Terraform will print a detailed list of every resource it intends to create. At the bottom, you should see:
Plan: 9 to add, 0 to change, 0 to destroy.
What do these 9 resources mean?
The plan shows Terraform will create exactly 9 resources in this order (respecting dependencies):
- VPC (devops-vpc).
- Subnet (devops-public-subnet).
- Internet gateway (devops-igw).
- Route table (devops-public-rt).
- Route table association.
- Security group (devops-ssh-sg).
- TLS private key.
- Key pair (devops-key).
- EC2 instance (devops-server).
Terraform figures out the dependency graph automatically. It knows the subnet depends on the VPC, so it creates the VPC first.
Seeing errors in the plan?
- If you see a credentials error, make sure your AWS CLI is configured. Run aws sts get-caller-identity to verify.
- If you see a provider error, make sure you ran terraform init successfully in the previous step.
- If you see a syntax error, double check your main.tf against the complete code from the previous step.
Still stuck? Help me debug my terraform plan error.
Deploy all resources with terraform apply
The plan looked good. Now you will tell Terraform to actually create those 9 resources on AWS.
- Deploy the infrastructure by running this command:
terraform apply
- Terraform will show the same plan again, then ask for confirmation.
- Type yes and press Enter to proceed.
Terraform will now create each resource, printing its progress as it goes. This takes about 30-60 seconds.
When it finishes, you should see output similar to:
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.
Outputs:
instance_id = "i-0abc123def456789"
instance_public_ip = "54.x.x.x"
vpc_id = "vpc-0abc123def456789"
What just happened?
Terraform created all 9 resources in the correct dependency order. It also saved the state of everything it created into a local terraform.tfstate file.
The outputs section shows you the public IP of your new EC2 instance. You will use this IP to connect via SSH in the next substep.
Apply failed or timed out?
- If you see an "UnauthorizedOperation" error, your AWS credentials may not have permission to create VPC or EC2 resources. Check your IAM user has the required policies.
- If the apply hangs on the EC2 instance, wait up to 2 minutes. Instance creation can take time.
- If you see a "limit exceeded" error, your account may have hit a resource quota in us-east-1. Try running terraform destroy and then apply again.
Need more help? Help me debug my terraform apply failure.
Save the SSH key and connect to the instance
Your EC2 instance is running, but you need to prove you can actually reach it. The private SSH key Terraform generated is stored in its state, and you need to extract it to a file before you can connect.
- Save the private key to a local file by running this command:
terraform output -raw private_key > devops-key.pem
What does this command do?
The terraform output -raw command extracts the value of a specific output without any formatting. The > devops-key.pem redirects that value into a file. This is the RSA private key that matches the public key registered with your EC2 instance.
- Set the correct file permissions on the key by running:
chmod 600 devops-key.pem
Why chmod 600?
SSH refuses to use a private key file if other users on the system can read it. The 600 permission means only your user can read or write the file. Without this, SSH will display a "Permissions too open" warning and reject the key.
- Now connect to your EC2 instance via SSH by running this command:
ssh -i devops-key.pem ubuntu@[[EC2_IP="your instance public IP from the terraform apply output"]]
- When prompted to confirm the host fingerprint, type yes and press Enter.
Find your IP quickly
If you scrolled past the apply output, you can retrieve the public IP at any time by running:
terraform output instance_public_ip
You should see your terminal prompt change to something like ubuntu@ip-10-0-1-xxx:~$. You are now inside your EC2 instance, connected through the VPC, subnet, internet gateway, and security group you defined in code.
- Type exit and press Enter to disconnect from the instance and return to your local terminal.
Can't connect via SSH?
- If you see "Connection timed out", your security group or route table might not be configured correctly. Run terraform plan to check for drift.
- If you see "Permission denied (publickey)", make sure you ran chmod 600 devops-key.pem and that you are connecting as user ubuntu (not ec2-user or root).
- If you see "Permissions too open", you skipped the chmod 600 step. Run it and try the SSH command again.
- The instance may still be initializing. Wait 1-2 minutes after terraform apply completes before attempting SSH.
Still unable to connect? Help me debug my SSH connection to an EC2 instance in a custom VPC.
You just brought an entire cloud environment to life with a single command. Nine resources, all wired together correctly, all from the code you wrote. Next up, you will tear it all down just as cleanly to prove the full lifecycle works.
Tear Down the Infrastructure
You just SSHed into a cloud server that you provisioned entirely through code. That is the core Infrastructure as Code loop in action.
There is one final piece of the Terraform lifecycle to experience: tearing everything down. A single command will remove all 9 resources cleanly, leaving no trace (and no bill) behind.
In this step, get ready to:
- Destroy all 9 AWS resources with a single Terraform command.
- Verify cleanup in the AWS console.
- Inspect the Terraform state file to understand how it tracks infrastructure.
Run terraform destroy
The terraform destroy command reads your state file, identifies every resource Terraform manages, and deletes them in the correct dependency order. It removes the EC2 instance before the subnet, and the subnet before the VPC.
- Make sure you are in your terraform-vpc directory, then run the destroy command:
terraform destroy
What does this command do?
Terraform reads terraform.tfstate to find all 9 resources it created. It then calculates the correct order to delete them (instance first, then security group, then subnet, then VPC) and shows you a plan of what will be destroyed.
- Terraform will display a destruction plan listing all 9 resources marked with a red minus sign. Type yes when prompted to confirm.
You should see output like this as Terraform removes each resource:
aws_instance.web: Destroying... [id=i-0abc123def456]
aws_instance.web: Still destroying... [10s elapsed]
aws_instance.web: Destruction complete after 32s
aws_route_table_association.public: Destroying...
...
aws_vpc.main: Destruction complete after 1s
Destroy complete! Resources: 9 destroyed.
The final line confirms all 9 resources have been removed.
Destroy hanging or timing out?
EC2 instances can take 30-60 seconds to terminate. If the command hangs longer than 5 minutes, check that your AWS credentials are still valid by running aws sts get-caller-identity in another terminal.
If you see a dependency error, run terraform destroy again. Terraform will retry from where it left off.
Help me troubleshoot my terraform destroy command.
Verify cleanup in the AWS console
The terminal output says everything is gone. Confirming in the AWS console builds trust that Terraform actually removed the real cloud resources.
- Open the AWS Management Console in your browser.
- Navigate to the VPC service.
- In the left sidebar, click Your VPCs.
- Confirm that no VPC named devops-vpc exists. You will only see the default VPC.
- Navigate to the EC2 service and click Instances in the left sidebar.
- Confirm that no instance named devops-server is running. It may briefly appear as terminated before disappearing entirely.
Why verify in the console?
Terraform is trustworthy, but verifying in the console builds the habit of confirming cleanup. On a paid account, a missed resource can quietly accumulate charges for weeks.
Review the Terraform state file
Terraform tracks every resource it manages in a file called terraform.tfstate. This is how it knows what exists in your cloud account and what needs to change on the next apply or destroy.
- View the contents of your state file by running:
cat terraform.tfstate
You should see a JSON structure with an empty resources array. This confirms Terraform no longer manages anything in this directory.
What is the state file?
The state file is Terraform's memory. Before the destroy, it contained the IDs, attributes, and metadata of all 9 resources. Terraform used this to map your .tf code to real AWS resources.
After destroy, the state is empty. If you ran terraform apply again right now, Terraform would recreate all 9 resources from scratch. This is the power of declarative infrastructure: your code is always the source of truth.
State files in team environments
In production, teams store the state file in a remote backend like an S3 bucket with locking via DynamoDB. This prevents two people from modifying the same infrastructure simultaneously.
You have now experienced the full Terraform lifecycle: init, plan, apply, and destroy. Your code can recreate this entire environment in minutes whenever you need it.
Secret mission
Add a Private Subnet and Second Instance
Production environments rarely expose every server to the internet. In this secret mission, you will add a private subnet and a second EC2 instance to simulate a two-tier network architecture using the bastion host pattern.
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.
Cost warning
The EC2 instance in this project is free tier eligible (750 hours/month of t2.micro usage for 12 months). If you have other t2.micro instances running in the same account, those hours add up across all of them.
If you are past your free tier period or have exceeded 750 hours this month, the instance will incur hourly charges. The safest option is to run terraform destroy when you are done experimenting.
Resources you used:
- 1 x EC2 instance (t2.micro, free tier eligible for 750 hours/month).
- 1 x EBS volume (8GB gp2, free tier eligible up to 30GB).
- VPC, subnet, internet gateway, route table, security group (no charge).
- 1 x SSH key pair (stored in AWS, no charge).
- Local Terraform state file and project directory.
Keep everything running
No action needed. Choose this if you are still actively building or plan to use this infrastructure for another project (like an Ansible configuration project).
- Your EC2 instance will keep running and remain accessible via SSH.
- The t2.micro instance costs nothing if you are within free tier limits (750 hours/month across all t2.micro instances in your account).
- VPC resources (subnet, internet gateway, route table, security group) have no hourly charge.
Pause - I'll come back to this later
Stop the EC2 instance to pause compute billing. Your EBS volume and VPC resources remain intact so you can start the instance again later.
- Find your instance ID from the Terraform output or by running:
terraform output instance_id
- Stop the instance by running:
aws ec2 stop-instances --instance-ids [[INSTANCE_ID="your instance ID from the output above"]]
- EBS storage (8GB) still applies while stopped but is within free tier (30GB).
- To resume later, run aws ec2 start-instances --instance-ids <id> or simply run terraform apply again from your project directory.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is the safest option if you are done experimenting. You can always recreate everything in minutes by running terraform apply again.
- Navigate to your project directory in the terminal.
- Run the following command to destroy all AWS resources:
terraform destroy
- Type yes when prompted to confirm the destruction.
- Terraform will remove all 9 resources (VPC, subnet, internet gateway, route table, route table association, security group, TLS key, key pair, and EC2 instance).
- Optionally, delete the local project directory and any saved private key files from your machine.
That's a Wrap!
That's a Wrap!
Nice work! You just provisioned a complete cloud environment using nothing but code.
You've learned how to:
- Define a custom VPC with a public subnet, internet gateway, and route table using Terraform configuration files.
- Create a security group with precise SSH access rules and launch an EC2 instance inside your VPC.
- Execute the full Terraform lifecycle of init, plan, apply, and destroy to manage infrastructure that would take 15+ clicks in the AWS console.
- Added a private subnet and bastion host pattern for production-like two-tier networking.
Ready to quiz yourself?