Compare EC2 Handoffs: Terraform, Ansible

Compare user data, remote-exec, and Ansible Actions on an EC2 web server.

Introduction

30 Second Summary

A server can be online while the software it should run is still unfinished. That gap becomes harder to manage when several tools can configure the same machine.

In this project, you will launch one Amazon EC2 server with Terraform. The live page moves from EC2 user data to remote-exec to an Ansible action.

What You'll Build

You will open a live web page that moves from two pending handoffs to a final state where all three configuration phases report complete.

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

  • Open a live EC2 page that proves user data completed while the later handoffs still show pending.
  • Follow a separate marker URL that proves remote-exec reached the server over SSH after cloud-init completed.
  • Refresh the home page to see Ansible converge the final configuration after Terraform triggers the provider-defined action.
  • Secret Mission: edit the desired page content. Invoke only the Ansible action to deliver a day-two update without rebuilding the AWS infrastructure.

Are there any prerequisites?

You need a Mac with Terraform, Ansible, Visual Studio Code, and an AWS account that can create EC2 networking resources. AWS usage can incur charges, so the project includes stop and destroy paths.

Before We Start

Before any hands-on work begins, commit to the role each configuration handoff plays in this comparison lab.

Prepare Your Mac and AWS Credentials

Your Mac is the control point for this lab. Terraform creates the AWS infrastructure from this machine.

Ansible handles repeatable configuration from the same machine. OpenSSH provides the connection that the later handoffs rely on.

Visual Studio Code keeps every check in one terminal. The AWS CLI gives Terraform a process profile backed by temporary credentials.

In this step, get ready to:
  • Verify the local Terraform, Ansible Core, AWS CLI, and OpenSSH tools.
  • Create temporary AWS credentials that Terraform can consume.
  • Prepare the project folder, SSH key pair, and restricted administrator CIDR.
Verify the local toolchain

Terraform Actions require Terraform 1.14.0 or newer. The current stable release is 1.16.5.

Console sign-in credentials require AWS CLI 2.32.0 or newer. Ansible Core and OpenSSH must also respond before this Mac can configure the server.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Enter to open Visual Studio Code.
  • Open the integrated terminal panel in Visual Studio Code.
  • Check all four tools by running these commands:
terraform version
ansible-playbook --version
aws --version
ssh -V

What do these checks prove?

  • The Terraform output must report version 1.14.0 or newer.
  • The Ansible output must identify an installed Ansible Core version.
  • The AWS CLI output must report version 2.32.0 or newer.
  • The OpenSSH output must display a version number.

✔️ All four tools meet the requirements

Your local control tools are ready. Keep the integrated terminal open for the credential checks.

ⓧ Terraform or AWS CLI is too old

Older versions can run familiar commands while missing the features this lab needs. Update each outdated tool before continuing.

aws update

What does this command do?

The AWS CLI updater replaces an older official installation with the current release. This unlocks browser-based console sign-in credentials.

  • Rerun the four version checks from above.

Still seeing an older version?

Your shell may still be finding an older installation earlier in its command path. Restart the Visual Studio Code integrated terminal before checking again.

Ask for help with the conflicting installation path:

ⓧ One or more commands are missing

A missing command means the corresponding tool is unavailable to the integrated terminal. Install only the tools that failed their checks.

  • Install Terraform with the official macOS package-manager commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

What do these commands do?

The first command adds HashiCorp's package source to Homebrew. The second command installs the current Terraform release.

  • Install the minimal Ansible Core package with pipx by running this command:
pipx install ansible-core

What does this command do?

The command installs Ansible Core in an isolated Python environment. This keeps its Python dependencies separate from the rest of your Mac.

  • Install AWS CLI for your macOS user by running this command:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What does this command do?

The command downloads the official AWS CLI installer for the current user. It installs the CLI without requiring a system-wide package.

  • Apply available macOS updates if the OpenSSH check alone fails.
  • Rerun the four version checks from above.

Having trouble installing a tool?

An installation can fail when Homebrew or pipx is unavailable. Use the linked official installation guide for the affected tool.

Ask for help with your exact missing command:

Create temporary AWS credentials

Credential setup can feel sensitive. This browser-based flow creates temporary credentials without asking you to store a long-term access key.

Terraform cannot consume this login session directly in every environment. The process profile bridges the session into a standard process-credential response.

  • Start the browser sign-in flow for the signin profile by running this command:
aws login --profile signin

What happens during sign-in?

The AWS CLI asks for an AWS Region when the profile has no Region yet. It then opens your default browser for console authentication.

The completed flow stores temporary login material in the local AWS CLI cache. The signin profile can now request credentials.

  • Enter the AWS Region where you want to build the lab when the terminal prompts you.
  • Record that Region here: your AWS Region.
  • Complete the sign-in flow in your browser.
  • Return to the Visual Studio Code integrated terminal after authentication succeeds.
  • Use the Visual Studio Code file picker to open ~/.aws/config.
  • Find the generated signin profile in the file.
  • Add this process profile below the generated profile:
[profile process]
credential_process = aws configure export-credentials --profile signin --format process
region = [[AWS_REGION="your AWS Region"]]

How does the credential bridge work?

The credential_process setting asks the AWS CLI to export temporary credentials from the signin profile. Terraform later selects the process profile.

Using the same Region keeps authentication and infrastructure commands aligned.

  • Save ~/.aws/config.

Before you test the profile, do you expect the identity command to use the temporary browser session or require a long-term access key?

  • Verify the credential bridge by running this command:
aws sts get-caller-identity --profile process

What should I see?

You should see a response containing UserId, Account, and Arn.

Check that the account is the one you intend to use for this lab. This proves the process profile can supply Terraform with temporary credentials.

Identity command not working?

Check that the profile header is [profile process]. Confirm that both profiles use the same AWS Region.

Ask for help reviewing the profile without sharing cached credentials:

That is the sensitive part complete. Terraform now has a temporary credential bridge to the intended AWS identity.

Create the project key and record your CIDR

The EC2 connection needs a local key pair before Terraform starts. The private key remains on your Mac while AWS receives the matching public key later.

Your administrator CIDR limits SSH and HTTP access to your current public IPv4 address. The /32 suffix targets one address.

  • Open the folder picker in Visual Studio Code.
  • Select Desktop as the location.
  • Create a folder named terraform-handoff-lab.
  • Choose terraform-handoff-lab as the folder to open.
  • Open the integrated terminal inside the new folder.

This lab key has no passphrase because Terraform needs to use it without an interactive prompt. Keep the private key inside the project folder.

  • Generate the RSA key pair by running this command:
ssh-keygen -t rsa -b 3072 -f lab-key -N ""

What does this command create?

OpenSSH creates a 3072-bit RSA private key in lab-key. It writes the corresponding public key to lab-key.pub.

The private key authenticates your Mac as the Amazon Linux ec2-user account. Terraform imports only the public key into AWS.

  • Check the project file list in Visual Studio Code.

You should see both lab-key and lab-key.pub inside terraform-handoff-lab.

Key files missing?

Confirm that the integrated terminal is inside terraform-handoff-lab. Check that the command uses lab-key as its output filename.

Ask for help locating the generated files:

  • Open https://checkip.amazonaws.com in your browser.
  • Append /32 to the displayed IPv4 address.
  • Record the complete value here: your public IPv4 address with /32.

Before the final check, which result would tell you that the local tools and AWS credential bridge are both ready?

  • Run the final tool and identity checks from the project terminal:
terraform version
ansible-playbook --version
aws --version
ssh -V
aws sts get-caller-identity --profile process

What should the final check show?

Terraform must report version 1.14.0 or newer. AWS CLI must report version 2.32.0 or newer.

Ansible Core and OpenSSH must each report a version. The final response must contain the intended AWS UserId, Account, and Arn.

✔️ Awesome, I've got everything!

Your Mac now has the local tools, temporary AWS credential bridge, project key pair, and restricted administrator CIDR required by the lab.

ⓧ I'd like to double check the full code

Confirm these exact artifact states before moving on:

  • The terraform-handoff-lab/lab-key file is an RSA 3072-bit private SSH key generated for the EC2 ec2-user account.
  • The terraform-handoff-lab/lab-key.pub file is the public key corresponding to terraform-handoff-lab/lab-key.
  • The ~/.aws/config file includes an authenticated signin profile.
  • The ~/.aws/config file includes a process profile in the same AWS Region.
  • The process profile contains credential_process = aws configure export-credentials --profile signin --format process.

Your Mac can now authenticate to AWS without long-term access keys. Next up, you will launch the first version of the web server through EC2 user data.

Bootstrap the Web Server with User Data

Your Terraform toolchain and temporary AWS credentials are ready. The lab can now move from an empty folder to a live Amazon EC2 web server.

First-boot user data gives the instance an immediate job. It installs Apache and publishes a status page.

The page stays deliberately incomplete. That shortfall exposes the boundary between bootstrapping and the later configuration handoffs.

In this step, get ready to:
  • Define the AWS provider input and restricted network.
  • Add the EC2 instance and first-boot status page.
  • Apply the configuration and inspect the designed shortfall.
Define the Terraform baseline and network

The configuration begins with version constraints and the AWS provider. These constraints keep the lab on versions that support every resource used here.

  • Click the New File icon in the Visual Studio Code Explorer sidebar.
  • Name the file versions.tf.
  • Paste the provider configuration below into versions.tf:
terraform {
  required_version = ">= 1.14.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.68.0"
    }
  }
}

provider "aws" {
  profile = "process"
}

What does this configuration do?

  • The required_version constraint requires Terraform 1.14.0 or newer while staying below 2.0.0.
  • The provider block pins hashicorp/aws to 6.68.0.
  • The AWS provider reads temporary credentials from the existing process profile.
  • Save versions.tf.
  • Confirm that versions.tf appears in the Explorer sidebar.

✔️ Awesome, I've got everything!

Your provider requirements now give Terraform a precise version contract.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.14.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.68.0"
    }
  }
}

provider "aws" {
  profile = "process"
}

Terraform downloads provider plugins and writes local state as the project runs. A .gitignore file keeps those generated files and both SSH keys out of Git.

  • Click the New File icon in the Explorer sidebar.
  • Name the file .gitignore.
  • Paste these ignore rules into .gitignore:
.terraform/
*.tfstate
*.tfstate.*
crash.log
lab-key
lab-key.pub

What do these rules protect?

  • The Terraform entries exclude downloaded plugins and local state files.
  • The key entries exclude lab-key and lab-key.pub from version control.
  • Save .gitignore.
  • Confirm that .gitignore appears beside versions.tf in the Explorer sidebar.

✔️ Awesome, I've got everything!

Your generated Terraform files and project SSH keys are now excluded from Git.

ⓧ I'd like to double check the full code

.terraform/
*.tfstate
*.tfstate.*
crash.log
lab-key
lab-key.pub

The provider declaration is ready. Terraform can now install the pinned AWS provider in this project folder.

  • Initialize the terraform-handoff-lab folder by running this command in the integrated terminal:
terraform init

Terraform downloads the AWS provider and confirms that initialization completed. You will also see the .terraform folder in the Explorer sidebar if hidden files are visible.

Initialization not completing?

Confirm that the integrated terminal is inside the open terraform-handoff-lab folder. A provider download failure can also point to a network connection problem.

If Terraform reports an authentication problem later, confirm that your existing AWS sign-in session is still active. Help me diagnose Terraform initialization in this project.

The network accepts one learner-specific value. The admin_cidr variable supplies your recorded public IPv4 address with its /32 suffix.

  • Click the New File icon in the Explorer sidebar.
  • Name the file variables.tf.
  • Paste the input declaration below into variables.tf:
variable "admin_cidr" {
  description = "Your current public IPv4 address with a /32 suffix"
  type        = string
}

What does this input control?

The admin_cidr input carries the exact network range allowed to reach the lab. Later security-group rules use this value for SSH and HTTP access.

  • Save variables.tf.
  • Validate the current configuration by running this command:
terraform validate

Terraform confirms that the configuration is valid. This proves that the provider and input declarations fit together.

Variable declaration not validating?

Check that the variable name is exactly admin_cidr. Confirm that both braces are present.

Ask for a focused comparison if the error remains. Help me compare my variables.tf with the required admin_cidr declaration.

✔️ Awesome, I've got everything!

Terraform now has a validated input for your restricted access range.

ⓧ I'd like to double check the full code

variable "admin_cidr" {
  description = "Your current public IPv4 address with a /32 suffix"
  type        = string
}

A dedicated Amazon VPC gives the lab its own network boundary. The public subnet assigns a public address to the instance.

  • Click the New File icon in the Explorer sidebar.
  • Name the file network.tf.
  • Paste the VPC and subnet resources below into network.tf:
resource "aws_vpc" "lab" {
  cidr_block           = "10.42.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name = "terraform-handoff-lab"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.lab.id
  cidr_block              = "10.42.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "terraform-handoff-public"
  }
}

How is the network divided?

  • The VPC uses 10.42.0.0/16 as its private address range.
  • The public subnet uses 10.42.1.0/24 inside that range.
  • The subnet requests public IP addresses for launched instances.
  • Save network.tf.
  • Validate the VPC and subnet resources by running this command:
terraform validate

Terraform confirms that the subnet can reference the VPC resource.

VPC or subnet not validating?

Check that the subnet references aws_vpc.lab.id exactly. Confirm that each resource has its own closing brace.

Use the validation message to narrow the mismatch. Help me troubleshoot my Terraform VPC and subnet blocks.

The subnet needs a route to the public internet. An internet gateway and route table provide that path.

  • Place your cursor after the final brace in network.tf.
  • Paste the gateway and route resources below:
resource "aws_internet_gateway" "lab" {
  vpc_id = aws_vpc.lab.id

  tags = {
    Name = "terraform-handoff-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.lab.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.lab.id
  }

  tags = {
    Name = "terraform-handoff-public"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

How does traffic reach the internet?

  • The internet gateway attaches to the lab VPC.
  • The 0.0.0.0/0 route sends public traffic through that gateway.
  • The association applies the public route table to the subnet.
  • Save network.tf.
  • Validate the public route by running this command:
terraform validate

Terraform confirms that the gateway and routing references resolve.

Public route not validating?

Check that gateway_id references aws_internet_gateway.lab.id. Confirm that the association references the public subnet and public route table.

Ask for help with the exact dependency chain if needed. Help me trace the references in my public route configuration.

The security group acts as the instance firewall. Its first ingress rule limits SSH to your recorded admin_cidr value.

  • Place your cursor after the route table association in network.tf.
  • Paste the security group and SSH rule below:
resource "aws_security_group" "web" {
  name        = "terraform-handoff-web"
  description = "Restricted SSH and HTTP access for the handoff lab"
  vpc_id      = aws_vpc.lab.id

  tags = {
    Name = "terraform-handoff-web"
  }
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = var.admin_cidr
  from_port         = 22
  ip_protocol       = "tcp"
  to_port           = 22
}

Why restrict SSH?

Port 22 accepts administrative SSH traffic. The admin_cidr reference limits that access to your current public IPv4 address.

  • Save network.tf.
  • Validate the SSH rule by running this command:
terraform validate

Terraform confirms that the SSH rule can use the security group and your input variable.

SSH rule not validating?

Confirm that cidr_ipv4 uses var.admin_cidr. Check that both port values are 22.

Get focused help if the resource reference still fails. Help me troubleshoot the restricted SSH ingress rule.

The web page needs an HTTP ingress rule. The instance also needs outbound access so user data can install the web server package.

  • Place your cursor after the SSH ingress rule in network.tf.
  • Paste the HTTP and egress rules below:
resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = var.admin_cidr
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

What traffic is allowed?

  • Port 80 accepts HTTP traffic only from your admin_cidr range.
  • The egress rule allows the instance to make outbound connections.
  • Save network.tf.
  • Validate the completed network configuration by running this command:
terraform validate

Terraform confirms that the full network configuration is valid.

HTTP or egress rule not validating?

Confirm that the HTTP rule uses port 80. Check that the egress protocol is -1.

Compare the final rules with the full file if the error continues. Help me troubleshoot the final security-group rules in network.tf.

✔️ Awesome, I've got everything!

Your dedicated VPC now has public routing and restricted inbound access.

ⓧ I'd like to double check the full code

resource "aws_vpc" "lab" {
  cidr_block           = "10.42.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name = "terraform-handoff-lab"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.lab.id
  cidr_block              = "10.42.1.0/24"
  map_public_ip_on_launch = true

  tags = {
    Name = "terraform-handoff-public"
  }
}

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

  tags = {
    Name = "terraform-handoff-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.lab.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.lab.id
  }

  tags = {
    Name = "terraform-handoff-public"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "web" {
  name        = "terraform-handoff-web"
  description = "Restricted SSH and HTTP access for the handoff lab"
  vpc_id      = aws_vpc.lab.id

  tags = {
    Name = "terraform-handoff-web"
  }
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = var.admin_cidr
  from_port         = 22
  ip_protocol       = "tcp"
  to_port           = 22
}

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = var.admin_cidr
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}
Add the instance and boot script

The EC2 instance reads bootstrap.sh during its first launch. Cloud-init runs this script as the instance starts.

The script installs Apache and writes the first status page. Its pending entries make the later handoffs visible.

  • Click the New File icon in the Explorer sidebar.
  • Name the file bootstrap.sh.
  • Paste the package and service commands below into bootstrap.sh:
#!/bin/bash
set -euo pipefail

dnf install -y httpd

systemctl start httpd
systemctl enable httpd

What does the boot script prepare?

  • The shell options stop the script when a command fails.
  • The package command installs the HTTP server.
  • The service commands start Apache and enable it for future boots.
  • Save bootstrap.sh.
  • Confirm that bootstrap.sh appears in the Explorer sidebar.

Apache needs project-specific content to replace its default page. The next block writes the deliberately incomplete handoff status.

  • Place your cursor below dnf install -y httpd in bootstrap.sh.
  • Paste the page-writing block below:
cat > /var/www/html/index.html <<'HTML'
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Terraform handoff lab</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 760px; margin: 4rem auto; padding: 0 1rem; }
    li { margin: 0.8rem 0; }
    .complete { color: #137333; }
    .pending { color: #b06000; }
  </style>
</head>
<body>
  <h1>Bootstrap page</h1>
  <p>The instance can serve a page, but configuration is not finished.</p>
  <ul>
    <li class="complete">USER_DATA: complete</li>
    <li class="pending">REMOTE_EXEC: pending</li>
    <li class="pending">ANSIBLE_ACTION: pending</li>
  </ul>
</body>
</html>
HTML

What does this page show?

  • The heredoc writes a complete HTML document to /var/www/html/index.html.
  • The green entry proves that user data completed.
  • The pending entries expose the configuration work that bootstrapping has not performed.
  • Save bootstrap.sh.
  • Confirm that the file ends with systemctl enable httpd.

Page block looking incomplete?

Confirm that the opening marker ends with HTML. Check that the closing HTML marker is on its own line.

A mismatched heredoc marker prevents the remaining boot commands from running. Help me compare the heredoc boundaries in bootstrap.sh.

✔️ Awesome, I've got everything!

Your boot script now installs Apache and publishes the first handoff status page.

ⓧ I'd like to double check the full code

#!/bin/bash
set -euo pipefail

dnf install -y httpd

cat > /var/www/html/index.html <<'HTML'
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Terraform handoff lab</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 760px; margin: 4rem auto; padding: 0 1rem; }
    li { margin: 0.8rem 0; }
    .complete { color: #137333; }
    .pending { color: #b06000; }
  </style>
</head>
<body>
  <h1>Bootstrap page</h1>
  <p>The instance can serve a page, but configuration is not finished.</p>
  <ul>
    <li class="complete">USER_DATA: complete</li>
    <li class="pending">REMOTE_EXEC: pending</li>
    <li class="pending">ANSIBLE_ACTION: pending</li>
  </ul>
</body>
</html>
HTML

systemctl start httpd
systemctl enable httpd

The compute configuration imports your existing public key and launches Amazon Linux 2023. It also requires IMDSv2 for instance metadata requests.

  • Click the New File icon in the Explorer sidebar.
  • Name the file compute.tf.
  • Paste the key pair and instance resources below into compute.tf:
resource "aws_key_pair" "lab" {
  key_name   = "terraform-handoff-lab"
  public_key = file("${path.module}/lab-key.pub")
}

resource "aws_instance" "web" {
  ami                         = "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.public.id
  associate_public_ip_address = true
  vpc_security_group_ids      = [aws_security_group.web.id]
  key_name                    = aws_key_pair.lab.key_name
  user_data                   = file("${path.module}/bootstrap.sh")
  user_data_replace_on_change = true

  metadata_options {
    http_tokens = "required"
  }

  tags = {
    Name = "terraform-handoff-web"
  }

  depends_on = [aws_internet_gateway.lab]
}

How is the instance configured?

  • The key-pair resource imports lab-key.pub while the private key stays on your Mac.
  • The instance uses the official dynamic Amazon Linux 2023 AMI reference.
  • The t3.micro instance launches in the public subnet with the restricted security group.
  • The metadata options require IMDSv2 tokens.
  • The user_data argument sends bootstrap.sh to the first boot.
  • Save compute.tf.
  • Confirm that the file references the existing lab-key.pub and bootstrap.sh files.

Outputs expose the instance address after the apply completes. The browser-friendly output saves you from building the URL manually.

  • Place your cursor after the instance resource in compute.tf.
  • Paste the outputs below:
output "public_ip" {
  value = aws_instance.web.public_ip
}

output "web_url" {
  value = "http://${aws_instance.web.public_ip}"
}

What do the outputs provide?

  • The public_ip output exposes the assigned IPv4 address.
  • The web_url output adds the HTTP scheme for direct browser access.
  • Save compute.tf.
  • Validate the complete infrastructure configuration by running this command:
terraform validate

Terraform confirms that the compute resources can resolve every network and file reference.

Compute configuration not validating?

Confirm that lab-key.pub and bootstrap.sh remain inside the open terraform-handoff-lab folder. The file() calls require both files to exist before Terraform runs.

Check the resource references if the files exist. Help me troubleshoot the references in compute.tf.

✔️ Awesome, I've got everything!

Your instance definition now connects the network, key pair, boot script, and browser outputs.

ⓧ I'd like to double check the full code

resource "aws_key_pair" "lab" {
  key_name   = "terraform-handoff-lab"
  public_key = file("${path.module}/lab-key.pub")
}

resource "aws_instance" "web" {
  ami                         = "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.public.id
  associate_public_ip_address = true
  vpc_security_group_ids      = [aws_security_group.web.id]
  key_name                    = aws_key_pair.lab.key_name
  user_data                   = file("${path.module}/bootstrap.sh")
  user_data_replace_on_change = true

  metadata_options {
    http_tokens = "required"
  }

  tags = {
    Name = "terraform-handoff-web"
  }

  depends_on = [aws_internet_gateway.lab]
}

output "public_ip" {
  value = aws_instance.web.public_ip
}

output "web_url" {
  value = "http://${aws_instance.web.public_ip}"
}
Launch and inspect the bootstrap page

The project files now describe one complete first-boot deployment. Formatting and validation give you a final local check before AWS creates anything.

  • Format the Terraform files by running this command:
terraform fmt

Terraform formats the configuration files in place. Any filename printed in the terminal was adjusted.

  • Validate the formatted configuration by running this command:
terraform validate

Terraform confirms that the formatted configuration is valid.

Final validation failing?

Start with the filename and line number in the validation message. Compare that file with its full-code tab before applying.

Ask for help with the exact message if the mismatch is unclear. Help me diagnose my final terraform validate error.

The next command creates EC2 and EBS resources that can incur AWS charges. You will destroy them in the cleanup section when the comparison is complete.

Terraform shows a proposed plan before it creates resources. It pauses so you can review that plan and approve it.

Before you run the apply, which handoff do you expect the instance to complete during its first boot?

  • Create the lab infrastructure by running this command with your recorded CIDR value:
terraform apply -var='admin_cidr=[[ADMIN_CIDR="your public IPv4 address with /32 suffix"]]'

What does this command do?

  • The apply command compares the configuration with the current empty AWS state.
  • The -var argument supplies your restricted admin_cidr value for ports 22 and 80.
  • Review the proposed AWS resources in the terminal.
  • Approve the plan using Terraform's confirmation prompt.
  • Wait for the apply to finish.

The first apply can take a few minutes while AWS launches the instance. Cloud-init then installs Apache and writes the page.

Apply not reaching completion?

An expired browser sign-in can prevent the process profile from returning credentials. Reuse the AWS login flow from the previous step before retrying the apply.

An AWS authorization message means the signed-in identity lacks permission for one of the proposed resources. Help me interpret my Terraform apply failure.

Once the apply completes, Terraform prints public_ip and web_url outputs. The URL becomes the browser checkpoint for every later handoff.

  • Copy the web_url value from the completed apply output.
  • Record the URL here: your Terraform web_url output.

Before you open the page, which status do you expect to be complete? Which two statuses should still be pending?

  • Paste your Terraform web_url output into your browser address bar.
  • Refresh the page after a short wait if Apache is still starting.

You will see the Bootstrap page heading. The page reports USER_DATA: complete, REMOTE_EXEC: pending, and ANSIBLE_ACTION: pending.

Why is the page incomplete?

This is the intended shortfall. User data handled the instance's first-boot setup successfully.

The pending entries prove that bootstrapping has not performed either post-provision handoff. The visible gap gives each later mechanism a separate job to prove.

Page not loading?

Confirm that your current public IPv4 address still matches the value supplied as admin_cidr. A changed network address leaves port 80 restricted to the previous address.

If the address still matches, wait briefly for cloud-init to finish before refreshing. Help me troubleshoot why the bootstrap page is not loading.

That is the first visible win. Your EC2 server now proves that first-boot user data can publish a working page while leaving the later handoffs clearly unresolved.

Next, you will add the SSH-coupled remote-exec marker and test its creation-time lifecycle.

Add the Remote-Exec Handoff

Your Amazon EC2 server is live. Its Bootstrap page proves that EC2 user data produced the first visible result.

The missing post-boot marker creates the next problem. You will use a Terraform remote-exec provisioner to reach the server over SSH after cloud-init has completed.

In this step, get ready to:
  • Define an SSH-coupled provisioner inside a separate Terraform resource.
  • Apply the handoff to create a browser-visible remote marker.
  • Repeat the apply to inspect the provisioner's lifecycle limitation.
Add the SSH-coupled provisioner

A terraform_data resource can host a provisioner without attaching that provisioner directly to your EC2 instance. Its triggers_replace value links the handoff lifecycle to the instance ID.

  • Use the file controls in Visual Studio Code to create remote-exec.tf inside the terraform-handoff-lab folder.
  • Define the remote probe by pasting the code below into remote-exec.tf:
resource "terraform_data" "remote_probe" {
  triggers_replace = [aws_instance.web.id]

  connection {
    type        = "ssh"
    user        = "ec2-user"
    private_key = file("${path.module}/lab-key")
    host        = aws_instance.web.public_ip
  }

  provisioner "remote-exec" {
    inline = [
      "cloud-init status --wait",
      "echo 'REMOTE_EXEC: complete' | sudo tee /var/www/html/remote-exec.txt",
    ]
  }
}

What does this code do?

  • The triggers_replace value replaces the probe whenever the EC2 instance ID changes.
  • The connection uses the existing lab-key private key to authenticate as ec2-user.
  • The host value follows the public IP exported by aws_instance.web.
  • The first inline command waits for the first-boot configuration to finish.
  • The second inline command writes the remote marker into the web server directory.
  • Save remote-exec.tf.
  • Format the new file with the first command below.
  • Validate the combined Terraform configuration with the second command below:
terraform fmt
terraform validate

Why format and validate now?

The formatter makes the new file consistent with your existing Terraform files. The validator checks that the resource references and provisioner structure form a valid configuration.

Seeing a validation problem?

Compare the braces and quoted values in remote-exec.tf with the reference below. Confirm that the file sits beside compute.tf inside terraform-handoff-lab.

Ask for help with my remote-exec Terraform validation error.

✔️ Awesome, I've got everything!

Your saved remote-exec.tf file is ready to apply.

ⓧ I'd like to double check the full code

resource "terraform_data" "remote_probe" {
  triggers_replace = [aws_instance.web.id]

  connection {
    type        = "ssh"
    user        = "ec2-user"
    private_key = file("${path.module}/lab-key")
    host        = aws_instance.web.public_ip
  }

  provisioner "remote-exec" {
    inline = [
      "cloud-init status --wait",
      "echo 'REMOTE_EXEC: complete' | sudo tee /var/www/html/remote-exec.txt",
    ]
  }
}

What should match?

Your full file should contain one terraform_data.remote_probe resource. Its connection should use the existing instance IP and local key.

Apply and inspect its lifecycle limitation

Terraform now evaluates the handoff beside your existing infrastructure. The apply remains interactive so you can review its proposed change before approving it.

This apply reaches your running server while preserving its current EC2 configuration. The terminal may pause while cloud-init reports that first-boot work has finished.

Before you run the apply, do you expect Terraform to replace the EC2 instance or create a separate handoff resource?

  • Apply the remote probe by running this command:
terraform apply -var='admin_cidr=YOUR_PUBLIC_IP/32'

What does this command do?

Terraform evaluates the saved configuration with your restricted administration CIDR. It then presents the proposed resource change for your approval.

  • Review the plan for one new terraform_data.remote_probe resource.
  • Enter yes at the approval prompt.

Terraform creates terraform_data.remote_probe. The terminal prints REMOTE_EXEC: complete when the remote command writes the marker file.

That is the SSH handoff working. Terraform reached the existing server without replacing it.

  • Switch back to the browser tab that displays the web_url page from earlier.
  • Append /remote-exec.txt to the address.
  • Press Enter to load the marker.

You will see REMOTE_EXEC: complete as the page content. This proves that Terraform waited for cloud-init before writing through SSH.

  • Return to the base web_url address.

You will still see the original Bootstrap page. It continues to report ANSIBLE_ACTION: pending.

Can't load the marker?

  • Confirm that your current public IPv4 address still matches var.admin_cidr.
  • Confirm that lab-key remains inside the terraform-handoff-lab folder.
  • Confirm that the browser address ends with /remote-exec.txt.

Help me troubleshoot the SSH handoff and missing remote-exec marker.

The marker proves that a creation-time provisioner can complete a post-boot command. A repeated apply now reveals whether that provisioner acts like an ongoing desired-state engine.

Before you repeat the apply, do you think Terraform will run the SSH commands again or leave the completed probe alone?

  • Repeat the same apply by running this command:
terraform apply -var='admin_cidr=YOUR_PUBLIC_IP/32'

What did Terraform just prove?

Terraform reports no infrastructure changes because the EC2 instance ID has stayed the same. The creation-time provisioner does not run again.

Terraform cannot model the remote file as managed resource state. This is why provisioners are reserved for cases where purpose-built alternatives have been exhausted.

  • Refresh the /remote-exec.txt marker page.
  • Return to the base web_url page.

The marker still shows REMOTE_EXEC: complete. The Bootstrap home page still reports the Ansible action as pending.

Seeing a proposed change on the second apply?

Check whether remote-exec.tf changed after the first apply. Also confirm that the original EC2 instance remains in Terraform state.

Help me understand why my repeated Terraform apply still proposes changes.

You have proved both sides of the provisioner handoff. Next, you will use Ansible to manage the final web configuration through a repeatable Terraform Action.

Trigger Ansible with a Terraform Action

Your remote-exec marker proves Terraform can reach the server over SSH. The second no-change apply exposed its limit: a creation-time provisioner does not keep remote configuration aligned.

The pending Ansible status now needs a repeatable owner. In this step, a provider-defined Terraform Action runs an Ansible playbook after the remote probe completes.

The action changes the server without pretending those remote changes belong in Terraform resource state.

In this step, get ready to:
  • Add the Ansible provider to the Terraform configuration.
  • Connect the playbook action to the remote probe lifecycle.
  • Converge the live web server with an idempotent playbook.
Add the Ansible provider and action

A provider-defined action gives Terraform a named operation it can invoke. The ansible/ansible provider supplies the playbook action plus the generated inventory it needs.

  • Switch back to versions.tf in Visual Studio Code.
  • Find this provider block:
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.68.0"
    }
  }

What are you changing?

This existing block declares only the AWS provider. Terraform needs a second provider declaration before it can understand the Ansible inventory or action.

  • Replace the existing provider block with this expanded block:
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.68.0"
    }

    ansible = {
      source  = "ansible/ansible"
      version = "1.5.0"
    }
  }

What does this provider block do?

  • The source value identifies the official Ansible provider namespace.
  • The version value pins the provider to 1.5.0 so the action schema stays consistent.
  • The existing AWS provider declaration remains unchanged.
  • Save versions.tf.
  • Reinitialize the project with the new provider by running this command:
terraform init

What does this command do?

Terraform reads the updated provider requirements. It downloads the pinned Ansible provider for this project.

You should see Terraform confirm that initialization completed with the newly declared provider available.

Provider download failing?

Confirm that versions.tf contains the ansible/ansible source plus version 1.5.0. Check that your Mac can reach the provider registry.

Ask for help with the initialization failure.

✔️ Awesome, I've got everything!

Great. Save versions.tf before you continue.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.14.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.68.0"
    }

    ansible = {
      source  = "ansible/ansible"
      version = "1.5.0"
    }
  }
}

provider "aws" {
  profile = "process"
}

What should match?

Your file should contain both pinned provider declarations. The AWS provider should still use the process profile.

The action needs an inventory that translates the running EC2 instance into Ansible connection details. The generated inventory keeps the host address tied to Terraform output instead of duplicating it in a separate file.

  • Click the New File button in the Visual Studio Code Explorer sidebar.
  • Enter ansible.tf as the file name.
  • Define the generated inventory by pasting this code into ansible.tf:
data "ansible_inventory" "web" {
  group {
    name = "webservers"

    host {
      name                     = aws_instance.web.public_ip
      ansible_user             = "ec2-user"
      ansible_private_key_file = "${path.module}/lab-key"
      ansible_ssh_extra_args   = "-o StrictHostKeyChecking=no"
    }
  }
}

How does the inventory connect?

  • The webservers group gives the playbook a stable host target.
  • The instance public IP becomes the generated host name.
  • The ec2-user account plus lab-key provide the same SSH handoff that worked for the remote probe.
  • The -o StrictHostKeyChecking=no option avoids an interactive host-key prompt for this disposable instance.

Keep this shortcut inside the lab

Disabling strict host-key checking removes identity verification for the remote host. Production systems should distribute trusted host keys instead.

  • Save ansible.tf.
  • Check the inventory configuration by running this command:
terraform validate

What does this check prove?

Terraform loads the installed provider schema. A successful validation confirms that the generated inventory matches that schema.

You should see Terraform report that the configuration is valid.

Inventory validation failing?

Check the indentation inside the group and host blocks. Confirm that the public IP reference still reads aws_instance.web.public_ip.

Ask for help with the inventory schema.

The inventory explains where Ansible connects. The action now names the playbook that should run against that generated inventory.

  • Place your cursor below the inventory block in ansible.tf.
  • Add the provider-defined playbook action by pasting this code:
action "ansible_playbook_run" "configure_web" {
  config {
    playbooks   = ["${path.module}/playbook.yml"]
    inventories = [data.ansible_inventory.web.json]
  }
}

What does this action configure?

  • The configure_web name gives Terraform a specific action to trigger.
  • The playbooks setting points to playbook.yml inside the lab folder.
  • The inventories setting passes the provider-generated JSON inventory to Ansible.
  • Save ansible.tf.
  • Validate the action declaration by running this command:
terraform validate

What does this validation cover?

Terraform checks that the action accepts the declared playbook list plus generated inventory. It also confirms that all referenced Terraform identifiers exist.

You should see another successful configuration validation.

Action declaration rejected?

Confirm that the action type is ansible_playbook_run. Check that the inventory reference ends with .json.

Ask for help with the action declaration.

Attach the action to the handoff lifecycle

The action exists now, but Terraform still needs an event that invokes it. A new terraform_data resource places that event after both the EC2 instance and remote probe.

  • Place your cursor below the action block in ansible.tf.
  • Add the lifecycle-triggered handoff by pasting this code:
resource "terraform_data" "ansible_handoff" {
  triggers_replace = [
    aws_instance.web.id,
    terraform_data.remote_probe.id,
  ]

  lifecycle {
    action_trigger {
      events     = [after_create]
      actions    = [action.ansible_playbook_run.configure_web]
      on_failure = taint
    }
  }
}

How does the lifecycle handoff work?

  • The two triggers_replace values place this handoff after the instance plus remote probe.
  • The after_create event invokes the action when Terraform creates the handoff resource.
  • The ordered actions list points to action.ansible_playbook_run.configure_web.
  • The on_failure = taint setting marks a failed handoff for replacement on the next apply.
  • Save ansible.tf.
  • Format the Terraform files by running these commands:
terraform fmt
terraform validate

What do these checks prove?

The first command normalizes the HCL formatting. The second command verifies the complete provider action plus lifecycle trigger.

You should see Terraform confirm that the configuration is valid.

Lifecycle trigger failing validation?

Check that action_trigger sits inside lifecycle. Confirm that both trigger identifiers match the existing resources exactly.

Ask for help with the lifecycle configuration.

✔️ Awesome, I've got everything!

Your generated inventory, playbook action, and lifecycle handoff are ready.

ⓧ I'd like to double check the full code

data "ansible_inventory" "web" {
  group {
    name = "webservers"

    host {
      name                     = aws_instance.web.public_ip
      ansible_user             = "ec2-user"
      ansible_private_key_file = "${path.module}/lab-key"
      ansible_ssh_extra_args   = "-o StrictHostKeyChecking=no"
    }
  }
}

action "ansible_playbook_run" "configure_web" {
  config {
    playbooks   = ["${path.module}/playbook.yml"]
    inventories = [data.ansible_inventory.web.json]
  }
}

resource "terraform_data" "ansible_handoff" {
  triggers_replace = [
    aws_instance.web.id,
    terraform_data.remote_probe.id,
  ]

  lifecycle {
    action_trigger {
      events     = [after_create]
      actions    = [action.ansible_playbook_run.configure_web]
      on_failure = taint
    }
  }
}

What should match?

Your file should contain three top-level constructs. They are the generated inventory, the playbook action, and the lifecycle-triggered handoff resource.

Converge the web server

An idempotent playbook describes the page plus service state the server should keep. Repeated Ansible runs can compare that desired state with the live host.

The first part of the playbook identifies the generated inventory group. It also enables privilege escalation for the web files and service.

  • Click the New File button in the Visual Studio Code Explorer sidebar.
  • Enter playbook.yml as the file name.
  • Define the play target by pasting this code into playbook.yml:
---
- name: Converge the web server with Ansible
  hosts: webservers
  become: true
  gather_facts: false

  tasks:

What does the play header define?

  • The hosts value matches the inventory group from ansible.tf.
  • The become setting gives the tasks permission to manage web files plus the service.
  • The gather_facts setting skips facts that these two tasks do not need.
  • Save playbook.yml.
  • Confirm that playbook.yml appears beside ansible.tf in the Explorer sidebar.

Playbook file missing?

Check that you created playbook.yml inside the terraform-handoff-lab folder. Confirm that the file name ends with .yml.

Ask for help locating the playbook file.

The copy task replaces the incomplete bootstrap page with the final handoff report. Its content keeps the original user-data result visible while marking the later phases complete.

  • Place your cursor below tasks: in playbook.yml.
  • Add the Ansible-managed landing page by pasting this task:
    - name: Replace the landing page with the final handoff status
      ansible.builtin.copy:
        dest: /var/www/html/index.html
        mode: "0644"
        content: |
          <!doctype html>
          <html lang="en">
          <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title>Terraform handoff lab complete</title>
            <style>
              body { font-family: system-ui, sans-serif; max-width: 760px; margin: 4rem auto; padding: 0 1rem; }
              li { color: #137333; margin: 0.8rem 0; }
            </style>
          </head>
          <body>
            <h1>Configuration handoff complete</h1>
            <p>Ansible now owns the repeatable web configuration.</p>
            <ul>
              <li>USER_DATA: complete</li>
              <li>REMOTE_EXEC: complete</li>
              <li>ANSIBLE_ACTION: complete</li>
            </ul>
            <p><a href="/remote-exec.txt">Open the remote-exec marker</a></p>
          </body>
          </html>

What does the copy task manage?

  • The dest value targets the page already served by Apache.
  • The mode value keeps the page readable by the web server.
  • The managed content reports all three handoffs as complete.
  • The final link preserves access to the separate remote-exec marker.
  • Save playbook.yml.
  • Confirm that the copied page contains the three completion list items in the editor.

YAML indentation looking uneven?

Keep the copy task indented beneath tasks:. Preserve the deeper indentation for every line inside content: |.

Ask for help checking the page task indentation.

The service task makes Apache part of the desired configuration. Ansible checks that the service is enabled plus running whenever the playbook executes.

  • Place your cursor below the copy task in playbook.yml.
  • Add the Apache service task by pasting this code:
    - name: Keep Apache enabled and running
      ansible.builtin.service:
        name: httpd
        enabled: true
        state: started

What does the service task enforce?

  • The name value selects the existing httpd service.
  • The enabled setting keeps Apache configured for startup.
  • The state setting keeps Apache running now.
  • Save playbook.yml.

✔️ Awesome, I've got everything!

Your playbook now manages the final page plus the Apache service.

ⓧ I'd like to double check the full code

---
- name: Converge the web server with Ansible
  hosts: webservers
  become: true
  gather_facts: false

  tasks:
    - name: Replace the landing page with the final handoff status
      ansible.builtin.copy:
        dest: /var/www/html/index.html
        mode: "0644"
        content: |
          <!doctype html>
          <html lang="en">
          <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title>Terraform handoff lab complete</title>
            <style>
              body { font-family: system-ui, sans-serif; max-width: 760px; margin: 4rem auto; padding: 0 1rem; }
              li { color: #137333; margin: 0.8rem 0; }
            </style>
          </head>
          <body>
            <h1>Configuration handoff complete</h1>
            <p>Ansible now owns the repeatable web configuration.</p>
            <ul>
              <li>USER_DATA: complete</li>
              <li>REMOTE_EXEC: complete</li>
              <li>ANSIBLE_ACTION: complete</li>
            </ul>
            <p><a href="/remote-exec.txt">Open the remote-exec marker</a></p>
          </body>
          </html>

    - name: Keep Apache enabled and running
      ansible.builtin.service:
        name: httpd
        enabled: true
        state: started

What should match?

Your playbook should contain one play with two tasks. The first task manages the final page. The second task manages Apache.

Before you apply, do you expect Terraform to replace the existing AWS infrastructure or create only the new handoff resource?

Terraform displays its proposed changes before it asks for approval. The lifecycle action runs after the new handoff resource is created.

  • Start the configuration handoff by running this command:
  • Review the proposed changes when Terraform pauses for approval.
  • Enter yes at the approval prompt.
terraform apply -var='admin_cidr=YOUR_PUBLIC_IP/32'

What happens during this apply?

Terraform creates terraform_data.ansible_handoff after the existing remote probe. Its after_create event starts the configured playbook action.

Ansible connects through the generated inventory. It copies the final page plus confirms the Apache service state.

You should see the action output show the Ansible playbook running against the web server. The apply should finish without replacing the existing EC2 instance or network.

Ansible action unable to connect?

Confirm that the instance is still running. Check that lab-key remains inside the terraform-handoff-lab folder.

Confirm that your current public IPv4 address still matches the CIDR supplied to Terraform. A changed address can prevent the generated inventory from reaching SSH.

Ask for help diagnosing the action connection.

Before you refresh the site, which status do you expect to replace ANSIBLE_ACTION: pending?

  • Return to the web_url page from earlier.
  • Refresh the page.

You should see Configuration handoff complete above three green statuses. They should read USER_DATA: complete, REMOTE_EXEC: complete, and ANSIBLE_ACTION: complete.

  • Click the Open the remote-exec marker link.

You should still see REMOTE_EXEC: complete in the marker file. This confirms that Ansible replaced the home page without removing the earlier SSH proof.

  • Use your browser's Back button to return to the final page.

That is the hard part done: your existing EC2 server now converges through a Terraform-triggered Ansible playbook.

Secret mission

Deliver a Day-Two Update Without Rebuilding AWS

Your live server already shows all three configuration handoffs. Now edit the Ansible-managed page and invoke only the Terraform Action. A second invocation proves the desired state is idempotent while the AWS infrastructure stays unchanged.

Clean Up Your Resources

Clean Up Your Resources

Your day-two update is still running on the live server. Decide whether to keep the lab running, pause the instance for later, or delete the resources because AWS usage can incur charges.

Cost warning

The running Amazon EC2 instance can incur usage charges. Its root Amazon EBS volume can incur storage charges.

Data transfer can add further charges. Eligible new accounts may have credits.

This project does not assume free usage. Delete the lab promptly if you have finished using it.

Resources you used:

  • One running t3.micro instance named terraform-handoff-web with its root volume.
  • One dedicated Amazon VPC with a public subnet, internet gateway, route table, and route table association.
  • One security group with restricted SSH and HTTP rules.
  • One imported EC2 key pair named terraform-handoff-lab.
  • The local terraform-handoff-lab folder containing Terraform state, downloaded provider plugins, configuration files, and SSH key files.

Keep everything running

No action is needed. Choose this option if you are still testing the live configuration handoffs.

  • Leave the AWS resources running.
  • Keep the terraform-handoff-lab folder on your Mac.
  • Protect lab-key because it grants private-key access to the instance.
  • Monitor the usage charges associated with your AWS account.

Pause - I'll come back to this later

Stopping the instance preserves the lab for later. Storage charges plus other applicable charges can continue.

The stop action is reversible. Your web page remains unavailable until you start the instance again.

  • In the AWS Management Console, return to the Amazon EC2 console.
  • Select the instance named terraform-handoff-web.
  • Choose Instance state.
  • Choose Stop instance.
  • Keep the terraform-handoff-lab folder on your Mac.

You should see the instance enter a stopped state. The direct Ansible action cannot connect while the instance is stopped.

Delete - I don't want to use this again

Deletion permanently removes the managed AWS resources. Terraform shows you a destroy plan before removing anything.

Keep Your State Until Cleanup Finishes

Terraform needs the local state inside terraform-handoff-lab to identify the managed AWS resources.

Keep the folder in place until the destroy operation finishes successfully.

  • Return to the Visual Studio Code terminal from earlier.
  • Confirm that the terminal prompt points to terraform-handoff-lab.
  • Replace YOUR_PUBLIC_IP with your current public IPv4 address.
  • Destroy every AWS resource managed by the lab by running this command:
terraform destroy -var='admin_cidr=YOUR_PUBLIC_IP/32'

What Does This Command Do?

Terraform reads the current configuration plus its local state. It uses both to identify every managed AWS object.

The destroy operation deprovisions those objects after you approve the plan. The admin_cidr value keeps the configuration valid while Terraform prepares that plan.

  • Review every object shown in the destroy plan.
  • Approve the destroy plan when Terraform asks for confirmation.
  • Wait for the destroy operation to finish.

You should see Terraform report that the destruction completed successfully. The instance, root volume, network resources, security rules, and imported key pair are now removed.

Did the Destroy Operation Fail?

  • Repeat the temporary AWS sign-in flow from Step 1 if your credentials expired.
  • Use your current public IPv4 address with the /32 suffix if your address changed.
  • Keep the local project folder until Terraform finishes destroying every managed resource.

Help me troubleshoot this Terraform destroy failure.

Remove the Local Folder Last

Removing the folder permanently deletes the Terraform state, downloaded providers, project configuration, and both SSH key files.

Only continue after the AWS destroy operation succeeds.

  • Remove the local terraform-handoff-lab folder by running these commands:
cd ..
rm -rf terraform-handoff-lab

What Do These Commands Do?

The first command moves the terminal one level above the project folder. The second command permanently removes terraform-handoff-lab.

This also removes lab-key plus lab-key.pub from your Mac.

  • Confirm that the project folder is gone by listing the remaining files and folders:
ls

What Should I See?

You should no longer see terraform-handoff-lab in the output. Your cloud resources plus local lab files are now cleaned up.

Is the Folder Still Listed?

  • Confirm that the terminal moved one level above the project folder.
  • Check that the folder name matches terraform-handoff-lab exactly.

Help me remove the local lab folder safely.

Nice Work!

Nice Work!

You made it! Your Amazon EC2 web server now proves how each configuration handoff behaves.

You've learned how to:

  • Bootstrap a browser-visible web server with EC2 user data. Restrict SSH access to your public /32 CIDR. Apply the same restriction to HTTP access.
  • Isolate remote-exec inside terraform_data. Wait for cloud-init to complete. Produce a separate marker that exposes the coupling of a creation-time handoff.
  • Trigger an Ansible inventory and playbook through Terraform Actions. Converge the final landing page from a lifecycle event. Keep httpd enabled and running.
  • Secret Mission: Invoke only the Ansible action for a day-two update. Prove idempotency with a second run. Leave the EC2 instance and its network unchanged.

Ready to quiz yourself?