Your First Infrastructure Stack (Terraform)
Pinnacle Provisions is opening a second location. Gerald asked if you can “copy the IT” to the new restaurant. You pulled up your notes from setting up the first location and realized they say things like “click the orange button” and “the password is on a sticky note on the monitor.” Nothing is documented. Nothing is reproducible. If the building burned down, you would be starting from memory.
Infrastructure as Code (IaC) solves this by letting you define your infrastructure in configuration files that can be stored in Git, reviewed in pull requests, and applied repeatably. Terraform, created by HashiCorp, is the most widely used IaC tool in the industry. In this lab, you will write Terraform configurations that provision the same resources you have been creating by hand, and then destroy them with a single command.
Before You Start
Section titled “Before You Start”You need:
- An AWS Academy Learner Lab environment
- An SSH client on your laptop
- A text editor (VS Code recommended for HCL syntax highlighting)
Declarative vs. Imperative
Section titled “Declarative vs. Imperative”Terraform uses a declarative approach: you describe the desired end state (“I want an EC2 instance with these properties”), and Terraform figures out what actions to take to reach that state. This contrasts with imperative tools like shell scripts, where you list the exact steps (“create this, then create that”). Declarative tools are easier to reason about because you state what you want, not how to get there.
Questions
Section titled “Questions”Watch for the answers to these questions as you follow the tutorial.
- How many resources does
terraform plansay it will create? List every resource type. (4 points) - After
terraform apply, write down the public IPs of both instances from the Terraform outputs. Verify you can SSH into the control node. (5 points) - What is the difference between the two security groups? Why does the managed node’s security group reference the control node’s security group instead of
0.0.0.0/0for SSH? (5 points) - When you changed the instance type from t3.micro to t3.small, did
terraform planshow “destroy and recreate” or “update in-place”? Why does this distinction matter? (4 points) - Find the
"arn"of the managed node interraform.tfstate. Write down the 12-digit AWS account ID embedded in the ARN. (3 points) - After
terraform destroy, what doesterraform showdisplay? (2 points) - Get your TA’s initials showing a successful SSH session into your Terraform-provisioned control node. (2 points)
Tutorial
Section titled “Tutorial”Installing Terraform
Section titled “Installing Terraform”-
Install Terraform on your laptop
Terraform runs on your local machine (or a bastion host) and talks to the AWS Application Programming Interface (API) remotely. Install it based on your operating system:
macOS (with Homebrew):
Terminal window brew tap hashicorp/tapbrew install hashicorp/tap/terraformUbuntu/Debian:
Terminal window wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpgecho "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.listsudo apt update && sudo apt install terraformWindows (with Chocolatey):
Terminal window choco install terraformAlternatively, you can install OpenTofu, an open-source fork of Terraform. The commands are nearly identical; just replace
terraformwithtofu. -
Verify the installation
Terminal window terraform --version -
Configure AWS credentials
Terraform needs AWS credentials to create resources. In AWS Academy, download your credentials from the Learner Lab page (click AWS Details, then Show next to AWS CLI). Copy the credentials block and paste it into
~/.aws/credentials:Terminal window mkdir -p ~/.awsvim ~/.aws/credentialsIf you only use AWS for this course, paste the credentials under the
[default]profile:[default]aws_access_key_id=ASIA...aws_secret_access_key=...aws_session_token=...If you already use the AWS CLI for another account (personal projects, a job, etc.), use a named profile instead to avoid overwriting your existing credentials:
[cs312]aws_access_key_id=ASIA...aws_secret_access_key=...aws_session_token=...With a named profile, you must tell Terraform which profile to use. Add a
profileargument to the provider block inmain.tf:provider "aws" {region = "us-east-1"profile = "cs312"}You can also set the
AWS_PROFILE=cs312environment variable instead of hardcoding it in the provider block.
Writing the Terraform Configuration
Section titled “Writing the Terraform Configuration”Terraform configurations are written in HashiCorp Configuration Language (HCL). You will create three files: main.tf (resources), variables.tf (inputs), and outputs.tf (values to display after apply).
The stack you are about to define provisions two EC2 instances and two security groups: a control node and a managed node. The next lab builds directly on this two-node infrastructure, so you are provisioning both now. This is also a more realistic Terraform configuration than a single instance: two resources with different security rules that reference each other.
-
Create a project directory
Terminal window mkdir ~/terraform-lab && cd ~/terraform-lab -
Write
main.tfTerminal window vim main.tfAdd the following:
terraform {required_providers {aws = {source = "hashicorp/aws"version = "~> 5.0"}}}provider "aws" {region = "us-east-1"# If you configured a named profile above, add: profile = "cs312"}# Use the default VPC instead of creating a new onedata "aws_vpc" "default" {default = true}# Security Group for the control node: SSH access from your laptopresource "aws_security_group" "control" {name = "cs312-tf-control-sg"description = "Control node: SSH only"vpc_id = data.aws_vpc.default.idingress {description = "SSH"from_port = 22to_port = 22protocol = "tcp"cidr_blocks = ["0.0.0.0/0"]}egress {from_port = 0to_port = 0protocol = "-1"cidr_blocks = ["0.0.0.0/0"]}tags = {Name = "cs312-tf-control-sg"}}# Security Group for the managed node: SSH from control node only, HTTP from anywhereresource "aws_security_group" "managed" {name = "cs312-tf-managed-sg"description = "Managed node: SSH from control node, HTTP from anywhere"vpc_id = data.aws_vpc.default.idingress {description = "SSH from control node"from_port = 22to_port = 22protocol = "tcp"security_groups = [aws_security_group.control.id]}ingress {description = "HTTP"from_port = 80to_port = 80protocol = "tcp"cidr_blocks = ["0.0.0.0/0"]}egress {from_port = 0to_port = 0protocol = "-1"cidr_blocks = ["0.0.0.0/0"]}tags = {Name = "cs312-tf-managed-sg"}}# Control node: you SSH into this instance from your laptopresource "aws_instance" "control" {ami = var.ami_idinstance_type = var.instance_typekey_name = var.key_namevpc_security_group_ids = [aws_security_group.control.id]iam_instance_profile = "LabInstanceProfile"tags = {Name = "cs312-tf-control"}}# Managed node: the server that will run the applicationresource "aws_instance" "managed" {ami = var.ami_idinstance_type = var.instance_typekey_name = var.key_namevpc_security_group_ids = [aws_security_group.managed.id]iam_instance_profile = "LabInstanceProfile"tags = {Name = "cs312-tf-managed"}}# ECR repository for the CI/CD pipeline in Lab 6resource "aws_ecr_repository" "wordpress" {name = "cs312-wordpress-lab"image_tag_mutability = "MUTABLE"image_scanning_configuration {scan_on_push = false}}Let’s walk through this:
- The
terraformblock specifies which providers (plugins) to use. The AWS provider lets Terraform manage AWS resources. - The
datablock reads an existing resource (the default VPC) without creating it. References likedata.aws_vpc.default.idlet other resources use the value Terraform read. - The two
aws_security_groupresources have different rules. The control node’s SG allows SSH from anywhere (your laptop). The managed node’s SG usessecurity_groups = [aws_security_group.control.id]to allow SSH only from instances in the control SG, and HTTP from anywhere for WordPress. This reference also tells Terraform thatmanageddepends oncontroland must be created after it. - The two
aws_instanceresources use different security groups, placing each instance in a distinct network boundary. iam_instance_profile = "LabInstanceProfile"attaches a pre-existing IAM role to each instance, granting it permissions (including ECR access) without hardcoding credentials. AWS Academy Learner Labs provide this profile automatically; creating IAM roles is not permitted in the sandbox environment.- The
aws_ecr_repositoryresource creates the private container registry that the CI/CD pipeline in Lab 6 will push images to. Creating it here ensures it exists when the pipeline runs.
- The
-
Write
variables.tfVariables make your configuration reusable. Instead of hardcoding values, you parameterize them:
Terminal window vim variables.tfvariable "ami_id" {description = "AMI ID for the EC2 instance (Ubuntu 26.04 in us-east-1)"type = stringdefault = "ami-0d13e2317a7e75c95"}variable "instance_type" {description = "EC2 instance type"type = stringdefault = "t3.micro"}variable "key_name" {description = "Name of the SSH key pair (must already exist in AWS)"type = string} -
Write
outputs.tfOutputs display useful information after
terraform apply:Terminal window vim outputs.tfoutput "control_node_public_ip" {description = "Public IP of the control node: SSH here from your laptop"value = aws_instance.control.public_ip}output "managed_node_public_ip" {description = "Public IP of the managed node: WordPress will be accessible here"value = aws_instance.managed.public_ip}output "managed_node_private_ip" {description = "Private IP of the managed node: use this in the Ansible inventory"value = aws_instance.managed.private_ip}output "ecr_repository_url" {description = "ECR repository URL: use this in the GitHub Actions workflow"value = aws_ecr_repository.wordpress.repository_url}
The Terraform Lifecycle: Init, Plan, Apply
Section titled “The Terraform Lifecycle: Init, Plan, Apply”-
Initialize the project
Terminal window terraform initThis downloads the AWS provider plugin and sets up the working directory. You will see a
.terraform/directory appear; this is where Terraform stores its plugins. You only need to runinitonce per project (or when you change providers). -
Preview the changes
Terminal window terraform plan -var="key_name=cs312-key"Replace
cs312-keywith the name of your existing SSH key pair. Terraform will show you exactly what it plans to create, change, or destroy, without actually doing it. This is the “measure twice” step before “cutting once.”Review the output carefully. You should see several resources listed with a
+symbol, meaning they will be created. Record the first 20 lines for your lab questions. -
Apply the configuration
Terminal window terraform apply -var="key_name=cs312-key"Terraform will show the plan again and ask for confirmation. Type
yes. It will then make API calls to AWS to create each resource. This takes 30-60 seconds.After completion, Terraform prints the output values. Record the public IP and instance ID.
-
Verify by SSHing in
Terminal window ssh -i ~/Downloads/cs312-key.pem ubuntu@<public-ip-from-output>Run
hostnameandwhoamito confirm you are on the new instance.
Modifying Infrastructure
Section titled “Modifying Infrastructure”-
Change the instance type
Edit
variables.tfand change the default instance type fromt3.microtot3.small:default = "t3.small" -
Preview the change
Terminal window terraform plan -var="key_name=cs312-key"Terraform will show whether this change requires an “update in-place” (the instance is modified without being destroyed) or “destroy and recreate” (the old instance is terminated and a new one launched). Record which it shows; the answer reveals how AWS handles instance type changes.
-
Provision a dedicated VPC and network stack
The lab currently uses AWS’s default VPC (every account gets one automatically), which is convenient for experimentation. In production you always provision your own VPC so you control the address space, subnet layout, and routing policy.
Remove the
data "aws_vpc" "default"block frommain.tfand replace it with these five resources:resource "aws_vpc" "cs312" {cidr_block = "10.0.0.0/16"enable_dns_hostnames = truetags = {Name = "cs312-vpc"}}resource "aws_subnet" "cs312_public" {vpc_id = aws_vpc.cs312.idcidr_block = "10.0.1.0/24"availability_zone = "us-east-1a"map_public_ip_on_launch = truetags = {Name = "cs312-public-subnet"}}resource "aws_internet_gateway" "cs312_igw" {vpc_id = aws_vpc.cs312.idtags = {Name = "cs312-igw"}}resource "aws_route_table" "cs312_public_rt" {vpc_id = aws_vpc.cs312.idroute {cidr_block = "0.0.0.0/0"gateway_id = aws_internet_gateway.cs312_igw.id}tags = {Name = "cs312-public-rt"}}resource "aws_route_table_association" "cs312_public_rta" {subnet_id = aws_subnet.cs312_public.idroute_table_id = aws_route_table.cs312_public_rt.id}What each resource does:
aws_vpccreates a logically isolated network with a/16CIDR block (65,536 addresses).aws_subnetcarves out a/24public subnet inus-east-1a.map_public_ip_on_launchmeans instances launched here automatically receive a public IP.aws_internet_gatewayattaches an IGW to the VPC so instances can communicate with the internet.aws_route_tabledefines a routing policy that sends all outbound traffic (0.0.0.0/0) through the IGW.aws_route_table_associationapplies that routing policy to the public subnet.
Next, update the
vpc_idin both security groups to reference the new VPC:# In aws_security_group.control and aws_security_group.managed, replace:vpc_id = data.aws_vpc.default.id# with:vpc_id = aws_vpc.cs312.idThen add
subnet_idto both instance resources so Terraform places them in the new subnet:# Add this argument to aws_instance.control and aws_instance.managed:subnet_id = aws_subnet.cs312_public.idFinally, add a VPC output to
outputs.tf:output "vpc_id" {description = "ID of the provisioned VPC"value = aws_vpc.cs312.id}Run the plan:
Terminal window terraform plan -var="key_name=cs312-key"You will see a large diff: the instances and security groups attached to the default VPC must be destroyed and recreated in the new one. This is expected: network interfaces are attached at instance launch time and cannot be moved between VPCs in place.
Understanding State
Section titled “Understanding State”-
Inspect the state file
After
terraform apply, Terraform writes aterraform.tfstatefile containing the current state of all managed resources. This is how Terraform knows what exists in AWS:Terminal window cat terraform.tfstate | python3 -m json.tool | head -80Find the
"arn"field for the managed node (cs312-tf-managed). An ARN (Amazon Resource Name) is a globally unique identifier for any AWS resource. It contains your account ID, the region, and the resource ID.
Destroying Infrastructure
Section titled “Destroying Infrastructure”-
Tear everything down
Terminal window terraform destroy -var="key_name=cs312-key"Terraform will show you what it plans to destroy and ask for confirmation. Type
yes. All resources will be deleted from AWS. -
Verify nothing remains
Terminal window terraform showThis should display an empty state. You can also check the EC2 console to confirm the instance is terminated.
You have now defined infrastructure as code, provisioned two EC2 instances with distinct security group boundaries, previewed changes before applying them, and destroyed everything cleanly. These two instances (a control node and a managed node) are the foundation for the next lab, where Ansible will configure the managed node automatically over SSH.
Before starting the Ansible lab, re-run terraform apply -var="key_name=cs312-key" to bring the instances back up. The state file on your laptop remembers exactly what was provisioned, so Terraform recreates identical infrastructure in under a minute.