Terraform
This activity puts into practice the concepts from the Infrastructure as Code lecture. Starting from a single provider block, you will incrementally build a configuration: two data sources, a security group, an EC2 instance, outputs, and variables. You will then import a VPC that was created by hand in the previous activity, compare the imported state to the HCL that manages it, and remove that VPC from this configuration before the final destroy. By the end, you will have run every phase of the Terraform workflow: plan, apply, state inspection, import, and destroy.
What You Will Need
Section titled “What You Will Need”- Terraform 1.6 or newer installed on your laptop
- AWS CLI installed on your laptop
- Access to your AWS Academy Learner Lab
- The AWS-managed default VPC and its default subnets still present in
us-east-1for the EC2 portion of the activity - A text editor
- The
cs312-vpcfrom the AWS Infrastructure activity is used in the import section (optional)
Verify the AWS CLI and Configure Credentials
Section titled “Verify the AWS CLI and Configure Credentials”Terraform’s AWS provider reads credentials from the same sources as the AWS CLI: the ~/.aws/credentials file and the AWS_* environment variables. The CLI also lets you verify credentials and run targeted queries, like looking up a VPC ID, without writing Terraform code.
-
Verify the AWS CLI is installed:
Terminal window aws --versionYou should see a version string like
aws-cli/2.x.x. If the command is not found, install the CLI using the link in the prerequisites above, then reopen your terminal. -
Start the AWS Academy Learner Lab and wait for the status indicator to show as ready.
-
Open the CLI credentials block. Click AWS Details, then Show next to the AWS CLI section. You will see three values: an access key ID, a secret access key, and a session token.
-
Store the credentials using the method that suits your environment:
Terminal window mkdir -p ~/.awsvim ~/.aws/credentialsPaste the current Learner Lab credentials under the
defaultprofile:[default]aws_access_key_id=ASIA...aws_secret_access_key=...aws_session_token=...Terraform’s AWS provider reads
~/.aws/credentialsautomatically. You will overwrite these three lines at the start of every Learner Lab session.Copy each value from the AWS Details panel and export it in your terminal:
Terminal window export AWS_ACCESS_KEY_ID="ASIA..."export AWS_SECRET_ACCESS_KEY="..."export AWS_SESSION_TOKEN="..."Terraform (and the AWS CLI) check for these three environment variables before falling back to the credentials file. The exports are valid for the current shell session only; if you open a new terminal, you will need to run them again.
-
Verify the credentials work before Terraform touches anything:
Terminal window aws sts get-caller-identityYou should see a JSON response containing your account ID and an ARN. If this command fails, Terraform will fail for the same reason, so fix credential issues here before moving on.
Initialize the Project
Section titled “Initialize the Project”Before adding any resources, set up the working directory, introduce two data sources, and define your ONID as a variable so every resource name and tag is consistent from the start.
-
Create a project directory:
Terminal window mkdir ~/tf-activity && cd ~/tf-activity -
Write
main.tf:terraform {required_version = ">= 1.6"required_providers {aws = {source = "hashicorp/aws"version = ">= 6.41.0"}}}provider "aws" {region = "us-east-1"# To use a named profile instead of default, add: profile = "profile-name"}data "aws_vpc" "default" {default = true}data "aws_ami" "ubuntu" {most_recent = trueowners = ["099720109477"]filter {name = "name"values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*"]}filter {name = "architecture"values = ["x86_64"]}}main.tfis the primary configuration file for this project. Theterraform {}block sets the required Terraform version and constrains provider dependencies. This AWS constraint allows any compatible 6.x provider release at or above 6.41.0. Theprovider "aws" {}block configures how the AWS provider connects to your account: which region to target and how to authenticate. See the AWS provider documentation for the full set of arguments. The twodata {}blocks are data sources: unlike resource blocks, data sources read existing infrastructure rather than create it. They are read-only queries whose results you can reference anywhere in the configuration.owners = ["099720109477"]limits results to images published by Canonical. Ubuntu 26.04 images uses thehvm-ssd-gp3naming family, so an olderhvm-ssdfilter will not match current releases. -
Write
variables.tf:variable "onid" {description = "Your ONID, used to name and tag resources"type = string}variables.tfdeclares the input variables this configuration accepts: their names, types, descriptions, and optional default values. This file does not assign values; it only describes what inputs exist. -
Write
terraform.tfvars:onid = "ulbrical"terraform.tfvarsis the values file: it assigns concrete values to the variables declared invariables.tf. Replaceulbricalwith your actual ONID. Terraform loads this file automatically; any variable declared invariables.tfwithout a default must appear here or be supplied another way. -
Initialize the working directory:
Terminal window terraform initYou will see Terraform download the AWS provider plugin. A
.terraform/directory and a.terraform.lock.hcllock file appear in your project directory. -
Validate the configuration:
Terminal window terraform validateExpected output:
Success! The configuration is valid. -
Read the first plan:
Terminal window terraform planThe output shows zero resources to add, change, or destroy. Terraform may also show the two data sources being read during planning because all of their arguments are already known. They do not appear in the resource counts because data sources are read-only.
Add an EC2 Instance
Section titled “Add an EC2 Instance”With the data sources and variables in place, add the first managed resource. The ami argument references the data source output, and the tags reference var.onid so there is nothing to manually substitute.
-
Add an
aws_instanceresource below the data sources inmain.tf:resource "aws_instance" "web" {ami = data.aws_ami.ubuntu.idinstance_type = "t3.micro"tags = {Name = "tf-activity-web-${var.onid}"Owner = var.onid}} -
Read the plan:
Terminal window terraform planThe plan now shows one resource to add:
aws_instance.web. The data sources appear as reads in the header but are not counted in the additions. -
Notice
(known after apply). Scan the instance section of the plan output. Fields likeid,private_ip,public_ip, andavailability_zoneall show(known after apply). These are assigned by AWS when the instance actually launches. Theamifield already shows the resolved ID because the data source queried it at plan time.
Add a Security Group
Section titled “Add a Security Group”An instance with no explicit security group uses the VPC default, which gives you no visibility or control. Adding a dedicated security group and referencing it from the instance also produces a visible dependency in the plan, illustrating how Terraform builds its execution order from attribute references.
-
Add a security group below the instance resource in
main.tf:resource "aws_security_group" "web" {name = "tf-activity-web-sg-${var.onid}"description = "Activity web security group"vpc_id = data.aws_vpc.default.idingress {description = "SSH"from_port = 22to_port = 22protocol = "tcp"cidr_blocks = ["0.0.0.0/0"]}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 = "tf-activity-web-sg-${var.onid}"Owner = var.onid}}This activity keeps security group rules inline so each example stays in one block. Current AWS provider guidance prefers separate
aws_vpc_security_group_ingress_ruleandaws_vpc_security_group_egress_ruleresources in production code. Do not mix those resources with inline rules on the same security group. -
Wire the security group to the instance by adding this line inside
resource "aws_instance" "web":vpc_security_group_ids = [aws_security_group.web.id] -
Read the plan:
Terminal window terraform planYou should see two resources to add. Notice that
aws_security_group.webappears beforeaws_instance.webin the output. Terraform inferred this order from the referenceaws_security_group.web.idinside the instance block: that reference is the dependency declaration. -
Find the
vpc_security_group_idsline in the instance section of the plan:+ vpc_security_group_ids = [+ (known after apply),]The security group’s ID is
known after applybecause the group does not exist yet. Terraform records the dependency and will create the security group first, then substitute the real ID when it creates the instance.
Apply and Inspect Outputs
Section titled “Apply and Inspect Outputs”The configuration now describes two resources. Before applying, add an outputs file so Terraform prints the values you will most likely need after provisioning.
-
Create
outputs.tf:output "instance_id" {description = "EC2 instance ID"value = aws_instance.web.id}output "public_ip" {description = "Public IP address"value = aws_instance.web.public_ip}output "security_group_id" {description = "Security group ID"value = aws_security_group.web.id}output "ami_id" {description = "AMI ID in use"value = aws_instance.web.ami} -
Apply the configuration:
Terminal window terraform applyReview the plan in the confirmation prompt before typing
yes. You should see two resources to add. -
Read the outputs:
Terminal window terraform outputYou should see all four values resolved to real AWS IDs and an IP address.
-
Read one output by name:
Terminal window terraform output -raw public_ipThe
-rawflag prints just the value with no surrounding quotes or labels, which is the form a shell script or downstream tool usually wants. -
Open
terraform.tfstatein your text editor. It is a JSON file that records every managed resource and all of its current attributes. Find theinstance_typefield inside the instance entry. Note that state stores concrete values, not expressions: theamifield records the resolved AMI ID string, not a reference to the data source that produced it.
Introduce Locals and the Instance Type Variable
Section titled “Introduce Locals and the Instance Type Variable”The onid variable is already in use, but two things are still hardcoded: the instance size appears as a literal string, and the Owner tag is repeated in every resource block. This section extracts both into a variable and a locals block. The plan after the refactor demonstrates idempotency: configuration changes do not imply infrastructure changes.
-
Add
instance_typetovariables.tf:variable "onid" {description = "Your ONID, used to name and tag resources"type = string}variable "instance_type" {description = "EC2 instance size"type = stringdefault = "t3.micro"} -
Add a
localsblock tomain.tf, below the AMI data source:locals {common_tags = {Owner = var.onid}}A
localsblock defines computed values that are derived from variables or expressions. Unlike input variables, locals cannot be set from the outside; they are internal to the configuration. You reference a local value aslocal.<name>(singular, nos). Themerge()call in the next step combineslocal.common_tagswith a per-resource map, so theOwnertag is written once instead of repeated in every resource block. -
Update both resource blocks to use
var.instance_typeandlocal.common_tags:resource "aws_security_group" "web" {name = "tf-activity-web-sg-${var.onid}"description = "Activity web security group"vpc_id = data.aws_vpc.default.idingress {description = "SSH"from_port = 22to_port = 22protocol = "tcp"cidr_blocks = ["0.0.0.0/0"]}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 = merge(local.common_tags, {Name = "tf-activity-web-sg-${var.onid}"})}resource "aws_instance" "web" {ami = data.aws_ami.ubuntu.idinstance_type = var.instance_typevpc_security_group_ids = [aws_security_group.web.id]tags = merge(local.common_tags, {Name = "tf-activity-web-${var.onid}"})} -
Format and validate:
Terminal window terraform fmtterraform validateterraform fmtrewrites.tffiles in place using the canonical HCL style: consistent indentation, aligned=signs within blocks, and normalized spacing. It is safe to run at any time; it never changes behavior, only whitespace. Run it before every commit so the output ofgit diffstays readable. -
Run the plan:
Terminal window terraform planThe plan should report no infrastructure changes. The resource names and tag values are identical to what was applied; the configuration is now just expressing them differently.
Create a Reusable Module
Section titled “Create a Reusable Module”The configuration now manages two resources at root level. As configurations grow, repeating similar resource blocks across projects becomes error-prone. Terraform modules let you package a set of resources into a reusable unit with a declared input and output interface.
In this section you will write a small child module for a database-tier security group, call it from the root configuration, and observe how module addresses appear throughout Terraform’s output.
-
Create the module directory:
Terminal window mkdir -p modules/db_sgEach directory containing
.tffiles is a module. The project root you have been editing is the root module. A directory you create and call from the root is a child module. A child module has no awareness of the root module; it only sees the variables passed to it. -
Write
modules/db_sg/variables.tf:variable "onid" {description = "ONID, used for naming and tagging"type = string}variable "web_sg_id" {description = "ID of the web-tier security group, allowed to reach the database"type = string} -
Write
modules/db_sg/main.tf:data "aws_vpc" "default" {default = true}resource "aws_security_group" "db" {name = "tf-activity-db-sg-${var.onid}"description = "Database tier security group"vpc_id = data.aws_vpc.default.idingress {description = "MySQL from web tier"from_port = 3306to_port = 3306protocol = "tcp"security_groups = [var.web_sg_id]}egress {from_port = 0to_port = 0protocol = "-1"cidr_blocks = ["0.0.0.0/0"]}tags = {Name = "tf-activity-db-sg-${var.onid}"Owner = var.onid}}The module has its own
datablock for the default VPC. Data sources inside a module are scoped to that module:data.aws_vpc.defaulthere is a separate lookup from the identically named block in the root module. Child modules inherit the root module’s provider configuration, so noprovider "aws"block is needed here. -
Write
modules/db_sg/outputs.tf:output "security_group_id" {description = "Database security group ID"value = aws_security_group.db.id} -
Call the module from root
main.tfby adding this block below thelocalsblock:module "db_sg" {source = "./modules/db_sg"onid = var.onidweb_sg_id = aws_security_group.web.id}web_sg_id = aws_security_group.web.idis an attribute reference, so Terraform infers thatmodule.db_sgdepends onaws_security_group.web. The web security group will be provisioned before the module runs. -
Add an output for the module’s result by appending to
outputs.tf:output "db_sg_id" {description = "Database security group ID"value = module.db_sg.security_group_id}Module outputs are accessed as
module.<name>.<output_name>, the same dot notation as resource attributes. -
Re-initialize to register the new module:
Terminal window terraform initYou should see:
Initializing modules...- db_sg in modules/db_sgTerraform must register new modules during
initeven when the source is a local path. -
Read the plan:
Terminal window terraform planFind the new resource in the output. Its address is
module.db_sg.aws_security_group.db. Every resource declared inside a child module is prefixed with the module call path. Notice it appears afteraws_security_group.webin the plan, reflecting the dependency Terraform inferred from theweb_sg_idreference. -
Apply and inspect:
Terminal window terraform applyAfter applying, run:
Terminal window terraform state listterraform output db_sg_idterraform state listnow shows three managed resources. The module resource appears asmodule.db_sg.aws_security_group.db. The new output prints the security group ID directly from the module’s exposed output.
Import an Existing Resource
Section titled “Import an Existing Resource”The lecture describes a common real-world situation: inheriting infrastructure created by hand that must be brought under Terraform management. In this section you will import the cs312-vpc from the previous activity, inspect what Terraform recorded, and reconcile any meaningful differences between the real VPC and your HCL before removing that VPC from this configuration.
-
Find the
cs312-vpcID:Terminal window aws ec2 describe-vpcs \--filters "Name=tag:Name,Values=cs312-vpc" \--query "Vpcs[0].VpcId" \--output text \--region us-east-1Record the VPC ID (it looks like
vpc-XXXXXXXXXXXXXXXXX). -
Add a resource block for the VPC at the bottom of
main.tf:resource "aws_vpc" "cs312" {cidr_block = "10.0.0.0/16"tags = {Name = "cs312-vpc"}} -
Import the existing VPC into Terraform state:
Terminal window terraform import aws_vpc.cs312 <VPC_ID>Replace
<VPC_ID>with the value from step 1. You should see:Import successful!The resources that were imported are shown above. These resources are now inyour Terraform state and will henceforth be managed by Terraform. -
Run the plan:
Terminal window terraform planThe plan may show one or more in-place changes to
aws_vpc.cs312if the minimal HCL you wrote does not fully describe the live VPC. Common examples are configurable arguments such asenable_dns_hostnames,enable_dns_support, orinstance_tenancy, but the exact diff depends on how your VPC was created. This is the core challenge of import: Terraform wrote a state record, but you still have to write HCL that matches the real object you intend to manage. -
Inspect the recorded state to see the full attribute set:
Terminal window terraform state show aws_vpc.cs312Compare the output to the two attributes you wrote in step 2. Focus on configurable arguments. Values like
id,arn,default_route_table_id, anddefault_security_group_idare computed outputs, not arguments you should copy into HCL. -
Reconcile the HCL instead of hiding the drift. If step 4 showed a change to a real argument, add that current value explicitly to
aws_vpc.cs312and rerunterraform planuntil the plan is clean. For example, ifterraform state showreportsenable_dns_support = true, add that line to the resource block.ignore_changesis useful when some other process owns an attribute, but it is not the first tool to reach for when you are still trying to describe an imported resource accurately. -
Remove the imported VPC from this configuration before proceeding to the destroy step:
Terminal window terraform state rm aws_vpc.cs312Then delete the
aws_vpc.cs312block frommain.tf.terraform state rmremovesaws_vpc.cs312from Terraform’s tracking without touching the VPC in AWS. Deleting the resource block removes it from the configuration as well, so the finalterraform destroydoes not try to recreate or delete a VPC that still contains the subnets and other resources from the previous activity.
Plan a Real Change, Inspect State, and Clean Up
Section titled “Plan a Real Change, Inspect State, and Clean Up”Terraform becomes most useful when the configuration and the live infrastructure diverge. In this final section you will trigger a real change, read the plan’s diff symbols, compare the plan against state, and then destroy everything.
-
Add an instance type override to
terraform.tfvars:onid = "ulbrical"instance_type = "t3.small"Keep your real ONID. Only the second line is new.
-
Run the plan:
Terminal window terraform planFind
aws_instance.webin the output. The~symbol means an in-place modification is planned. Foraws_instance, changinginstance_typetriggers a stop/start rather than a replacement. Theinstance_typeline will show:~ instance_type = "t3.micro" -> "t3.small" -
Compare the plan with the current state:
Terminal window terraform state show aws_instance.webFind the
instance_typefield. It still showst3.microbecause you only planned the change; you have not applied it. State records what Terraform last applied, not what the configuration currently says. -
List all managed resources:
Terminal window terraform state listYou should see
aws_instance.web,aws_security_group.web, andmodule.db_sg.aws_security_group.db. -
Destroy everything:
Terminal window terraform destroyReview the plan in the confirmation prompt before typing
yes. All three managed resources should appear in the destroy list:aws_instance.web,aws_security_group.web, andmodule.db_sg.aws_security_group.db. -
Confirm the state is empty:
Terminal window terraform state listNo output means no managed resources remain.
-
Verify in the AWS Console. Navigate to EC2 > Instances and confirm the instance tagged with your ONID is no longer listed as running. Navigate to EC2 > Security Groups and confirm both security groups created by this activity, the web group and the database group, are gone.
Going Further
Section titled “Going Further”You have run the complete Terraform loop: plan, apply, inspect, import, and destroy. The next step that matters in practice is removing state from your laptop.
Configure a remote S3 backend so state survives beyond a single machine and supports team access. Enable bucket versioning and set use_lockfile = true so the backend can recover from mistakes and prevent concurrent writes. Follow the S3 backend documentation to set up the bucket, add the backend block, and then run terraform init -migrate-state to move your local state into S3. Notice that your plan output and resource list look identical before and after the migration; the backend configuration is invisible to the infrastructure itself. In practice, teams usually manage the backend bucket in a separate bootstrap configuration. If you create that bucket by hand as a learning exercise, import it into that bootstrap configuration before relying on it for other Terraform states.