Skip to content

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.

You need:

  • An AWS Academy Learner Lab environment
  • An SSH client on your laptop
  • A text editor (VS Code recommended for HCL syntax highlighting)

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.

Watch for the answers to these questions as you follow the tutorial.

  1. How many resources does terraform plan say it will create? List every resource type. (4 points)
  2. 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)
  3. 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/0 for SSH? (5 points)
  4. When you changed the instance type from t3.micro to t3.small, did terraform plan show “destroy and recreate” or “update in-place”? Why does this distinction matter? (4 points)
  5. Find the "arn" of the managed node in terraform.tfstate. Write down the 12-digit AWS account ID embedded in the ARN. (3 points)
  6. After terraform destroy, what does terraform show display? (2 points)
  7. Get your TA’s initials showing a successful SSH session into your Terraform-provisioned control node. (2 points)
  1. 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/tap
    brew install hashicorp/tap/terraform

    Ubuntu/Debian:

    Terminal window
    wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
    echo "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.list
    sudo apt update && sudo apt install terraform

    Windows (with Chocolatey):

    Terminal window
    choco install terraform

    Alternatively, you can install OpenTofu, an open-source fork of Terraform. The commands are nearly identical; just replace terraform with tofu.

  2. Verify the installation

    Terminal window
    terraform --version
  3. 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 ~/.aws
    vim ~/.aws/credentials

    If 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 profile argument to the provider block in main.tf:

    provider "aws" {
    region = "us-east-1"
    profile = "cs312"
    }

    You can also set the AWS_PROFILE=cs312 environment variable instead of hardcoding it in the provider block.

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.

  1. Create a project directory

    Terminal window
    mkdir ~/terraform-lab && cd ~/terraform-lab
  2. Write main.tf

    Terminal window
    vim main.tf

    Add 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 one
    data "aws_vpc" "default" {
    default = true
    }
    # Security Group for the control node: SSH access from your laptop
    resource "aws_security_group" "control" {
    name = "cs312-tf-control-sg"
    description = "Control node: SSH only"
    vpc_id = data.aws_vpc.default.id
    ingress {
    description = "SSH"
    from_port = 22
    to_port = 22
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    }
    egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    }
    tags = {
    Name = "cs312-tf-control-sg"
    }
    }
    # Security Group for the managed node: SSH from control node only, HTTP from anywhere
    resource "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.id
    ingress {
    description = "SSH from control node"
    from_port = 22
    to_port = 22
    protocol = "tcp"
    security_groups = [aws_security_group.control.id]
    }
    ingress {
    description = "HTTP"
    from_port = 80
    to_port = 80
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    }
    egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    }
    tags = {
    Name = "cs312-tf-managed-sg"
    }
    }
    # Control node: you SSH into this instance from your laptop
    resource "aws_instance" "control" {
    ami = var.ami_id
    instance_type = var.instance_type
    key_name = var.key_name
    vpc_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 application
    resource "aws_instance" "managed" {
    ami = var.ami_id
    instance_type = var.instance_type
    key_name = var.key_name
    vpc_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 6
    resource "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 terraform block specifies which providers (plugins) to use. The AWS provider lets Terraform manage AWS resources.
    • The data block reads an existing resource (the default VPC) without creating it. References like data.aws_vpc.default.id let other resources use the value Terraform read.
    • The two aws_security_group resources have different rules. The control node’s SG allows SSH from anywhere (your laptop). The managed node’s SG uses security_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 that managed depends on control and must be created after it.
    • The two aws_instance resources 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_repository resource 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.
  3. Write variables.tf

    Variables make your configuration reusable. Instead of hardcoding values, you parameterize them:

    Terminal window
    vim variables.tf
    variable "ami_id" {
    description = "AMI ID for the EC2 instance (Ubuntu 26.04 in us-east-1)"
    type = string
    default = "ami-0d13e2317a7e75c95"
    }
    variable "instance_type" {
    description = "EC2 instance type"
    type = string
    default = "t3.micro"
    }
    variable "key_name" {
    description = "Name of the SSH key pair (must already exist in AWS)"
    type = string
    }
  4. Write outputs.tf

    Outputs display useful information after terraform apply:

    Terminal window
    vim outputs.tf
    output "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”
  1. Initialize the project

    Terminal window
    terraform init

    This 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 run init once per project (or when you change providers).

  2. Preview the changes

    Terminal window
    terraform plan -var="key_name=cs312-key"

    Replace cs312-key with 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.

  3. 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.

  4. Verify by SSHing in

    Terminal window
    ssh -i ~/Downloads/cs312-key.pem ubuntu@<public-ip-from-output>

    Run hostname and whoami to confirm you are on the new instance.

  1. Change the instance type

    Edit variables.tf and change the default instance type from t3.micro to t3.small:

    default = "t3.small"
  2. 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.

  3. 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 from main.tf and replace it with these five resources:

    resource "aws_vpc" "cs312" {
    cidr_block = "10.0.0.0/16"
    enable_dns_hostnames = true
    tags = {
    Name = "cs312-vpc"
    }
    }
    resource "aws_subnet" "cs312_public" {
    vpc_id = aws_vpc.cs312.id
    cidr_block = "10.0.1.0/24"
    availability_zone = "us-east-1a"
    map_public_ip_on_launch = true
    tags = {
    Name = "cs312-public-subnet"
    }
    }
    resource "aws_internet_gateway" "cs312_igw" {
    vpc_id = aws_vpc.cs312.id
    tags = {
    Name = "cs312-igw"
    }
    }
    resource "aws_route_table" "cs312_public_rt" {
    vpc_id = aws_vpc.cs312.id
    route {
    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.id
    route_table_id = aws_route_table.cs312_public_rt.id
    }

    What each resource does:

    • aws_vpc creates a logically isolated network with a /16 CIDR block (65,536 addresses).
    • aws_subnet carves out a /24 public subnet in us-east-1a. map_public_ip_on_launch means instances launched here automatically receive a public IP.
    • aws_internet_gateway attaches an IGW to the VPC so instances can communicate with the internet.
    • aws_route_table defines a routing policy that sends all outbound traffic (0.0.0.0/0) through the IGW.
    • aws_route_table_association applies that routing policy to the public subnet.

    Next, update the vpc_id in 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.id

    Then add subnet_id to 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.id

    Finally, 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.

  1. Inspect the state file

    After terraform apply, Terraform writes a terraform.tfstate file 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 -80

    Find 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.

  1. 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.

  2. Verify nothing remains

    Terminal window
    terraform show

    This 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.