Build an AWS Web Server with Terraform

Build a multi-environment AWS web server with Terraform and Ansible.

Introduction

30 Second Summary

A change can work perfectly in a test environment. It can still fail later because another environment was assembled differently.

In this project, you will use Infrastructure as Code with Terraform to define one reusable Amazon Web Services configuration for Development, Staging, and Production. Only Development goes live as a web server configured by Ansible.

What You'll Build

Your Development URL loads a custom page that shows Terraform created the cloud host while Ansible configured the software inside it.

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

  • Launch Development safely from one reusable configuration. Open its public website in your browser.
  • Use isolated Terraform CLI workspaces to preview distinct Staging and Production plans. No cloud resources are created for either environment.
  • Prove idempotency by rerunning the Ansible playbook. See changed=0 when the server already matches your configuration.
  • Secret Mission: Replace the inline page with a reusable Jinja2 template that displays the active environment.

Are there any prerequisites?

You need an AWS account with active Free Tier credits. The account must have permission to manage Amazon VPC resources plus Amazon EC2 resources.

This project also assumes the listed local tools are already installed on Windows. Your Ubuntu on Windows Subsystem for Linux environment must be ready to use.

Before We Start

This is your moment to commit to the project architecture before any infrastructure work begins. You will use one reusable Terraform configuration driven by environment-specific variable files across isolated Terraform CLI workspaces, with only Development applied while Staging and Production remain plan-only.

Prepare the WSL Workspace

The handoff between Terraform and Ansible depends on consistent Linux paths. Running every command inside Windows Subsystem for Linux (WSL) prevents Windows path differences from breaking that handoff.

This step also confirms that your tools use the intended Amazon Web Services (AWS) account. You will prepare the Amazon EC2 key pair that later gives Ansible secure access to the Development server.

In this step, get ready to:
  • Prepare the WSL project workspace.
  • Verify the WSL toolchain against the project requirements.
  • Prepare the EC2 key pair for secure access.
Build the project workspace in WSL

Your project files need one home inside Ubuntu. This keeps Terraform state paths consistent when Ansible uses Terraform outputs later.

  • Press the Windows key to open the Windows search bar.
  • Type Ubuntu and press Enter to launch the WSL terminal.

You will see an Ubuntu shell prompt. Every terminal command in this project runs from this Linux environment.

  • Create the project directory structure inside your Linux home folder by running these commands:
mkdir -p ~/projects/terraform-ansible-foundation/ansible ~/projects/terraform-ansible-foundation/environments
cd ~/projects/terraform-ansible-foundation

What do these commands do?

  • The mkdir command creates the project directory plus its two subdirectories.
  • The -p flag also creates any missing parent directories.
  • The cd command places your terminal inside terraform-ansible-foundation.
  • Confirm that both subdirectories exist by running this command:
ls -ld ansible environments

What does this check prove?

The command lists the two directories from your current location. Seeing both names confirms that later file paths can stay relative to terraform-ansible-foundation.

You should see one entry for ansible plus one entry for environments.

The first WSL launch can take a moment while Visual Studio Code fetches its Linux-side components. That brief pause establishes the editor connection to Ubuntu.

  • Open the current WSL project folder in Visual Studio Code by running this command:
code .

Why launch the editor this way?

The dot tells Visual Studio Code to open your current directory. Launching from Ubuntu keeps the editor connected to the WSL filesystem.

You should see terraform-ansible-foundation in the Explorer sidebar. The window should show a WSL connection with ansible plus environments underneath it.

Project folder not opening?

  • Restart the Ubuntu terminal if Visual Studio Code does not launch.
  • Confirm that the terminal prompt is inside terraform-ansible-foundation before retrying the launch command.

Ask for help with opening the WSL project in Visual Studio Code.

Verify the toolchain and AWS identity

Version checks confirm that Ubuntu resolves the expected command-line tools. The identity check confirms that the AWS CLI reads credentials from your shared AWS configuration.

Before you run the checks, do you expect every command to return details from the WSL environment?

  • Verify the installed tools plus the active AWS identity by running these commands:
terraform version
ansible --version
aws --version
aws sts get-caller-identity

What do these checks confirm?

  • The Terraform check prints the installed binary version plus provider plugin details when plugins are present.
  • The Ansible check identifies the installed Ansible Core version.
  • The AWS CLI check confirms that Ubuntu can resolve version 2 of the command-line client.
  • The identity check returns the active Account plus Arn without exposing your secret credentials.

You should see Terraform 1.16.x plus Ansible Core 2.21.x. You should also see AWS CLI 2.

The final response should show the intended AWS Account plus Arn. This proves your shared AWS configuration is active inside WSL.

Why keep credentials outside Terraform?

The AWS provider can use the shared AWS configuration already available to the CLI. Keeping credentials out of Terraform files prevents sensitive values from entering project files or state.

Seeing the wrong version or identity?

  • Confirm that you launched Ubuntu instead of a Windows command shell.
  • Stop before creating AWS resources if the returned account is not the one you intend to use.
  • Repair the affected WSL tool if its command is unavailable or its version is below the project requirement.

Ask for help with diagnosing the WSL toolchain output.

Secure the EC2 key pair

An EC2 key pair links a regional public key to a local private key. Ansible later uses the local PEM file to authenticate over SSH.

Private keys need careful handling. The creation command writes the key directly into WSL without printing it for you to copy.

  • Check for the regional key pair plus its local PEM file by running these commands:
aws ec2 describe-key-pairs --key-names terraform-ansible-lab --region us-east-1
ls -l ~/.ssh/terraform-ansible-lab.pem

What do these checks inspect?

  • The AWS command checks for terraform-ansible-lab in us-east-1.
  • The file command checks for the matching private key at ~/.ssh/terraform-ansible-lab.pem.
  • Neither check displays the private key contents.

One or both checks may report an absent item. That result identifies which path you need below.

Which state did the two checks reveal?

✔️ Neither key part exists

A fresh key pair gives AWS the public half. The redirected output stores the private half only in your WSL home directory.

  • Create the regional key pair plus its protected PEM file by running these commands:
aws ec2 create-key-pair --key-name terraform-ansible-lab --key-type ed25519 --key-format pem --query "KeyMaterial" --output text --region us-east-1 > ~/.ssh/terraform-ansible-lab.pem
chmod 400 ~/.ssh/terraform-ansible-lab.pem

How is the private key protected?

  • The AWS command creates an ed25519 key pair named terraform-ansible-lab in us-east-1.
  • The output redirection writes the private key directly to ~/.ssh/terraform-ansible-lab.pem.
  • The permission command gives the owner read access while removing access for every other user.

The creation command should finish quietly because its output becomes the PEM file. Your terminal prompt returns when both commands finish.

ⓧ Both key parts already exist

Reuse the pair only when you know this local PEM file came from the regional key pair named terraform-ansible-lab.

  • Restore owner-only read permissions on the existing PEM file by running this command:
chmod 400 ~/.ssh/terraform-ansible-lab.pem

Why apply the permission again?

The command is safe to repeat. It ensures later SSH connections do not reject the private key because its file permissions are too broad.

The command should return you to the terminal prompt without changing the key contents.

Only one key part exists?

A regional key without its original private file cannot recreate that private key. A local file without its regional public key cannot authenticate a new instance launched with this key-pair name.

Ask for help with repairing the incomplete EC2 key pair safely.

Before you run the final check, which results would prove that the directories plus both key-pair parts are ready?

  • Verify the WSL directories plus both key-pair parts by running these commands:
ls -ld ~/projects/terraform-ansible-foundation/ansible ~/projects/terraform-ansible-foundation/environments
aws ec2 describe-key-pairs --key-names terraform-ansible-lab --region us-east-1
ls -l ~/.ssh/terraform-ansible-lab.pem

What does the final check prove?

  • The first command proves that both project subdirectories exist inside WSL.
  • The second command proves that AWS has the regional public key.
  • The third command proves that WSL has the matching private-key file with restricted permissions.

You should see directory entries for ansible plus environments. The AWS response should name terraform-ansible-lab with a KeyType of ed25519.

The PEM entry should show read permission for its owner. It should show no permissions for the group or other users.

✔️ Awesome, I've got everything!

Your WSL project folder is open in Visual Studio Code. The regional EC2 key pair now has its protected private-key file inside WSL.

ⓧ I'd like to double check the full code

The only target artifact in this step is a private key. Do not open its contents or paste them anywhere.

At ~/.ssh/terraform-ansible-lab.pem, you should have an existing or newly created ed25519 PEM private key for the regional EC2 key pair named terraform-ansible-lab. Its owner permissions should be 0400.

Your Linux workspace now has verified tools plus a secured EC2 key pair. Next, you will define the three environment configurations before applying Development.

Provision Development Infrastructure

Your Ubuntu on WSL workspace now has a verified toolchain. Your regional key pair is also ready for the Development server.

One reusable Terraform configuration needs environment-specific inputs. Terraform CLI workspaces isolate each environment's state so one plan cannot claim another environment's resources.

In this step, get ready to:
  • Define the provider requirements and reusable environment inputs.
  • Build the Development network and EC2 host from explicit Terraform resources.
  • Apply Development in an isolated workspace and test its HTTP endpoint.
Create the environment inputs

Variable definition files give the same resource graph different values for Development, Staging, and Production. The ignored local input keeps your public IPv4 restriction outside the environment files intended for version control.

Why variable files and workspaces?

Variable files control each environment's configuration. Terraform CLI workspaces keep the resulting state separate inside this local lab.

This makes the input boundary visible without duplicating the infrastructure code. Production systems that require separate credentials or access controls need separate configurations or backends.

  • Create .gitignore inside terraform-ansible-foundation with the New File control in the Visual Studio Code Explorer.
  • Paste these ignore rules into .gitignore:
.terraform/
terraform.tfstate.d/
*.tfstate
*.tfstate.*
terraform.tfvars
tfplan
ansible/inventory.ini

What do these rules protect?

  • The state patterns keep local Terraform state out of version control.
  • The terraform.tfvars rule excludes your learner-specific SSH CIDR.
  • The inventory rule excludes the runtime file that later contains your server's public address.
  • Save .gitignore.
  • Create versions.tf inside terraform-ansible-foundation with the Explorer's New File control.
  • Paste the pinned Terraform and provider configuration into versions.tf:
terraform {
  required_version = "= 1.16.3"

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

provider "aws" {
  region = var.aws_region
}

What does this configuration do?

  • The Terraform block pins the CLI to 1.16.3.
  • The provider requirement pins the AWS provider to 6.66.0.
  • The provider block reads its region from the reusable aws_region input.
  • Save versions.tf.
  • Create variables.tf inside terraform-ansible-foundation with the Explorer's New File control.
  • Add the region and validated environment inputs to variables.tf by pasting this first section:
variable "aws_region" {
  description = "AWS Region for the disposable lab"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Deployment environment name"
  type        = string

  validation {
    condition     = contains(["development", "staging", "production"], var.environment)
    error_message = "Environment must be development, staging, or production."
  }
}

How are the environment names controlled?

The region defaults to us-east-1. The validation accepts only the three environment names used by this project.

  • Add the network and capacity inputs below the environment variable:
variable "vpc_cidr" {
  description = "IPv4 CIDR block for the environment VPC"
  type        = string
}

variable "public_subnet_cidrs" {
  description = "Two public subnet CIDR blocks for the environment"
  type        = list(string)
}

variable "instance_type" {
  description = "EC2 instance type for the environment web server"
  type        = string
}

variable "root_volume_size" {
  description = "Root gp3 volume size in GiB"
  type        = number
}

What changes between environments?

These inputs control the VPC range and two subnet ranges. They also control the server type and root-volume size.

  • Add the key pair and restricted SSH inputs at the bottom of variables.tf:
variable "key_name" {
  description = "Existing regional EC2 key pair used by Ansible over SSH"
  type        = string
  default     = "terraform-ansible-lab"
}

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

How is SSH restricted?

The key name defaults to the regional key pair from the previous step. The allowed_ssh_cidr input limits SSH access to one public IPv4 address with a /32 prefix.

  • Save variables.tf.
  • Create environments/development.tfvars with the Explorer's New File control.
  • Paste the Development values into environments/development.tfvars:
environment         = "development"
vpc_cidr            = "10.20.0.0/16"
public_subnet_cidrs = ["10.20.1.0/24", "10.20.2.0/24"]
instance_type       = "t3.micro"
root_volume_size    = 8

What defines Development?

Development uses the 10.20.0.0/16 VPC range. Its t3.micro host receives an 8 GiB root volume.

  • Save environments/development.tfvars.
  • Create environments/staging.tfvars with the Explorer's New File control.
  • Paste the Staging values into environments/staging.tfvars:
environment         = "staging"
vpc_cidr            = "10.30.0.0/16"
public_subnet_cidrs = ["10.30.1.0/24", "10.30.2.0/24"]
instance_type       = "t3.micro"
root_volume_size    = 10

What defines Staging?

Staging moves to the 10.30.0.0/16 range. Its root volume increases to 10 GiB.

  • Save environments/staging.tfvars.
  • Create environments/production.tfvars with the Explorer's New File control.
  • Paste the Production values into environments/production.tfvars:
environment         = "production"
vpc_cidr            = "10.40.0.0/16"
public_subnet_cidrs = ["10.40.1.0/24", "10.40.2.0/24"]
instance_type       = "t3.micro"
root_volume_size    = 20

What defines Production?

Production uses the 10.40.0.0/16 range. Its root volume increases to 20 GiB.

  • Save environments/production.tfvars.
  • Create terraform.tfvars.example inside terraform-ansible-foundation with the Explorer's New File control.
  • Paste the safe example value into terraform.tfvars.example:
allowed_ssh_cidr = "replace-with-your-public-ip/32"

Why keep an example file?

The example documents the required input without publishing your address. Your real value belongs in the ignored terraform.tfvars file.

  • Save terraform.tfvars.example.
  • Open the AWS check IP page in your browser.
  • Copy the public IPv4 address shown on the page.
  • Record the address with /32 appended as your public IPv4 address with /32.
  • Create a copy of terraform.tfvars.example named terraform.tfvars with the Explorer.
  • Replace replace-with-your-public-ip/32 with your public IPv4 address with /32.
  • Save terraform.tfvars.
  • Format the files created so far by running this command in the existing WSL terminal:
terraform fmt

What does this command do?

Terraform formats the configuration files into its standard layout. A successful run returns control to your terminal.

Seeing a formatting problem?

Check the filename reported by Terraform. Compare that file's braces and quotation marks with the matching block above.

Ask for help with the exact message if the problem remains. Help me diagnose my Terraform formatting error.

Build the reusable AWS graph

The configuration now needs an explicit Amazon VPC path from the internet to one Amazon EC2 host. Terraform infers the creation order from references between these resources.

Why explicit resources instead of a module?

Explicit resources expose how the VPC connects to its subnets and route table. They also show how the internet gateway and security rules fit into the packet path.

A reusable module can hide those relationships. This project keeps the dependency graph visible while you learn the foundation.

  • Create main.tf inside terraform-ansible-foundation with the Explorer's New File control.
  • Add the shared name prefix and Development VPC to main.tf by pasting this first section:
locals {
  name_prefix = "terraform-ansible-${var.environment}"
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "lab" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "${local.name_prefix}-vpc"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

What does this section create?

  • The local value builds consistent names from the active environment.
  • The data source finds available Availability Zones in the selected region.
  • The VPC uses the environment's CIDR and enables DNS support.
  • Add public subnet A below the aws_vpc.lab resource:
resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.lab.id
  cidr_block              = var.public_subnet_cidrs[0]
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${local.name_prefix}-public-a"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

How does subnet A work?

Subnet A uses the first CIDR from the selected environment file. Instances launched there can receive public IPv4 addresses.

  • Save main.tf.
  • Format the first network section by running:
terraform fmt

What should happen?

Terraform formats the VPC and subnet blocks. Your terminal returns to its prompt when formatting succeeds.

  • Add public subnet B below the aws_subnet.public_a resource:
resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.lab.id
  cidr_block              = var.public_subnet_cidrs[1]
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${local.name_prefix}-public-b"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

Why add a second subnet?

Subnet B uses the second environment CIDR in another available zone. It creates a visible multi-zone foundation without launching another server.

  • Add the internet gateway and public route table below the aws_subnet.public_b resource:
resource "aws_internet_gateway" "lab" {
  vpc_id = aws_vpc.lab.id

  tags = {
    Name        = "${local.name_prefix}-igw"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

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        = "${local.name_prefix}-public-rt"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

How does traffic reach the internet?

The internet gateway attaches to the VPC. The default route sends IPv4 traffic for external destinations through that gateway.

  • Save main.tf.
  • Format the expanded network graph by running:
terraform fmt

What does this check show?

A successful formatting pass confirms Terraform can parse the added gateway and route-table structure.

  • Add the route-table associations and web security group below the aws_route_table.public resource:
resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

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

resource "aws_security_group" "web" {
  name        = "${local.name_prefix}-web-sg"
  description = "Allow restricted SSH and public HTTP for the environment"
  vpc_id      = aws_vpc.lab.id

  tags = {
    Name        = "${local.name_prefix}-web-sg"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

What do these resources connect?

The associations give both subnets the public route. The security group becomes the rule container for the web server.

  • Add the SSH, HTTP, and outbound rules below the aws_security_group.web resource:
resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.web.id
  description       = "SSH from the learner public IP"
  cidr_ipv4         = var.allowed_ssh_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
  description       = "Public HTTP for the demo page"
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_egress_rule" "all_ipv4" {
  security_group_id = aws_security_group.web.id
  description       = "Allow outbound IPv4 traffic"
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

How is traffic controlled?

  • The SSH rule accepts port 22 traffic only from your allowed_ssh_cidr value.
  • The HTTP rule allows public traffic on port 80 for the demonstration page.
  • The egress rule lets the instance reach IPv4 destinations for package installation.
  • Save main.tf.
  • Format the routing and security sections by running:
terraform fmt

What does success look like?

Terraform returns to the prompt after formatting the new resources. Any parsing problem points to the file and nearby line.

  • Add the Development EC2 instance below the aws_vpc_security_group_egress_rule.all_ipv4 resource:
resource "aws_instance" "web" {
  ami                         = "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
  instance_type               = var.instance_type
  subnet_id                   = aws_subnet.public_a.id
  vpc_security_group_ids      = [aws_security_group.web.id]
  associate_public_ip_address = true
  key_name                    = var.key_name

  metadata_options {
    http_tokens = "required"
  }

  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.root_volume_size
    encrypted             = true
    delete_on_termination = true
  }

  depends_on = [aws_internet_gateway.lab]

  tags = {
    Name        = "${local.name_prefix}-web"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

How is the server configured?

  • The AMI reference resolves the latest regional Amazon Linux 2023 image through AWS Systems Manager Parameter Store.
  • The host uses subnet A and the existing terraform-ansible-lab key pair.
  • IMDSv2 tokens are required through http_tokens.
  • The encrypted gp3 root volume is deleted with the instance.
  • Save main.tf.
  • Format the completed resource graph by running:
terraform fmt

What has Terraform formatted?

The complete graph now covers networking, access rules, and the EC2 host. Terraform can infer their order from the resource references.

  • Create outputs.tf inside terraform-ansible-foundation with the Explorer's New File control.
  • Add the environment, workspace, and public IP outputs to outputs.tf by pasting this first section:
output "environment" {
  description = "Environment supplied by the selected variable file"
  value       = var.environment
}

output "workspace" {
  description = "Terraform CLI workspace holding this environment state"
  value       = terraform.workspace
}

output "public_ip" {
  description = "Public IPv4 address of the web server"
  value       = aws_instance.web.public_ip
}

Why expose these values?

These outputs identify the selected environment and state workspace. They also expose the server address for later automation.

  • Add the website and subnet outputs below the public_ip output:
output "website_url" {
  description = "HTTP URL for the web server"
  value       = "http://${aws_instance.web.public_ip}"
}

output "public_subnets" {
  description = "Public subnet IDs and Availability Zones"
  value = {
    public_a = {
      id = aws_subnet.public_a.id
      az = aws_subnet.public_a.availability_zone
    }
    public_b = {
      id = aws_subnet.public_b.id
      az = aws_subnet.public_b.availability_zone
    }
  }
}

What can you inspect after the apply?

The website output constructs an HTTP URL from the instance address. The subnet output preserves each subnet ID and Availability Zone.

  • Save outputs.tf.
  • Format the first output sections by running:
terraform fmt

What should Terraform confirm?

Terraform formats the output expressions and returns to the terminal prompt. The output file is now ready for its Ansible handoff value.

  • Add the Ansible inventory output at the bottom of outputs.tf:
output "ansible_inventory" {
  description = "INI inventory content for the Ansible web group"
  value       = <<-EOT
    [web]
    ${aws_instance.web.public_ip} ansible_user=ec2-user deployment_environment=${var.environment}
  EOT
}

How does Terraform hand off to Ansible?

This output turns the instance address into an INI inventory entry. It includes the Amazon Linux SSH user and active environment value.

  • Save outputs.tf.
  • Initialize the Terraform working directory by running:
terraform init

What does initialization prepare?

Initialization installs the pinned AWS provider and prepares Terraform's local working data. The first run can take several minutes while the provider downloads.

Initialization not completing?

Confirm WSL can reach the internet. Check that versions.tf pins the provider source and version exactly as shown.

Share the exact message without sharing AWS credentials. Help me troubleshoot terraform init.

  • Validate the complete configuration by running:
terraform validate

What does validation prove?

Validation checks the configuration syntax and internal references. A successful result means the files form one coherent Terraform configuration.

Validation finding a problem?

Open the reported file in VS Code. Compare the named block with the full-code reference below.

Ask for targeted help if the reference still matches. Help me fix my Terraform validation error.

✔️ Awesome, I've got everything!

Strong work. Terraform is initialized with the pinned provider and your complete configuration passes validation.

ⓧ I'd like to double check the full code

Compare each saved file with these complete references.

.terraform/
terraform.tfstate.d/
*.tfstate
*.tfstate.*
terraform.tfvars
tfplan
ansible/inventory.ini
terraform {
  required_version = "= 1.16.3"

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

provider "aws" {
  region = var.aws_region
}
variable "aws_region" {
  description = "AWS Region for the disposable lab"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Deployment environment name"
  type        = string

  validation {
    condition     = contains(["development", "staging", "production"], var.environment)
    error_message = "Environment must be development, staging, or production."
  }
}

variable "vpc_cidr" {
  description = "IPv4 CIDR block for the environment VPC"
  type        = string
}

variable "public_subnet_cidrs" {
  description = "Two public subnet CIDR blocks for the environment"
  type        = list(string)
}

variable "instance_type" {
  description = "EC2 instance type for the environment web server"
  type        = string
}

variable "root_volume_size" {
  description = "Root gp3 volume size in GiB"
  type        = number
}

variable "key_name" {
  description = "Existing regional EC2 key pair used by Ansible over SSH"
  type        = string
  default     = "terraform-ansible-lab"
}

variable "allowed_ssh_cidr" {
  description = "Your current public IPv4 address with a /32 prefix"
  type        = string
}
allowed_ssh_cidr = "replace-with-your-public-ip/32"
environment         = "development"
vpc_cidr            = "10.20.0.0/16"
public_subnet_cidrs = ["10.20.1.0/24", "10.20.2.0/24"]
instance_type       = "t3.micro"
root_volume_size    = 8
environment         = "staging"
vpc_cidr            = "10.30.0.0/16"
public_subnet_cidrs = ["10.30.1.0/24", "10.30.2.0/24"]
instance_type       = "t3.micro"
root_volume_size    = 10
environment         = "production"
vpc_cidr            = "10.40.0.0/16"
public_subnet_cidrs = ["10.40.1.0/24", "10.40.2.0/24"]
instance_type       = "t3.micro"
root_volume_size    = 20
locals {
  name_prefix = "terraform-ansible-${var.environment}"
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "lab" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "${local.name_prefix}-vpc"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.lab.id
  cidr_block              = var.public_subnet_cidrs[0]
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${local.name_prefix}-public-a"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.lab.id
  cidr_block              = var.public_subnet_cidrs[1]
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${local.name_prefix}-public-b"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

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

  tags = {
    Name        = "${local.name_prefix}-igw"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

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        = "${local.name_prefix}-public-rt"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

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

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

resource "aws_security_group" "web" {
  name        = "${local.name_prefix}-web-sg"
  description = "Allow restricted SSH and public HTTP for the environment"
  vpc_id      = aws_vpc.lab.id

  tags = {
    Name        = "${local.name_prefix}-web-sg"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.web.id
  description       = "SSH from the learner public IP"
  cidr_ipv4         = var.allowed_ssh_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
  description       = "Public HTTP for the demo page"
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_egress_rule" "all_ipv4" {
  security_group_id = aws_security_group.web.id
  description       = "Allow outbound IPv4 traffic"
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

resource "aws_instance" "web" {
  ami                         = "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
  instance_type               = var.instance_type
  subnet_id                   = aws_subnet.public_a.id
  vpc_security_group_ids      = [aws_security_group.web.id]
  associate_public_ip_address = true
  key_name                    = var.key_name

  metadata_options {
    http_tokens = "required"
  }

  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.root_volume_size
    encrypted             = true
    delete_on_termination = true
  }

  depends_on = [aws_internet_gateway.lab]

  tags = {
    Name        = "${local.name_prefix}-web"
    Project     = "terraform-ansible-foundation"
    Environment = var.environment
  }
}
output "environment" {
  description = "Environment supplied by the selected variable file"
  value       = var.environment
}

output "workspace" {
  description = "Terraform CLI workspace holding this environment state"
  value       = terraform.workspace
}

output "public_ip" {
  description = "Public IPv4 address of the web server"
  value       = aws_instance.web.public_ip
}

output "website_url" {
  description = "HTTP URL for the web server"
  value       = "http://${aws_instance.web.public_ip}"
}

output "public_subnets" {
  description = "Public subnet IDs and Availability Zones"
  value = {
    public_a = {
      id = aws_subnet.public_a.id
      az = aws_subnet.public_a.availability_zone
    }
    public_b = {
      id = aws_subnet.public_b.id
      az = aws_subnet.public_b.availability_zone
    }
  }
}

output "ansible_inventory" {
  description = "INI inventory content for the Ansible web group"
  value       = <<-EOT
    [web]
    ${aws_instance.web.public_ip} ansible_user=ec2-user deployment_environment=${var.environment}
  EOT
}
Apply Development in an isolated workspace

The configuration is reusable. Its first applied state belongs in a dedicated Development workspace so later previews remain isolated.

  • Select the Development workspace by running:
terraform workspace select -or-create development

What does this workspace command do?

Terraform selects the existing Development workspace or creates it when absent. Its local state is stored separately from the default workspace.

  • Confirm the selected workspace by running:
terraform workspace show

What should you see?

You should see development as the current workspace. This proves the upcoming plan targets Development state.

  • Preview the Development resources by running:
terraform plan -var-file="environments/development.tfvars"

What should the plan contain?

The plan should propose a VPC using 10.20.0.0/16 and two public subnets. It should also propose one t3.micro instance with an 8 GiB root volume.

Review the planned names for the terraform-ansible-development prefix. Check that SSH uses your single /32 CIDR.

Plan showing unexpected values?

Confirm the command references environments/development.tfvars. Check that terraform workspace show previously returned development.

Do not apply until the plan matches Development. Help me review my Terraform Development plan.

Development starts consuming AWS credits

This apply creates an EC2 instance, an 8 GiB gp3 root volume, and a public IPv4 address. These resources can consume Free Tier credits or accrue charges until you clean them up.

Only Development is applied in this project. Staging and Production remain plan-only.

Before you run the apply, do you expect Terraform to create resources in the Development workspace or modify another environment?

  • Apply the reviewed Development plan by running:
terraform apply -var-file="environments/development.tfvars"

What happens during the apply?

Terraform shows the proposed actions again before requesting approval. Review the plan before entering the confirmation requested by Terraform.

Provisioning can take several minutes while AWS creates the network and instance. The terminal prints progress until the apply completes.

The completed apply gives Development its own VPC, public subnets, routing, security rules, and EC2 host. Your output values now expose the resulting addresses.

Apply not completing?

Check that your shared AWS configuration still uses the intended account. Confirm your IAM permissions allow VPC and EC2 resource creation.

If AWS rejects the SSH CIDR, confirm terraform.tfvars contains one IPv4 address followed by /32.

Ask for help with the exact failure before running another apply. Help me troubleshoot my Terraform apply.

  • Confirm the state still belongs to Development by running:
terraform workspace show

What does this final workspace check prove?

You should see development again. The newly created AWS resources are recorded in Development state.

  • Print the Development website address by running:
terraform output -raw website_url

What does this output provide?

Terraform prints the HTTP URL built from the EC2 instance's public IPv4 address. The raw form is ready to copy into your browser.

  • Record the printed address as your Development website URL.

Before you open the URL, do you think an EC2 instance with an allowed HTTP rule is enough to serve a website?

  • Paste your Development website URL into your browser's address bar.
  • Press Enter to send the request.

Your browser cannot load the page. This is the intended result for the current Development host.

Why does the browser request fail?

Terraform created the network path and allowed HTTP traffic through the security group. The instance does not yet have web-server software listening on port 80.

The failed request separates infrastructure reachability from software configuration. That boundary is the handoff to Ansible.

Website URL missing?

Confirm the apply completed before rerunning the output command above. Check that the current workspace remains development.

The browser failure is expected at this point. Help me verify my Development output URL.

Development now has isolated Terraform state and a complete AWS foundation. Next, you'll turn its output into an Ansible inventory and prove the host is ready for configuration.

Connect Terraform to Ansible

Your Development server is running. Terraform already knows its public address. Ansible still needs a trustworthy route to that host.

Here, Terraform turns its output into an Ansible inventory. You will verify the server identity through SSH before testing the connection.

In this step, get ready to:
  • Generate the Development inventory from Terraform output.
  • Verify the EC2 host identity through SSH.
  • Confirm Ansible reaches the host with its ping module.
Generate and inspect the inventory

An Ansible inventory maps a named group to one or more managed hosts. Terraform can generate that map from the address stored in Development state.

Before you run the check, take a moment to predict which workspace Terraform reports.

  • Confirm the selected workspace by running this command:
terraform workspace show

What does this check prove?

The command reads the workspace selected in this Terraform directory. Seeing development confirms that the next output comes from the applied Development state.

  • Generate ansible/inventory.ini from the Terraform output by running this command:
terraform output -raw ansible_inventory > ansible/inventory.ini

How does the handoff work?

Terraform prints the ansible_inventory output without extra formatting. The shell writes that text into ansible/inventory.ini.

The output supplies the current public address. It also carries ansible_user=ec2-user plus deployment_environment=development into Ansible.

  • Inspect the generated inventory structure by running this command:
ansible-inventory -i ansible/inventory.ini --graph

What does the graph confirm?

The inventory command parses ansible/inventory.ini as Ansible input. You should see the web group with one Development host beneath it.

Great, the Terraform output is now a valid Ansible target.

Can't inspect the inventory?

  • Confirm your terminal is still inside ~/projects/terraform-ansible-foundation.
  • Rerun the inventory-generation command if the web group is missing.

Ask for help with the output you see: Help me troubleshoot my Terraform-generated Ansible inventory.

✔️ Awesome, I've got everything!

Your generated inventory is valid. It now points the web group at the Development host.

ⓧ I'd like to double check the full code

  • Compare ansible/inventory.ini with this reference structure:
[web]
<Terraform development public_ip> ansible_user=ec2-user deployment_environment=development

How should the generated file look?

  • The [web] line defines the managed host group.
  • The placeholder represents the actual Development public IPv4 address generated by Terraform.
  • The ansible_user=ec2-user value selects the Amazon Linux login user.
  • The deployment_environment=development value carries the environment into Ansible.
Verify the EC2 host fingerprint

A host-key fingerprint identifies the remote server during the first SSH connection. Comparing it with the Amazon EC2 system log protects the trust decision.

Finding the fingerprint is a little fiddly because it sits inside the instance system log.

  • Open the Amazon EC2 console in your browser.
  • Choose Instances from the left navigation.
  • Select terraform-ansible-development-web from the instance list.
  • Choose Actions above the instance list.
  • Choose Monitor and troubleshoot.
  • Choose Get system log.
  • Find BEGIN SSH HOST KEY FINGERPRINTS in the console output.

Before you connect, predict whether SSH already trusts this new host.

  • Return to Ubuntu on WSL.
  • Connect to the Development instance by running this command:
ssh -i ~/.ssh/terraform-ansible-lab.pem ec2-user@$(terraform output -raw public_ip)

What does this command do?

Terraform supplies the current Development public address. SSH uses ~/.ssh/terraform-ansible-lab.pem to authenticate as ec2-user.

The first connection pauses because this host has not been trusted from WSL before.

Accepting a host key is a security decision. The console comparison gives you a trusted reference.

  • Compare the fingerprint shown by SSH with the matching fingerprint in the EC2 system log.
  • Enter yes only when the fingerprints match.
  • Press Ctrl+D after the remote shell opens.

You return to your project terminal. Ansible can now keep host-key checking enabled for this instance.

SSH won't connect?

  • Confirm your current public IPv4 address still matches allowed_ssh_cidr in the applied Development configuration.
  • Confirm ~/.ssh/terraform-ansible-lab.pem is the private key paired with terraform-ansible-lab.
  • Stop if the SSH fingerprint differs from every fingerprint in the selected instance system log.

Share the connection symptom without sharing your private key: Help me troubleshoot this SSH connection from WSL to my Development EC2 instance.

Test the Ansible connection

The Ansible ping module tests the complete control path. A successful result proves the inventory resolves to the host. It also proves SSH authentication works with a usable Python runtime.

Before you run the final check, predict whether every part of the handoff is ready.

  • Test the Development host through Ansible by running this command:
ansible web -i ansible/inventory.ini --private-key ~/.ssh/terraform-ansible-lab.pem -m ansible.builtin.ping

What does the final check cover?

  • The web argument selects the inventory group.
  • The -i flag points Ansible to ansible/inventory.ini.
  • The --private-key flag supplies the matching EC2 private key.
  • The ansible.builtin.ping module checks login access. It also confirms usable Python on the managed host.

You should see the Development host report pong.

That's the handoff proven. Terraform has supplied a target that Ansible can authenticate to and manage.

Not seeing pong?

  • Confirm the web group still contains the current Development public IPv4 address.
  • Confirm your SSH connection succeeded with the same private key.
  • Rerun the inventory-generation command if Terraform has replaced the EC2 instance.

Use the returned Ansible details to get targeted help: Help me diagnose why ansible.builtin.ping is not returning pong.

Your Terraform state now feeds a trusted Ansible target. Next, you'll use that connection to configure the Development web server.

Configure the Web Server

Your Development Amazon EC2 instance now answers Ansible with pong. That result proves the inventory, SSH connection, and Python runtime work.

The browser still cannot load a page because the host has no web software. In this step, an Ansible playbook takes responsibility for the package, page content, and service state inside the host.

In this step, get ready to:
  • Create a syntax-valid playbook that installs Apache HTTP Server.
  • Publish the custom automation page to the Development host.
  • Keep Apache HTTP Server running across reboots.
Create the playbook foundation

An Ansible playbook uses YAML to describe the state you want on a managed host. The first task ensures the httpd package is present on every host in the web inventory group.

  • In the Visual Studio Code window from earlier, select the ansible folder in the file sidebar.
  • Use the new-file control in the file sidebar.
  • Enter playbook.yml as the file name.
  • Paste this play definition into ansible/playbook.yml:
---
- name: Configure the lab web server
  hosts: web
  become: true
  gather_facts: false

  tasks:
    - name: Install Apache HTTP Server
      ansible.builtin.dnf:
        name: httpd
        state: present

What Does This Play Do?

  • The hosts: web line targets the group in ansible/inventory.ini.
  • The become: true setting gives package tasks the privileges they need on the managed host.
  • The gather_facts: false setting skips host discovery because this play does not use those facts.
  • The ansible.builtin.dnf task ensures the httpd package is installed.
  • Save ansible/playbook.yml.

Before you check the first task, do you expect Ansible to parse the playbook successfully?

  • Check the playbook syntax by running:
ansible-playbook -i ansible/inventory.ini --private-key ~/.ssh/terraform-ansible-lab.pem --syntax-check ansible/playbook.yml

What Does This Check Prove?

Ansible reads the inventory and parses ansible/playbook.yml without changing the Development host. A successful check identifies the playbook path without reporting a syntax problem.

Seeing a YAML Problem?

Check that tasks: lines up with hosts:. Make sure the package task is indented beneath tasks:.

Compare each space near the reported line because YAML indentation defines the structure.

Help me fix the syntax in my Ansible playbook.

Publish the page and manage Apache

The package gives the host web server software. The next task writes your page to Apache HTTP Server's document root.

  • In ansible/playbook.yml, place your cursor after the state: present line.
  • Paste the page task below the package task:
    - name: Publish the automation page
      ansible.builtin.copy:
        content: |
          <!doctype html>
          <html lang="en">
          <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title>Infrastructure Automated</title>
            <style>
              body { font-family: system-ui, sans-serif; max-width: 760px; margin: 4rem auto; padding: 0 1rem; color: #172033; }
              main { border: 1px solid #d8dee9; border-radius: 12px; padding: 2rem; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); }
              h1 { color: #5c4ee5; }
              code { background: #eef0ff; padding: 0.15rem 0.35rem; border-radius: 4px; }
            </style>
          </head>
          <body>
            <main>
              <h1>Infrastructure Automated</h1>
              <p>Terraform built the AWS network and EC2 instance.</p>
              <p>Ansible installed Apache HTTP Server and published this page.</p>
              <p>Run the playbook again to prove <code>changed=0</code>.</p>
            </main>
          </body>
          </html>
        dest: /var/www/html/index.html
        owner: root
        group: root
        mode: "0644"

How Does the Page Get Published?

  • The content: | block holds the complete page that Ansible writes to the server.
  • The dest value places that page at /var/www/html/index.html.
  • The owner value assigns the file to root.
  • The group value assigns the file to the root group.
  • The mode value makes the page readable while keeping write access restricted.
  • Save ansible/playbook.yml.
  • Check the updated YAML by running:
ansible-playbook -i ansible/inventory.ini --private-key ~/.ssh/terraform-ansible-lab.pem --syntax-check ansible/playbook.yml

What Does This Check Confirm?

The successful result confirms that Ansible can parse the multiline page content as part of the copy task. The command still leaves the Development host unchanged.

Page Task Failing the Syntax Check?

Make sure every page line is indented beneath content: |. Check that dest lines up with content.

Help me fix the indentation in my Ansible copy task.

The page file can only respond to requests while Apache HTTP Server is running. The final task starts the service now and enables it after a reboot.

  • Place your cursor after the mode: "0644" line in ansible/playbook.yml.
  • Paste the service task below the page task:
    - name: Start Apache and enable it at boot
      ansible.builtin.service:
        name: httpd
        state: started
        enabled: true

How Does the Service Task Help?

  • The name: httpd value targets the Apache HTTP Server service.
  • The state: started value ensures the service is running.
  • The enabled: true value ensures the service starts after a reboot.
  • Save ansible/playbook.yml.

Before the final syntax check, do you expect Ansible to recognize all three tasks?

  • Check the complete playbook syntax by running:
ansible-playbook -i ansible/inventory.ini --private-key ~/.ssh/terraform-ansible-lab.pem --syntax-check ansible/playbook.yml

What Should You See?

Ansible should complete the check without a syntax problem. That confirms the package, page, and service tasks form one valid play.

Final Syntax Check Still Failing?

Check that all three task names begin at the same indentation level beneath tasks:. Make sure each module name sits beneath its task name.

Help me compare my playbook structure with the expected three-task layout.

✔️ Awesome, I've got everything!

Great. Your three-task playbook is saved and passes Ansible's syntax check.

ⓧ I'd like to double check the full code

Compare your complete ansible/playbook.yml with this file:

---
- name: Configure the lab web server
  hosts: web
  become: true
  gather_facts: false

  tasks:
    - name: Install Apache HTTP Server
      ansible.builtin.dnf:
        name: httpd
        state: present

    - name: Publish the automation page
      ansible.builtin.copy:
        content: |
          <!doctype html>
          <html lang="en">
          <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title>Infrastructure Automated</title>
            <style>
              body { font-family: system-ui, sans-serif; max-width: 760px; margin: 4rem auto; padding: 0 1rem; color: #172033; }
              main { border: 1px solid #d8dee9; border-radius: 12px; padding: 2rem; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); }
              h1 { color: #5c4ee5; }
              code { background: #eef0ff; padding: 0.15rem 0.35rem; border-radius: 4px; }
            </style>
          </head>
          <body>
            <main>
              <h1>Infrastructure Automated</h1>
              <p>Terraform built the AWS network and EC2 instance.</p>
              <p>Ansible installed Apache HTTP Server and published this page.</p>
              <p>Run the playbook again to prove <code>changed=0</code>.</p>
            </main>
          </body>
          </html>
        dest: /var/www/html/index.html
        owner: root
        group: root
        mode: "0644"

    - name: Start Apache and enable it at boot
      ansible.builtin.service:
        name: httpd
        state: started
        enabled: true
Run the playbook and verify the website

A syntax check proves that Ansible can parse the playbook. Running it applies the declared package, file, and service states to the Development host.

Before you run the playbook, which tasks do you expect to report a change on this first configuration run?

  • Configure the Development host by running:
ansible-playbook -i ansible/inventory.ini --private-key ~/.ssh/terraform-ansible-lab.pem ansible/playbook.yml

What Happens During This Run?

  • The package task installs Apache HTTP Server through the managed host's package repository.
  • The copy task publishes your custom page at /var/www/html/index.html.
  • The service task starts httpd.
  • The service task also configures httpd to start after a reboot.

When the run finishes, the recap should show successful task results with no failed hosts. The package, page, and service tasks normally report changes on this first run.

Playbook Run Not Completing?

If the host is unreachable, confirm the Development instance is running. Confirm your current public IPv4 address still matches the SSH rule managed by Terraform.

If a task fails, read the task name in the recap first. That name identifies whether the package, page, or service state needs attention.

Help me troubleshoot my failed Ansible playbook run.

The earlier browser request failed because no process listened for HTTP traffic. Before you refresh it now, what do you expect the same URL to display?

  • Print the Development website URL by running:
terraform output -raw website_url

Where Does This URL Come From?

Terraform reads the website_url output from the selected Development workspace. The value points to the public IPv4 address of the EC2 instance you just configured.

  • Return to the browser tab from the earlier failed HTTP check.
  • Refresh the page.

You should see the heading Infrastructure Automated. The page should explain that Terraform built the AWS foundation and Ansible configured the server.

Website Still Not Loading?

Confirm the play recap contains no failed tasks. Check that the browser URL matches the latest website_url output from the Development workspace.

If the page opens without the custom content, confirm the copy task completed successfully. Refresh the browser after the playbook finishes.

Help me diagnose why my Terraform website URL is not showing the Ansible page.

You closed the failed-browser gap. Your Terraform-provisioned host now serves a page managed by Ansible.

Next, you will rerun both automation layers to prove they converge without unnecessary changes.

Prove Idempotency and Compare Plans

Your Development page is live. Ansible now keeps Apache HTTP Server aligned with the playbook.

Reliable automation must reach the same result without making unnecessary changes. This repeatable behavior is called idempotency.

Terraform CLI workspaces isolate each environment's state. This step previews both later environments before returning to Development.

In this step, get ready to:
  • Rerun the Ansible playbook to prove changed=0.
  • Compare plan results across all three environment workspaces.
  • Return to the live Development website.
Confirm Development convergence

Ansible modules compare the desired configuration with the server's current state. A second run reveals whether the playbook changes an already configured host.

  • Return to the Ubuntu on WSL terminal in ~/projects/terraform-ansible-foundation from earlier.

Before you rerun the playbook, predict whether any task still needs to change the Development server.

  • Test the playbook's idempotency by running:
ansible-playbook -i ansible/inventory.ini --private-key ~/.ssh/terraform-ansible-lab.pem ansible/playbook.yml

What does the second run prove?

Each module checks the managed host before taking action. Ansible skips work when the package, page content, and service already match ansible/playbook.yml.

A recap containing changed=0 proves the playbook reached the same desired state without modifying the server.

You should see changed=0 in the play recap. You have a clean convergence because the Development server already matches the playbook.

Playbook run failing?

  • Confirm ansible/inventory.ini still contains the Development host.
  • Confirm ~/.ssh/terraform-ansible-lab.pem still has read-only owner permissions.
  • Check that your current public IPv4 address still matches the SSH rule if the connection times out.

Help me diagnose why my second Ansible playbook run does not report changed=0.

Terraform performs the same convergence check for infrastructure. A Development plan should find that the live resources already match the configuration.

Before you run the plan, predict whether Terraform needs to change any Development resource.

  • Compare the Development configuration with its live infrastructure by running:
terraform plan -var-file="environments/development.tfvars"

What does this plan check?

Terraform reads the selected Development workspace state. It compares that state with the configuration in environments/development.tfvars.

An unchanged plan proves the infrastructure layer has converged too.

The plan should report no infrastructure changes. Your Development configuration now agrees with both the workspace state and the live resources.

Development plan showing changes?

  • Confirm the terminal still reports Development as the selected workspace.
  • Check that the plan uses environments/development.tfvars.
  • Review any proposed field to identify configuration drift before continuing.

Help me understand why my Development Terraform plan proposes changes.

Compare plan-only environments

A newly selected workspace starts with isolated state. Its first plan proposes a complete environment because that workspace does not track Development's resources.

Keep the previews plan-only

These previews read from Amazon Web Services without creating Staging or Production resources. Only the existing Development environment remains live.

  • Stop after each plan.
  • Do not apply either later environment.
  • Create or select the isolated Staging workspace by running:
terraform workspace select -or-create staging

How does workspace selection help?

The command selects staging when it already exists. It creates the workspace when it is missing.

The Staging workspace holds state separately from Development.

You should see confirmation that the staging workspace is selected.

  • Generate the Staging preview by running:
terraform plan -var-file="environments/staging.tfvars"

Why does Staging propose new resources?

The Staging workspace has no applied infrastructure in its state. Terraform therefore proposes an independent copy of the reusable resource graph.

The plan remains a preview because you do not apply it.

Terraform plans are dense, so scanning for a few environment signals makes this comparison easier.

  • Find the Staging VPC CIDR 10.30.0.0/16 in the plan.
  • Find the first public subnet CIDR 10.30.1.0/24.
  • Find the second public subnet CIDR 10.30.2.0/24.
  • Confirm the Environment tag is staging.
  • Confirm generated names begin with terraform-ansible-staging.
  • Confirm the root volume size is 10 GiB.
  • Confirm the root volume type is gp3.

The Staging plan should propose a complete resource set from empty Staging state. The command created only a proposal.

Staging values missing?

  • Confirm staging is the selected workspace.
  • Confirm the plan uses environments/staging.tfvars.
  • Compare any unexpected value with the Staging variable file.

Help me find why my Staging plan does not show the expected environment values.

  • Create or select the isolated Production workspace by running:
terraform workspace select -or-create production

What changes for Production?

The command moves Terraform to the production workspace. This workspace has its own isolated local state.

Development resources remain associated with Development state.

You should see confirmation that the production workspace is selected.

  • Generate the Production preview by running:
terraform plan -var-file="environments/production.tfvars"

What does the Production plan prove?

The plan uses the same Terraform resource graph with Production inputs. Its empty workspace state causes Terraform to propose a separate environment.

Production remains plan-only because no apply follows this preview.

  • Find the Production VPC CIDR 10.40.0.0/16 in the plan.
  • Find the first public subnet CIDR 10.40.1.0/24.
  • Find the second public subnet CIDR 10.40.2.0/24.
  • Confirm the Environment tag is production.
  • Confirm generated names begin with terraform-ansible-production.
  • Confirm the root volume size is 20 GiB.
  • Confirm the root volume type is gp3.

The Production plan should propose its own complete resource set. Both later environments remain previews with no applied resources.

Production values missing?

  • Confirm production is the selected workspace.
  • Confirm the plan uses environments/production.tfvars.
  • Compare any unexpected value with the Production variable file.

Help me find why my Production plan does not show the expected environment values.

Both later workspaces now hold isolated plan-only state. You kept the live cloud footprint limited to Development.

Return to Development

Workspace selection controls which state Terraform reads. Returning to Development restores the state view for the live web server.

  • Return Terraform to the Development workspace by running:
terraform workspace select development

Why return to Development?

The Development workspace tracks the resources you actually applied. Selecting it prevents later commands from targeting an empty plan-only workspace.

You should see confirmation that development is selected again.

Before you check the workspace list, predict which environment Terraform should mark as selected.

  • Verify all workspaces and the active selection by running:
terraform workspace list

What should the list confirm?

The list should contain development, staging, and production. Development should be marked as the selected workspace.

This confirms that all three state boundaries exist.

You should see all three named workspaces with Development marked as selected.

Before you reload the page, predict whether the plan-only previews changed the live Development server.

  • Return to the Development browser tab from the previous step.
  • Refresh the browser tab.

The custom Infrastructure Automated page should still load from the Development server. Strong finish: your live environment remained available throughout both isolated previews.

Development page not loading?

  • Confirm Development remains marked as the selected workspace.
  • Refresh the existing browser tab once more.
  • Check that your current network can still reach the Development public address.

Help me troubleshoot why my Development automation page stopped loading after the plan-only previews.

Secret mission

Make the Web Page Data-Driven

Turn the fixed Development page into a reusable Jinja2 template. Carry the active environment from Terraform inventory into the rendered website while preserving Ansible idempotency.

Clean Up Your Resources

Development is the only Terraform workspace that has been applied. Its Amazon EC2 compute, 8 GiB gp3 root volume, public IPv4 address, CPU-credit overages, and data transfer can consume AWS credits.

Without credits in US East (N. Virginia), the Linux t3.micro rate is $0.0104 per hour. The public IPv4 rate is $0.005 per hour.

Gp3 storage costs $0.08/GB-month. Staging and Production remain plan-only, so neither environment has AWS resources.

Nice Work!

Nice Work!

Excellent work! You built a reusable environment-aware AWS web server with Terraform. Ansible now configures its data-driven page idempotently.

You've learned how to:

  • Built one reusable Terraform configuration for three environments. Each environment uses its own variable definition file. Each also has isolated CLI workspace state.
  • Applied only Development to AWS. Previewed Staging without applying it. Previewed Production without applying it.
  • Passed Terraform outputs into Ansible inventory. Configured Apache HTTP Server on the Development EC2 host. Confirmed idempotency when the second playbook run reported changed=0.
  • Secret Mission: Rendered the page from a reusable Jinja2 template. It displays development from the inventory value. A second run still reports changed=0.

Ready to quiz yourself?