Provision and Harden AWS Infra with IaC

Provision hardened AWS infrastructure with Terraform and configure it with Ansible.

Introduction

30 Second Summary

Every app on your phone went through a chain of build, test, and deploy steps before reaching you. If any link in that chain gets compromised, attackers can slip malicious code into production without a single alarm going off.

In this project, you will provision and harden real AWS infrastructure using Terraform and Ansible. By the end, you will have a monitored, locked-down server running in the cloud, ready to anchor a complete supply chain security pipeline.

What You'll Build

When you are done, you will open your browser to a Grafana dashboard streaming real-time CPU and memory metrics from a hardened EC2 instance you provisioned and configured entirely through code.

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

  • A live, code-provisioned AWS environment where you can run terraform apply and watch an ECR repository, an encrypted S3 bucket, and a hardened EC2 instance spin up in minutes.
  • A running Prometheus and Grafana monitoring stack you can open in your browser, streaming real-time host metrics from your server.
  • Firsthand experience hitting a deliberate security group wall and tightening an IAM role to least privilege, proving how Terraform's infrastructure state and Ansible's configuration state work together as defense layers.
  • Secret Mission: Write a CIS-style hardening verification playbook that audits your security controls and reports pass/fail for each check.

Are there any prerequisites?

This is Part 1 of the Secure Software Supply Chain Pipeline series. You will need an AWS account with billing enabled and CLI credentials configured, a Windows machine with WSL2 and Ubuntu installed, and VS Code.

Before We Start

You're about to build real AWS infrastructure that costs money and touches your cloud account. Before writing a single line of code, let's commit to what you're building and why it matters.

Set Up Your Toolkit

You have a clear goal: provision AWS infrastructure with Terraform and configure it with Ansible. Before you can do either, you need both tools installed and working in their respective environments.

Terraform runs natively on Windows in your PowerShell terminal. Ansible runs on Linux, so you will use your WSL2 Ubuntu terminal. In this step, you will install both tools, generate an SSH key pair for connecting to your future EC2 instance, and verify everything works.

In this step, get ready to:
  • Install Terraform on Windows and generate an SSH key pair for EC2 access.
  • Install Ansible in WSL2 Ubuntu.
  • Set up dual terminals in VS Code and verify AWS credentials from both environments.
Install Terraform on Windows and generate an SSH key pair

Terraform needs to run from PowerShell so it can provision AWS resources using Windows-native AWS CLI credentials. You will also generate an SSH key pair in WSL2 that Terraform will later attach to your EC2 instance.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a new terminal by pressing Ctrl+Shift+` or clicking Terminal in the top menu bar, then New Terminal.
  • Make sure the terminal is set to PowerShell. Click the dropdown arrow next to the + icon in the terminal panel and select PowerShell if it is not already selected.

You need to run the install command as Administrator because Chocolatey requires elevated privileges to install packages system-wide.

  • Install Terraform by running this command in your PowerShell terminal:
choco install terraform -y

What does this command do?

Chocolatey is a package manager for Windows, similar to apt on Ubuntu. The -y flag automatically confirms the installation prompt.

If you do not have Chocolatey installed, the command will fail. See the troubleshooting callout below for how to install it or use an alternative method.

  • Verify Terraform installed correctly by running:
terraform version

✔️ I see version 1.15 or higher

Terraform is ready to go. Move on to the next part of this substep.

ⓧ I see an older version

You have an older version of Terraform installed. Upgrade it by running:

choco upgrade terraform -y
  • Run terraform version again to confirm it now shows 1.15 or higher.

ⓧ Command not found

The install did not complete. This usually means Chocolatey is not installed on your system.

  • Open a new PowerShell window as Administrator by right-clicking the Windows icon and selecting Terminal (Admin).
  • Install Chocolatey by running this command:
Set-ExecutionPolicy Bypass -Scope Process -Force; iwr https://community.chocolatey.org/install.ps1 -UseBasicParsing | iex
  • Close and reopen your PowerShell terminal, then run choco install terraform -y followed by terraform version.

Still seeing an error after installing Chocolatey?

Make sure you opened PowerShell as Administrator. Chocolatey requires elevated privileges for both its own installation and for installing packages like Terraform.

If Chocolatey is not an option, you can download the Terraform binary directly from the HashiCorp install page. Download the AMD64 zip, extract terraform.exe to a folder like C:\terraform, and add that folder to your system PATH.

help me install Terraform on Windows

Now you need to create the project directories on your Desktop so you have a place to store files that both PowerShell and WSL2 can access.

  • Move to your Desktop by running this command:
cd ~\Desktop
  • Create the project directory with its subdirectories by running:
mkdir supply-chain-infra\terraform, supply-chain-infra\ansible
  • Confirm the directories were created by running:
ls supply-chain-infra

You should see terraform and ansible listed as subdirectories.

Next, you will switch to your WSL2 terminal to generate an SSH key pair. The private key stays in WSL2 where file permissions are handled correctly. Only the public key gets copied to the Windows filesystem so Terraform can read it.

  • Open a WSL2/Ubuntu terminal tab by clicking the dropdown arrow next to the + icon in the terminal panel and selecting Ubuntu (WSL).
  • Generate an SSH key pair by running this command:
ssh-keygen -t ed25519 -f ~/.ssh/supply-chain-key -N ""

What does this command do?

This creates two files: a private key at ~/.ssh/supply-chain-key and a public key at ~/.ssh/supply-chain-key.pub. The -t ed25519 flag selects a modern, secure key algorithm. The -N "" flag sets an empty passphrase so you are not prompted during automation.

  • Confirm the keys were created by running:
ls ~/.ssh/supply-chain-key*

You should see both supply-chain-key and supply-chain-key.pub listed.

Now copy the public key to the terraform/ directory on the Windows filesystem so Terraform can use it when creating the EC2 instance.

  • Copy the public key to the project directory by running this command:
cp ~/.ssh/supply-chain-key.pub /mnt/c/Users/[[WIN_USERNAME="your Windows username"]]/Desktop/supply-chain-infra/terraform/supply-chain-key.pub

How do I find my Windows username?

Your Windows username is the name of your user folder. You can find it by running ls /mnt/c/Users/ in WSL2 and looking for your personal folder (not Public or Default).

  • Confirm the public key was copied by running:
ls /mnt/c/Users/[[WIN_USERNAME="your Windows username"]]/Desktop/supply-chain-infra/terraform/

You should see supply-chain-key.pub listed in the terraform/ directory.

No such file or directory?

Double-check your Windows username in the path. Run ls /mnt/c/Users/ to see the exact folder name. Usernames with spaces need to be wrapped in quotes.

Also confirm the supply-chain-infra/terraform/ directory exists on your Desktop. If not, go back to the PowerShell terminal and run the mkdir command again.

help me copy my SSH public key from WSL2 to my Windows project directory

Install Ansible in WSL2

Ansible does not run natively on Windows. It runs in your WSL2 Ubuntu environment and connects to remote Linux servers over SSH. This is exactly how it will reach your EC2 instance later.

Why run Ansible from WSL2?

Ansible requires a Linux or macOS control node. Windows without WSL is not natively supported as a control node.

For a learning lab targeting a remote Linux EC2 instance over SSH, WSL2 works reliably. In a production environment, you would run Ansible from a dedicated Linux server or CI/CD runner instead.

Ubuntu 24.04 ships with Python 3.12, which meets Ansible's requirement of Python 3.12 or higher.

  • In your WSL2 terminal, make sure pip is available by running:
sudo apt update && sudo apt install python3-pip -y
  • Install Ansible by running:
pip install --user ansible

What does this command do?

The --user flag installs Ansible into your home directory instead of system-wide. This avoids needing sudo and keeps your system Python clean.

Pip may warn about PATH

If you see a warning that the install location is not on your PATH, run export PATH="$HOME/.local/bin:$PATH" and add it to your ~/.bashrc so it persists across sessions.

  • Verify Ansible installed correctly by running:
ansible --version

✔️ I see ansible-core 2.x

Ansible is installed and ready. You should see output that starts with ansible [core 2. followed by a version number. Move on to the next substep.

ⓧ I see an older version

You have an older version installed. Upgrade it by running:

pip install --user --upgrade ansible
  • Run ansible --version again to confirm it now shows ansible [core 2. or higher.

ⓧ Command not found

The Ansible binary is not on your PATH. This is common with --user installs.

  • Add the local bin directory to your PATH by running:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
  • Run ansible --version again. You should now see the version output.

Still not working?

Confirm Python 3.12+ is installed by running python3 --version. If you see a version below 3.12, you may need to update your Ubuntu installation in WSL2.

help me install Ansible in WSL2 Ubuntu

Open dual terminals in VS Code and verify AWS credentials

Throughout this project, you will switch between two terminals constantly: PowerShell for Terraform commands and WSL2/Ubuntu for Ansible commands. Both terminals need working AWS CLI credentials so each tool can interact with your AWS account.

  • Check that your VS Code terminal panel has both a PowerShell tab and an Ubuntu (WSL) tab. If either is missing, click the dropdown arrow next to the + icon and add it.

Before you predict: do you think aws sts get-caller-identity will return the same account from both terminals?

  • In your PowerShell terminal, verify AWS credentials by running:
aws sts get-caller-identity

You should see a JSON response with your Account, UserId, and Arn fields.

  • Switch to your WSL2 terminal tab and run the same command:
aws sts get-caller-identity

You should see the same AWS account information from both terminals. This confirms that both environments can authenticate with AWS.

Seeing 'Unable to locate credentials' from WSL2?

WSL2 does not automatically inherit your Windows AWS CLI configuration. You may need to run aws configure inside WSL2 separately and enter your access key ID, secret access key, and default region.

help me configure AWS CLI credentials in WSL2

  • In your PowerShell terminal, navigate to the project directory by running:
cd ~\Desktop\supply-chain-infra
  • In your WSL2 terminal, navigate to the same project directory by running:
cd /mnt/c/Users/[[WIN_USERNAME="your Windows username"]]/Desktop/supply-chain-infra
  • Confirm you are in the right place from WSL2 by running:
ls

You should see ansible and terraform listed. Both terminals are now pointing at the same project directory.

Why the same directory from two paths?

Your project files live on the Windows filesystem. PowerShell sees them at the Windows path (~\Desktop\supply-chain-infra). WSL2 accesses the same files through a mount point at /mnt/c/. Both terminals edit the same files, just through different paths.

Your toolkit is locked and loaded. Terraform is ready in PowerShell, Ansible is ready in WSL2, your SSH keys are generated, and both terminals can talk to AWS. Next up, you will write your first Terraform configuration to provision real AWS infrastructure.

Write and Deploy Your Terraform Infrastructure

Ansible can only configure software on machines that already exist. Before you write a single Ansible task, the AWS resources need to be live and reachable. That means an ECR repository for container images, an S3 bucket for SBOM storage, an EC2 instance to run the monitoring stack, and an IAM role for the pipeline.

In this step, you will write all of your Terraform configuration files and deploy them to AWS. The security group will deliberately allow only SSH inbound, and the IAM role will deliberately use wildcard permissions. Both are intentional security gaps that you will close in a later step.

In this step, get ready to:
  • Write Terraform configuration files for all AWS resources.
  • Initialize Terraform and review the deployment plan.
  • Apply the configuration and verify SSH connectivity to the EC2 instance.
Write the Terraform configuration files

Your Terraform configuration is split across three files inside the terraform/ directory. variables.tf defines all inputs. main.tf declares the AWS resources. outputs.tf surfaces values you will need later.

  • In your VS Code PowerShell terminal, make sure you are inside the terraform/ directory.
  • Create a new file called variables.tf and add the first four variable declarations:
variable "aws_region" {
  description = "AWS region for all resources"
  type        = string
  default     = "us-east-1"
}

variable "ecr_repo_name" {
  description = "Name of the ECR repository"
  type        = string
  default     = "supply-chain-app"
}

variable "sbom_bucket_name" {
  description = "Globally unique name for the SBOM S3 bucket"
  type        = string
}

variable "ssh_public_key_path" {
  description = "Path to the SSH public key file"
  type        = string
  default     = "./supply-chain-key.pub"
}

What does this code do?

Each variable block declares an input that other Terraform files can reference. aws_region and ecr_repo_name have sensible defaults. sbom_bucket_name has no default because S3 bucket names must be globally unique, so Terraform will prompt you to choose one.

  • Add the remaining three variables to the bottom of variables.tf:
variable "allowed_cidr" {
  description = "CIDR block allowed to access the monitoring instance"
  type        = string
  default     = "0.0.0.0/0"
}

variable "instance_type" {
  description = "EC2 instance type for the monitoring stack"
  type        = string
  default     = "t3.micro"
}

variable "common_tags" {
  description = "Tags applied to all resources"
  type        = map(string)
  default = {
    Project     = "supply-chain-security"
    Environment = "lab"
    ManagedBy   = "terraform"
  }
}

What does this code do?

allowed_cidr controls which IP addresses can reach the EC2 instance. Its default is 0.0.0.0/0 (open to the internet), which is intentionally insecure for now. You will tighten this to your own IP in a later step.

common_tags attaches a consistent set of labels to every resource so you can identify them in the AWS console.

  • Save variables.tf.

✔️ Awesome, I've got everything!

Great. Make sure you have saved variables.tf before continuing.

ⓧ I'd like to double check the full code

Since the full file is longer than 30 lines, scroll up and confirm you have both chunks in order: the first four variables (aws_region through ssh_public_key_path) followed by the remaining three variables (allowed_cidr through common_tags). There should be seven variable blocks total.

  • Confirm the file exists by running:
ls

You should see variables.tf listed in the output (along with supply-chain-key.pub that you copied earlier).

  • Now create a new file called main.tf and add the Terraform provider block and the ECR repository resource:
terraform {
  required_version = ">= 1.2"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

resource "aws_ecr_repository" "supply_chain" {
  name                 = var.ecr_repo_name
  image_tag_mutability = "IMMUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }

  tags = var.common_tags
}

What does this code do?

The terraform block pins the AWS provider to version ~> 6.0 so your configuration stays compatible with the provider you download.

The ECR repository uses IMMUTABLE tags so once you push an image with a given tag, nobody can overwrite it with a different image. scan_on_push tells ECR to automatically scan every pushed image for known vulnerabilities. These are your first two supply chain security controls.

  • Add the S3 bucket resources below the ECR repository in main.tf:
resource "aws_s3_bucket" "sbom_storage" {
  bucket        = var.sbom_bucket_name
  force_destroy = true

  tags = var.common_tags
}

resource "aws_s3_bucket_versioning" "sbom_versioning" {
  bucket = aws_s3_bucket.sbom_storage.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "sbom_encryption" {
  bucket = aws_s3_bucket.sbom_storage.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

What does this code do?

This creates three companion resources for the SBOM bucket. The bucket itself stores your software bill of materials artifacts. Versioning keeps a history of every object, so no SBOM can be silently overwritten. KMS encryption protects the data at rest.

force_destroy = true lets terraform destroy delete the bucket even if it contains objects. This is appropriate for a lab but would be dangerous in production.

  • Save main.tf and download the AWS provider by running:
terraform init

You should see Terraform download the AWS provider and print Terraform has been successfully initialized! at the end.

Seeing a provider error?

  • Make sure you are running terraform init from inside the terraform/ directory, not the project root.
  • Check that variables.tf and main.tf are both saved in the same directory.
  • If you see a credentials error, verify your AWS CLI is still configured by running aws sts get-caller-identity in the same PowerShell terminal.

ask me to help debug a Terraform init error

  • Add the SSH key pair and security group resources below the S3 configuration in main.tf:
resource "aws_key_pair" "monitoring" {
  key_name   = "supply-chain-monitoring-key"
  public_key = file(var.ssh_public_key_path)

  tags = var.common_tags
}

resource "aws_security_group" "monitoring" {
  name        = "supply-chain-monitoring-sg"
  description = "Security group for the supply chain monitoring instance"

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.allowed_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = var.common_tags
}

Why is SSH the only inbound rule?

This security group deliberately allows only SSH (port 22) inbound. Later, Ansible will install Grafana (port 3000) and Prometheus (port 9090) on the EC2 instance. When you try to open Grafana in your browser, the connection will time out because this security group blocks it.

That is the designed learning moment. Ansible configured the software correctly, but Terraform's infrastructure state is blocking it. You will fix the security group in a later step.

  • Add the AMI data source below the security group in main.tf. This looks up the latest Ubuntu 24.04 image ID from Canonical:
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

What does this code do?

A data block queries AWS at plan time instead of creating a resource. This one finds the most recent Ubuntu 24.04 AMI owned by Canonical (account 099720109477). The EC2 instance you create next will reference this AMI ID automatically.

  • Add the EC2 instance resource below the AMI data source in main.tf:
resource "aws_instance" "monitoring" {
  ami                         = data.aws_ami.ubuntu.id
  instance_type               = var.instance_type
  key_name                    = aws_key_pair.monitoring.key_name
  vpc_security_group_ids      = [aws_security_group.monitoring.id]
  associate_public_ip_address = true

  root_block_device {
    volume_size = 20
    volume_type = "gp3"
  }

  tags = merge(var.common_tags, {
    Name = "supply-chain-monitoring"
  })
}

What does this code do?

This creates a t3.micro EC2 instance running Ubuntu 24.04. It references the AMI data source, your SSH key pair, and the security group you just defined. The 20 GB gp3 root volume gives enough space for Docker images and monitoring data.

associate_public_ip_address = true assigns a public IP so you can SSH in from your WSL2 terminal and access the monitoring stack from your browser.

  • Save main.tf and validate the configuration so far by running:
terraform validate

You should see Success! The configuration is valid. confirming all the resource references resolve correctly.

Seeing a validation error?

  • Check that every resource and data block in main.tf has matching opening and closing braces.
  • Make sure you ran terraform init first. Validation needs the downloaded provider schemas.

ask me to help debug a Terraform validation error

  • Add the IAM role resource below the EC2 instance in main.tf:
resource "aws_iam_role" "pipeline" {
  name = "supply-chain-pipeline-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  tags = var.common_tags
}

What does this code do?

This creates an IAM role that the EC2 service can assume. The assume_role_policy says only ec2.amazonaws.com is allowed to take on this role. The actual permissions come next in a separate policy attachment.

  • Add the IAM role policy below the role in main.tf:
resource "aws_iam_role_policy" "pipeline_permissions" {
  name = "supply-chain-pipeline-policy"
  role = aws_iam_role.pipeline.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ECRAll"
        Effect   = "Allow"
        Action   = ["ecr:*"]
        Resource = "*"
      },
      {
        Sid      = "S3All"
        Effect   = "Allow"
        Action   = ["s3:*"]
        Resource = "*"
      }
    ]
  })
}

Why are the permissions so broad?

This policy grants ecr:* and s3:* on every resource in the account. That is deliberately over-permissioned. In a real pipeline, wildcard permissions violate the principle of least privilege.

You will tighten these to specific actions on specific resource ARNs in a later step. Starting broad and then scoping down teaches you to think about exactly which permissions a pipeline actually needs.

  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Confirm you have saved main.tf before moving on to outputs.

ⓧ I'd like to double check the full code

Scroll through main.tf and confirm you have all seven blocks in this order:

  • terraform block with required_providers and the provider "aws" block.
  • aws_ecr_repository resource with IMMUTABLE tags and scan_on_push.
  • Three S3 resources: aws_s3_bucket, aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration.
  • aws_key_pair and aws_security_group with SSH-only ingress.
  • aws_ami data source filtering for Ubuntu 24.04.
  • aws_instance referencing the AMI, key pair, and security group.
  • aws_iam_role and aws_iam_role_policy with broad ecr:* and s3:* permissions.
  • Create a new file called outputs.tf and add all four output declarations:
output "ec2_public_ip" {
  description = "Public IP of the monitoring EC2 instance"
  value       = aws_instance.monitoring.public_ip
}

output "ecr_repository_url" {
  description = "URL of the ECR repository"
  value       = aws_ecr_repository.supply_chain.repository_url
}

output "sbom_bucket_name" {
  description = "Name of the S3 bucket for SBOM storage"
  value       = aws_s3_bucket.sbom_storage.id
}

output "ssh_command" {
  description = "SSH command to connect to the monitoring instance"
  value       = "ssh -i ~/.ssh/supply-chain-key ubuntu@${aws_instance.monitoring.public_ip}"
}

What does this code do?

Outputs print important values after terraform apply completes. You will use ec2_public_ip to SSH into the instance and to create the Ansible inventory. ssh_command gives you a ready-made command you can paste directly into your WSL2 terminal.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Make sure outputs.tf is saved before continuing.

ⓧ I'd like to double check the full code

output "ec2_public_ip" {
  description = "Public IP of the monitoring EC2 instance"
  value       = aws_instance.monitoring.public_ip
}

output "ecr_repository_url" {
  description = "URL of the ECR repository"
  value       = aws_ecr_repository.supply_chain.repository_url
}

output "sbom_bucket_name" {
  description = "Name of the S3 bucket for SBOM storage"
  value       = aws_s3_bucket.sbom_storage.id
}

output "ssh_command" {
  description = "SSH command to connect to the monitoring instance"
  value       = "ssh -i ~/.ssh/supply-chain-key ubuntu@${aws_instance.monitoring.public_ip}"
}
  • Confirm all three files exist by running:
ls

You should see main.tf, outputs.tf, supply-chain-key.pub, and variables.tf in the output.

Initialize and plan

Your configuration files are ready. Before deploying anything to AWS, Terraform can show you exactly what it plans to create.

  • Re-validate the complete configuration (now including outputs) by running:
terraform validate

You should see Success! The configuration is valid. now that all three files are complete.

Before you preview the plan, think about this: how many AWS resources do you expect Terraform to create? Count the resource blocks in main.tf (not the data block).

  • Preview the deployment plan by running:
terraform plan

What happens when Terraform prompts for a value?

Terraform will ask you to enter a value for sbom_bucket_name because that variable has no default. S3 bucket names must be globally unique across all AWS accounts.

Type a name like yourname-sbom-bucket (replacing yourname with your own name or initials) and press Enter. Remember this name. You will use it again when you apply.

Review the plan output. You should see a summary line that says Plan: 8 to add, 0 to change, 0 to destroy. These eight resources are: the ECR repository, S3 bucket, bucket versioning, bucket encryption, key pair, security group, EC2 instance, and IAM role with its policy.

Plan shows a different number?

  • If the number is lower, check that main.tf has all seven resource blocks plus the IAM role policy. The data source does not count because it reads instead of creating.
  • If you see errors about missing variables, make sure variables.tf is saved in the same terraform/ directory.

ask me to help debug a Terraform plan error

Apply and verify connectivity

The plan looks right. Time to deploy the infrastructure to AWS and confirm you can reach the EC2 instance over SSH.

Before you apply, ask yourself: do you expect the deployment to succeed on the first try? Every resource in the plan references valid variables and other resources within the same configuration.

  • Deploy all resources by running:
terraform apply

What happens during apply?

Terraform will prompt for sbom_bucket_name again. Enter the same unique name you chose during the plan step.

After that, Terraform will show the plan and ask Do you want to perform these actions? Type yes and press Enter. Terraform will create all eight resources. This takes 1-2 minutes.

When the apply finishes, you should see Apply complete! followed by Resources: 8 added, 0 changed, 0 destroyed. and four output values.

  • Record the EC2 public IP from the ec2_public_ip output: your EC2 public IP address.

Apply failed?

  • If you see a bucket name error, your chosen name may already be taken globally. Try a more unique name.
  • If you see an authorization error, confirm your AWS CLI credentials are configured by running aws sts get-caller-identity in the same terminal.
  • If a specific resource fails, Terraform will have already created the earlier resources. Fix the issue and run terraform apply again. Terraform is idempotent, so it will only create what is missing.

ask me to help debug a Terraform apply error

Now verify that the EC2 instance is reachable. Think about what you expect: the security group allows SSH on port 22, and you have a matching private key in WSL2. Should the connection succeed?

  • Switch to your VS Code WSL2/Ubuntu terminal.
  • SSH into the EC2 instance by running:
ssh -i ~/.ssh/supply-chain-key ubuntu@[[EC2_PUBLIC_IP="your EC2 public IP address"]]

You should see an Ubuntu shell prompt. This confirms your infrastructure is live and reachable. The security group allows SSH, and your key pair authenticates successfully.

  • Exit the SSH session by running:
exit

SSH connection timed out or refused?

  • Make sure you are running SSH from the WSL2/Ubuntu terminal, not the PowerShell terminal. The private key lives in WSL2's filesystem at ~/.ssh/supply-chain-key.
  • If the connection times out, the instance may still be booting. Wait 30 seconds and try again.
  • Double-check the IP address matches the ec2_public_ip output from terraform apply.

ask me to help debug an SSH connection to my EC2 instance

Your AWS infrastructure is live. An ECR repository is waiting for container images, an encrypted S3 bucket is ready for SBOMs, and a bare Ubuntu EC2 instance is reachable over SSH. Next up, you will bridge Terraform's outputs to Ansible so Ansible knows where to find this instance.

Bridge Terraform Outputs to Ansible

Terraform provisioned your AWS infrastructure, but Ansible has no idea it exists. Ansible needs an inventory file that tells it which servers to reach and how to connect to them.

This step creates the handoff between your PowerShell terminal (where Terraform runs) and your WSL2 terminal (where Ansible runs). The inventory file you create is itself a security surface because it contains your EC2 instance's public IP and points to your private SSH key.

In this step, get ready to:
  • Read Terraform outputs to get the EC2 public IP.
  • Create the Ansible inventory and configuration files.
  • Test that Ansible can reach the EC2 instance over SSH.
Read Terraform outputs in PowerShell

Terraform stored the key details of every resource it created. You need one value in particular: the EC2 instance's public IP address.

  • In the PowerShell terminal (still in the terraform/ directory from the previous step), display your outputs by running this command:
terraform output

You should see four values printed: ec2_public_ip, ecr_repository_url, sbom_bucket_name, and ssh_command.

  • Record the ec2_public_ip value here: your EC2 public IP address

Seeing no output or an error?

  • Make sure your PowerShell terminal is inside the terraform/ directory where your .tf files live. Run ls and confirm you see main.tf.
  • If the outputs are empty, run terraform apply again to make sure your infrastructure is deployed.
  • Why isn't terraform output showing my EC2 IP?
Create the Ansible inventory and configuration

Ansible uses two files to know where to connect and how. The inventory file lists your target servers. The configuration file sets connection defaults like which SSH key to use.

  • In the VS Code Explorer sidebar, right-click the ansible folder and select New File.
  • Name the file inventory.ini.
  • Paste the following content into the file:
# List of servers Ansible will configure
[monitoring]
[[EC2_PUBLIC_IP="your EC2 public IP address"]]
  • Right-click the ansible folder again and select New File.
  • Name the file ansible.cfg.
  • Paste the following content into the file:
# Ansible configuration for this project
[defaults]
inventory = inventory.ini
remote_user = ubuntu
private_key_file = ~/.ssh/supply-chain-key
host_key_checking = False

What do these files do?

  • inventory.ini puts your EC2 instance under a group called [monitoring]. When you run Ansible commands, you target this group name.
  • ansible.cfg tells Ansible to use ubuntu as the SSH user and ~/.ssh/supply-chain-key as the private key. These match the key pair and AMI you configured in Terraform.
  • host_key_checking = False skips SSH fingerprint verification. This is fine for a lab, but in production you would verify the host key to prevent man-in-the-middle attacks.
  • Save both inventory.ini and ansible.cfg.
  • Confirm both files appear under the ansible folder in the VS Code Explorer sidebar.

Why is this handoff a security surface?

The inventory file contains your EC2's public IP and the config file points to your private SSH key. Anyone who reads these files knows exactly where your server is and which key unlocks it.

In a real pipeline, you would generate the inventory dynamically from Terraform outputs and store it in a secrets manager. For this lab, a static file keeps the concepts clear.

Don't see the files in the Explorer?

  • Make sure you created the files inside the ansible/ folder, not in the project root or the terraform/ folder.
  • Check that the file names are exactly inventory.ini and ansible.cfg. A typo like ansible.config will not be picked up by Ansible.
  • Help me find my Ansible configuration files.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is inventory.ini in full:

[monitoring]
your-ec2-ip-here

And here is ansible.cfg in full:

[defaults]
inventory = inventory.ini
remote_user = ubuntu
private_key_file = ~/.ssh/supply-chain-key
host_key_checking = False

Replace your-ec2-ip-here in inventory.ini with your actual EC2 public IP from the Terraform output.

Test Ansible connectivity

The config files are in place. Now you need to prove that Ansible in WSL2 can actually reach the EC2 instance that Terraform provisioned from PowerShell.

  • Switch to your WSL2 terminal in VS Code.
  • Navigate to the ansible/ directory inside your project folder by running this command:
cd ansible

Not at your project root?

If the cd ansible command fails, navigate to your full project path first. From WSL2, your Windows project folder is accessible at /mnt/c/Users/<your-username>/... followed by whatever path you created the project in.

  • Confirm you are in the right directory by running:
ls

You should see ansible.cfg and inventory.ini in the output.

Why run Ansible from WSL2?

Ansible does not run natively on Windows. The official Ansible documentation states that WSL is not supported for production systems, but for a lab targeting a remote Linux EC2 over SSH, it works reliably.

Before you run the next command, what do you think will happen? Will Ansible successfully reach your EC2 instance, or will it fail?

  • Test that Ansible can connect to your EC2 instance by running:
ansible all -m ping

You should see output containing SUCCESS and "ping": "pong" for your monitoring host. This proves that Ansible in WSL2 can reach the EC2 instance that Terraform provisioned from PowerShell.

What just happened?

The ansible all -m ping command told Ansible to connect to every host in your inventory (all) and run the ping module (-m ping). This is not a network ping. Ansible's ping module connects over SSH, runs a small Python script on the remote host, and confirms it can execute commands.

Seeing UNREACHABLE or permission denied?

  • Check that the IP address in inventory.ini matches the ec2_public_ip from your Terraform output exactly. A single digit off will point to a different server.
  • Verify the SSH key exists by running ls ~/.ssh/supply-chain-key. If the file is missing, regenerate it with the ssh-keygen command from Step 1.
  • Confirm your EC2 instance is still running. In the PowerShell terminal, run terraform output ec2_public_ip to check. If the IP changed, update inventory.ini with the new value.
  • Make sure you are running the command from the ansible/ directory so that Ansible can find ansible.cfg.
  • Why can't Ansible reach my EC2 instance?

The bridge is built. Ansible in WSL2 can reach the EC2 instance that Terraform provisioned from PowerShell. Next up, you will use Ansible to transform that bare Ubuntu box into a hardened monitoring node running Docker, Prometheus, and Grafana.

Configure and Harden the EC2 with Ansible

Your EC2 instance is running, but it is a blank Ubuntu box with nothing installed on it. Ansible will transform it into a hardened monitoring node running Docker, Prometheus, Grafana, and node_exporter.

This is the configuration-state layer that sits on top of Terraform's infrastructure-state layer. Terraform created the resources. Ansible now configures what runs on them.

In this step, get ready to:
  • Install Docker Engine on the EC2 instance via an Ansible playbook.
  • Deploy Prometheus, Grafana, and node_exporter as monitoring services.
  • Apply OS hardening controls to the SSH configuration.
Write the Ansible playbook

You will build playbook.yml piece by piece, running the playbook after each major section to confirm it works before adding more tasks. The first section installs Docker on the remote EC2 instance.

  • In the WSL2 terminal in VS Code, make sure you are in the ansible/ directory.
  • Create a new file called playbook.yml and paste the following code to define the play header and Docker prerequisite tasks:
---
- name: Configure monitoring stack and harden EC2
  hosts: monitoring
  become: true

  tasks:
    - name: Install prerequisite packages
      ansible.builtin.apt:
        name:
          - ca-certificates
          - curl
          - gnupg
        state: present
        update_cache: true

    - name: Ensure apt keyrings directory exists
      ansible.builtin.file:
        path: /etc/apt/keyrings
        state: directory
        mode: "0755"

What does this code do?

The play header tells Ansible to target the monitoring group from your inventory and to run all tasks with elevated privileges (become: true).

The first two tasks install the packages Docker's official APT repository requires and ensure the keyrings directory exists for the GPG key.

  • Add the Docker GPG key, repository, and installation tasks below the existing tasks in playbook.yml:
    - name: Download Docker GPG key
      ansible.builtin.get_url:
        url: https://download.docker.com/linux/ubuntu/gpg
        dest: /etc/apt/keyrings/docker.asc
        mode: "0644"

    - name: Add Docker apt repository
      ansible.builtin.apt_repository:
        repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
        state: present

    - name: Install Docker Engine
      ansible.builtin.apt:
        name:
          - docker-ce
          - docker-ce-cli
          - containerd.io
          - docker-buildx-plugin
          - docker-compose-plugin
        state: present
        update_cache: true

    - name: Start and enable Docker
      ansible.builtin.systemd:
        name: docker
        state: started
        enabled: true

What does this code do?

These tasks follow Docker's official Ubuntu install process. They download the GPG key, add the Docker APT repository using the host's Ubuntu release codename, then install the full Docker Engine suite.

The final task starts the Docker daemon and enables it to start on boot automatically.

  • Save playbook.yml.
  • Run the playbook to verify Docker installs correctly by running this command:
ansible-playbook playbook.yml

The playbook takes a couple of minutes as it downloads and installs Docker packages. You should see each task report ok or changed, and the play recap should show failed=0.

Playbook failing on Docker install?

  • Check that your EC2 instance is still running. If it was stopped, restart it from the AWS console and update the IP in inventory.ini.
  • Confirm your YAML indentation is consistent. Each task should be indented with 4 spaces under tasks:.
  • Help me debug my Ansible playbook Docker installation failure.

Docker is installed. Now add the node_exporter tasks. Prometheus uses node_exporter to collect host-level metrics like CPU, memory, and disk usage from the EC2 instance.

  • Add the node_exporter download and install tasks below the Docker tasks in playbook.yml:
    - name: Create node_exporter system user
      ansible.builtin.user:
        name: node_exporter
        shell: /usr/sbin/nologin
        system: true
        create_home: false

    - name: Download node_exporter v1.12.1
      ansible.builtin.get_url:
        url: https://github.com/prometheus/node_exporter/releases/download/v1.12.1/node_exporter-1.12.1.linux-amd64.tar.gz
        dest: /tmp/node_exporter.tar.gz
        mode: "0644"

    - name: Extract node_exporter
      ansible.builtin.unarchive:
        src: /tmp/node_exporter.tar.gz
        dest: /tmp/
        remote_src: true

    - name: Install node_exporter binary
      ansible.builtin.copy:
        src: /tmp/node_exporter-1.12.1.linux-amd64/node_exporter
        dest: /usr/local/bin/node_exporter
        mode: "0755"
        remote_src: true

What does this code do?

These tasks create a dedicated node_exporter system user with no login shell. This follows the principle of least privilege. The service runs under a restricted user that cannot log in or access anything beyond what it needs.

The remaining tasks download the v1.12.1 binary, extract it, and copy it to /usr/local/bin/.

  • Add the systemd service definition and startup tasks below the node_exporter install tasks:
    - name: Create node_exporter systemd service
      ansible.builtin.copy:
        dest: /etc/systemd/system/node_exporter.service
        mode: "0644"
        content: |
          [Unit]
          Description=Prometheus Node Exporter
          After=network.target

          [Service]
          User=node_exporter
          ExecStart=/usr/local/bin/node_exporter

          [Install]
          WantedBy=multi-user.target

    - name: Start and enable node_exporter
      ansible.builtin.systemd:
        name: node_exporter
        state: started
        enabled: true
        daemon_reload: true

Why a systemd service?

Unlike Docker containers that have their own restart policies, node_exporter runs as a native binary. Registering it as a systemd service ensures the OS starts it on boot and restarts it if it crashes.

  • Save playbook.yml.
  • Run the playbook again to deploy node_exporter:
ansible-playbook playbook.yml

The Docker tasks show ok this time because Ansible is idempotent. Only the new node_exporter tasks show changed. The play recap should again show failed=0.

What is idempotency?

Ansible tasks are designed to be run multiple times safely. If Docker is already installed, Ansible skips the install instead of reinstalling it. This means you can re-run the same playbook without breaking anything.

Now add the Prometheus configuration and the Docker containers that run the monitoring stack.

  • Add the Prometheus configuration tasks below the node_exporter tasks in playbook.yml:
    - name: Create Prometheus config directory
      ansible.builtin.file:
        path: /opt/prometheus
        state: directory
        mode: "0755"

    - name: Write Prometheus configuration
      ansible.builtin.copy:
        dest: /opt/prometheus/prometheus.yml
        mode: "0644"
        content: |
          global:
            scrape_interval: 15s
            evaluation_interval: 15s

          scrape_configs:
            - job_name: "prometheus"
              static_configs:
                - targets: ["localhost:9090"]

            - job_name: "node"
              static_configs:
                - targets: ["localhost:9100"]

What does this Prometheus config do?

Prometheus scrapes metrics from HTTP endpoints at regular intervals. This configuration tells it to scrape two targets every 15 seconds: itself on port 9090, and node_exporter on port 9100.

  • Add the Docker container tasks for Prometheus and Grafana below the Prometheus config tasks:
    - name: Run Prometheus container
      ansible.builtin.shell: |
        docker run -d \
          --name prometheus \
          --network host \
          --restart unless-stopped \
          -v /opt/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml \
          prom/prometheus:latest
      register: prom_result
      changed_when: prom_result.rc == 0
      failed_when: prom_result.rc != 0 and 'already in use' not in prom_result.stderr

    - name: Run Grafana container
      ansible.builtin.shell: |
        docker run -d \
          --name grafana \
          --restart unless-stopped \
          -p 3000:3000 \
          grafana/grafana:latest
      register: grafana_result
      changed_when: grafana_result.rc == 0
      failed_when: grafana_result.rc != 0 and 'already in use' not in grafana_result.stderr

Why does Prometheus use --network host?

Prometheus needs to scrape localhost:9100 where node_exporter is running as a native systemd service. Using --network host gives the container direct access to the host's network, so localhost resolves correctly.

Grafana only serves a web UI, so it uses standard port mapping (-p 3000:3000) instead.

  • Save playbook.yml.
  • Run the playbook again to deploy the monitoring containers:
ansible-playbook playbook.yml

The play recap should show failed=0. The new container tasks show changed while all the earlier tasks report ok.

Seeing a container name conflict?

If you ran the playbook multiple times and see an error about a container name already in use, the failed_when condition in the playbook should handle this gracefully. If it still fails, SSH into the EC2 and remove the existing container manually by running sudo docker rm -f prometheus or sudo docker rm -f grafana, then re-run the playbook.

Help me fix my Ansible Docker container deployment error.

Add OS hardening tasks

The monitoring stack is deployed. Now add security hardening to the same playbook. These tasks lock down the SSH service so that only key-based authentication is allowed.

  • Add the hardening tasks and handler at the end of playbook.yml. The hardening tasks go below the Grafana container task (still indented under tasks:). The handlers: block is at the same indent level as tasks::
    - name: Disable root SSH login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^#?PermitRootLogin"
        line: "PermitRootLogin no"
      notify: Restart SSH

    - name: Disable password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^#?PasswordAuthentication"
        line: "PasswordAuthentication no"
      notify: Restart SSH

  handlers:
    - name: Restart SSH
      ansible.builtin.systemd:
        name: ssh
        state: restarted

What does this code do?

These two tasks edit /etc/ssh/sshd_config to disable root login and password-based authentication. Only SSH key authentication remains, which is what you are already using to connect.

The notify: Restart SSH line triggers the handler at the bottom. Handlers only run once at the end of the play, and only if a task that notifies them actually changed something. This avoids unnecessary SSH restarts.

  • Save playbook.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved playbook.yml before running the final playbook execution.

ⓧ I'd like to double check the full code

Your playbook.yml is too long to show in a single block. Scroll up and confirm these sections are present in this order:

  • Play header (hosts: monitoring, become: true) and prerequisite package tasks.
  • Docker GPG key, APT repository, Docker Engine install, and Docker start/enable tasks.
  • node_exporter user creation, download, extract, binary install, systemd service, and start/enable tasks.
  • Prometheus config directory, prometheus.yml write, and Prometheus/Grafana docker run tasks.
  • SSH hardening tasks (PermitRootLogin no, PasswordAuthentication no) with notify: Restart SSH.
  • A handlers: section at the same level as tasks: with a Restart SSH handler.
Run the playbook and verify the full stack

The playbook is complete. This final run applies the OS hardening tasks and confirms every component is working together.

Before you run this, what do you expect the play recap to show for the Docker and node_exporter tasks that already ran in earlier executions?

  • Execute the full playbook by running this command:
ansible-playbook playbook.yml

The previously completed tasks show ok, while the new hardening tasks show changed. The play recap should show failed=0.

Now SSH into the EC2 to verify everything is running. What do you expect to see when you check the Docker containers?

  • SSH into your EC2 instance by running this command:
ssh -i ~/.ssh/supply-chain-key ubuntu@[[EC2_IP="your EC2 public IP"]]
  • Check that the Prometheus and Grafana containers are running:
sudo docker ps

You should see two containers listed: prometheus and grafana, both with a status of Up.

  • Check that node_exporter is active by running:
systemctl status node_exporter

You should see active (running) in the output. Press q to exit the status view.

  • Exit the SSH session by running:
exit

Containers not showing as Up?

  • Run sudo docker ps -a to see all containers including stopped ones. If a container exited, check its logs with sudo docker logs prometheus or sudo docker logs grafana.
  • If the Prometheus config file has a YAML syntax error, the container will crash on startup. Check that /opt/prometheus/prometheus.yml on the EC2 matches the configuration you wrote in the playbook.
  • Help me troubleshoot my Prometheus or Grafana containers not starting.

Your EC2 instance is now a fully configured monitoring node with Docker, Prometheus, Grafana, and node_exporter all running. OS hardening has locked down SSH to key-only authentication. Next up, you will try to access Grafana in your browser and discover why infrastructure state and configuration state must work together.

Verify the Monitoring Stack and Close Security Gaps

In the last step, Ansible installed Docker, Prometheus, Grafana, and node_exporter on your EC2 instance. The playbook completed with zero failures, and every service is running.

But there is a gap. Your Terraform security group only allows SSH on port 22. Grafana is listening on port 3000 inside the instance, but no outside traffic can reach it. This is the infrastructure-state vs configuration-state boundary: Ansible did its job correctly, but Terraform's infrastructure is blocking the result.

In this step, get ready to:
  • Hit the security group wall and open monitoring ports in Terraform.
  • Configure Grafana with a Prometheus data source and confirm live metrics.
  • Tighten the IAM pipeline role from wildcard permissions to least privilege.
Hit the wall and fix the security group

Before you try this, think about what you expect to happen. If you open your browser and navigate to Grafana on port 3000, do you think it will load?

  • Open a new browser tab.
  • Type http:// followed by your EC2 public IP, then :3000 in the address bar and press Enter.

The page times out. Your browser spins and eventually gives up. This is intentional.

Why did it time out?

Ansible configured Grafana perfectly. The container is running and listening on port 3000 inside the EC2 instance.

But the security group you defined in Terraform only has one ingress rule: SSH on port 22. Traffic on port 3000 never reaches the instance because the infrastructure layer blocks it before the configuration layer can respond. The fix belongs in Terraform, not Ansible.

To fix this, you need to add ingress rules for the monitoring ports. You will also restrict all rules to your specific IP address instead of the open 0.0.0.0/0 default.

  • Find your public IP address by running this command in the PowerShell terminal:
Invoke-RestMethod -Uri "https://ifconfig.me/ip"
  • Copy the IP address printed in the terminal. You will use it in a moment.

What does this command do?

Invoke-RestMethod sends an HTTP request to ifconfig.me, a service that returns your public IP address. The /ip path ensures you get plain text instead of an HTML page.

  • In the PowerShell terminal, open terraform/main.tf in VS Code.
  • Find the aws_security_group "monitoring" resource. Locate the closing brace of the existing SSH ingress block:
  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.allowed_cidr]
  }
  • Add these three ingress blocks directly below the SSH block, before the egress block:
  # Allow Grafana web UI access
  ingress {
    description = "Grafana"
    from_port   = 3000
    to_port     = 3000
    protocol    = "tcp"
    cidr_blocks = [var.allowed_cidr]
  }

  # Allow Prometheus query access
  ingress {
    description = "Prometheus"
    from_port   = 9090
    to_port     = 9090
    protocol    = "tcp"
    cidr_blocks = [var.allowed_cidr]
  }

  # Allow node_exporter metrics scraping
  ingress {
    description = "Node Exporter"
    from_port   = 9100
    to_port     = 9100
    protocol    = "tcp"
    cidr_blocks = [var.allowed_cidr]
  }

What do these rules do?

Each ingress block opens a specific port for inbound traffic.

  • Port 3000 lets your browser reach Grafana's web UI.
  • Port 9090 opens the Prometheus query interface.
  • Port 9100 exposes node_exporter metrics for Prometheus to scrape.

All three rules use var.allowed_cidr, the same variable as the SSH rule. When you pass your specific IP at apply time, every rule locks to your machine only.

  • Save main.tf.
  • Apply the updated security group by running this command in the PowerShell terminal, replacing the placeholders with your values:
terraform apply -var="sbom_bucket_name=[[SBOM_BUCKET="your-unique-sbom-bucket-name"]]" -var="allowed_cidr=[[YOUR_CIDR="your IP followed by /32, e.g. 203.0.113.50/32"]]"
  • Terraform will show that the security group is being modified. Type yes when prompted to confirm.

Why /32?

A CIDR block defines a range of IP addresses. The /32 suffix means "exactly this one IP address." It restricts the security group to your machine only, following the principle of least privilege at the network layer.

Once terraform apply completes, return to your browser and refresh the Grafana tab.

  • Refresh the browser tab where you tried to load Grafana earlier.

The Grafana login page loads. This is the moment the infrastructure-state and configuration-state layers align: Terraform now permits the traffic that Ansible's services need.

Still seeing a timeout?

  • Double check that you appended /32 after your IP address in the allowed_cidr variable. Without it, Terraform rejects the value.
  • Confirm you are using your public IP, not a local/private IP like 192.168.x.x.
  • If your ISP recently changed your IP, re-run Invoke-RestMethod -Uri "https://ifconfig.me/ip" to get your current address.

Why can't I access Grafana after updating the security group?

Configure Grafana and confirm metrics

Grafana is running, but it does not know where to find metrics yet. You need to connect it to the Prometheus instance running on the same EC2 box.

  • On the Grafana login page, enter admin as both the username and password.
  • Grafana will prompt you to change the default password. Set a new password or click Skip for this lab.

You should see the Grafana home dashboard.

  • Click the hamburger menu icon in the top left corner.
  • Click Connections in the left sidebar.
  • Click Data sources.
  • Click Add data source.
  • Select Prometheus from the list.
  • In the Prometheus server URL field, enter http://localhost:9090.
  • Scroll to the bottom and click Save & test.

You should see a green confirmation message indicating the data source is working.

Why localhost:9090?

Both Grafana and Prometheus run on the same EC2 instance. Prometheus uses --network host, so it listens directly on the host's port 9090. From Grafana's perspective inside the EC2, Prometheus is reachable at localhost:9090.

Now confirm that node_exporter metrics are flowing through.

  • Click the hamburger menu icon in the top left corner.
  • Click Explore in the left sidebar.
  • In the metric browser field, type node_cpu_seconds_total.
  • Click Run query.

You should see a graph with CPU usage data from node_exporter. This is the supply chain observability layer that Project 3 will build dashboards on.

No data showing up?

  • Verify you selected Prometheus as the data source in the Explore view's dropdown at the top.
  • SSH into the EC2 and run sudo docker ps to confirm both prometheus and grafana containers are running.
  • Check that systemctl status node_exporter shows active (running) on the EC2.

Why is my Grafana Explore view not showing any Prometheus metrics?

Tighten the IAM pipeline role to least privilege

The security group is fixed, but there is another gap. The IAM pipeline role you created in Step 2 has broad ecr:* and s3:* permissions on every resource in the account. That was convenient for getting started, but it violates the principle of least privilege.

You will now scope the policy to only the specific actions and resources the pipeline actually needs.

  • In main.tf, scroll to the aws_iam_role_policy "pipeline_permissions" resource.
  • Find the existing broad policy that looks like this:
resource "aws_iam_role_policy" "pipeline_permissions" {
  name = "supply-chain-pipeline-policy"
  role = aws_iam_role.pipeline.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "ECRAll"
        Effect   = "Allow"
        Action   = ["ecr:*"]
        Resource = "*"
      },
      {
        Sid      = "S3All"
        Effect   = "Allow"
        Action   = ["s3:*"]
        Resource = "*"
      }
    ]
  })
}
  • Delete this entire resource block. Replace it with the scoped ECR permissions by pasting this code in its place:
resource "aws_iam_role_policy" "pipeline_permissions" {
  name = "supply-chain-pipeline-policy"
  role = aws_iam_role.pipeline.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        # Auth token needed for any ECR operation
        Sid      = "ECRAuth"
        Effect   = "Allow"
        Action   = ["ecr:GetAuthorizationToken"]
        Resource = "*"
      },
      {
        # Only push-related actions on this specific repository
        Sid    = "ECRPush"
        Effect = "Allow"
        Action = [
          "ecr:BatchCheckLayerAvailability",
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:PutImage",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload"
        ]
        Resource = aws_ecr_repository.supply_chain.arn
      },

What changed in the ECR permissions?

  • ecr:GetAuthorizationToken must target * because it is an account-level action, not scoped to a single repository.
  • The seven ECRPush actions are the minimum set needed to push a container image. Each targets only the supply-chain-app repository ARN, not every repository in the account.
  • Directly below the ECRPush statement you just pasted, add the S3 permissions and close the resource:
      {
        # Read/write SBOM files inside the bucket
        Sid    = "SBOMStorage"
        Effect = "Allow"
        Action = [
          "s3:PutObject",
          "s3:GetObject"
        ]
        Resource = "${aws_s3_bucket.sbom_storage.arn}/*"
      },
      {
        # List bucket contents for inventory checks
        Sid      = "SBOMList"
        Effect   = "Allow"
        Action   = ["s3:ListBucket"]
        Resource = aws_s3_bucket.sbom_storage.arn
      }
    ]
  })
}

What changed in the S3 permissions?

  • s3:PutObject and s3:GetObject target ${aws_s3_bucket.sbom_storage.arn}/* so the role can only read and write objects inside the SBOM bucket.
  • s3:ListBucket targets the bucket ARN itself (without /*) because listing is a bucket-level operation, not an object-level one.

The broad s3:* on * could delete buckets, change policies, or exfiltrate data from any bucket in the account. The scoped version can only store and retrieve SBOM artifacts in one specific bucket.

  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before continuing.

ⓧ I'd like to double check the full code

The main.tf file is long, so scroll through and confirm these sections are present in order:

  • terraform block with required_providers for AWS.
  • provider "aws" with var.aws_region.
  • aws_ecr_repository "supply_chain" with immutable tags and scan-on-push.
  • aws_s3_bucket, aws_s3_bucket_versioning, and aws_s3_bucket_server_side_encryption_configuration resources.
  • aws_key_pair "monitoring".
  • aws_security_group "monitoring" with four ingress rules: SSH (22), Grafana (3000), Prometheus (9090), Node Exporter (9100).
  • data "aws_ami" "ubuntu" filtering for Ubuntu 24.04.
  • aws_instance "monitoring" with the 20GB gp3 volume.
  • aws_iam_role "pipeline" with the EC2 assume role policy.
  • aws_iam_role_policy "pipeline_permissions" with four scoped Statements: ECRAuth, ECRPush, SBOMStorage, SBOMList.

Before applying, preview the changes to confirm you are only modifying the IAM policy.

  • Run the plan by executing this command in the PowerShell terminal:
terraform plan -var="sbom_bucket_name=[[SBOM_BUCKET="your-unique-sbom-bucket-name"]]" -var="allowed_cidr=[[YOUR_CIDR="your IP followed by /32, e.g. 203.0.113.50/32"]]"

Before you read the output, do you think the plan will show changes to the EC2 instance, the security group, or just the IAM policy?

The plan output should show only the aws_iam_role_policy being updated. The security group was already applied in the previous terraform apply, so it shows no changes now.

  • Apply the least-privilege policy by running this command:
terraform apply -var="sbom_bucket_name=[[SBOM_BUCKET="your-unique-sbom-bucket-name"]]" -var="allowed_cidr=[[YOUR_CIDR="your IP followed by /32, e.g. 203.0.113.50/32"]]"
  • Type yes when prompted to confirm.

Terraform updates the IAM policy. The output shows 1 changed, 0 added, 0 destroyed.

Why tighten IAM after everything works?

Starting with broad permissions and narrowing them is a deliberate pattern. It lets you prove the infrastructure works first, then lock it down without wondering whether a failure is caused by missing permissions or a misconfiguration.

In production, you would start tight from the beginning. For learning, the broad-then-narrow progression makes the value of least privilege visible.

Seeing more changes than expected in the plan?

  • If the plan shows changes to the security group, make sure you used the same allowed_cidr value as your previous terraform apply. A different IP triggers a security group update.
  • If the plan shows changes to the EC2 instance, check that you did not accidentally edit the aws_instance resource block.

Why does my terraform plan show unexpected changes?

Your infrastructure is now hardened at multiple layers. The security group restricts network access to your IP only. The IAM role can only push images and store SBOMs in the specific resources you created. OS hardening from Ansible keeps root SSH and password authentication locked down. This is defense in depth: multiple overlapping controls so that no single failure exposes the system.

You have a fully provisioned, configured, and hardened monitoring environment. Next up: the Secret Mission challenges you to write an automated audit that verifies all of these controls in one playbook run.

Secret mission

Add CIS-Style Hardening Verification

You've hardened your EC2 instance and verified things manually. But in a real security pipeline, manual checks don't scale. Your mission: write a CIS-style audit playbook that programmatically verifies every hardening control you applied, and prints a pass/fail report card for your infrastructure.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project created AWS resources that cost approximately $0.25/day while the EC2 instance is running.

Cost warning

Your supply-chain-monitoring EC2 instance (t3.micro) costs approximately $0.0104/hour. ECR is free for the first 500MB of storage, and S3 storage costs are negligible for this lab.

If you are not continuing to Project 2 within the next few days, choose Pause or Delete to avoid unnecessary charges.

Resources you used:

  • AWS resources provisioned by Terraform: EC2 instance (supply-chain-monitoring), ECR repository (supply-chain-app), S3 bucket, IAM role (supply-chain-pipeline-role), security group, and key pair.
  • Local project files: terraform/ and ansible/ directories.
  • SSH key pair at ~/.ssh/supply-chain-key in WSL2.
  • Terraform (installed via Chocolatey on Windows).
  • Ansible (installed via pip in WSL2).

Keep everything running

No action needed. Choose this if you plan to start Project 2 (GitHub Actions CI/CD) within the next few days and want your infrastructure ready to go.

  • Your EC2 instance will continue running at approximately $0.25/day.
  • Grafana remains accessible at http://<EC2_IP>:3000 and Prometheus continues scraping metrics.
  • Terraform, Ansible, and your SSH key remain on your local machine, ready for the next project.

Pause - I'll come back to this later

Stop the EC2 instance to pause the main cost while keeping all your infrastructure definitions and Terraform state intact. EBS storage still costs approximately $0.08/month.

  • Open the AWS Console and navigate to EC2.
  • Click Instances in the left navigation pane.
  • Select the instance named supply-chain-monitoring.
  • Click Instance state, then Stop instance.
  • Click Stop to confirm.
  • ECR and S3 resources have negligible costs while idle.
  • When you are ready to resume, select the instance and choose Instance state, then Start instance.

Delete - I don't want to use this again

Remove all AWS resources and local project files to stop all charges and start fresh.

  • In your VS Code PowerShell terminal, navigate to the terraform/ directory and run the following command to destroy all AWS resources:
terraform destroy
  • Type yes when prompted to confirm. This removes the EC2 instance, ECR repository, S3 bucket, IAM role, security group, and key pair.
  • Delete the project directory from your Windows filesystem. In PowerShell, navigate to the parent directory of your project and run:
Remove-Item -Recurse -Force .\terraform\
Remove-Item -Recurse -Force .\ansible\
  • In your WSL2 terminal, delete the SSH key pair:
rm ~/.ssh/supply-chain-key ~/.ssh/supply-chain-key.pub
  • Optionally, uninstall Terraform from Windows. In PowerShell, run:
choco uninstall terraform -y
  • Optionally, uninstall Ansible from WSL2. In the WSL2 terminal, run:
pip uninstall ansible -y
  • You can recreate everything by running terraform apply again from a fresh clone of your configuration files.

Nice Work!

Nice Work!

Nice work! You've just built a hardened, monitored AWS environment from scratch using Terraform and Ansible. This is the foundation that Project 2 will deploy signed container images into.

You've learned how to:

  • Provision AWS infrastructure entirely through Terraform code, including an ECR repository with immutable tags and scan-on-push, an encrypted S3 bucket for SBOM storage, a hardened EC2 instance, and a least-privilege IAM role.
  • Configure and harden a live server using Ansible, installing Docker, Prometheus, Grafana, and node_exporter as a real-time monitoring stack, with OS hardening controls that disable root SSH login and password authentication.
  • Experience the infrastructure-state vs. configuration-state boundary firsthand when the security group blocked Grafana access despite Ansible configuring it correctly, then close the gap by tightening IAM from wildcard permissions to least-privilege actions scoped to specific ECR and S3 resources.
  • Secret Mission: Created a CIS-style audit playbook that programmatically verifies your hardening controls and reports pass/fail for each check.

Ready to quiz yourself?