Secure AWS Workloads with Terraform
Use Terraform to deploy, test, and audit a least-privilege AWS workload.
Introduction
30 Second Summary
A change can look safe on screen while quietly leaving a door open to the internet or weakening protection for stored files. Once that mistake goes live, proving who had access becomes much harder.
In this project, you will build a disposable AWS security lab with Terraform that stops an unsafe design before deployment. You will deploy the hardened version to prove least-privilege access against a live workload.
What You'll Build
Picture showing someone a live AWS workload with terminal evidence for every security claim you make.
By the end of this project, you'll have:
- A preventative policy test that visibly fails when internet-wide SSH or weak Amazon S3 protections appear in the design.
- A hardened live workload that you can administer through AWS Systems Manager Session Manager without an inbound port or SSH key.
- A complete evidence record combining an approved object read from Amazon EC2, an AccessDenied result for account-wide discovery, and an AWS CloudTrail record of your administrative session.
- Secret Mission: Add plan-time regression tests that prevent EC2 deployments without IMDSv2 or encrypted root storage.
Are there any prerequisites?
You need an AWS account with permission to use browser-based AWS CloudShell and create Amazon VPC, Amazon EC2, Amazon S3, and AWS Identity and Access Management resources.
No local Windows installation is required. Amazon EC2 and Amazon S3 can incur pay-as-you-go charges until cleanup.
Before We Start
This checkpoint commits you to a disposable AWS security lab that uses only fictional data with no production dependencies or secrets. After collecting your evidence, you will pause or destroy every billable resource.
Prepare Your CloudShell Workspace
A security lab is only trustworthy when you know which AWS identity will make changes. A production account would turn this disposable exercise into a real operational risk.
You will work in browser-based AWS CloudShell. It provides a pre-authenticated AWS CLI v2 environment for your console identity.
You will install Terraform 1.16.6. You will keep the project inside your private CloudShell home directory.
In this step, get ready to:
- Launch AWS CloudShell in us-east-1.
- Verify the active identity in your intended non-production AWS account.
- Install Terraform 1.16.6 and create the private project workspace.
Launch and verify AWS CloudShell
CloudShell forwards your console credentials into its terminal session. The identity check proves that every later command targets your disposable development account.
- Sign in to the AWS Management Console with your intended non-production account.
- Set the console region selector to us-east-1.
- Choose the CloudShell icon in the console navigation bar.
- Wait for the terminal prompt to become available.
This safety check protects your production account before any resources can exist. Before you run it, which account do you expect the terminal to report? Hold that prediction while the command runs.
- Check the active AWS identity by running this command:
aws sts get-caller-identity
What does this identity check show?
- The UserId value identifies the active principal session.
- The Account value shows the AWS account number.
- The Arn value identifies the active user or assumed role.
You should see the account number for your intended development account. You should also see an ARN for the role or user you expect to use.
- Compare the Account value with your intended non-production account.
- Confirm the Arn value identifies your intended development role or user.
- Stop this project if either value points to a production account.
CloudShell unavailable or identity unexpected?
- Ask your AWS administrator to confirm that your identity can use CloudShell.
- Sign out if the account number belongs to a production environment.
- Sign back in with the intended non-production AWS account.
Use this identity troubleshooting prompt for targeted help.
Install the pinned Terraform CLI
A pinned Terraform version keeps formatting and policy-test behavior reproducible. This project uses exactly 1.16.6.
- Check the Terraform version currently available by running this command:
terraform version
What does this version check tell you?
The command asks the local Terraform binary to report its version. Your result determines whether you can continue or need the official installation workflow.
- Choose the tab that matches the result shown in your terminal.
✔️ I see version 1.16.6
That removes a major source of version drift. Your Terraform binary matches the project pin.
- Continue to the project workspace substep.
ⓧ I see an older version
The older binary does not match the project pin. Use the official Amazon Linux package workflow to install the current pinned release.
- Install the required package utilities and Terraform by running these commands:
- Confirm the package installation if the final command pauses for approval.
sudo yum install -y yum-utils shadow-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
sudo yum install terraform
What does this installation do?
- The first command installs the package-management utilities required by the repository workflow.
- The second command adds HashiCorp's Amazon Linux package repository.
- The third command installs the Terraform package from that repository.
- Verify the installed version by running this command:
terraform version
What confirms the installation?
Terraform prints the active CLI version from the binary CloudShell can now run. The first line should show Terraform v1.16.6.
The pinned CLI is now ready for the plan-only security tests you will build later.
Terraform installation failed?
- Confirm that you are running the commands inside AWS CloudShell.
- Repeat the repository setup command before retrying the Terraform installation.
Use this Terraform installation prompt for targeted help.
ⓧ Command not found
Terraform is not installed in this CloudShell environment. The official package workflow adds HashiCorp's repository before installing the CLI.
- Install the required package utilities and Terraform by running these commands:
- Confirm the package installation if the final command pauses for approval.
sudo yum install -y yum-utils shadow-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
sudo yum install terraform
What does this installation do?
- The first command installs the package-management utilities required by the repository workflow.
- The second command adds HashiCorp's Amazon Linux package repository.
- The third command installs the Terraform package from that repository.
- Verify the installed version by running this command:
terraform version
What confirms the installation?
Terraform prints the active CLI version from the newly installed binary. The first line should show Terraform v1.16.6.
The pinned CLI is now ready for the plan-only security tests you will build later.
Terraform installation failed?
- Confirm that you are running the commands inside AWS CloudShell.
- Repeat the repository setup command before retrying the Terraform installation.
Use this Terraform installation prompt for targeted help.
- Keep account numbers and full ARNs outside your Terraform version screenshot.
Create the private project workspace
CloudShell provides persistent storage in your home directory. Keeping the lab there gives you a private workspace for the configuration and local Terraform state.
Terraform state can contain sensitive infrastructure details. Keep it private even though this lab uses only fictional demonstration data.
- Create the nested project workspace and enter its top-level directory by running these commands:
mkdir -p ~/secure-aws-foundation/tests
cd ~/secure-aws-foundation
What do these workspace commands do?
- The mkdir command creates the secure-aws-foundation directory plus its nested tests directory.
- The cd command makes secure-aws-foundation your current directory.
Before the final check, what identity, Terraform version, path, and directory name do you expect to see? Keep those predictions in mind while the commands run.
- Confirm the complete workspace state by running these checks:
aws sts get-caller-identity
terraform version
pwd
ls
What does this final check prove?
- The identity output confirms the active AWS account and role.
- The terraform output confirms the pinned CLI version.
- The pwd output confirms your current project path.
- The ls output confirms that the nested test directory exists.
You should see your intended development account plus its role ARN. You should also see Terraform v1.16.6.
The path should end with secure-aws-foundation. The directory listing should contain tests.
Workspace path or test directory missing?
- Repeat the workspace creation block if the reported path is different.
- Repeat the mkdir command from the creation block if tests is missing.
Use this workspace troubleshooting prompt for targeted help.
Your authenticated workspace now has the pinned Terraform CLI plus the private directory structure it needs. Next, you will model an unsafe design and make policy tests reject it before AWS creates a workload.
Test the Unsafe Design
Your private AWS CloudShell workspace now has a verified identity plus the pinned Terraform CLI. That gives every change in this lab a known starting point.
A valid Terraform plan can still expose SSH or weaken storage controls. In this step, you will model those unsafe choices and prove that plan-only policy tests reject them before any AWS workload exists.
In this step, get ready to:
- Create the version-pinned Terraform configuration with its deliberately unsafe security mode.
- Add plan-only tests for network access plus S3 protection.
- Initialize the project before capturing the expected test failure.
Create the version-pinned design
The configuration describes the complete workload before Terraform creates anything. The secure_mode value selects the deliberately unsafe branch for this first plan.
Why Use Terraform Tests?
Terraform separates planning from applying. Plan-only tests can inspect proposed infrastructure without deploying it.
An AWS-native template could model the same resources. Terraform keeps the project aligned with the pinned toolchain while turning the rejected plan into repeatable evidence.
- Switch back to the CloudShell terminal from earlier.
- Move into ~/secure-aws-foundation by running this command:
cd ~/secure-aws-foundation
What Does This Command Do?
The command returns you to the private project directory created earlier. Every configuration file stays inside the persistent CloudShell home directory.
- Use the native edit command in CloudShell to create .gitignore inside ~/secure-aws-foundation.
- If CloudShell displays a Safe Paste warning during a multiline paste, review the text before choosing Paste.
- Add the following state-protection patterns to .gitignore by pasting this content:
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
What Does This File Protect?
- The patterns exclude downloaded provider files plus local Terraform state.
- The final patterns exclude saved plans plus crash logs.
- Terraform state can contain sensitive infrastructure values, so it must stay out of anything you publish.
- Save .gitignore.
- Confirm the editor shows five ignore patterns.
Missing an Ignore Pattern?
Check that each pattern occupies its own line. Confirm that the file name begins with a period.
Ask for help if the hidden file does not save correctly: Help me check my .gitignore file in AWS CloudShell.
- Create versions.tf inside ~/secure-aws-foundation with the CloudShell edit command.
- Add the pinned Terraform plus AWS provider configuration by pasting this code:
terraform {
required_version = "= 1.16.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.69.1"
}
}
}
provider "aws" {
region = var.aws_region
}
What Does This Configuration Do?
- The Terraform constraint requires CLI version 1.16.6.
- The provider constraint requires HashiCorp AWS Provider version 6.69.1.
- The provider reads the deployment region from var.aws_region.
- Save versions.tf.
- Confirm the editor shows both exact version constraints.
Seeing a Version Syntax Problem?
Check the braces around required_providers. Confirm that both version values retain their leading equals signs.
Ask for help with the version file: Help me compare my versions.tf file with the pinned configuration.
- Create variables.tf inside ~/secure-aws-foundation with the CloudShell edit command.
- Define the project inputs with the intentionally unsafe default by pasting this code:
variable "aws_region" {
description = "AWS Region for the disposable security lab."
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Prefix for project resources."
type = string
default = "secure-aws-foundation"
}
variable "secure_mode" {
description = "When true, remove public SSH and enforce S3 protection controls."
type = bool
default = false
}
What Do These Variables Control?
- The region variable keeps the disposable lab in us-east-1.
- The project name supplies a consistent prefix for named resources.
- The false security mode deliberately activates the weak network plus storage settings that the tests must catch.
- Save variables.tf.
- Confirm the secure_mode block ends with default = false.
Do Not Apply This Configuration
The false security mode exists only for a speculative plan plus a failing policy test. Do not run terraform apply while this value is false.
- Create network.tf inside ~/secure-aws-foundation with the CloudShell edit command.
- Add the Amazon VPC plus application subnet by pasting this code:
resource "aws_vpc" "main" {
cidr_block = "10.40.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
Environment = "development"
}
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.40.1.0/24"
map_public_ip_on_launch = false
tags = {
Name = "${var.project_name}-app-subnet"
Environment = "development"
}
}
What Does This Network Chunk Do?
- The VPC reserves the private 10.40.0.0/16 address range.
- The application subnet uses 10.40.1.0/24 inside that range.
- Automatic public IP assignment stays disabled at the subnet level.
- Append the internet gateway plus route table resources to network.tf by pasting this code:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
resource "aws_route_table" "app" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app-routes"
}
}
What Does This Routing Chunk Do?
- The internet gateway gives the VPC a route target for outbound traffic.
- The dedicated route table keeps the application subnet routing explicit.
- Append the default route plus subnet association to network.tf by pasting this code:
resource "aws_route" "internet" {
route_table_id = aws_route_table.app.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
resource "aws_route_table_association" "app" {
subnet_id = aws_subnet.app.id
route_table_id = aws_route_table.app.id
}
How Does Traffic Leave the Subnet?
The default route sends IPv4 destinations to the internet gateway. The association applies that route table to the application subnet.
- Append the application security group plus deliberately unsafe SSH rule to network.tf by pasting this code:
resource "aws_security_group" "app" {
name = "${var.project_name}-app"
description = "Application instance with no inbound access in secure mode"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app"
}
}
resource "aws_vpc_security_group_ingress_rule" "ssh_from_internet" {
count = var.secure_mode ? 0 : 1
security_group_id = aws_security_group.app.id
description = "Intentionally unsafe SSH rule used only for the failing plan test"
cidr_ipv4 = "0.0.0.0/0"
from_port = 22
ip_protocol = "tcp"
to_port = 22
}
Where Is the Unsafe Network Path?
The false security mode gives the ingress resource a count of one. That proposed rule permits TCP port 22 from every IPv4 address.
This rule remains a plan-time specimen. The baseline test must stop it before deployment.
- Append the outbound rule to network.tf by pasting this code:
resource "aws_vpc_security_group_egress_rule" "all_ipv4" {
security_group_id = aws_security_group.app.id
description = "Outbound access for AWS service endpoints and package services"
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Why Is Outbound Access Present?
The egress rule permits the instance to initiate outbound IPv4 connections. Session Manager depends on outbound connectivity to regional AWS service endpoints.
- Save network.tf.
- Confirm the editor shows one conditional ingress rule plus one egress rule.
Network File Showing Red Markers?
Check that each resource closes before the next resource begins. Confirm that the ingress rule uses the exact name ssh_from_internet.
Ask for help with the network file: Help me find the HCL structure problem in network.tf.
- Create storage.tf inside ~/secure-aws-foundation with the CloudShell edit command.
- Add the account lookup plus private Amazon S3 bucket by pasting this code:
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "demo" {
bucket = "${var.project_name}-${data.aws_caller_identity.current.account_id}-${var.aws_region}"
force_destroy = true
tags = {
Name = "${var.project_name}-demo-data"
Environment = "development"
DataType = "synthetic"
}
}
How Is the Bucket Named?
The caller identity contributes the active AWS account ID to the bucket name. The region plus project prefix complete the account-specific name.
The synthetic-data tag makes the fictional purpose explicit.
- Append the public-access block plus default encryption resources to storage.tf by pasting this code:
resource "aws_s3_bucket_public_access_block" "demo" {
bucket = aws_s3_bucket.demo.id
block_public_acls = var.secure_mode
block_public_policy = var.secure_mode
ignore_public_acls = var.secure_mode
restrict_public_buckets = var.secure_mode
}
resource "aws_s3_bucket_server_side_encryption_configuration" "demo" {
bucket = aws_s3_bucket.demo.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
Which Storage Controls Are Modeled?
All four Block Public Access settings follow secure_mode. Their planned values are false in this intentionally unsafe configuration.
Default server-side encryption remains enabled with AES256.
- Append the versioning plus fictional object resources to storage.tf by pasting this code:
resource "aws_s3_bucket_versioning" "demo" {
bucket = aws_s3_bucket.demo.id
versioning_configuration {
status = var.secure_mode ? "Enabled" : "Suspended"
}
}
resource "aws_s3_object" "demo" {
bucket = aws_s3_bucket.demo.id
key = "demo/customer.txt"
content = "Fictional customer: Example Corp\nClassification: synthetic demo data\n"
content_type = "text/plain"
server_side_encryption = "AES256"
depends_on = [
aws_s3_bucket_public_access_block.demo,
aws_s3_bucket_server_side_encryption_configuration.demo,
aws_s3_bucket_versioning.demo
]
}
Where Is the Versioning Weakness?
The false security mode plans the bucket versioning status as Suspended. The test will require Enabled.
The object contains fictional demonstration data. Its explicit encryption remains enabled in both modes.
- Save storage.tf.
- Confirm the editor shows four public-access settings plus one versioning status expression.
Storage File Showing an Invalid String?
Keep both escaped newline sequences inside the object content string. Confirm that the dependency list ends before the object resource closes.
Ask for help with the storage file: Help me check storage.tf for an HCL string or block error.
- Create iam.tf inside ~/secure-aws-foundation with the CloudShell edit command.
- Add the EC2 trust policy plus application IAM role by pasting this code:
data "aws_iam_policy_document" "ec2_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "app" {
name = "${var.project_name}-ec2-role"
assume_role_policy = data.aws_iam_policy_document.ec2_trust.json
tags = {
Name = "${var.project_name}-ec2-role"
}
}
Who Can Assume This Role?
The trust policy allows the Amazon EC2 service principal to assume the role. The instance receives temporary role credentials through its instance profile.
- Append the approved S3 access document to iam.tf by pasting this code:
data "aws_iam_policy_document" "approved_s3_access" {
statement {
sid = "ListOnlyApprovedBucket"
effect = "Allow"
actions = ["s3:ListBucket"]
resources = [aws_s3_bucket.demo.arn]
}
statement {
sid = "ReadOnlyApprovedObjects"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.demo.arn}/*"]
}
}
How Is Least Privilege Expressed?
The first statement permits listing only the project bucket. The second statement permits reading only objects inside that bucket.
The policy omits s3:ListAllMyBuckets. Account-wide bucket discovery receives no permission from this role.
- Append the inline policy plus Session Manager attachment plus instance profile to iam.tf by pasting this code:
resource "aws_iam_role_policy" "s3_access" {
name = "approved-s3-read-only"
role = aws_iam_role.app.id
policy = data.aws_iam_policy_document.approved_s3_access.json
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "app" {
name = "${var.project_name}-instance-profile"
role = aws_iam_role.app.name
}
How Does the Instance Receive Permissions?
The inline policy binds the bucket-specific permissions to the application role. The managed policy supplies the Systems Manager core permissions.
The instance profile makes the role attachable to the EC2 instance.
- Save iam.tf.
- Confirm the editor shows no s3:ListAllMyBuckets action.
IAM References Not Lining Up?
Confirm that the policy data source is named approved_s3_access. Check that the instance profile uses the application role name.
Ask for help with the IAM file: Help me trace the references in iam.tf.
- Create compute.tf inside ~/secure-aws-foundation with the CloudShell edit command.
- Add the base Amazon EC2 instance resource by pasting this code:
resource "aws_instance" "app" {
ami = "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
instance_type = "t3.micro"
subnet_id = aws_subnet.app.id
associate_public_ip_address = true
vpc_security_group_ids = [aws_security_group.app.id]
iam_instance_profile = aws_iam_instance_profile.app.name
tags = {
Name = "${var.project_name}-app"
Environment = "development"
DataType = "synthetic"
}
depends_on = [
aws_route.internet,
aws_route_table_association.app,
aws_iam_role_policy.s3_access,
aws_iam_role_policy_attachment.ssm_core
]
}
What Does the Base Instance Use?
The instance resolves the current Amazon Linux 2023 AMI through the documented Systems Manager parameter. It uses the t3.micro instance type plus the application subnet.
The resource attaches the application security group plus instance profile. Its dependencies ensure routing plus IAM exist first.
- Place the following hardening blocks after the iam_instance_profile line in compute.tf.
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
root_block_device {
encrypted = true
volume_type = "gp3"
}
credit_specification {
cpu_credits = "standard"
}
Which Instance Controls Are Present?
- Required metadata tokens enforce IMDSv2 for instance metadata requests.
- The root EBS volume uses encryption plus the gp3 volume type.
- Standard CPU credits prevent the T3 instance from using unlimited credit mode.
- Save compute.tf.
- Confirm the metadata plus root-volume plus credit blocks appear before the tags block.
Compute Blocks Outside the Resource?
Make sure the three nested blocks sit inside resource "aws_instance" "app". Place them before the tags block.
Ask for help with the instance structure: Help me place the nested blocks inside compute.tf.
- Create outputs.tf inside ~/secure-aws-foundation with the CloudShell edit command.
- Add the workload references plus runtime test commands by pasting this code:
output "instance_id" {
description = "EC2 instance to select in Session Manager."
value = aws_instance.app.id
}
output "bucket_name" {
description = "Private bucket containing fictional demonstration data."
value = aws_s3_bucket.demo.id
}
output "approved_test_command" {
description = "Run inside Session Manager to prove approved object access."
value = "aws s3 cp s3://${aws_s3_bucket.demo.id}/${aws_s3_object.demo.key} -"
}
output "denied_test_command" {
description = "Run inside Session Manager to prove account-wide bucket listing is denied."
value = "aws s3api list-buckets"
}
What Will These Outputs Provide?
The first two outputs identify the eventual instance plus bucket. The final two outputs prepare an approved object read plus an unauthorized account-wide bucket listing.
These values remain proposed until the hardened design is applied in a later step.
- Save outputs.tf.
- Confirm the editor shows four output blocks.
Output String Showing an Error?
Keep the bucket plus object references inside the same quoted S3 command string. Confirm that each output has one description plus one value.
Ask for help with the outputs: Help me check the interpolation in outputs.tf.
- Confirm the configuration files exist by running this command:
ls
What Should You See?
The output should list compute.tf, iam.tf, network.tf, outputs.tf, storage.tf, tests, variables.tf, and versions.tf.
Add the baseline policy tests
A plan-only test inspects proposed values without creating infrastructure. Each assertion turns a security expectation into an automated gate.
- Create tests/security.tftest.hcl inside the existing ~/secure-aws-foundation/tests directory with the CloudShell edit command.
- Add the three baseline assertions by pasting this code:
run "secure_baseline" {
command = plan
assert {
condition = length(aws_vpc_security_group_ingress_rule.ssh_from_internet) == 0
error_message = "Secure mode must not create an internet-wide SSH rule."
}
assert {
condition = (
aws_s3_bucket_public_access_block.demo.block_public_acls &&
aws_s3_bucket_public_access_block.demo.block_public_policy &&
aws_s3_bucket_public_access_block.demo.ignore_public_acls &&
aws_s3_bucket_public_access_block.demo.restrict_public_buckets
)
error_message = "All four S3 Block Public Access settings must be enabled."
}
assert {
condition = aws_s3_bucket_versioning.demo.versioning_configuration[0].status == "Enabled"
error_message = "S3 versioning must be enabled."
}
}
What Do the Tests Require?
- The first assertion requires the internet-wide SSH rule count to equal zero.
- The second assertion requires all four S3 Block Public Access settings to evaluate to true.
- The third assertion requires the planned versioning status to equal Enabled.
- The plan command lets Terraform evaluate these controls without creating resources.
- Save tests/security.tftest.hcl.
- Confirm the editor shows three assert blocks inside secure_baseline.
Test File Showing a Parsing Problem?
Check the parentheses around the four public-access conditions. Confirm that every assertion sits inside the secure_baseline run block.
Ask for help with the policy tests: Help me find the HCL error in security.tftest.hcl.
Use the comparison below before initializing the project. The second tab contains the complete unsafe configuration for this checkpoint.
✔️ Awesome, I've got everything!
Great. Double-check that every file is saved before returning to the terminal.
ⓧ I'd like to double check the full code
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
terraform {
required_version = "= 1.16.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.69.1"
}
}
}
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
description = "AWS Region for the disposable security lab."
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Prefix for project resources."
type = string
default = "secure-aws-foundation"
}
variable "secure_mode" {
description = "When true, remove public SSH and enforce S3 protection controls."
type = bool
default = false
}
resource "aws_vpc" "main" {
cidr_block = "10.40.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
Environment = "development"
}
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.40.1.0/24"
map_public_ip_on_launch = false
tags = {
Name = "${var.project_name}-app-subnet"
Environment = "development"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
resource "aws_route_table" "app" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app-routes"
}
}
resource "aws_route" "internet" {
route_table_id = aws_route_table.app.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
resource "aws_route_table_association" "app" {
subnet_id = aws_subnet.app.id
route_table_id = aws_route_table.app.id
}
resource "aws_security_group" "app" {
name = "${var.project_name}-app"
description = "Application instance with no inbound access in secure mode"
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-app"
}
}
resource "aws_vpc_security_group_ingress_rule" "ssh_from_internet" {
count = var.secure_mode ? 0 : 1
security_group_id = aws_security_group.app.id
description = "Intentionally unsafe SSH rule used only for the failing plan test"
cidr_ipv4 = "0.0.0.0/0"
from_port = 22
ip_protocol = "tcp"
to_port = 22
}
resource "aws_vpc_security_group_egress_rule" "all_ipv4" {
security_group_id = aws_security_group.app.id
description = "Outbound access for AWS service endpoints and package services"
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "demo" {
bucket = "${var.project_name}-${data.aws_caller_identity.current.account_id}-${var.aws_region}"
force_destroy = true
tags = {
Name = "${var.project_name}-demo-data"
Environment = "development"
DataType = "synthetic"
}
}
resource "aws_s3_bucket_public_access_block" "demo" {
bucket = aws_s3_bucket.demo.id
block_public_acls = var.secure_mode
block_public_policy = var.secure_mode
ignore_public_acls = var.secure_mode
restrict_public_buckets = var.secure_mode
}
resource "aws_s3_bucket_server_side_encryption_configuration" "demo" {
bucket = aws_s3_bucket.demo.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_versioning" "demo" {
bucket = aws_s3_bucket.demo.id
versioning_configuration {
status = var.secure_mode ? "Enabled" : "Suspended"
}
}
resource "aws_s3_object" "demo" {
bucket = aws_s3_bucket.demo.id
key = "demo/customer.txt"
content = "Fictional customer: Example Corp\nClassification: synthetic demo data\n"
content_type = "text/plain"
server_side_encryption = "AES256"
depends_on = [
aws_s3_bucket_public_access_block.demo,
aws_s3_bucket_server_side_encryption_configuration.demo,
aws_s3_bucket_versioning.demo
]
}
data "aws_iam_policy_document" "ec2_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "app" {
name = "${var.project_name}-ec2-role"
assume_role_policy = data.aws_iam_policy_document.ec2_trust.json
tags = {
Name = "${var.project_name}-ec2-role"
}
}
data "aws_iam_policy_document" "approved_s3_access" {
statement {
sid = "ListOnlyApprovedBucket"
effect = "Allow"
actions = ["s3:ListBucket"]
resources = [aws_s3_bucket.demo.arn]
}
statement {
sid = "ReadOnlyApprovedObjects"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.demo.arn}/*"]
}
}
resource "aws_iam_role_policy" "s3_access" {
name = "approved-s3-read-only"
role = aws_iam_role.app.id
policy = data.aws_iam_policy_document.approved_s3_access.json
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "app" {
name = "${var.project_name}-instance-profile"
role = aws_iam_role.app.name
}
resource "aws_instance" "app" {
ami = "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
instance_type = "t3.micro"
subnet_id = aws_subnet.app.id
associate_public_ip_address = true
vpc_security_group_ids = [aws_security_group.app.id]
iam_instance_profile = aws_iam_instance_profile.app.name
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
root_block_device {
encrypted = true
volume_type = "gp3"
}
credit_specification {
cpu_credits = "standard"
}
tags = {
Name = "${var.project_name}-app"
Environment = "development"
DataType = "synthetic"
}
depends_on = [
aws_route.internet,
aws_route_table_association.app,
aws_iam_role_policy.s3_access,
aws_iam_role_policy_attachment.ssm_core
]
}
output "instance_id" {
description = "EC2 instance to select in Session Manager."
value = aws_instance.app.id
}
output "bucket_name" {
description = "Private bucket containing fictional demonstration data."
value = aws_s3_bucket.demo.id
}
output "approved_test_command" {
description = "Run inside Session Manager to prove approved object access."
value = "aws s3 cp s3://${aws_s3_bucket.demo.id}/${aws_s3_object.demo.key} -"
}
output "denied_test_command" {
description = "Run inside Session Manager to prove account-wide bucket listing is denied."
value = "aws s3api list-buckets"
}
run "secure_baseline" {
command = plan
assert {
condition = length(aws_vpc_security_group_ingress_rule.ssh_from_internet) == 0
error_message = "Secure mode must not create an internet-wide SSH rule."
}
assert {
condition = (
aws_s3_bucket_public_access_block.demo.block_public_acls &&
aws_s3_bucket_public_access_block.demo.block_public_policy &&
aws_s3_bucket_public_access_block.demo.ignore_public_acls &&
aws_s3_bucket_public_access_block.demo.restrict_public_buckets
)
error_message = "All four S3 Block Public Access settings must be enabled."
}
assert {
condition = aws_s3_bucket_versioning.demo.versioning_configuration[0].status == "Enabled"
error_message = "S3 versioning must be enabled."
}
}
Initialize and experience the designed obstacle
The files now describe a complete speculative workload. Formatting plus validation checks their structure before the security assertions inspect the planned values.
- Format every Terraform file by running:
terraform fmt
What Does Formatting Check?
Terraform rewrites the configuration into its canonical style. Any printed file names identify files whose formatting changed.
- Confirm the command returns to the prompt without reporting a parsing error.
Formatting Reports an HCL Error?
The message usually points to the file plus line where a quote or brace is incomplete. Compare that file with the full-code tab before continuing.
Ask for help with the reported location: Help me fix the Terraform formatting error.
Terraform must download the pinned provider before it can validate provider-specific resources. Allow a few minutes if the provider download is slow.
- Initialize the project by running:
terraform init
What Does Initialization Do?
Terraform initializes the current directory plus installs the required AWS provider. The lock information records the selected provider version for reproducible runs.
- Confirm the initialization output reports a successful setup for the pinned provider.
Provider Download Failed?
Confirm that CloudShell still has outbound connectivity. Check that versions.tf contains the exact provider source plus version constraint.
Ask for environment-specific help: Help me troubleshoot terraform init in AWS CloudShell.
- Validate the initialized configuration by running:
terraform validate
What Does Validation Prove?
Validation checks the configuration syntax plus provider arguments. A successful result proves Terraform can understand the design.
Security still needs a separate test because valid infrastructure can contain unsafe values.
- Confirm validation completes successfully before continuing.
Validation Found an Invalid Reference?
Compare the resource address in the message with the matching full-code file. A single changed resource name can break references across several files.
Ask for help tracing the reference: Help me resolve my terraform validate error.
Before you run the policy suite, which planned control do you think the tests will reject first?
- Run the plan-only policy suite by running:
terraform test
This Failure Is the Intended Result
The test summary reports failure because the speculative plan contains one internet-wide SSH rule. It also evaluates all four S3 Block Public Access settings as false.
The planned S3 versioning status is Suspended instead of Enabled. The assertion messages identify each rejected baseline control.
The run block uses command = plan, so Terraform creates no VPC, EC2 instance, S3 bucket, or IAM role.
That is the first security gate working: Terraform understood the design, while the tests stopped its unsafe values before deployment.
Test Passed Unexpectedly?
Confirm that variables.tf still sets secure_mode to false. Confirm that the test file is saved under tests/security.tftest.hcl.
Ask for help if the planned values do not trigger the assertions: Why did my intentionally unsafe Terraform test pass?
You now have evidence that a valid plan can still fail a security gate. Next, you will harden the same configuration until every baseline assertion passes.
Harden the Terraform Design
The failed Terraform policy test exposed three unsafe choices before AWS received any workload resources. That failure gives you a precise checklist for hardening the design.
You will switch the shared security control to its protected state. You will then prove the hardened proposal is ready before anything reaches AWS.
In this step, get ready to:
- Enable secure_mode so Terraform removes the proposed SSH ingress rule.
- Review every hardened control in the existing configuration.
- Prove the hardened configuration is ready for deployment.
Enable secure mode
The secure_mode variable controls the deliberately unsafe paths you tested earlier. Setting it to true makes the same policy suite evaluate the protected design.
- In the AWS CloudShell editor, open variables.tf.
- Find the secure_mode block with its default set to false.
- Enable secure mode by replacing that block with the code below:
variable "secure_mode" {
description = "When true, remove public SSH and enforce S3 protection controls."
type = bool
default = true
}
What does this change do?
- The true default makes the conditional SSH ingress count resolve to zero.
- Each public-access protection setting now resolves to true.
- The bucket versioning status now resolves to Enabled.
- Save variables.tf.
Before you run the policy suite, consider whether the same assertions can still fail after secure_mode changes to true.
- Test the protected design by running:
terraform test
What should you see?
Terraform evaluates the plan-only assertions against the protected configuration. The test summary should report zero failures.
You have turned the earlier security failure into a passing preventative control. No workload resources have been deployed.
Are the baseline tests still failing?
- Confirm that you saved variables.tf before running the test.
- Check that the secure_mode default is the Boolean value true without quotation marks.
- Compare the edited block with the complete file below.
Help me diagnose a failing Terraform baseline test after enabling secure_mode.
✔️ Awesome, I've got everything
Your variables.tf file now defaults to the protected design. Keep the passing test output available for the final checkpoint.
ⓧ I'd like to double check the full code
variable "aws_region" {
description = "AWS Region for the disposable security lab."
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Prefix for project resources."
type = string
default = "secure-aws-foundation"
}
variable "secure_mode" {
description = "When true, remove public SSH and enforce S3 protection controls."
type = bool
default = true
}
Compare every variable name and default with your saved file. The final block must use the Boolean value true.
Review storage and identity controls
Passing tests prove the network assertion. They also prove the Amazon S3 assertions.
A manual IAM review connects those test results to least privilege. It confirms that the workload role can reach only the approved bucket data.
Session Manager keeps administration off inbound SSH. The Amazon EC2 role supplies temporary permissions to the workload without embedded access keys.
- In the CloudShell editor, open network.tf.
- Locate count = var.secure_mode ? 0 : 1 in aws_vpc_security_group_ingress_rule.ssh_from_internet.
- Confirm that secure_mode now makes the ingress-rule count resolve to 0.
- Open storage.tf in the same editor.
- Confirm that block_public_acls, block_public_policy, ignore_public_acls, and restrict_public_buckets all use var.secure_mode.
- Confirm that default encryption uses AES256.
- Confirm that the versioning expression resolves to Enabled.
How do the storage controls work together?
The four public-access settings block common routes to public exposure. Versioning keeps recoverable object revisions.
Default AES256 encryption protects stored objects. The fictional demonstration object also requests the same server-side encryption explicitly.
- Open iam.tf in the CloudShell editor.
- Locate the statement granting s3:ListBucket on aws_s3_bucket.demo.arn.
- Locate the statement granting s3:GetObject on objects below the project bucket ARN.
- Confirm that s3:ListAllMyBuckets is absent from the workload policy.
How does least privilege show up?
The first statement permits listing keys inside the project bucket. The second statement permits reading objects from that bucket.
The policy omits account-wide bucket discovery. The separate AmazonSSMManagedInstanceCore attachment supplies the Systems Manager permissions needed for administration.
Run the preventative checks
The configuration now reflects the secure design in every relevant file. A final format, validation, test, and plan cycle gives you evidence that the proposal is consistent.
- Format the updated configuration by running:
terraform fmt
What does this command do?
Terraform rewrites configuration files into its canonical style. A clean format keeps later reviews focused on meaningful changes.
- Validate the formatted configuration by running:
terraform validate
What does validation prove?
Terraform checks the configuration syntax and resource arguments. A successful result confirms that the secure-mode edit did not break the configuration structure.
Is validation failing?
- Check that every brace in variables.tf matches the complete file above.
- Confirm that true has no quotation marks.
- Use the file name in the validation output to locate the configuration that needs attention.
Help me fix my Terraform validation failure.
Before you rerun the tests, consider whether any baseline assertion should still reject the protected design.
- Run the complete baseline policy suite by running:
terraform test
What should the final test show?
The test summary should report zero failures. This proves the proposed design contains no internet-wide SSH ingress rule.
The same summary proves that all four public-access protections resolve to true. It also proves that S3 versioning resolves to Enabled.
Does a security assertion still fail?
- Read the failed assertion message to identify the control that still differs.
- Compare the referenced resource with its matching block in network.tf or storage.tf.
- Rerun the test only after saving the corrected file.
Help me trace a failing Terraform security assertion to the responsible resource.
Before you generate the deployment preview, consider whether any internet-wide SSH ingress should appear in the hardened proposal.
- Review the hardened deployment proposal by running:
terraform plan
What should the plan prove?
- The proposal contains no instance security-group ingress rule for internet-wide SSH.
- The bucket proposal enables every public-access protection setting.
- The bucket proposal enables versioning with AES256 encryption.
- The workload policy grants only bucket-scoped listing plus object-scoped reading.
Terraform only previews these changes. No AWS workload resources are created by this command.
Does the plan still show an unsafe control?
- Confirm that variables.tf still sets secure_mode to true.
- Rerun terraform test after correcting any changed security value.
- Keep the configuration unapplied until every test passes.
Help me review a Terraform plan that still proposes an unsafe security control.
You have turned the failed policy suite into a hardened plan without deploying anything. Next, you will deploy the workload to prove its least-privilege behavior.
Deploy and Prove Least Privilege
Your hardened Terraform plan now passes every baseline policy test. Static checks prove the intended design.
Deploying the plan creates live Amazon EC2 resources plus Amazon S3 resources. You will use AWS Systems Manager Session Manager to prove the workload role grants only its intended access.
In this step, get ready to:
- Apply the reviewed hardened plan to your development account.
- Connect to the EC2 instance through Session Manager.
- Prove the workload role grants only its intended S3 access.
Apply the reviewed plan
Applying the plan creates pay-as-you-go resources. Keep the lab deployed only long enough to gather your evidence.
You stay in control because Terraform pauses for approval before creating anything. Expect the resource creation to take a few minutes.
- Return to the AWS CloudShell terminal from earlier.
- Confirm secure_mode in variables.tf still has the value true.
- Start the reviewed deployment by running this command:
terraform apply
What Does the Apply Do?
The apply command calculates the proposed changes again. It pauses before creating resources so you can inspect the final plan.
Approval turns the reviewed configuration into live AWS resources. The pause is your final security checkpoint.
- Review the proposed resource list in the terminal.
- Confirm the plan does not include aws_vpc_security_group_ingress_rule.ssh_from_internet.
- Enter yes at the approval prompt.
Terraform creates the hardened workload. The completion summary confirms that the managed resources now exist in AWS.
- Display the deployment outputs by running this command:
terraform output
What Do the Outputs Provide?
- The instance_id output identifies the EC2 instance you will connect to.
- The bucket_name output identifies the private bucket holding the fictional object.
- The approved_test_command output contains the bucket-specific read command.
- The denied_test_command output contains the account-wide discovery request.
- Record the instance ID here: your Terraform instance_id output.
- Record the bucket name here: your Terraform bucket_name output.
- Record only the command text from approved_test_command here: your approved_test_command output.
- Record only the command text from denied_test_command here: your denied_test_command output.
Did the Apply Stop?
- Read the first reported resource error to identify which AWS permission blocked the deployment.
- Confirm the signed-in identity belongs to the intended development account.
- Keep secure_mode set to true while resolving the problem.
Ask for help with the failure before approving another plan: Help me diagnose why my hardened Terraform apply failed in AWS CloudShell.
Connect without SSH
Session Manager relies on an outbound connection from the instance. The security group can keep every inbound administration path closed.
The Session Manager connection option can take a few minutes to become available while the instance registers. A short delay here is normal.
- Switch back to the AWS Management Console from earlier.
- Confirm the console region is us-east-1.
- Use the console search to open Amazon EC2.
- Choose Instances.
- Select the instance matching this ID: your Terraform instance_id output.
- Choose Connect.
- Choose Session Manager.
- Choose Connect.
You will see a shell for the project instance in your browser. You reached it without an SSH key or an inbound port.
Session Manager Not Ready?
- Wait a few minutes for the new instance to finish registering.
- Confirm you selected the instance matching your saved Terraform instance ID.
- Confirm the instance role still includes AmazonSSMManagedInstanceCore.
- Confirm the security group still permits outbound IPv4 traffic.
Get targeted help with the connection: Why is Session Manager unavailable for my new EC2 instance?
Exercise the workload role
The instance receives temporary permissions from its IAM role. The next two requests test the exact boundary defined in the inline S3 policy.
- Retrieve the approved fictional object by running your saved command in the Session Manager shell:
[[APPROVED_TEST_COMMAND="your approved_test_command output"]]
Why Does This Request Succeed?
The command streams demo/customer.txt from the project bucket to the terminal. The instance role grants s3:GetObject for objects in that bucket.
The application uses role-based access without an embedded access key. The policy limits that access to the approved bucket.
You should see Fictional customer: Example Corp on the first line. You have live proof that the approved read works.
Before you run the next command, do you think this role can discover every bucket in the account?
- Request the account-wide bucket list by running this command:
aws s3api list-buckets
Why Is the Request Denied?
The project role grants s3:ListBucket only for the named project bucket. Its policy does not include s3:ListAllMyBuckets.
The account-wide request crosses the policy boundary. AWS denies the request before returning a bucket list.
You should see an AccessDenied result. You have now proved that the workload can read its approved object without discovering every bucket in the account.
Do the Results Look Wrong?
- Compare the approved command with the saved approved_test_command output if the object read fails.
- Confirm the Session Manager shell belongs to the instance matching your saved instance ID.
- Stop the test if account-wide bucket listing succeeds.
- Review the instance role for permissions outside the project configuration.
Get help comparing the live permissions with the intended policy: Help me troubleshoot my EC2 role's S3 least-privilege test results.
Your hardened workload is live. Its allowed action succeeds while its unauthorized action is blocked. Next, you will trace this administrative session through AWS CloudTrail.
Trace Session Access in CloudTrail
Your approved object read returned the fictional customer record. The denied account-wide bucket listing proved that the workload role enforces least privilege.
The Session Manager connection also needs an audit record. Without that evidence, an investigation has a gap.
In this step, you will query AWS CloudTrail for the session. You will preserve a sanitized record that identifies the access without exposing account details.
In this step, get ready to:
- Query CloudTrail Event history for the StartSession management event.
- Record sanitized evidence of the administrative session.
Query the regional event history
CloudTrail Event history provides a regional view of management events from the most recent 90 days. A focused lookup connects an administrative action to its audit record.
- Switch back to AWS CloudShell from earlier.
Before you run the lookup, do you think the session is already visible in the regional event history?
- Search the us-east-1 event history for session starts by running this command:
aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=StartSession --max-items 10 --region us-east-1
What Does This Command Do?
- The lookup-events operation searches the regional management-event history.
- The AttributeKey=EventName,AttributeValue=StartSession lookup limits the results to session starts.
- The --max-items 10 option limits the number of returned matches.
- The --region us-east-1 option searches the region where you opened the session.
You should see returned JSON containing a StartSession event for your administrative session.
Fresh management events can take a brief moment to appear. A missing first result does not mean the session went unrecorded.
- Wait briefly if the first lookup does not contain the new event.
- Run the same lookup again.
The matching result should identify when the session started. It should also contain the service, identity, and resource details needed for your evidence.
No StartSession Event Yet?
- Confirm that the administrative session was opened in us-east-1.
- Check that the lookup still targets StartSession.
- Wait briefly before running the lookup again.
Help me investigate the missing event.
Build a sanitized evidence record
Raw CloudTrail output can contain account-specific identifiers. A sanitized evidence record keeps the investigation useful while protecting details that should stay private.
- Read the matching event to find its event time.
- Find the event source in the same event.
- Identify the username or assumed role associated with the event.
- Identify the resource reference associated with the session.
Keep the Evidence Safe
- Keep the event time.
- Keep the event source.
- Use a short username or assumed-role label.
- Use a short resource reference.
- Exclude account IDs.
- Exclude session tokens.
- Exclude public IP addresses.
- Exclude full ARNs.
- Enter the sanitized event time in the text checkpoint below.
- Add the event source on a new line.
- Add the short identity label on a new line.
- Add the short resource reference on a new line.
You have turned raw event data into a safe audit record. The record preserves the evidence without publishing account-specific values.
Before the final check, do you expect this event to align with the Session Manager connection that produced your access evidence?
- Compare the event time with the session you opened earlier.
- Confirm that the event source matches the service behind the Session Manager session.
- Confirm that the identity belongs to the intended development account role.
- Confirm that the resource reference points to the administrative session or managed instance.
- Group the sanitized CloudTrail record with your existing approved and denied access evidence.
You should now have a sanitized StartSession record paired with the successful object read and denied bucket listing.
That closes the investigation gap. Your administrative session is traceable through CloudTrail without exposing sensitive account details.
Secret mission
Expand the EC2 Hardening Tests
Your baseline suite protects the network and S3 controls. However, a later edit could weaken the EC2 instance metadata or root volume without tripping those checks. Add two nested plan-time assertions. Prove each assertion catches a deliberate regression. Restore the secure values for a clean test run.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the lab running, pause its compute, or delete the managed stack. Amazon EC2 and Amazon S3 use pay-as-you-go pricing, so leaving the workload deployed can continue generating charges.
Cost warning
Amazon EC2 compute is billed while the instance runs. A stopped instance keeps its EBS root volume.
Amazon S3 data remains billable while stored. The Delete option is the safest choice when you have finished collecting evidence.
Resources you used:
- Network resources: one Amazon VPC, one subnet, one internet gateway, one route table, one route-table association, and one internet route.
- Network controls: one no-ingress security group and one egress rule.
- Compute resources: one Amazon EC2 instance with an encrypted EBS root volume.
- Storage resources: one private Amazon S3 bucket, one fictional object, one public-access block, one encryption configuration, and one versioning configuration.
- Identity resources: one IAM role, one inline policy, one managed-policy attachment, and one instance profile.
Keep everything running
No action is needed if you are actively extending the lab. The EC2 instance and S3 data remain deployed.
- Keep the Terraform state inside your private AWS CloudShell home directory.
- Review your current account charges while the lab remains deployed.
- Keep all fictional workload data separate from production resources.
Pause - I'll come back to this later
Pausing stops the running EC2 instance while preserving the rest of the lab. This keeps the workload ready for another testing session.
- Return to the Amazon EC2 console in us-east-1.
- Select the project instance that you used for Session Manager.
- Use the instance state controls to stop the selected instance.
You should see the instance change to a stopped state.
The EBS volume remains attached. The S3 data and other persistent resources also remain, so some charges can continue.
Delete - I don't want to use this again
Deleting the lab is final for the deployed resources. Your sanitized source and evidence can remain available if you keep them separately.
- Switch back to the CloudShell session from earlier.
- Return to the ~/secure-aws-foundation project directory.
- Start the Terraform-managed cleanup by running this command:
terraform destroy
What does this command do?
Terraform uses the local state in ~/secure-aws-foundation to build a destroy plan.
The confirmation prompt gives you a final chance to review every managed resource before deletion.
- Review the destroy plan for every resource listed above.
- Enter yes at the confirmation prompt.
- Wait for Terraform to finish removing the managed resources.
That is the lab safely torn down. You should see Terraform report that every managed resource in the destroy plan was removed.
Did the cleanup stop early?
Read the first resource named in the failure details. Confirm that CloudShell still uses the development account identity from earlier.
Get help diagnosing your unfinished Terraform cleanup.
- Remove every local state file matching *.tfstate or *.tfstate.* before publishing the source.
- Retain only sanitized Terraform source and sanitized evidence.
- Confirm that the retained evidence contains no account IDs, session tokens, public IP addresses, or full ARNs.
Your project directory should no longer contain local Terraform state.
Nice Work!
Nice Work!
Excellent work! Your AWS security lab now rejects unsafe infrastructure before deployment.
You've learned how to:
- Catch unsafe infrastructure before deployment with Terraform plan-only policy tests for internet-wide SSH, S3 public-access protection, and versioning.
- Deploy a hardened AWS workload with no inbound administration port. Reach its EC2 instance through Session Manager without an SSH key.
- Prove least-privilege IAM with successful approved object access. Confirm account-wide S3 discovery returns AccessDenied. Trace the administrative session through a sanitized StartSession event in CloudTrail Event history.
- Secret Mission: Add Terraform regression tests that reject weakened IMDSv2 requirements or unencrypted EC2 root storage.
Ready to quiz yourself?