Provision AWS Infrastructure with Terraform

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

Introduction

30 Second Summary

Every website, app, or API you use runs on servers that live inside a carefully designed network. With Infrastructure as Code, you define that entire network in files you can version, share, and rerun.

In this project, you will use Terraform to provision a complete AWS environment from scratch. You will write the code that creates a custom VPC, security groups, and three EC2 instances, then bring them all to life with a single command.

What You'll Build

Run terraform output and see three public IP addresses printed to your terminal, each one a live server you can SSH into, all provisioned from code you wrote.

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

  • A custom VPC with a public subnet, internet gateway, and route table that give your instances internet access, all defined as raw Terraform resources you wrote from scratch.
  • Three EC2 instances you can SSH into (a Jenkins server, a Kubernetes control plane, and a Kubernetes worker), locked down with security groups scoped to your IP address.
  • All three public IPs retrievable with a single terraform output command, ready to feed into the next project in the series.
  • Secret Mission: Add a second Kubernetes worker node and an IAM instance profile so your instances can call AWS APIs.

Are there any prerequisites?

This project assumes you have macOS with Homebrew installed and the AWS CLI already configured. You also need an AWS account with IAM user access keys (or the ability to create them).

Before We Start

Before we dive in, take a moment to lock in what you are about to build and why it matters.

Install Terraform and Configure AWS Access

Terraform is the Infrastructure as Code tool you'll use to define your entire AWS environment in code files. Before you can provision a single resource, Terraform needs to be installed on your machine and configured to talk to your AWS account.

In this step, you'll install Terraform, configure your AWS credentials for programmatic access, and generate a dedicated SSH key pair for connecting to the EC2 instances you'll launch later.

In this step, get ready to:
  • Install Terraform via Homebrew and verify the version.
  • Configure AWS credentials for programmatic access.
  • Generate an SSH key pair and create the project directory.
Install Terraform via Homebrew

Terraform lets you define cloud resources in configuration files and provision them with a single command. This project pins to Terraform v1.15.x, so you need that version installed before writing any infrastructure code.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal and press Enter to open it.
  • Check if Terraform is already installed by running this command:
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 this project requires version 1.15 or higher.

  • Update the HashiCorp tap and upgrade Terraform by running these commands:
brew tap hashicorp/tap
brew upgrade hashicorp/tap/terraform
  • Verify the upgrade by running:
terraform -version

You should see Terraform v1.15.x in the output.

ⓧ Command not found

Terraform is not installed yet. You'll install it from HashiCorp's official Homebrew tap.

  • Install Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

What do these commands do?

The first command adds HashiCorp's official package repository (called a "tap") to Homebrew. The second command installs the Terraform binary from that tap.

  • Verify the installation by running:
terraform -version

You should see Terraform v1.15.x in the output.

Still not seeing the right version?

Make sure you ran brew tap hashicorp/tap before the install or upgrade command. Without the tap, Homebrew cannot find the official HashiCorp formula.

If you previously installed Terraform from a different source, try uninstalling it first with brew uninstall terraform, then re-run the install commands above.

Help me troubleshoot my Terraform installation on macOS with Homebrew.

Configure your AWS credentials

Terraform authenticates with AWS using the credentials stored by the AWS CLI. You'll enter your IAM user's access keys so Terraform can create and manage resources in your account.

The aws configure command is interactive. It prompts you for four values, one at a time.

  • Start the credential configuration by running this command:
aws configure
  • Enter your IAM user's Access Key ID when prompted.
  • Enter your IAM user's Secret Access Key when prompted.
  • Enter us-east-1 for the Default region name.
  • Enter json for the Default output format.

Where do I find my access keys?

Your access key ID and secret access key come from your IAM user in the AWS console. If you have not created an IAM user with programmatic access yet, go to the IAM service in the AWS console, create a user, and generate an access key pair.

Before you run the next command, what do you expect to see if your credentials are configured correctly?

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

You should see a JSON response containing your AWS account ID, user ARN, and user ID. This confirms that Terraform will be able to authenticate with your AWS account.

Seeing an error instead of your account info?

Double-check that you entered the correct access key ID and secret access key. Even a single mistyped character will cause authentication to fail. Run aws configure again to re-enter your credentials.

Make sure your IAM user has programmatic access enabled. Keys from an IAM user without this permission will not authenticate.

Help me fix my AWS CLI credential configuration.

Generate an SSH key pair and create the project directory

Later in the project you'll SSH into EC2 instances. You need a dedicated key pair for this, and a project directory to hold all your Terraform files.

  • Generate a dedicated SSH key pair for this project by running this command:
ssh-keygen -t ed25519 -f ~/.ssh/k8s-cicd -C "k8s-cicd-key" -N ""

What does this command do?

  • -t ed25519 selects the Ed25519 algorithm, which is modern and secure.
  • -f ~/.ssh/k8s-cicd saves the key pair to a specific file path instead of the default.
  • -C "k8s-cicd-key" adds a label so you can identify this key later.
  • -N "" sets an empty passphrase so you won't be prompted every time you SSH in.
  • Confirm the key pair was created by running:
ls ~/.ssh/k8s-cicd*

You should see two files: k8s-cicd (your private key) and k8s-cicd.pub (your public key). Terraform will upload the public key to AWS so your instances recognize you when you SSH in.

  • Create the project directory and move into it by running these commands:
mkdir -p ~/k8s-cicd-terraform && cd ~/k8s-cicd-terraform
  • Confirm you're in the right directory by running:
pwd

The output should end with k8s-cicd-terraform. This is where all your Terraform configuration files will live.

Don't see both key files?

If ls ~/.ssh/k8s-cicd* shows only one file or an error, the ssh-keygen command may have failed. Re-run it and check that the ~/.ssh/ directory exists.

Help me troubleshoot my SSH key generation.

Your toolchain is locked and loaded. Terraform is installed, AWS knows who you are, and you have an SSH key pair ready for your future instances. Next up, you'll write your first Terraform configuration and create a VPC on AWS.

Write Your First Terraform Config and Create a VPC

In Step 1, you installed Terraform, configured your AWS credentials, and generated an SSH key pair. Your tools are ready to start building real infrastructure.

Before launching any servers, you need a virtual network to put them in. A VPC (Virtual Private Cloud) is your own isolated section of the AWS cloud where your instances will live. In this step, you will create a VPC, a subnet inside it, and an internet gateway to connect it to the public internet.

In this step, get ready to:
  • Configure Terraform's provider and variable declarations.
  • Define a VPC with a public subnet and internet gateway.
  • Initialize Terraform and create your first AWS resources.
Create main.tf and variables.tf

Every Terraform project starts with a provider configuration that tells Terraform which cloud to talk to and which plugin versions to use.

  • Create a file called main.tf in your k8s-cicd-terraform directory and paste the following code:
# Pin the Terraform CLI version
terraform {
  required_version = "~> 1.15"

  # Pin the AWS provider version
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

# Use the aws_region variable to set the deployment region
provider "aws" {
  region = var.aws_region
}

What does this code do?

The terraform block locks your project to Terraform ~> 1.15 (any 1.15.x release) and the AWS provider to ~> 6.0 (any 6.x release). This prevents surprise breaking changes when either tool updates.

The provider block tells the AWS plugin which region to create resources in. Instead of hardcoding a region, it reads from a variable you will define next.

  • Save main.tf.

Next, you need a place to declare the variables your project uses. Terraform reads variable declarations from any .tf file, but convention is to keep them in a dedicated variables.tf file.

  • Create a file called variables.tf and paste the following code:
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

# Marked sensitive so Terraform hides your IP in CLI output
variable "my_ip" {
  description = "Your public IP in CIDR notation (e.g. 203.0.113.10/32) for SSH and Jenkins access"
  type        = string
  sensitive   = true
}

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "k8s-cicd"
}

What does this code do?

  • aws_region defaults to us-east-1 so you never need to type it on the command line.
  • my_ip has sensitive = true, which tells Terraform to hide your IP address in plan and apply output. It does not encrypt the value in state, but it keeps it off your screen.
  • project_name defaults to k8s-cicd and is used in resource name tags so every resource is easy to identify in the AWS console.
  • Save variables.tf.
  • Confirm both files exist by running:
ls

You should see main.tf and variables.tf in the output.

Create terraform.tfvars and .gitignore

Your my_ip variable has no default value, so Terraform will prompt you for it every time you run a command. To avoid that, you will store the value in a terraform.tfvars file that Terraform auto-loads.

  • Find your public IP address by running this command:
curl -4 ifconfig.me

You should see your public IP address printed in the terminal (something like 203.0.113.10). You will need this value with /32 appended to the end.

What does /32 mean?

/32 is CIDR notation meaning "exactly this one IP address." When you lock security group rules to your IP with /32, only traffic from your machine is allowed in.

  • Create a file called terraform.tfvars and paste the following, replacing the placeholder with the IP you just found plus /32:
my_ip = "[[MY_PUBLIC_IP="your-public-ip/32"]]"
  • Save terraform.tfvars.
  • Create a file called .gitignore and paste the following:
terraform.tfvars
*.tfstate
*.tfstate.backup
.terraform/

Why gitignore these files?

  • terraform.tfvars contains your IP address. Committing it to version control would expose your network location.
  • *.tfstate files contain the full state of your infrastructure, including sensitive values. In production, you would use a remote backend with encryption.
  • .terraform/ contains downloaded provider plugins. These are large binary files that should be downloaded fresh on each machine.
  • Save .gitignore.
  • Confirm all four files exist by running:
ls -a

You should see .gitignore, main.tf, terraform.tfvars, and variables.tf in the output.

What is terraform.tfvars?

Terraform automatically loads any file named terraform.tfvars in the working directory. You never need to pass -var flags on the command line. If you ever needed to override a value, CLI flags take the highest precedence.

Don't see all four files?

  • Make sure you are in the k8s-cicd-terraform directory. Run pwd to check.
  • The .gitignore file is hidden by default. Use ls -a (with the -a flag) to show hidden files.

Help me troubleshoot my missing Terraform config files.

Create network.tf with the VPC resource

With your project configured, you can start defining real AWS resources. The first building block is the VPC itself.

  • Create a file called network.tf and paste the following code:
# Create the VPC with a /16 CIDR block (65,536 IP addresses)
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

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

What is a VPC?

A VPC is a logically isolated virtual network inside AWS. Think of it as renting your own floor in an office building. The 10.0.0.0/16 CIDR block gives you 65,536 private IP addresses to carve up into subnets.

enable_dns_support and enable_dns_hostnames let instances inside the VPC resolve DNS names and receive public DNS hostnames. Without these, your instances would only be reachable by raw IP addresses.

  • Save network.tf.

A VPC on its own is just a big empty address space. You need a subnet to actually place instances in.

  • Add the following subnet resource below the VPC resource in network.tf:
# Carve out a /24 subnet (256 IPs) in a single availability zone
resource "aws_subnet" "public" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "${var.project_name}-public-subnet"
  }
}

What does this code do?

  • vpc_id = aws_vpc.main.id links this subnet to the VPC you just defined. Terraform resolves the reference automatically.
  • 10.0.1.0/24 gives this subnet 256 addresses, carved from the VPC's larger /16 block.
  • availability_zone pins the subnet to us-east-1a. All instances in this subnet will live in the same physical data center zone.
  • Save network.tf.

You now have two Terraform resources defined. Before adding the third, initialize your project so Terraform downloads the AWS provider plugin.

  • Initialize your Terraform project by running this command:
terraform init

Terraform downloads the AWS provider plugin and creates a .terraform/ directory to store it. You should see the message Terraform has been successfully initialized! at the end of the output.

Seeing a provider error?

  • Check that main.tf has source = "hashicorp/aws" inside the required_providers block. A typo in the source will cause a download failure.
  • Make sure you are running terraform init from inside the k8s-cicd-terraform directory, not a parent folder.

Help me fix my terraform init error.

The last piece of the network foundation is an internet gateway. This resource connects your VPC to the public internet so traffic can flow in and out.

  • Add the following internet gateway resource below the subnet in network.tf:
# Attach an internet gateway so traffic can reach the public internet
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

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

What is an internet gateway?

An internet gateway is the front door of your VPC. Without it, instances inside the VPC have no path to the outside world. However, a gateway alone is not enough. You also need a route table to tell the subnet "send internet-bound traffic through this door." You will discover exactly why in a later step.

  • Save network.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved network.tf with all three resources (VPC, subnet, internet gateway).

ⓧ I'd like to double check the full code

Here is what your network.tf should look like at this point:

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

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

resource "aws_subnet" "public" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Name = "${var.project_name}-public-subnet"
  }
}

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

  tags = {
    Name = "${var.project_name}-igw"
  }
}
Run terraform plan and terraform apply

Your Terraform config describes three AWS resources. Before creating anything, you can preview exactly what Terraform intends to do.

  • Preview the planned changes by running this command:
terraform plan

You should see three resources prefixed with a + symbol, which means Terraform will create them. Each resource lists the attributes it will set, with values marked (known after apply) for anything AWS assigns at creation time (like IDs and ARNs).

What is a Terraform plan?

The terraform plan command compares your .tf files against Terraform's state to figure out what needs to change. Since you have no state yet, everything shows as new. In later steps, you will see how the plan shows updates and deletions too.

Before you apply, predict: how many resources do you expect Terraform to create?

  • Create the resources by running this command:
terraform apply
  • Terraform shows you the same plan and asks for confirmation. Type yes and press Enter.

You should see Apply complete! Resources: 3 added, 0 changed, 0 destroyed. at the bottom of the output. Three real AWS resources now exist in your account.

What is Terraform state?

When Terraform creates resources, it records every detail (IDs, ARNs, IP addresses) in a local file called terraform.tfstate. This is how Terraform knows what it has already built. Without state, Terraform would try to create duplicate resources every time you run apply.

  • Inspect the VPC you just created by running this command:
terraform show

Terraform prints the full state of all three resources. You can see the VPC ID, subnet ID, and internet gateway ID that AWS assigned. These are the real resource identifiers in your account.

Seeing an authentication or permissions error?

  • Run aws sts get-caller-identity to confirm your AWS credentials are still configured. If it fails, re-run aws configure with your IAM access key.
  • Make sure your IAM user has permissions to create VPC, subnet, and internet gateway resources. An AdministratorAccess policy attached to your IAM user will cover everything in this project.

Help me fix my terraform apply error.

Your VPC, subnet, and internet gateway are live in AWS. Next up, you will add security groups to control exactly which traffic can reach your future instances.

Add Security Groups and an SSH Key Pair

Your VPC is live with a subnet and internet gateway. But right now, anything you launch inside it would use the VPC's default security group, which blocks all inbound traffic from outside the VPC.

Security groups act as virtual firewalls for your EC2 instances, controlling exactly which ports and IP addresses can reach them. In this step, you will create two security groups and upload your SSH key to AWS so your future instances are locked down from day one.

In this step, get ready to:
  • Create a Jenkins security group that allows SSH and web UI access from your IP only.
  • Create a Kubernetes security group for SSH access and internal cluster communication.
  • Upload your SSH public key to AWS as a key pair for instance authentication.
Create security.tf with the Jenkins security group

Each security group defines which traffic is allowed in (ingress) and out (egress). The Jenkins server needs two inbound doors: port 22 for SSH access and port 8080 for the Jenkins web UI.

  • Create a new file called security.tf in your k8s-cicd-terraform directory.
  • Add the Jenkins security group by pasting this code:
resource "aws_security_group" "jenkins" {
  name        = "${var.project_name}-jenkins-sg"
  description = "Allow SSH and Jenkins from my IP"
  vpc_id      = aws_vpc.main.id
  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }
  ingress {
    description = "Jenkins UI from my IP"
    from_port   = 8080
    to_port     = 8080
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }
  egress {
    description = "Allow all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name = "${var.project_name}-jenkins-sg"
  }
}

What does this code do?

  • vpc_id = aws_vpc.main.id attaches this security group to the VPC you created in the last step. Without this, Terraform would attach it to the default VPC instead.
  • The two ingress blocks open port 22 (SSH) and port 8080 (Jenkins web UI). Both are locked to var.my_ip, so only your machine can reach them.
  • The egress block with protocol = "-1" allows all outbound traffic. Your instances can reach the internet (to download packages, for example), but inbound access is tightly restricted.
  • Save security.tf.
  • Confirm your new file is valid by running:
terraform validate

You should see Success! The configuration is valid.

Seeing an error instead?

  • Check that every opening { has a matching closing }. A missing brace on the outer resource or on a nested ingress block is the most common culprit.
  • Make sure you are running the command from inside the k8s-cicd-terraform directory where your .tf files live.

Why is terraform validate failing on my security group?

Add the Kubernetes security group and SSH key pair

The Kubernetes nodes need a different set of rules. They still need SSH from your IP, but they also need to talk to each other on specific cluster ports.

  • In security.tf, below the Jenkins security group, add the Kubernetes security group by pasting this code:
resource "aws_security_group" "k8s" {
  name        = "${var.project_name}-k8s-sg"
  description = "Allow SSH from my IP and internal K8s traffic"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }

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

  tags = {
    Name = "${var.project_name}-k8s-sg"
  }
}

Why does this look like the Jenkins group?

Every security group in this project shares the same skeleton: SSH ingress from your IP, all-outbound egress, and a tag. The difference is what extra ports each group opens. You are about to add the Kubernetes-specific ports next.

  • Inside the k8s security group, find the closing } of the SSH ingress block. Add these two ingress blocks between the SSH ingress and the egress block:
  ingress {
    description = "K8s API server"
    from_port   = 6443
    to_port     = 6443
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"]
  }

  ingress {
    description = "Kubelet API"
    from_port   = 10250
    to_port     = 10250
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"]
  }

What are ports 6443 and 10250?

  • Port 6443 is the Kubernetes API server port. Worker nodes and kubectl use this port to communicate with the control plane.
  • Port 10250 is the Kubelet API port. The control plane uses it to send instructions to worker nodes.
  • Both rules restrict traffic to 10.0.0.0/16 (your VPC's CIDR). Only instances inside your VPC can reach these ports, not the public internet.
  • Save security.tf.
  • Confirm the file is still valid by running:
terraform validate

You should see Success! The configuration is valid.

The last piece is an SSH key pair. AWS needs a copy of your public key so it can install it on every instance you launch.

  • Below the k8s security group in security.tf, add the key pair resource by pasting this code:
resource "aws_key_pair" "deployer" {
  key_name   = "${var.project_name}-key"
  public_key = file("~/.ssh/k8s-cicd.pub")

  tags = {
    Name = "${var.project_name}-key"
  }
}

What does this code do?

The file() function reads the contents of your public key file at ~/.ssh/k8s-cicd.pub and uploads it to AWS. When you launch an instance with this key pair, AWS installs the public key on the instance so you can SSH in with your private key.

  • Save security.tf.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Your security.tf file should contain three resources in this order. Scroll up and confirm each one is present:

  • aws_security_group "jenkins" with two ingress blocks (SSH port 22 and Jenkins port 8080), one egress block, and a tags block.
  • aws_security_group "k8s" with three ingress blocks (SSH port 22, K8s API port 6443, Kubelet port 10250), one egress block, and a tags block.
  • aws_key_pair "deployer" with key_name and public_key referencing ~/.ssh/k8s-cicd.pub.
Run terraform apply and verify

Before you apply, think about what Terraform will do. You already have 3 resources (VPC, subnet, internet gateway) in state. How many new resources do you expect Terraform to add?

  • Apply the new configuration by running:
terraform apply
  • Review the plan output. You should see 3 resources to add.
  • Type yes when prompted and press Enter.

You should see Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

What is the total resource count?

Terraform now manages 6 AWS resources: 3 from the previous step (VPC, subnet, internet gateway) and 3 new ones (Jenkins security group, K8s security group, key pair). Notice that var.my_ip is marked as sensitive in the plan output. Terraform hides its value because you set sensitive = true in variables.tf.

Seeing a key file error?

  • If Terraform cannot find ~/.ssh/k8s-cicd.pub, the SSH key pair from Step 1 may not have been generated. Run ls ~/.ssh/k8s-cicd* to check. If no files appear, go back to Step 1 and regenerate the key pair.
  • If you see a permissions error, make sure the file path in security.tf matches exactly: file("~/.ssh/k8s-cicd.pub").

Why is terraform apply failing on the key pair resource?

Your security groups and key pair are in place. Next up, you will launch three EC2 instances into your VPC and discover a critical networking gap that blocks SSH access.

Launch EC2 Instances and SSH In

Your security groups and SSH key pair are uploaded to AWS. The access rules are defined, but there are no servers to protect yet.

In this step, you will launch three EC2 instances: a Jenkins server, a Kubernetes control plane, and a Kubernetes worker node. Along the way, you will hit the most common custom-VPC pitfall. Your instances will get public IPs, but SSH will time out because there is no route from the subnet to the internet gateway.

In this step, get ready to:
  • Launch 3 EC2 instances using a dynamic AMI lookup from AWS SSM Parameter Store.
  • Discover and fix the route table gap that blocks SSH access.
  • SSH into all 3 instances and capture their public IPs with terraform output.
Create instances.tf with the SSM data source and Jenkins instance

Ubuntu AMI IDs change every time Canonical publishes a security update. Hardcoding an AMI ID would make your config break within weeks.

What is an AMI?

An AMI (Amazon Machine Image) is a template that contains the operating system and software for an EC2 instance. Think of it as the "blueprint" your server boots from.

Instead of hardcoding an AMI ID, you will use a data source to look up the latest Ubuntu 24.04 LTS AMI from Canonical's SSM Parameter Store path. This way your config always resolves to a current image.

  • Create a new file called instances.tf in your k8s-cicd-terraform folder and paste the following code:
# Look up the latest Ubuntu 24.04 LTS AMI from Canonical's SSM parameter
data "aws_ssm_parameter" "ubuntu_ami" {
  name = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id"
}

# Jenkins server instance
resource "aws_instance" "jenkins" {
  ami                         = data.aws_ssm_parameter.ubuntu_ami.value
  instance_type               = "t3.medium"
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.jenkins.id]
  key_name                    = aws_key_pair.deployer.key_name
  associate_public_ip_address = true

  tags = {
    Name = "${var.project_name}-jenkins"
    Role = "jenkins"
  }

  depends_on = [aws_internet_gateway.main]
}

What does this code do?

  • data "aws_ssm_parameter" reads the latest Ubuntu 24.04 LTS AMI ID from Canonical's official SSM parameter. The path includes ebs-gp3 because Ubuntu 23.10+ uses gp3 volumes.
  • vpc_security_group_ids attaches the Jenkins security group you created in Step 3. In a custom VPC, you must use vpc_security_group_ids instead of security_groups.
  • depends_on = [aws_internet_gateway.main] ensures the internet gateway is fully created before the instance launches. AWS recommends this dependency for instances that need public IP assignment.
  • Save instances.tf.

Seeing a syntax error?

  • Make sure the SSM parameter path is wrapped in double quotes and matches the path exactly, including ebs-gp3 at the end.
  • Check that vpc_security_group_ids uses square brackets [] around the value. It expects a list, not a single string.
  • Still stuck? Help me fix a syntax error in my instances.tf file.
Add the Kubernetes instances

The Kubernetes control plane and worker follow the same pattern as Jenkins, but they use the K8s security group instead.

  • Add the K8s control plane instance below the Jenkins resource in instances.tf:
# K8s control plane node
resource "aws_instance" "k8s_control" {
  ami                         = data.aws_ssm_parameter.ubuntu_ami.value
  instance_type               = "t3.medium"
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.k8s.id]
  key_name                    = aws_key_pair.deployer.key_name
  associate_public_ip_address = true

  tags = {
    Name = "${var.project_name}-k8s-control"
    Role = "k8s-control-plane"
  }

  depends_on = [aws_internet_gateway.main]
}

What's different here?

This instance uses aws_security_group.k8s.id instead of the Jenkins security group. That means it allows SSH from your IP plus internal K8s traffic on ports 6443 and 10250 from within the VPC.

  • Save instances.tf.
  • Verify your syntax so far by running:
terraform validate

You should see: Success! The configuration is valid.

  • Add the K8s worker node below the control plane in instances.tf:
# K8s worker node
resource "aws_instance" "k8s_worker" {
  ami                         = data.aws_ssm_parameter.ubuntu_ami.value
  instance_type               = "t3.small"
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.k8s.id]
  key_name                    = aws_key_pair.deployer.key_name
  associate_public_ip_address = true

  tags = {
    Name = "${var.project_name}-k8s-worker-1"
    Role = "k8s-worker"
  }

  depends_on = [aws_internet_gateway.main]
}

Why t3.small for the worker?

The worker node uses t3.small (2 vCPUs, 2 GiB memory) instead of t3.medium (2 vCPUs, 4 GiB memory). Workers run application containers, while the control plane and Jenkins handle heavier orchestration and build tasks. This keeps costs lower at $0.0209/hr vs $0.0418/hr.

  • Save instances.tf.

✔️ Awesome, I've got everything!

Great. Make sure you have saved instances.tf with all three resources (data source, Jenkins, k8s_control, k8s_worker).

ⓧ I'd like to double check the full code

Your instances.tf should contain the following four blocks, in this order:

  • data "aws_ssm_parameter" "ubuntu_ami" at the top.
  • resource "aws_instance" "jenkins" with t3.medium and aws_security_group.jenkins.id.
  • resource "aws_instance" "k8s_control" with t3.medium and aws_security_group.k8s.id.
  • resource "aws_instance" "k8s_worker" with t3.small and aws_security_group.k8s.id.

Scroll up and confirm each block is present and in order. Every instance should have depends_on = [aws_internet_gateway.main] and associate_public_ip_address = true.

Create outputs.tf

Terraform outputs let you print specific values after every terraform apply. You will need the public IPs of all three instances for SSH access and for Project 2 in this series.

  • Create a new file called outputs.tf in your k8s-cicd-terraform folder and paste the following code:
# Print the public IP of each instance after apply
output "jenkins_public_ip" {
  description = "Public IP of the Jenkins server"
  value       = aws_instance.jenkins.public_ip
}

output "k8s_control_public_ip" {
  description = "Public IP of the K8s control plane node"
  value       = aws_instance.k8s_control.public_ip
}

output "k8s_worker_1_public_ip" {
  description = "Public IP of K8s worker node 1"
  value       = aws_instance.k8s_worker.public_ip
}

# VPC ID for reference in future projects
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

What does this code do?

Each output block extracts one attribute from a managed resource. After terraform apply finishes, Terraform prints these values. You can also retrieve them any time with terraform output.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved outputs.tf with all four output blocks.

ⓧ I'd like to double check the full code

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

output "k8s_control_public_ip" {
  description = "Public IP of the K8s control plane node"
  value       = aws_instance.k8s_control.public_ip
}

output "k8s_worker_1_public_ip" {
  description = "Public IP of K8s worker node 1"
  value       = aws_instance.k8s_worker.public_ip
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}
Run terraform apply, hit the SSH timeout, and fix it

Your three instances are defined. Time to launch them.

  • Launch all three instances by running:
terraform apply
  • Review the plan. You should see 4 resources to add (1 data source read, 3 instances).
  • Type yes and press Enter.

After about a minute, Terraform reports the instances are created and prints the public IPs in the outputs section.

  • Record the Jenkins public IP from the output: your Jenkins public IP
  • Record the K8s control plane public IP: your K8s control plane public IP
  • Record the K8s worker public IP: your K8s worker public IP

Your instances are running and have public IPs. Your security groups allow SSH from your IP. Before you try to SSH in, what do you think will happen?

  • Try to SSH into the Jenkins server by running:
ssh -i ~/.ssh/k8s-cicd -o ConnectTimeout=10 ubuntu@[[JENKINS_PUBLIC_IP="your Jenkins public IP"]]

The connection hangs and eventually times out. This is intentional. You did everything right, but SSH still cannot reach your instance.

Why does SSH time out?

Your internet gateway exists, but the public subnet is still using the VPC's default route table. That default route table only has one route: 10.0.0.0/16 -> local. It knows how to route traffic within the VPC, but has no idea how to reach the internet.

An internet gateway without a route is like a door with no hallway. The door exists, but nothing leads to it. You need an explicit 0.0.0.0/0 -> igw route to tell the subnet to send all internet-bound traffic through the gateway.

This is the most common custom-VPC misconfiguration and a favorite interview question. Now fix it.

  • Open network.tf and add the following code below the aws_internet_gateway resource:
# Route table sending all internet traffic through the gateway
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 = "${var.project_name}-public-rt"
  }
}

# Link the route table to 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?

  • aws_route_table creates a new route table with a single route: all traffic destined for 0.0.0.0/0 (the internet) goes through the internet gateway.
  • aws_route_table_association attaches this route table to your public subnet. Without this link, the subnet would continue using the VPC's default route table and SSH would still time out.
  • Save network.tf.
  • Apply the fix by running:
terraform apply
  • Type yes and press Enter.

Terraform adds 2 new resources (the route table and the association). Your existing instances are untouched.

Still seeing an error on apply?

  • Make sure the route block is nested inside the aws_route_table resource, not at the top level of the file.
  • Check that gateway_id references aws_internet_gateway.main.id exactly.
  • Need a hand? Help me fix my route table Terraform config. I'm adding a route table and association to network.tf but terraform apply is failing.
SSH into each instance

The route table fix is in place. What do you think will happen this time when you try to SSH?

  • SSH into the Jenkins server by running:
ssh -i ~/.ssh/k8s-cicd ubuntu@[[JENKINS_PUBLIC_IP="your Jenkins public IP"]]
  • If prompted to confirm the host fingerprint, type yes and press Enter.

You should see an Ubuntu shell prompt. The route table fixed the connection.

  • Type exit and press Enter to return to your local terminal.
  • SSH into the K8s control plane by running:
ssh -i ~/.ssh/k8s-cicd ubuntu@[[K8S_CONTROL_PUBLIC_IP="your K8s control plane public IP"]]
  • Confirm you see the Ubuntu shell prompt, then type exit.
  • SSH into the K8s worker by running:
ssh -i ~/.ssh/k8s-cicd ubuntu@[[K8S_WORKER_PUBLIC_IP="your K8s worker public IP"]]
  • Confirm you see the Ubuntu shell prompt, then type exit.

All three instances are reachable. To see your IPs in one clean view, run terraform output.

  • Print all outputs by running:
terraform output

You should see all three public IPs and the VPC ID printed in your terminal. Save these IPs for Project 2 in this series.

SSH still timing out after adding the route table?

  • Make sure you ran terraform apply after saving network.tf. The route table only exists in AWS after a successful apply.
  • Check that your terraform.tfvars has your current public IP with /32 at the end. Your IP may have changed since you started the project.
  • Verify the route table association links to your public subnet by running terraform state show aws_route_table_association.public and confirming the subnet_id matches your subnet.
  • Still stuck? Help me debug why SSH is still timing out to my EC2 instance after adding a route table.

You have three running EC2 instances in a properly networked VPC, all provisioned through code. Next up, you will explore how Terraform tracks all of this behind the scenes in its state file.

Explore Terraform State and Plan a Change

Your 3 EC2 instances are running, SSH works, and all public IPs are a single terraform output away. But here is a question you will face in any Cloud or DevOps interview: how does Terraform know what infrastructure exists?

The answer is the state file. Terraform stores a snapshot of every resource it manages, including IDs, attributes, and relationships. In this step, you will explore that state, plan a small change, and then tear everything down to stop billing.

In this step, get ready to:
  • Explore Terraform state to see how your 11 resources are tracked.
  • Add a tag and observe how Terraform plans an in-place update versus a replacement.
  • Tear down all resources with terraform destroy to stop AWS billing.
Explore Terraform state

Every time you run terraform apply, Terraform writes a record of what it created into a local file called terraform.tfstate. This file is how Terraform maps your .tf config to real AWS resources.

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

You should see 11 entries, one for each resource you created across your .tf files (plus the data source for the Ubuntu AMI).

  • Inspect the VPC resource in detail by running:
terraform state show aws_vpc.main

You should see every attribute Terraform knows about your VPC: its ID, ARN, CIDR block, DNS settings, and tags.

  • Now inspect the Jenkins instance by running:
terraform state show aws_instance.jenkins

You should see attributes like ami, instance_type, public_ip, subnet_id, and all the tags you set.

What is Terraform state?

Terraform stores all of this in a local file called terraform.tfstate. This file is the single source of truth for what Terraform has created. It maps your config blocks (like aws_instance.jenkins) to real AWS resource IDs.

If you delete the state file, Terraform loses track of all your resources. They still exist in AWS, but Terraform can no longer manage or destroy them.

This is why your .gitignore excludes *.tfstate. State files often contain sensitive data like IP addresses and resource IDs.

Why does this matter in interviews?

"How does Terraform know what infrastructure exists?" is one of the most common Terraform interview questions. The answer: it reads the state file, compares it against your config, and calculates what to create, update, or destroy.

If someone changes a resource directly in the AWS console, Terraform detects the drift on the next terraform plan and shows you the difference.

Add a tag and observe the plan

When you change a resource's configuration, Terraform compares your updated code against the state file to calculate what needs to happen. Some changes can be applied in place (like adding a tag). Others force Terraform to destroy and recreate the resource (like changing the AMI).

  • In instances.tf, find the tags block inside the aws_instance.jenkins resource.
  • Find this code:
  tags = {
    Name = "${var.project_name}-jenkins"
    Role = "jenkins"
  }
  • Replace the entire tags block with this updated version that adds an Environment = "dev" tag:
  tags = {
    Name        = "${var.project_name}-jenkins"
    Role        = "jenkins"
    Environment = "dev"
  }
  • Save instances.tf.

Before you run the next command, what do you think Terraform will do with this change? Will it destroy and recreate the Jenkins instance, or update it in place?

  • See what Terraform plans to do by running:
terraform plan

You should see a ~ symbol next to aws_instance.jenkins, indicating an in-place update. Terraform adds the new tag without replacing the instance.

What do the plan symbols mean?

  • ~ means update in-place. Terraform modifies the existing resource without deleting it. Adding or changing tags is always an in-place update.
  • -/+ means destroy and recreate. Terraform deletes the old resource and creates a new one. Changing an AMI triggers this because the instance must be rebuilt from a different image.
  • + means create. A brand new resource that does not exist yet.
  • - means destroy. A resource being removed entirely.

Plan shows no changes?

  • Make sure you saved instances.tf before running terraform plan. Unsaved changes are invisible to Terraform.
  • Check that you added the Environment = "dev" line inside the tags block of aws_instance.jenkins, not inside a different resource.

Help me debug why terraform plan shows no changes.

Tear down everything

Your 3 instances cost approximately $0.10 per hour while running. Now that you have explored state and planning, it is time to clean up and stop all AWS billing.

Before you run the next command, how many resources do you think Terraform will destroy?

  • Tear down all resources by running:
terraform destroy
  • Terraform shows a plan of everything it will delete. Type yes when prompted, then press Enter.

You should see Destroy complete! Resources: 11 destroyed. All EC2 instances, security groups, the key pair, route table, subnet, internet gateway, and VPC are now gone.

What just happened?

Terraform read the state file, found all 11 resources it was managing, and deleted each one from AWS in the correct order. It deleted the instances first, then the security groups and key pair, then the route table and subnet, and finally the internet gateway and VPC.

The local .tf files and the state file still exist in ~/k8s-cicd-terraform. You can recreate the entire environment at any time by running terraform apply again.

Destroy failed or timed out?

  • If you accidentally typed no at the prompt, Terraform cancelled without deleting anything. Run terraform destroy again and type yes.
  • If the destroy timed out on a specific resource, run terraform destroy again. Terraform picks up where it left off using the state file.

Help me debug a terraform destroy failure.

✔️ Awesome, I've got everything!

Great. Double check you have saved instances.tf with the new Environment = "dev" tag. Your local files are intact and ready to be re-applied whenever you need them.

ⓧ I'd like to double check the full code

The only file that changed in this step is instances.tf. Here is the full file with the new tag on the Jenkins instance:

data "aws_ssm_parameter" "ubuntu_ami" {
  name = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id"
}

resource "aws_instance" "jenkins" {
  ami                         = data.aws_ssm_parameter.ubuntu_ami.value
  instance_type               = "t3.medium"
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.jenkins.id]
  key_name                    = aws_key_pair.deployer.key_name
  associate_public_ip_address = true

  tags = {
    Name        = "${var.project_name}-jenkins"
    Role        = "jenkins"
    Environment = "dev"
  }

  depends_on = [aws_internet_gateway.main]
}
resource "aws_instance" "k8s_control" {
  ami                         = data.aws_ssm_parameter.ubuntu_ami.value
  instance_type               = "t3.medium"
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.k8s.id]
  key_name                    = aws_key_pair.deployer.key_name
  associate_public_ip_address = true

  tags = {
    Name = "${var.project_name}-k8s-control"
    Role = "k8s-control-plane"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_instance" "k8s_worker" {
  ami                         = data.aws_ssm_parameter.ubuntu_ami.value
  instance_type               = "t3.small"
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.k8s.id]
  key_name                    = aws_key_pair.deployer.key_name
  associate_public_ip_address = true

  tags = {
    Name = "${var.project_name}-k8s-worker-1"
    Role = "k8s-worker"
  }

  depends_on = [aws_internet_gateway.main]
}

All other files (main.tf, variables.tf, terraform.tfvars, network.tf, security.tf, outputs.tf, .gitignore) are unchanged from the previous step.

Secret mission

Add a Second Worker Node and IAM Instance Profile

Real Kubernetes clusters run multiple worker nodes, and production instances need IAM instance profiles to call AWS APIs. In this mission, you will add a second worker node to your cluster and create an IAM role with an instance profile attached to all four instances.

Clean Up Your Resources

Clean Up Your Resources

You already ran terraform destroy at the end of the project, so all AWS resources are gone and you are not being billed. Decide whether to keep your local project files to re-provision later, or delete them entirely.

Cost warning

If you re-provision your infrastructure with terraform apply to continue to the next project in this series, the running instances cost approximately $0.10/hr (2x t3.medium at $0.0418/hr each + 1x t3.small at $0.0209/hr).

Always run terraform destroy when you are done working to stop all billing.

Resources you used:

  • AWS resources managed by Terraform (VPC, subnet, internet gateway, route table, security groups, EC2 instances, IAM role, instance profile, SSH key pair).
  • Local project directory at ~/k8s-cicd-terraform with all .tf files and Terraform state.
  • SSH key pair at ~/.ssh/k8s-cicd and ~/.ssh/k8s-cicd.pub.
  • Terraform installed via Homebrew.

Keep everything running

No action needed. Choose this if you plan to start Project 2 soon and want your files ready to re-provision instantly.

  • Your Terraform configuration files in ~/k8s-cicd-terraform are ready to go. Run terraform apply to recreate all resources whenever you need them.
  • Your SSH key pair at ~/.ssh/k8s-cicd is needed for SSH access to any future instances.
  • Terraform remains installed on your machine for future projects.

Pause - I'll come back to this later

Since you already ran terraform destroy, your AWS billing has stopped. Keep your local files so you can pick up where you left off.

  • Keep the ~/k8s-cicd-terraform directory and the SSH key pair. You can recreate all AWS resources later with terraform apply.
  • If you re-provisioned resources since the last destroy and want to pause billing, stop each instance by running this command for each instance ID:
aws ec2 stop-instances --instance-ids [[INSTANCE_ID="your instance ID from terraform output"]]
  • Stopped instances still incur a small EBS storage charge, but compute billing stops.

Delete - I don't want to use this again

Remove all project files, keys, and optionally uninstall Terraform. You can always re-create the project from scratch.

  • If you re-provisioned AWS resources since the last destroy, run terraform destroy inside the project directory first to delete all cloud resources and stop billing.
cd ~/k8s-cicd-terraform
terraform destroy
  • Remove the project directory by running:
rm -rf ~/k8s-cicd-terraform
  • Remove the SSH key pair by running:
rm ~/.ssh/k8s-cicd ~/.ssh/k8s-cicd.pub
  • Optionally, uninstall Terraform if you do not plan to use it for other projects:
brew uninstall hashicorp/tap/terraform
  • Verify that no AWS resources remain by checking the EC2 console in the us-east-1 region. You should see no running instances, no custom VPCs, and no lingering security groups with the k8s-cicd prefix.

Nice Work!

Nice Work!

Nice work! You just provisioned a complete AWS networking and compute foundation entirely through Terraform code you wrote from scratch.

You've learned how to:

  • Written raw Terraform resources from scratch to provision a custom VPC with a public subnet, internet gateway, route table, and security groups on AWS.
  • Launched 3 EC2 instances (Jenkins server, K8s control plane, K8s worker) with IP-scoped security groups and SSH key authentication, using dynamic AMI lookup via AWS SSM Parameter Store.
  • Discovered the route table gap firsthand, explored Terraform state to understand how config maps to real infrastructure, and tore everything down with terraform destroy.
  • Secret Mission: Added a second worker node and an IAM instance profile for AWS API access.

Ready to quiz yourself?