Provision AWS Infrastructure with Terraform

Write Terraform config from scratch to provision a VPC and EC2 instances.

Introduction

30 Second Summary

Setting up servers by hand works fine the first time. The real challenge is recreating the same setup reliably, handing it to a teammate, or tearing everything down cleanly at the end of the day.

In this project, you will write Terraform configuration from scratch to provision a complete AWS environment. You'll define networking, security rules, and three server instances entirely in code, then create and destroy everything with a single command.

What You'll Build

Type terraform apply and watch an entire AWS network and three servers spin up in about two minutes, ready for you to SSH into on the spot.

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

  • Full control over your networking. You'll define every VPC, subnet, internet gateway, and route table line by line, with security groups that restrict access to your IP only.
  • Three EC2 instances with distinct roles (jenkins-server, k8s-control-plane, k8s-worker) that you can SSH into the moment they launch.
  • A repeatable Infrastructure as Code workflow. One terraform apply creates everything. One terraform destroy tears it all down and stops billing.
  • Secret Mission: Add a second worker node and an IAM instance profile that the instances can use for AWS API access.

Are there any prerequisites?

This project requires an active AWS account with billing set up, the AWS CLI installed and configured, and Docker installed on your machine. If you need to set any of these up, do so before starting.

Before We Start

Before We Start...

Before you touch any code, take a moment to lock in what you are about to build and why it matters.

Install Terraform and Verify Your Tools

Before you can write any infrastructure code, you need the right tools on your machine. Terraform is the CLI tool that reads your configuration files and talks to AWS to create real resources.

In this step, you will install Terraform, confirm your AWS CLI credentials are working, and set up a clean project directory with the files your configuration will live in.

In this step, get ready to:
  • Install the Terraform CLI and verify the version.
  • Confirm your AWS CLI credentials can reach your account.
  • Create a project directory with six configuration files.
Install the Terraform CLI

Terraform is a command-line tool that translates HCL configuration files into real cloud resources. Let's check if it is already on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check if Terraform is installed by running:
terraform version

✔️ I see version 1.15 or higher

Terraform is ready to go. Skip ahead to the next substep.

ⓧ I see an older version

You have Terraform installed, but the version is too old for this project. Upgrade it through Homebrew by running:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

If you see a message that the formula is already installed, run this instead:

brew upgrade hashicorp/tap/terraform
  • Run terraform version again to confirm you now see v1.15 or higher.

Not using Homebrew?

Visit the official Terraform install page for alternative installation methods.

ⓧ Command not found

Terraform is not installed yet. Install it through Homebrew by running these commands:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform
  • Run terraform version to confirm the install worked. You should see Terraform v1.15 followed by a patch number.

Still seeing command not found?

  • Make sure Homebrew itself is installed. Run brew --version to check.
  • If Homebrew is not available, visit the official Terraform install page for alternative methods.

Why can't my terminal find Terraform after installing it?

Verify your AWS CLI authentication

Terraform uses your AWS CLI credentials to authenticate with your AWS account. If these credentials are missing or expired, Terraform cannot create anything.

  • Confirm your credentials work by running:
aws sts get-caller-identity

You should see a JSON response containing your UserId, Account (your 12-digit AWS account number), and Arn.

  • Confirm the region your CLI is configured to use by running:
aws configure list

Check the region row in the output. This project uses us-east-1 by default, but your Terraform config will let you override it.

Seeing 'Unable to locate credentials'?

Your AWS CLI is not configured yet. Run aws configure and enter your access key, secret key, and default region (us-east-1).

Help me set up my AWS CLI credentials.

Create the project directory and files

Every Terraform project lives in a directory containing .tf files. You will create the directory and six files that your configuration will be split across.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir terraform-k8s-infra
cd terraform-k8s-infra
  • Create four empty Terraform configuration files by running:
touch terraform.tf main.tf variables.tf outputs.tf
  • Confirm the files exist by running:
ls

You should see main.tf, outputs.tf, terraform.tf, and variables.tf listed.

What are these files for?

  • terraform.tf tells Terraform which providers to download (in this case, the AWS provider).
  • main.tf holds the actual resource definitions (VPCs, instances, security groups).
  • variables.tf declares input variables so values are not hardcoded.
  • outputs.tf prints useful information (like IP addresses) after Terraform creates resources.
  • Find your public IP address by running:
curl -4 ifconfig.me

The command prints a single IP address. Record it here because you will use it in the next file:

your public IP address

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or Cursor) and press Enter to open your code editor.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the terraform-k8s-infra folder on your Desktop, then click Open.

You should see the four empty .tf files listed in the Explorer sidebar.

  • Create a new file called terraform.tfvars by right-clicking in the Explorer sidebar and selecting New File.
  • Paste the following into terraform.tfvars:
my_ip = "[[MY_PUBLIC_IP="your public IP address"]]/32"

What is this file for?

terraform.tfvars sets variable values that Terraform automatically loads at runtime. You are storing your public IP here so that security group rules can scope SSH and Jenkins access to your machine only.

The /32 suffix is CIDR notation meaning "this single IP address."

  • Create another new file called .gitignore (note the leading dot).
  • Paste the following into .gitignore:
# Terraform variable values (may contain sensitive data like IP addresses)
terraform.tfvars
*.tfvars

# Terraform working directory and provider binaries
.terraform/

# Terraform state files (contain resource IDs and sensitive attributes)
*.tfstate
*.tfstate.backup

# Crash log files
crash.log
crash.*.log

# Override files (used for local-only overrides)
override.tf
override.tf.json
*_override.tf
*_override.tf.json

Why exclude these files from version control?

terraform.tfvars contains your IP address, which is personally identifiable. State files (*.tfstate) contain resource IDs, IP addresses, and potentially sensitive attributes.

The .terraform/ directory holds downloaded provider binaries that are large and platform-specific. They are reproducible by running terraform init, so there is no reason to commit them.

  • Save both terraform.tfvars and .gitignore.

Before you check your project, how many files do you expect to see in the terraform-k8s-infra directory?

  • Switch back to your terminal and confirm all six files exist by running:
ls -a

You should see six files: .gitignore, main.tf, outputs.tf, terraform.tf, terraform.tfvars, and variables.tf.

Missing .gitignore or terraform.tfvars?

  • Files starting with a dot are hidden by default. Make sure you used ls -a (not just ls) to show hidden files.
  • If terraform.tfvars does not appear, check that you saved the file in the terraform-k8s-infra folder and not in a parent directory.

Help me find my missing project files.

✔️ Awesome, I've got everything!

Great. Double check that you have saved both terraform.tfvars and .gitignore.

ⓧ I'd like to double check the full code

Here is the expected content for both files. Your four .tf files should still be empty at this point.

my_ip = "[[MY_PUBLIC_IP="your public IP address"]]/32"
# Terraform variable values (may contain sensitive data like IP addresses)
terraform.tfvars
*.tfvars

# Terraform working directory and provider binaries
.terraform/

# Terraform state files (contain resource IDs and sensitive attributes)
*.tfstate
*.tfstate.backup

# Crash log files
crash.log
crash.*.log

# Override files (used for local-only overrides)
override.tf
override.tf.json
*_override.tf
*_override.tf.json

Your tools are installed, your credentials are confirmed, and your project directory is ready. Next up, you will write your first Terraform configuration and create a real VPC on AWS.

Write Your First Terraform Config and Create a VPC

Your tools are installed and your project directory is ready. Now it's time to write real Terraform code that creates cloud infrastructure.

Before you can launch any EC2 instances, AWS requires them to live inside a VPC with networking. In this step, you will write the Terraform configuration files that define a VPC, subnet, internet gateway, and route table, then run terraform apply to create all of it in your AWS account.

In this step, get ready to:
  • Define the AWS provider and input variables in Terraform configuration files.
  • Write VPC networking resources including a subnet, internet gateway, and route table.
  • Run the full Terraform workflow to create 5 resources in your AWS account.
Define the provider and variables

Every Terraform project starts with two pieces of configuration: which cloud provider to use, and what input variables the project accepts.

  • In your code editor, open terraform.tf and paste this code:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  required_version = ">= 1.2"
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

  • The terraform block tells Terraform to download the AWS provider from the HashiCorp registry, pinned to version ~> 6.0 (any 6.x release).
  • required_version = ">= 1.2" prevents the project from running on very old Terraform versions that might not support the syntax you use.
  • The provider "aws" block configures the AWS provider with a region. The value var.aws_region reads from a variable you will define next.
  • Save terraform.tf.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

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

  required_version = ">= 1.2"
}

provider "aws" {
  region = var.aws_region
}

Next, you will define the two variables that the provider and security rules depend on.

  • Open variables.tf and paste this code:
variable "aws_region" {
  description = "AWS region to deploy resources in"
  type        = string
  default     = "us-east-1"
}

variable "my_ip" {
  description = "Your public IP in CIDR notation (e.g. 203.0.113.42/32). Used to scope SSH and Jenkins access to your machine only. Set this in terraform.tfvars."
  type        = string
}

What does this code do?

  • aws_region has a default of us-east-1, so Terraform uses that value unless you override it.
  • my_ip has no default. Terraform will read its value from your terraform.tfvars file automatically. You will use this variable in a later step to lock SSH access to your IP only.

How does terraform.tfvars work?

Terraform automatically loads variable values from any file named exactly terraform.tfvars in the working directory. You never need to pass a -var-file flag for it.

The variable must still be declared in a .tf file (like variables.tf). The terraform.tfvars file only sets values. It cannot declare new variables.

  • Save variables.tf.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

variable "aws_region" {
  description = "AWS region to deploy resources in"
  type        = string
  default     = "us-east-1"
}

variable "my_ip" {
  description = "Your public IP in CIDR notation (e.g. 203.0.113.42/32). Used to scope SSH and Jenkins access to your machine only. Set this in terraform.tfvars."
  type        = string
}

With the provider and variables defined, Terraform has everything it needs to connect to AWS. Let's confirm that by initializing the project.

  • Download the AWS provider by running this command in your terminal (make sure you are inside the terraform-k8s-infra directory):
terraform init

You should see output ending with Terraform has been successfully initialized! along with a note about the installed provider version.

Seeing an error from terraform init?

  • If you see a provider version error, double check that terraform.tf has version = "~> 6.0" inside the required_providers block.
  • If you see a directory error, confirm you are inside the terraform-k8s-infra folder by running ls. You should see terraform.tf and variables.tf listed.
  • Still stuck? Help me troubleshoot a terraform init error
Define the VPC networking resources

A VPC is your own private network inside AWS. Every resource you launch (EC2 instances, databases, load balancers) must live inside one.

Why write each resource by hand?

Terraform has community modules that can create an entire VPC in a single block. Writing each resource individually takes more lines, but it teaches you exactly how AWS networking is wired together.

This knowledge is critical later when your Kubernetes cluster cannot reach the internet and you need to troubleshoot whether the problem is the subnet, the route table, or the internet gateway.

  • Open main.tf and paste this code to create the VPC and subnet:
# --- VPC and Networking ---

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

  tags = {
    Name = "k8s-lab-vpc"
  }
}

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 = "k8s-lab-public-subnet"
  }
}

What does this code do?

  • aws_vpc "main" creates a VPC with a 10.0.0.0/16 address range. That gives you over 65,000 private IP addresses to work with.
  • enable_dns_support and enable_dns_hostnames let your instances resolve DNS names, which Kubernetes needs for node-to-node communication.
  • aws_subnet "public" carves out a smaller 10.0.1.0/24 slice (256 addresses) inside the VPC. map_public_ip_on_launch = true gives every instance in this subnet a public IP automatically.
  • Save main.tf.

The VPC and subnet exist, but instances inside them still cannot reach the internet. You need an internet gateway and a route table to connect the subnet to the outside world.

  • In main.tf, paste this code below the subnet resource to add the internet gateway, route table, and route table association:
resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "k8s-lab-igw"
  }
}

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

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

  tags = {
    Name = "k8s-lab-public-rt"
  }
}

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?

  • aws_internet_gateway "gw" attaches an internet gateway to your VPC. This is the VPC's door to the public internet.
  • aws_route_table "public" creates a route table with a single rule: send all traffic headed for 0.0.0.0/0 (any destination) through the internet gateway.
  • aws_route_table_association "public" links the route table to your subnet. Without this association, the subnet would use the VPC's default route table, which has no internet route.
  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

Your main.tf should have two sections. Scroll up and confirm each is present, in this order:

  • The VPC and subnet chunk: starts with the # --- VPC and Networking --- comment, then aws_vpc "main" and aws_subnet "public".
  • The internet gateway and routing chunk: aws_internet_gateway "gw", aws_route_table "public", and aws_route_table_association "public".
Format and apply your infrastructure

All three config files are written. Time to let Terraform format your code and then create the actual AWS resources.

  • Auto-format all your Terraform files by running:
terraform fmt

If any files needed formatting, Terraform prints their names. If nothing prints, your formatting was already correct.

Before you run the next command, how many resources do you think Terraform will create? Count the resource blocks in your main.tf.

  • Create all 5 resources in your AWS account by running:
terraform apply

Terraform shows a plan listing every resource it will create. Review the plan and you should see Plan: 5 to add, 0 to change, 0 to destroy. Those 5 resources are the VPC, subnet, internet gateway, route table, and route table association.

  • Type yes and press Enter to approve the plan.

Terraform creates each resource and prints Apply complete! Resources: 5 added, 0 changed, 0 destroyed. at the end.

Where did Terraform get my_ip?

You did not pass a -var flag, yet Terraform knew the value of my_ip. It automatically loaded it from your terraform.tfvars file. Any file named exactly terraform.tfvars in the working directory is loaded without any flags.

Seeing an error during apply?

  • If you see an authentication error, confirm your AWS CLI credentials are configured by running aws sts get-caller-identity. If that fails, run aws configure to re-enter your access key and secret key.
  • If you see a variable error for my_ip, make sure your terraform.tfvars file is in the same directory as your .tf files and contains my_ip = "YOUR_IP/32" with your actual IP.
  • If you see a syntax error, run terraform validate to see which file and line has the issue. Check for missing braces or quotes.
  • Still stuck? Help me troubleshoot a terraform apply error

Now confirm the VPC exists in your AWS account by checking the console.

  • Open your browser and sign in to the AWS Management Console.
  • In the search bar at the top, type VPC and select VPC from the results.
  • In the left sidebar, click Your VPCs.
  • Confirm that a VPC named k8s-lab-vpc appears in the list with CIDR 10.0.0.0/16.

That VPC and all its networking were defined entirely in code. If you ever need to recreate this environment, you can run terraform apply again from the same configuration.

Your VPC is live and your networking is wired up. Next, you will add security groups to control which ports are open and create an SSH key pair so you can log into your servers.

Add Security Groups and an SSH Key Pair

Your VPC, subnet, internet gateway, and route table are live in AWS. You now have the network foundation for your servers.

Your instances still need two things before they can be useful: firewall rules that control which ports are open, and an SSH key pair so you can log in. In this step, you will create both.

In this step, get ready to:
  • Generate an SSH key pair and register it with AWS.
  • Define security groups that control SSH, Jenkins, and internal VPC traffic.
  • Apply the changes and verify the new resources in Terraform state.
Generate an SSH key pair

SSH key pairs let you log into EC2 instances securely without a password. You generate a private key (stays on your machine) and a public key (gets installed on the server).

  • Generate an ed25519 SSH key pair by running this command:
ssh-keygen -t ed25519 -f ~/.ssh/k8s-lab-key -N ""

What does this command do?

  • -t ed25519 chooses the ed25519 algorithm, which is modern and fast.
  • -f ~/.ssh/k8s-lab-key saves the key pair to a specific file instead of the default location.
  • -N "" sets an empty passphrase so you can SSH in without being prompted every time. This is fine for a lab environment.
  • Confirm the key files were created by running:
ls ~/.ssh/k8s-lab-key*

You should see two files: k8s-lab-key (your private key) and k8s-lab-key.pub (your public key).

Seeing 'file already exists'?

If you already have a key at this path from a previous attempt, the command will ask whether to overwrite it. Type y and press Enter to replace it.

Still stuck? Help me troubleshoot my SSH key generation.

Now you need to tell AWS about the public half of this key pair. Terraform can read the public key file and register it as an AWS key pair resource.

  • In main.tf, scroll to the bottom of the file (below the aws_route_table_association resource) and add the following code:
# --- SSH Key Pair ---

resource "aws_key_pair" "deployer" {
  key_name   = "k8s-lab-key"
  public_key = file("~/.ssh/k8s-lab-key.pub")
}

What does this code do?

  • key_name is the name AWS uses to identify this key pair. Any EC2 instance that references this name will have the public key installed automatically.
  • file() is a Terraform function that reads a file from disk. It reads your public key so Terraform can upload it to AWS.
  • Save main.tf.
Define security groups in main.tf

Security groups are firewalls attached to your instances. Each rule specifies a port, a protocol, and who can connect.

You will create two groups: a "common" group for rules every server needs (SSH access, internal VPC traffic, outbound internet), and a "jenkins" group specifically for the Jenkins web UI port.

  • In main.tf, add the common security group and its SSH ingress rule below the key pair block you just added:
# --- Security Groups ---

resource "aws_security_group" "common" {
  name        = "k8s-lab-common"
  description = "Allow SSH from my IP and internal VPC traffic"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "k8s-lab-common-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.common.id
  cidr_ipv4         = var.my_ip
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
}

What does this code do?

  • aws_security_group.common creates a firewall that you will attach to every instance. The vpc_id ties it to your VPC.
  • The SSH ingress rule opens port 22 but only from var.my_ip. This means only your machine can SSH in. Anyone else is blocked.

Why standalone rule resources instead of inline blocks?

In AWS provider v6.x, the recommended approach is to use standalone aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources instead of inline ingress/egress blocks inside the security group. Mixing the two approaches causes conflicts where Terraform tries to manage the same rule in two places.

  • Save main.tf.
  • Preview what Terraform will create so far by running:
terraform plan

You should see Terraform propose adding 3 new resources: the key pair, the common security group, and the SSH ingress rule. Your existing 5 VPC resources remain unchanged.

Your common security group also needs rules for internal VPC communication and outbound internet access.

  • In main.tf, add these two rules directly below the SSH ingress rule you just added:
resource "aws_vpc_security_group_ingress_rule" "internal" {
  security_group_id = aws_security_group.common.id
  cidr_ipv4         = "10.0.0.0/16"
  ip_protocol       = "-1"
}

resource "aws_vpc_security_group_egress_rule" "all_outbound" {
  security_group_id = aws_security_group.common.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

What do these rules do?

  • The internal rule allows all traffic (ip_protocol = "-1" means all protocols) from any IP inside your VPC (10.0.0.0/16). This is how your Kubernetes nodes will talk to each other.
  • The all_outbound rule lets instances reach the internet (0.0.0.0/0) on any protocol. Without this, your servers could not download packages or pull container images.

The second security group is for Jenkins. Jenkins runs a web UI on port 8080, and you want only your machine to reach it.

  • Still in main.tf, add the Jenkins security group and its ingress rule below the outbound rule:
resource "aws_security_group" "jenkins" {
  name        = "k8s-lab-jenkins"
  description = "Allow Jenkins web UI access from my IP"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "k8s-lab-jenkins-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "jenkins_ui" {
  security_group_id = aws_security_group.jenkins.id
  cidr_ipv4         = var.my_ip
  from_port         = 8080
  to_port           = 8080
  ip_protocol       = "tcp"
}

What does this code do?

This creates a separate security group just for Jenkins. Port 8080 is where the Jenkins web UI listens. Like the SSH rule, access is scoped to var.my_ip so only your machine can reach it.

Keeping Jenkins rules in their own security group means you can attach it only to the Jenkins server, not to every instance. The Kubernetes nodes do not need port 8080 open.

  • Save main.tf.

✔️ 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

Your main.tf is now too long to show in one block. Scroll through your file and confirm you have each of these sections in order:

  • # --- VPC and Networking --- containing the 5 resources from Step 2 (aws_vpc, aws_subnet, aws_internet_gateway, aws_route_table, aws_route_table_association).
  • # --- SSH Key Pair --- containing aws_key_pair.deployer.
  • # --- Security Groups --- containing aws_security_group.common, the SSH/internal/outbound rules, aws_security_group.jenkins, and the Jenkins UI rule.
Apply and verify the new resources

Before you apply, think about what Terraform will do. You already have 5 VPC resources live in AWS. You have just added a key pair, two security groups, and several rules.

  • How many new resources do you think terraform apply will create? Count them in your head before running the command.
  • Apply your changes by running:
terraform apply
  • Review the plan output. Terraform proposes adding 7 new resources and changing 0 existing ones.
  • Type yes and press Enter to confirm.

Why 7 new resources?

Here is the count: 1 key pair + 2 security groups + 3 ingress rules (SSH, internal, Jenkins UI) + 1 egress rule (all outbound) = 7. Your 5 VPC resources from Step 2 are untouched.

Now confirm that Terraform is tracking all the new resources in its state file.

  • List all managed resources by running:
terraform state list

You should see 12 resources listed. Look for aws_security_group.common, aws_security_group.jenkins, aws_key_pair.deployer, and the ingress/egress rule resources alongside the 5 VPC resources from Step 2.

Seeing an error during apply?

  • If you see an error about the key file not being found, make sure you ran the ssh-keygen command earlier in this step. Confirm the file exists with ls ~/.ssh/k8s-lab-key.pub.
  • If you see a syntax error, run terraform fmt to auto-format your code, then check for missing closing braces in main.tf.
  • If you see an error referencing var.my_ip, make sure your terraform.tfvars file still contains your IP in CIDR format (e.g. 203.0.113.42/32).

Still stuck? Help me troubleshoot my terraform apply error for security groups and key pair.

Your VPC now has firewall rules and an SSH key pair ready to go. Next up, you will launch three EC2 instances and SSH into them.

Launch EC2 Instances and SSH In

Your VPC is live, security groups are locked down, and your SSH key is registered with AWS. The networking backbone is fully wired. But there are no servers running on it yet.

In this step, you will launch three EC2 instances into your VPC. Each one plays a specific role: a Jenkins server for CI/CD, a Kubernetes control plane, and a Kubernetes worker node. You will also set up Terraform outputs that print each instance's public IP the moment they come online.

In this step, get ready to:
  • Define an AMI data source and three EC2 instances in main.tf.
  • Create Terraform outputs for the instance public IPs.
  • Apply the configuration and SSH into the Jenkins server.
Define the AMI data source and EC2 instances

Each EC2 instance needs an Amazon Machine Image (AMI) to boot from. Instead of hardcoding an AMI ID (which changes across regions and updates), you will use a Terraform data source to automatically look up the latest Ubuntu 24.04 LTS image.

  • In main.tf, add the AMI data source and the Jenkins instance at the bottom of the file by copying the following code:
# --- AMI Data Source ---

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }

  owners = ["099720109477"]
}

# --- EC2 Instances ---

resource "aws_instance" "jenkins" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = "t3.medium"
  key_name               = aws_key_pair.deployer.key_name
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.common.id, aws_security_group.jenkins.id]

  tags = {
    Name        = "jenkins-server"
    Environment = "lab"
  }
}

What does this code do?

  • data "aws_ami" queries AWS for the latest Ubuntu 24.04 LTS image. The owners filter ensures you get the official image from Canonical (099720109477).
  • The Jenkins instance uses t3.medium (2 vCPUs, 4 GiB RAM) because Jenkins is memory-intensive.
  • vpc_security_group_ids attaches both the common security group (SSH access) and the Jenkins security group (port 8080 for the web UI).
  • key_name links to the SSH key pair you created in the previous step, so you can log in.
  • Save main.tf.
  • Confirm your syntax is valid by running:
terraform validate

You should see Success! The configuration is valid.

Seeing a validation error?

  • Check that the data source block starts with data, not resource. Data sources read existing information instead of creating new resources.
  • Make sure the resource references (aws_key_pair.deployer, aws_subnet.public) match the names you defined in earlier steps exactly.
  • Verify every opening brace { has a matching closing brace } and every opening bracket [ has a matching ].

Help me debug this Terraform validation error for my EC2 instance configuration.

The Kubernetes control plane and worker node only need the common security group. They do not serve a web UI, so they skip the Jenkins security group entirely.

  • Add the two Kubernetes instances below the Jenkins instance in main.tf by copying this code:
resource "aws_instance" "k8s_control_plane" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = "t3.medium"
  key_name               = aws_key_pair.deployer.key_name
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.common.id]

  tags = {
    Name        = "k8s-control-plane"
    Environment = "lab"
  }
}

resource "aws_instance" "k8s_worker" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = "t3.small"
  key_name               = aws_key_pair.deployer.key_name
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.common.id]

  tags = {
    Name        = "k8s-worker-1"
    Environment = "lab"
  }
}

Why are the instance sizes different?

  • The control plane uses t3.medium (4 GiB RAM) because Kubernetes requires at least 2 GiB for the control plane components.
  • The worker uses t3.small (2 GiB RAM), which costs $0.0209/hr instead of $0.0418/hr. For a learning lab, 2 GiB is enough for a single worker node.
  • Both instances only attach the common security group. They communicate with each other over the internal VPC CIDR (10.0.0.0/16) that you opened in the previous step.
  • Save main.tf.
  • Preview the full plan by running:
terraform plan

Terraform reads the AMI data source and proposes adding 3 EC2 instances. Your existing VPC and security group resources show no changes.

Plan shows errors instead of 3 instances?

  • If the error mentions an unknown resource reference, double-check that names like aws_security_group.common and aws_key_pair.deployer match your security group and key pair blocks exactly.
  • Make sure the worker's instance_type is t3.small (not t3.medium). This is intentional to save cost.

Help me debug this Terraform plan error for my EC2 instances.

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

Your main.tf is now over 100 lines. Scroll through and confirm these sections appear in order:

  • # --- VPC and Networking --- with aws_vpc, aws_subnet, aws_internet_gateway, aws_route_table, and aws_route_table_association.
  • # --- SSH Key Pair --- with aws_key_pair.
  • # --- Security Groups --- with aws_security_group.common, its ingress/egress rules, aws_security_group.jenkins, and the Jenkins UI rule.
  • # --- AMI Data Source --- with data "aws_ami" "ubuntu".
  • # --- EC2 Instances --- with aws_instance.jenkins, aws_instance.k8s_control_plane, and aws_instance.k8s_worker.
Define outputs for the public IPs

When terraform apply finishes, you want to see the public IP of each instance immediately. Terraform outputs print specific values after every apply.

  • Switch to outputs.tf in your code editor and add the following:
output "jenkins_public_ip" {
  description = "Public IP of the Jenkins server"
  value       = aws_instance.jenkins.public_ip
}

output "k8s_control_plane_public_ip" {
  description = "Public IP of the Kubernetes control plane"
  value       = aws_instance.k8s_control_plane.public_ip
}

output "k8s_worker_public_ip" {
  description = "Public IP of the Kubernetes worker node"
  value       = aws_instance.k8s_worker.public_ip
}

What do Terraform outputs do?

Each output block prints a value after terraform apply completes. You can also retrieve them anytime with terraform output without re-running apply.

These outputs give you the public IPs you need to SSH into each server.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete outputs.tf file:

output "jenkins_public_ip" {
  description = "Public IP of the Jenkins server"
  value       = aws_instance.jenkins.public_ip
}

output "k8s_control_plane_public_ip" {
  description = "Public IP of the Kubernetes control plane"
  value       = aws_instance.k8s_control_plane.public_ip
}

output "k8s_worker_public_ip" {
  description = "Public IP of the Kubernetes worker node"
  value       = aws_instance.k8s_worker.public_ip
}
Apply and SSH into your Jenkins server

Before you apply, think about what Terraform needs to create. The VPC, subnets, security groups, and key pair already exist. How many new resources do you think Terraform will add?

  • Provision the instances by running:
terraform apply
  • Review the plan. Terraform proposes adding 3 EC2 instances.
  • Type yes and press Enter to confirm.

Terraform provisions all three instances in parallel. After about a minute, you will see an Outputs section with three public IPs printed at the bottom.

  • Record your Jenkins server's public IP: your Jenkins public IP
  • Record your Kubernetes control plane's public IP: your K8s control plane public IP
  • Record your Kubernetes worker's public IP: your K8s worker public IP

Retrieve IPs anytime

You can re-print these IPs anytime without re-running apply by running terraform output in your project directory. Save these IPs for the next project in this series.

Based on the security group rules you wrote in the previous step, do you think your SSH connection will work on the first try?

  • SSH into the Jenkins server by running:
ssh -i ~/.ssh/k8s-lab-key ubuntu@[[JENKINS_IP="your Jenkins public IP"]]
  • SSH will ask you to verify the server's fingerprint the first time. Type yes and press Enter to continue.

You are now inside the Jenkins server. You should see an Ubuntu welcome message and a command prompt showing ubuntu@ followed by the server's private hostname.

  • Return to your local machine by running:
exit

Can't SSH in?

  • If you see Permission denied (publickey), make sure you are using the correct key path: ~/.ssh/k8s-lab-key (the private key, not .pub).
  • If the connection times out, your public IP may have changed since you set terraform.tfvars. Run curl -4 ifconfig.me to check your current IP, update terraform.tfvars, and run terraform apply to refresh the security group rules.
  • If you see a connection refused error, the instance may still be booting. Wait 30 seconds and try again.

Help me troubleshoot why I can't SSH into my Terraform-provisioned EC2 instance.

All three servers are running and you have confirmed SSH access. In the next step, you will explore how Terraform tracks these resources in its state file and learn to make incremental changes.

Explore Terraform State and Plan a Change

Your three EC2 instances are running and you confirmed SSH access in the last step.

Running terraform apply once is not the real value of Infrastructure as Code. The real power is that Terraform tracks every resource in a state file, detects drift, plans incremental changes, and destroys everything with one command. In this step, you will inspect that state, make a small tag change to see Terraform plan an in-place update, and then tear it all down.

In this step, get ready to:
  • Inspect Terraform state to see every managed resource and its attributes.
  • Make an incremental tag change and watch Terraform plan an in-place update.
  • Destroy all AWS resources with a single command and confirm billing stops.
Inspect Terraform state

Every time you run terraform apply, Terraform records what it created in a local state file. This file is how Terraform knows which real-world resources it manages, so it can update or delete them later without you hunting through the AWS console.

  • List every resource Terraform is tracking by running this command:
terraform state list

You should see a list of all your resources, including aws_vpc.main, aws_subnet.public, your security groups, the key pair, and all three aws_instance resources.

What is Terraform state?

Terraform stores a mapping between your configuration files and the real-world resources they created. This mapping lives in terraform.tfstate in your project directory.

Without state, Terraform would have no idea which VPC, subnet, or instance it created. It would try to create duplicates every time you ran apply. State is what makes Terraform idempotent.

  • See the full details of every resource, including instance IDs and IP addresses, by running:
terraform show

This prints every attribute Terraform knows about. You will see instance IDs, AMI values, security group associations, and the public IPs of your three servers.

  • Print just the output values you defined in outputs.tf by running:
terraform output

You should see the three public IPs for your Jenkins server, control plane, and worker node. This command reads from state without contacting AWS, so it runs instantly.

Save these IPs for Project 2

If you plan to continue to Project 2 (bootstrapping Kubernetes), note these IPs now. You can always retrieve them later by running terraform output again, as long as the state file exists and the instances are running.

Seeing an empty list or an error?

Make sure you are running these commands from inside the terraform-k8s-infra directory. Terraform looks for the .terraform folder and state file in the current working directory.

Help me troubleshoot my Terraform state commands

Make an incremental tag change

One of the biggest advantages of Infrastructure as Code is making changes safely. Instead of clicking through the AWS console and hoping you update the right resource, you edit your code and let Terraform figure out the minimal change needed.

You will add an Environment tag to all three EC2 instances. This is a common pattern for labelling resources by environment (lab, staging, production).

  • In main.tf, scroll down to the aws_instance "jenkins" resource.
  • Find its tags block:
  tags = {
    Name = "jenkins-server"
  }
  • Add an Environment = "lab" line below Name, so the tags block looks like this:
  tags = {
    Name        = "jenkins-server"
    Environment = "lab"
  }
  • Do the same for the aws_instance "k8s_control_plane" tags block, so it looks like this:
  tags = {
    Name        = "k8s-control-plane"
    Environment = "lab"
  }
  • Do the same for the aws_instance "k8s_worker" tags block, so it looks like this:
  tags = {
    Name        = "k8s-worker-1"
    Environment = "lab"
  }
  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before running the next command.

ⓧ I'd like to double check the full code

Your main.tf is a long file. Scroll through it and confirm each of these sections is present, in order:

  • VPC and Networking section: aws_vpc, aws_subnet, aws_internet_gateway, aws_route_table, and aws_route_table_association.
  • SSH Key Pair section: aws_key_pair "deployer".
  • Security Groups section: aws_security_group "common" and aws_security_group "jenkins" with their standalone ingress and egress rules.
  • AMI Data Source section: data "aws_ami" "ubuntu".
  • EC2 Instances section: all three instances (jenkins, k8s_control_plane, k8s_worker) each with both Name and Environment = "lab" tags.

Before you run the next command, what do you think Terraform will propose for this change? Will it destroy and recreate the instances, or do something less disruptive?

  • Preview the change Terraform will make by running:
terraform plan

Terraform proposes an in-place update for each of the 3 instances. You should see something like ~ update in-place for each instance, with 1 attribute changed (the tags). No destroy, no recreate.

What does in-place update mean?

Terraform categorises changes into three types. A create adds a new resource. A destroy/recreate tears down and rebuilds a resource (happens when you change something immutable like the AMI). An in-place update modifies the resource without downtime.

Tags are mutable, so Terraform can update them without touching the running instance. This is why terraform plan is so valuable. It tells you exactly what will change before you commit.

  • Apply the tag change by running:
terraform apply
  • Review the plan and type yes to confirm.

Terraform updates the tags on all 3 instances in seconds. No reboot, no downtime.

  • Switch to the AWS Management Console in your browser.
  • Navigate to the EC2 service and click Instances.
  • Click on any of your instances (e.g. jenkins-server) and open the Tags tab.
  • You should see the new Environment = lab tag alongside the existing Name tag.

Don't see the Environment tag?

Make sure you added the Environment = "lab" line inside the tags = { } block for all three instances, not outside it. Check that you saved main.tf before running terraform apply.

Help me troubleshoot my Terraform tag change

Destroy all resources

The final command in the Terraform lifecycle tears down every resource it created. Because Terraform tracks everything in state, it knows exactly what to delete. No manual console cleanup, no orphaned resources quietly running up your bill.

Before you run this, take a guess: how many resources do you think Terraform will mark for destruction?

  • Destroy all AWS resources by running:
terraform destroy
  • Review the plan. Terraform lists every resource it will delete.
  • Type yes to confirm. This is irreversible for the running instances, but you can always re-create everything with terraform apply later.

Terraform deletes resources in the correct order, handling dependencies automatically. Instances are terminated first, then security groups, then the VPC networking layer.

Why does order matter?

AWS will not let you delete a VPC while resources (instances, subnets, gateways) still exist inside it. Terraform builds a dependency graph from your configuration and destroys resources in reverse order. You never have to worry about the sequence.

  • Return to the AWS EC2 console and click Instances.
  • Confirm that all three instances show a state of terminated.
  • Navigate to the VPC service in the AWS console.
  • Confirm that k8s-lab-vpc is no longer listed.

Billing has stopped

With all instances terminated and the VPC deleted, no AWS resources from this project are running. EC2 billing is per-second for Linux instances, so charges stopped the moment the instances terminated. Your total cost for this project is approximately $0.09.

Destroy failed or resources still showing?

If terraform destroy fails with a dependency error, run it again. Terraform will retry the remaining resources. If an instance shows as terminated rather than disappearing, that is normal. AWS keeps terminated instances visible for about an hour before removing them from the list.

Help me troubleshoot a failed terraform destroy

Extra for Experts: remote state

In this project you used a local state file. In a team setting, state is typically stored in a remote backend like an S3 bucket with DynamoDB locking, so multiple engineers can safely run Terraform against the same infrastructure without overwriting each other's state.

Secret mission

Add a Second Worker and IAM Instance Profile

A real Kubernetes cluster needs multiple worker nodes, and those nodes need AWS API access. Extend your Terraform configuration with a second worker and an IAM instance profile that grants ECR read access to all your instances.

Clean Up Your Resources

Clean Up Your Resources

You already ran terraform destroy at the end of this project, so all AWS resources have been deleted and billing has stopped. Decide whether to keep your local project files for future use or delete them entirely.

Cost warning

There are no ongoing AWS charges right now because all resources were destroyed. If you re-provision the infrastructure later by running terraform apply, EC2 costs resume at approximately $0.10/hr for 3 instances (2x t3.medium at $0.0418/hr + 1x t3.small at $0.0209/hr).

With the Secret Mission's 4 instances (adding a second t3.small), costs are approximately $0.13/hr. EC2 billing is per-second with a 60-second minimum for Linux instances, so you only pay for the exact time they run.

Resources you used:

  • terraform-k8s-infra/ project directory on your Desktop (Terraform config files, provider binaries, and state files).
  • SSH key pair at ~/.ssh/k8s-lab-key and ~/.ssh/k8s-lab-key.pub.
  • Terraform CLI (installed via Homebrew).

Keep everything running

No action needed. Choose this if you plan to continue to Project 2 (bootstrapping Kubernetes) and want your config files ready to go.

  • Your Terraform config files in terraform-k8s-infra/ are ready. Run terraform apply from that directory to recreate all AWS resources in about 2 minutes.
  • Your SSH key pair at ~/.ssh/k8s-lab-key is still on your machine and will work with any new instances Terraform creates.
  • Terraform CLI stays installed for future projects.

Pause - I'll come back to this later

Your AWS resources are already destroyed, so there is nothing to pause or shut down. Your local files are safe to leave on disk until you are ready to continue.

  • Keep the terraform-k8s-infra/ directory on your Desktop. When you return, run terraform apply to recreate everything.
  • Keep the SSH key pair at ~/.ssh/k8s-lab-key. Terraform will use it automatically when provisioning new instances.
  • If your public IP changes between sessions, update the value in terraform.tfvars before running terraform apply. You can find your current IP by running curl -4 ifconfig.me.

Delete - I don't want to use this again

Remove all local project files, the SSH key pair, and optionally the Terraform CLI.

macOS

  • Remove the project directory by running:
rm -rf ~/Desktop/terraform-k8s-infra
  • Remove the SSH key pair by running:
rm ~/.ssh/k8s-lab-key ~/.ssh/k8s-lab-key.pub
  • Optionally, uninstall Terraform by running:
brew uninstall hashicorp/tap/terraform

Windows

  • Remove the project directory by running:
Remove-Item -Recurse -Force ~\Desktop\terraform-k8s-infra
  • Remove the SSH key pair by running:
Remove-Item ~\.ssh\k8s-lab-key, ~\.ssh\k8s-lab-key.pub
  • Optionally, uninstall Terraform by following the official install guide for your installation method's uninstall steps.

Nice Work!

Nice Work!

Nice work! You just wrote Terraform configuration from scratch to provision an entire AWS environment, from networking to running servers, and tore it all down with a single command.

You've learned how to:

  • Write Terraform HCL configuration from scratch to define a VPC, public subnet, internet gateway, security groups, an SSH key pair, and three EC2 instances, then deploy the entire environment with a single terraform apply command.
  • Secure your infrastructure by scoping SSH and Jenkins access to your IP only, using a terraform.tfvars file for sensitive values and a .gitignore to keep secrets out of version control.
  • Run the full Terraform lifecycle (init, plan, apply, destroy) and use Terraform state to track resources, plan incremental changes, and tear down everything cleanly.
  • Secret Mission: Extended your configuration with a second worker node and an IAM instance profile so your instances can access AWS APIs directly.

Ready to quiz yourself?