Skip to content

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.


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.

  1. Verify the AWS CLI is installed:

    Terminal window
    aws --version

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

  2. Start the AWS Academy Learner Lab and wait for the status indicator to show as ready.

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

  4. Store the credentials using the method that suits your environment:

    Terminal window
    mkdir -p ~/.aws
    vim ~/.aws/credentials

    Paste the current Learner Lab credentials under the default profile:

    [default]
    aws_access_key_id=ASIA...
    aws_secret_access_key=...
    aws_session_token=...

    Terraform’s AWS provider reads ~/.aws/credentials automatically. You will overwrite these three lines at the start of every Learner Lab session.

  5. Verify the credentials work before Terraform touches anything:

    Terminal window
    aws sts get-caller-identity

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


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.

  1. Create a project directory:

    Terminal window
    mkdir ~/tf-activity && cd ~/tf-activity
  2. 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 = true
    owners = ["099720109477"]
    filter {
    name = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*"]
    }
    filter {
    name = "architecture"
    values = ["x86_64"]
    }
    }

    main.tf is the primary configuration file for this project. The terraform {} 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. The provider "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 two data {} 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 the hvm-ssd-gp3 naming family, so an older hvm-ssd filter will not match current releases.

  3. Write variables.tf:

    variable "onid" {
    description = "Your ONID, used to name and tag resources"
    type = string
    }

    variables.tf declares 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.

  4. Write terraform.tfvars:

    onid = "ulbrical"

    terraform.tfvars is the values file: it assigns concrete values to the variables declared in variables.tf. Replace ulbrical with your actual ONID. Terraform loads this file automatically; any variable declared in variables.tf without a default must appear here or be supplied another way.

  5. Initialize the working directory:

    Terminal window
    terraform init

    You will see Terraform download the AWS provider plugin. A .terraform/ directory and a .terraform.lock.hcl lock file appear in your project directory.

  6. Validate the configuration:

    Terminal window
    terraform validate

    Expected output:

    Success! The configuration is valid.
  7. Read the first plan:

    Terminal window
    terraform plan

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


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.

  1. Add an aws_instance resource below the data sources in main.tf:

    resource "aws_instance" "web" {
    ami = data.aws_ami.ubuntu.id
    instance_type = "t3.micro"
    tags = {
    Name = "tf-activity-web-${var.onid}"
    Owner = var.onid
    }
    }
  2. Read the plan:

    Terminal window
    terraform plan

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

  3. Notice (known after apply). Scan the instance section of the plan output. Fields like id, private_ip, public_ip, and availability_zone all show (known after apply). These are assigned by AWS when the instance actually launches. The ami field already shows the resolved ID because the data source queried it at plan time.


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.

  1. 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.id
    ingress {
    description = "SSH"
    from_port = 22
    to_port = 22
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    }
    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 = "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_rule and aws_vpc_security_group_egress_rule resources in production code. Do not mix those resources with inline rules on the same security group.

  2. 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]
  3. Read the plan:

    Terminal window
    terraform plan

    You should see two resources to add. Notice that aws_security_group.web appears before aws_instance.web in the output. Terraform inferred this order from the reference aws_security_group.web.id inside the instance block: that reference is the dependency declaration.

  4. Find the vpc_security_group_ids line in the instance section of the plan:

    + vpc_security_group_ids = [
    + (known after apply),
    ]

    The security group’s ID is known after apply because 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.


The configuration now describes two resources. Before applying, add an outputs file so Terraform prints the values you will most likely need after provisioning.

  1. 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
    }
  2. Apply the configuration:

    Terminal window
    terraform apply

    Review the plan in the confirmation prompt before typing yes. You should see two resources to add.

  3. Read the outputs:

    Terminal window
    terraform output

    You should see all four values resolved to real AWS IDs and an IP address.

  4. Read one output by name:

    Terminal window
    terraform output -raw public_ip

    The -raw flag prints just the value with no surrounding quotes or labels, which is the form a shell script or downstream tool usually wants.

  5. Open terraform.tfstate in your text editor. It is a JSON file that records every managed resource and all of its current attributes. Find the instance_type field inside the instance entry. Note that state stores concrete values, not expressions: the ami field 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.

  1. Add instance_type to variables.tf:

    variable "onid" {
    description = "Your ONID, used to name and tag resources"
    type = string
    }
    variable "instance_type" {
    description = "EC2 instance size"
    type = string
    default = "t3.micro"
    }
  2. Add a locals block to main.tf, below the AMI data source:

    locals {
    common_tags = {
    Owner = var.onid
    }
    }

    A locals block 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 as local.<name> (singular, no s). The merge() call in the next step combines local.common_tags with a per-resource map, so the Owner tag is written once instead of repeated in every resource block.

  3. Update both resource blocks to use var.instance_type and local.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.id
    ingress {
    description = "SSH"
    from_port = 22
    to_port = 22
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    }
    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 = merge(local.common_tags, {
    Name = "tf-activity-web-sg-${var.onid}"
    })
    }
    resource "aws_instance" "web" {
    ami = data.aws_ami.ubuntu.id
    instance_type = var.instance_type
    vpc_security_group_ids = [aws_security_group.web.id]
    tags = merge(local.common_tags, {
    Name = "tf-activity-web-${var.onid}"
    })
    }
  4. Format and validate:

    Terminal window
    terraform fmt
    terraform validate

    terraform fmt rewrites .tf files 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 of git diff stays readable.

  5. Run the plan:

    Terminal window
    terraform plan

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


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.

  1. Create the module directory:

    Terminal window
    mkdir -p modules/db_sg

    Each directory containing .tf files 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.

  2. 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
    }
  3. 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.id
    ingress {
    description = "MySQL from web tier"
    from_port = 3306
    to_port = 3306
    protocol = "tcp"
    security_groups = [var.web_sg_id]
    }
    egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    }
    tags = {
    Name = "tf-activity-db-sg-${var.onid}"
    Owner = var.onid
    }
    }

    The module has its own data block for the default VPC. Data sources inside a module are scoped to that module: data.aws_vpc.default here is a separate lookup from the identically named block in the root module. Child modules inherit the root module’s provider configuration, so no provider "aws" block is needed here.

  4. Write modules/db_sg/outputs.tf:

    output "security_group_id" {
    description = "Database security group ID"
    value = aws_security_group.db.id
    }
  5. Call the module from root main.tf by adding this block below the locals block:

    module "db_sg" {
    source = "./modules/db_sg"
    onid = var.onid
    web_sg_id = aws_security_group.web.id
    }

    web_sg_id = aws_security_group.web.id is an attribute reference, so Terraform infers that module.db_sg depends on aws_security_group.web. The web security group will be provisioned before the module runs.

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

  7. Re-initialize to register the new module:

    Terminal window
    terraform init

    You should see:

    Initializing modules...
    - db_sg in modules/db_sg

    Terraform must register new modules during init even when the source is a local path.

  8. Read the plan:

    Terminal window
    terraform plan

    Find 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 after aws_security_group.web in the plan, reflecting the dependency Terraform inferred from the web_sg_id reference.

  9. Apply and inspect:

    Terminal window
    terraform apply

    After applying, run:

    Terminal window
    terraform state list
    terraform output db_sg_id

    terraform state list now shows three managed resources. The module resource appears as module.db_sg.aws_security_group.db. The new output prints the security group ID directly from the module’s exposed output.


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.

  1. Find the cs312-vpc ID:

    Terminal window
    aws ec2 describe-vpcs \
    --filters "Name=tag:Name,Values=cs312-vpc" \
    --query "Vpcs[0].VpcId" \
    --output text \
    --region us-east-1

    Record the VPC ID (it looks like vpc-XXXXXXXXXXXXXXXXX).

  2. 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"
    }
    }
  3. 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 in
    your Terraform state and will henceforth be managed by Terraform.
  4. Run the plan:

    Terminal window
    terraform plan

    The plan may show one or more in-place changes to aws_vpc.cs312 if the minimal HCL you wrote does not fully describe the live VPC. Common examples are configurable arguments such as enable_dns_hostnames, enable_dns_support, or instance_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.

  5. Inspect the recorded state to see the full attribute set:

    Terminal window
    terraform state show aws_vpc.cs312

    Compare the output to the two attributes you wrote in step 2. Focus on configurable arguments. Values like id, arn, default_route_table_id, and default_security_group_id are computed outputs, not arguments you should copy into HCL.

  6. 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.cs312 and rerun terraform plan until the plan is clean. For example, if terraform state show reports enable_dns_support = true, add that line to the resource block. ignore_changes is 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.

  7. Remove the imported VPC from this configuration before proceeding to the destroy step:

    Terminal window
    terraform state rm aws_vpc.cs312

    Then delete the aws_vpc.cs312 block from main.tf.

    terraform state rm removes aws_vpc.cs312 from Terraform’s tracking without touching the VPC in AWS. Deleting the resource block removes it from the configuration as well, so the final terraform destroy does 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.

  1. Add an instance type override to terraform.tfvars:

    onid = "ulbrical"
    instance_type = "t3.small"

    Keep your real ONID. Only the second line is new.

  2. Run the plan:

    Terminal window
    terraform plan

    Find aws_instance.web in the output. The ~ symbol means an in-place modification is planned. For aws_instance, changing instance_type triggers a stop/start rather than a replacement. The instance_type line will show:

    ~ instance_type = "t3.micro" -> "t3.small"
  3. Compare the plan with the current state:

    Terminal window
    terraform state show aws_instance.web

    Find the instance_type field. It still shows t3.micro because you only planned the change; you have not applied it. State records what Terraform last applied, not what the configuration currently says.

  4. List all managed resources:

    Terminal window
    terraform state list

    You should see aws_instance.web, aws_security_group.web, and module.db_sg.aws_security_group.db.

  5. Destroy everything:

    Terminal window
    terraform destroy

    Review 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, and module.db_sg.aws_security_group.db.

  6. Confirm the state is empty:

    Terminal window
    terraform state list

    No output means no managed resources remain.

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


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.