Zero Trust Environment with Terraform

Deploy a Zero Trust security baseline on AWS using Terraform and IAM.

Introduction

30 Second Summary

Every time you walk into a bank, they check your ID regardless of how many times you've visited before. In cloud security, this "never trust, always verify" mindset is called Zero Trust.

In this project, you will deploy a complete Zero Trust security baseline on AWS using Terraform. You'll enforce MFA requirements, audit permissions automatically, and log all API activity to encrypted storage.

What You'll Build

A production-ready Zero Trust security environment on AWS, defined entirely as Terraform infrastructure-as-code.

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

  • Terraform code defining IAM policies that deny sensitive actions without MFA authentication.
  • An IAM Access Analyzer detecting overly-permissive access across your account.
  • CloudTrail logging all API activity to an encrypted, locked-down S3 bucket.
  • Secret Mission: Add a password policy and real-time alerts for destructive actions performed without MFA.

Are there any prerequisites?

Basic familiarity with the AWS Console and CLI is helpful. You should also understand basic Terraform syntax (resources, variables, providers).

Set Up Your Project Structure

Every Terraform project starts with a clean directory structure and a configured provider. Your Zero Trust environment will live in a dedicated folder with four files, each handling a specific piece of the Infrastructure as Code setup.

In this step, you will create the project folder, initialize Git for version control, lay out the Terraform file structure, and configure the AWS provider. By the end, Terraform will be ready to deploy resources.

In this step, get ready to:
  • Create a project directory and initialize a Git repository.
  • Create the four core Terraform files.
  • Configure the AWS provider and run terraform init.
Create the project directory and initialize Git

Every project needs a home on your machine. You will create a folder on your Desktop so it is easy to find.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir zero-trust
cd zero-trust
  • Initialize a Git repository by running:
git init

You should see a message confirming an empty Git repository was initialized in your zero-trust folder.

Seeing a git error?

Make sure Git is installed and available in your terminal's PATH. Close and reopen your terminal, then try git --version to confirm Git is accessible.

help me fix a git error in my terminal

Create the Terraform file structure

Terraform projects split configuration across multiple files by convention. Each file handles one concern: providers, variables, main resources, and outputs.

  • Create the four Terraform files by running the command for your operating system:

macOS

touch main.tf variables.tf outputs.tf providers.tf

Windows

New-Item -ItemType File -Path "main.tf", "variables.tf", "outputs.tf", "providers.tf"
  • Confirm the files exist by running:
ls

You should see main.tf, variables.tf, outputs.tf, and providers.tf listed in the output.

What is each file for?

  • providers.tf tells Terraform which cloud provider to use and which version to download.
  • variables.tf defines inputs like region and project name so you can reuse the code without hardcoding values.
  • main.tf holds the actual resources you want to create (IAM policies, S3 buckets, etc.).
  • outputs.tf declares values Terraform prints after a successful deploy, like resource ARNs.

Don't see all four files?

Make sure you ran the command from inside the zero-trust folder. Run pwd (macOS) or Get-Location (Windows) to confirm your terminal is in the right directory.

help me troubleshoot missing files after running touch or New-Item

Configure the AWS provider and run terraform init

Terraform needs to know which provider plugin to download before it can manage any resources. You will point it at the AWS provider and pin the version.

  • Open providers.tf in your code editor and paste the following code:
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.99"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

  • required_version = ">= 1.5.0" ensures anyone running this code has Terraform 1.5.0 or higher.
  • source = "hashicorp/aws" tells Terraform to download the official AWS provider from the Terraform Registry.
  • version = "~> 5.99" pins the provider to the latest 5.x release, avoiding surprise breaking changes from a major version bump.
  • region = var.aws_region reads the region from a variable you will define next, keeping the code flexible.
  • Save providers.tf. You won't see a change yet. The terraform init command will use this configuration.

✔️ Awesome, I've got everything!

Great. Make sure you have saved providers.tf before moving on.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.99"
    }
  }
}

provider "aws" {
  region = var.aws_region
}
  • Open variables.tf and paste the following code:
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "zero-trust"
}

What does this code do?

  • aws_region defaults to us-east-1 but can be overridden at deploy time without editing any files.
  • project_name is used as a prefix when naming resources later, keeping everything organized under one label.
  • Save variables.tf. Like providers.tf, this file is read by Terraform at init time.

✔️ Awesome, I've got everything!

Great. Make sure you have saved variables.tf before moving on.

ⓧ I'd like to double check the full code

variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "zero-trust"
}

Now that the provider is configured, Terraform can download the AWS plugin.

  • Initialize Terraform by running this command:
terraform init

You should see output ending with the message: Terraform has been successfully initialized!

What did terraform init do?

Terraform downloaded the AWS provider plugin into a hidden .terraform folder inside your project. It also created a .terraform.lock.hcl file that locks the exact provider version for reproducibility.

Seeing a provider download error?

Make sure your internet connection is active. Terraform downloads the AWS provider from the Terraform Registry on first init.

If you see a version constraint error, confirm that providers.tf has version = "~> 5.99" with no typos.

help me fix a terraform init failure

Your project structure is locked in and Terraform is ready to go. Next up, you will write your first IAM policy that enforces MFA for destructive actions.

Deploy MFA-Enforced IAM Policies

Your project structure is in place and Terraform is initialized. Now it is time to implement the first Zero Trust principle: "Never trust, always verify."

In practice, this means even authenticated users should prove their identity again before performing dangerous actions. You will create an IAM policy that blocks anyone from stopping or terminating EC2 instances unless they have authenticated with MFA.

In this step, get ready to:
  • Create an IAM policy enforcing MFA for destructive EC2 actions.
  • Attach the policy to an IAM group.
  • Deploy with Terraform and verify in the AWS Console.
Create the MFA-enforced IAM policy

The policy you are about to write uses a two-statement pattern. The first statement allows all EC2 actions. The second statement explicitly denies the destructive actions when MFA is absent.

  • Open main.tf in your editor. The file is currently empty.
  • Add the following code to define the data sources and the MFA-enforced IAM policy:
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# =============================================================================
# NEVER TRUST, ALWAYS VERIFY - MFA-Enforced IAM Policy
# =============================================================================

resource "aws_iam_policy" "mfa_enforce_destructive_actions" {
  name        = "${var.project_name}-mfa-enforce-destructive-actions"
  description = "Denies EC2 Stop/Terminate actions without MFA authentication"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowAllEC2Actions"
        Effect = "Allow"
        Action = "ec2:*"
        Resource = "*"
      },
      {
        Sid    = "DenyStopAndTerminateWithoutMFA"
        Effect = "Deny"
        Action = [
          "ec2:StopInstances",
          "ec2:TerminateInstances"
        ]
        Resource = "*"
        Condition = {
          BoolIfExists = {
            "aws:MultiFactorAuthPresent" = "false"
          }
        }
      }
    ]
  })
}

What does this code do?

The two data blocks fetch your AWS account ID and region at plan time. You will reference these in later steps for resource ARNs.

The policy itself has two statements. AllowAllEC2Actions grants full EC2 access, then DenyStopAndTerminateWithoutMFA overrides that for Stop and Terminate when MFA is missing. In IAM, an explicit Deny always wins over an Allow.

Why BoolIfExists instead of Bool?

The aws:MultiFactorAuthPresent key is not present at all in the request context when someone uses long-term access keys. A plain Bool condition would not match because the key does not exist, so the Deny would never fire.

BoolIfExists evaluates to true both when the key is explicitly "false" and when the key is missing entirely. AWS explicitly warns against using the plain Bool operator for this pattern.

  • Save main.tf.

You will not see a change yet. The next chunk adds the IAM group, then you will deploy everything together.

Seeing a syntax error on save?

  • Make sure the opening and closing braces in the jsonencode block are balanced. Each { needs a matching }.
  • Check that strings inside the policy use double quotes, not single quotes. HCL requires double quotes for string values.

Why is my Terraform policy syntax not working?

Create an IAM group with the policy attached

A policy on its own does nothing until it is attached to a user, group, or role. You will create an IAM group so that any user added to it automatically inherits the MFA enforcement.

  • In main.tf, add the following code below the IAM policy resource you just created:
resource "aws_iam_group" "zero_trust_ec2_admins" {
  name = "${var.project_name}-ec2-admins"
}

resource "aws_iam_group_policy_attachment" "mfa_enforce_attachment" {
  group      = aws_iam_group.zero_trust_ec2_admins.name
  policy_arn = aws_iam_policy.mfa_enforce_destructive_actions.arn
}

What does this code do?

The aws_iam_group resource creates a group named zero-trust-ec2-admins. Any IAM user added to this group will be subject to the MFA enforcement policy.

The aws_iam_group_policy_attachment resource links the policy to the group. It references both resources by their Terraform names so that Terraform knows the correct deployment order.

  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf so far:

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# =============================================================================
# NEVER TRUST, ALWAYS VERIFY - MFA-Enforced IAM Policy
# =============================================================================

resource "aws_iam_policy" "mfa_enforce_destructive_actions" {
  name        = "${var.project_name}-mfa-enforce-destructive-actions"
  description = "Denies EC2 Stop/Terminate actions without MFA authentication"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowAllEC2Actions"
        Effect = "Allow"
        Action = "ec2:*"
        Resource = "*"
      },
      {
        Sid    = "DenyStopAndTerminateWithoutMFA"
        Effect = "Deny"
        Action = [
          "ec2:StopInstances",
          "ec2:TerminateInstances"
        ]
        Resource = "*"
        Condition = {
          BoolIfExists = {
            "aws:MultiFactorAuthPresent" = "false"
          }
        }
      }
    ]
  })
}

resource "aws_iam_group" "zero_trust_ec2_admins" {
  name = "${var.project_name}-ec2-admins"
}

resource "aws_iam_group_policy_attachment" "mfa_enforce_attachment" {
  group      = aws_iam_group.zero_trust_ec2_admins.name
  policy_arn = aws_iam_policy.mfa_enforce_destructive_actions.arn
}

Now add an output so you can easily reference the policy ARN after deployment.

  • Open outputs.tf and add the following:
output "mfa_policy_arn" {
  description = "ARN of the MFA-enforced IAM policy"
  value       = aws_iam_policy.mfa_enforce_destructive_actions.arn
}

What does this output do?

Terraform outputs print values to the terminal after terraform apply completes. This output will display the full ARN of your MFA-enforced policy so you can confirm it was created successfully.

  • Save outputs.tf.
  • Confirm your syntax is correct by running this command:
terraform validate

You should see Success! The configuration is valid.

Seeing a validation error?

  • Check that the value in your output references the exact resource name aws_iam_policy.mfa_enforce_destructive_actions.arn.
  • Make sure every resource block in main.tf has a closing }. A missing brace is the most common validation error.

Help me debug my terraform validate error.

Deploy with terraform plan and apply

With the code validated, you can now preview what Terraform will create and then deploy it to your AWS account.

  • Preview the resources Terraform will create by running:
terraform plan

You should see Terraform listing 3 resources to add: the IAM policy, the IAM group, and the group policy attachment.

What is terraform plan?

The plan command is a dry run. It shows you exactly what Terraform will create, modify, or destroy without making any changes to your AWS account. Always review the plan before applying.

  • Deploy the resources to AWS by running:
terraform apply
  • Terraform will show the plan again and ask for confirmation. Type yes and press Enter.

After a few seconds, Terraform will confirm that 3 resources were created. You should also see your mfa_policy_arn output printed at the bottom.

Apply failed with an access denied error?

  • Confirm your AWS CLI credentials are configured. Run aws sts get-caller-identity to check that the CLI can reach your account.
  • Make sure your IAM user or role has permission to create IAM policies and groups. An AdministratorAccess policy is simplest for a learning project.

Help me fix my terraform apply access denied error.

Now verify the resources exist in the AWS Console.

  • Open the AWS Console in your browser and navigate to the IAM service.
  • Click Policies in the left sidebar.
  • Search for zero-trust-mfa. You should see zero-trust-mfa-enforce-destructive-actions listed.
  • Click User groups in the left sidebar. You should see zero-trust-ec2-admins listed with the policy attached.

Your MFA-enforced policy is live. Any user added to the zero-trust-ec2-admins group will be blocked from stopping or terminating instances without MFA. Next up, you will deploy IAM Access Analyzer to continuously audit your account for overly-permissive access.

Deploy IAM Access Analyzer

Your MFA-enforced IAM policy is deployed and protecting EC2 instances from unauthorized termination. Now it's time to tackle the second Zero Trust principle: least privilege.

How do you know if your account has permissions that are broader than they need to be? In this step, you'll deploy IAM Access Analyzer to continuously scan your account for overly-permissive access.

In this step, get ready to:
  • Create an IAM Access Analyzer resource scoped to your AWS account.
  • Deploy the resource with Terraform and verify it in the AWS Console.
  • Review Access Analyzer findings and document how they support least-privilege auditing.
Add the Access Analyzer resource to your Terraform code

IAM Access Analyzer uses automated reasoning to identify resources in your account that are shared with external entities or have overly-broad permissions. You'll create one scoped to your single AWS account using the ACCOUNT type.

  • In main.tf, scroll to the bottom of the file (after the aws_iam_group_policy_attachment resource).
  • Add the IAM Access Analyzer resource by copying this code below the existing content:
# =============================================================================
# LEAST PRIVILEGE - IAM Access Analyzer
# =============================================================================

resource "aws_accessanalyzer_analyzer" "account_analyzer" {
  analyzer_name = "${var.project_name}-access-analyzer"
  type          = "ACCOUNT"

  tags = {
    Project   = var.project_name
    Principle = "LeastPrivilege"
  }
}

What does this code do?

This resource creates an IAM Access Analyzer that monitors your entire AWS account for access issues.

  • analyzer_name gives your analyzer a descriptive name using the project variable.
  • type = "ACCOUNT" scopes the analysis to your single account. The alternative, ORGANIZATION, requires AWS Organizations setup.
  • The Principle tag labels this resource with the Zero Trust principle it enforces.
  • Save main.tf.

You won't see a change yet. The next chunk adds the output, and then you'll deploy both together.

  • In outputs.tf, add this output block below the existing mfa_policy_arn output:
output "access_analyzer_arn" {
  description = "ARN of the IAM Access Analyzer"
  value       = aws_accessanalyzer_analyzer.account_analyzer.arn
}

What does this output do?

This exports the Access Analyzer's ARN so you can reference it later or use it in other automation. Outputs also confirm that Terraform created the resource successfully when you run terraform apply.

  • Save outputs.tf.

You won't see a change yet. The next substep deploys these resources.

Seeing a syntax error in your editor?

Make sure the new resource block in main.tf starts on a new line after the closing } of the aws_iam_group_policy_attachment resource. Check that all opening braces { have matching closing braces }.

help me fix a syntax error in my Terraform main.tf file for IAM Access Analyzer

✔️ Awesome, I've got everything!

Great. Make sure both main.tf and outputs.tf are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full main.tf at this point:

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# =============================================================================
# NEVER TRUST, ALWAYS VERIFY - MFA-Enforced IAM Policy
# =============================================================================

resource "aws_iam_policy" "mfa_enforce_destructive_actions" {
  name        = "${var.project_name}-mfa-enforce-destructive-actions"
  description = "Denies EC2 Stop/Terminate actions without MFA authentication"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowAllEC2Actions"
        Effect = "Allow"
        Action = "ec2:*"
        Resource = "*"
      },
      {
        Sid    = "DenyStopAndTerminateWithoutMFA"
        Effect = "Deny"
        Action = [
          "ec2:StopInstances",
          "ec2:TerminateInstances"
        ]
        Resource = "*"
        Condition = {
          BoolIfExists = {
            "aws:MultiFactorAuthPresent" = "false"
          }
        }
      }
    ]
  })
}

resource "aws_iam_group" "zero_trust_ec2_admins" {
  name = "${var.project_name}-ec2-admins"
}

resource "aws_iam_group_policy_attachment" "mfa_enforce_attachment" {
  group      = aws_iam_group.zero_trust_ec2_admins.name
  policy_arn = aws_iam_policy.mfa_enforce_destructive_actions.arn
}

# =============================================================================
# LEAST PRIVILEGE - IAM Access Analyzer
# =============================================================================

resource "aws_accessanalyzer_analyzer" "account_analyzer" {
  analyzer_name = "${var.project_name}-access-analyzer"
  type          = "ACCOUNT"

  tags = {
    Project   = var.project_name
    Principle = "LeastPrivilege"
  }
}

And here is the full outputs.tf:

output "mfa_policy_arn" {
  description = "ARN of the MFA-enforced IAM policy"
  value       = aws_iam_policy.mfa_enforce_destructive_actions.arn
}

output "access_analyzer_arn" {
  description = "ARN of the IAM Access Analyzer"
  value       = aws_accessanalyzer_analyzer.account_analyzer.arn
}
Deploy the Access Analyzer with Terraform
  • Deploy the new resource by running:
terraform apply
  • Terraform will show you a plan with 1 resource to add. Type yes and press Enter to confirm.

You should see output ending with Apply complete! Resources: 1 added, 0 changed, 0 destroyed. along with the access_analyzer_arn output value.

Seeing an error about the Access Analyzer service?

If you see an error related to the Access Analyzer API, confirm your AWS CLI credentials are still valid by running aws sts get-caller-identity. If your session has expired, re-authenticate before running terraform apply again.

help me debug a Terraform error when deploying IAM Access Analyzer

Verify the Access Analyzer in the AWS Console

Now confirm the analyzer is active in your AWS account.

  • Open the AWS Console in your browser and navigate to the IAM service.
  • In the left navigation pane, under Access analyzer, click Analyzer settings.
  • You should see your analyzer named zero-trust-access-analyzer with a status of Active.
  • Click Access analyzer in the left nav to view the findings dashboard.

What if there are no findings?

It's normal to see no findings on a new or tightly-controlled account. Findings appear when Access Analyzer detects resources shared with external entities (like a public S3 bucket or a cross-account IAM role).

If you do see findings, each one represents an access path that you should review. The goal of least privilege is to reduce these to zero by tightening or removing unnecessary permissions.

Why ACCOUNT type instead of ORGANIZATION?

You're working with a single AWS account, so ACCOUNT is the correct scope. The ORGANIZATION type requires AWS Organizations setup and analyzes cross-account access across all member accounts.

Your account now has continuous access monitoring in place. Next up, you'll configure CloudTrail to log every API call in your account, completing the third Zero Trust principle: assume breach.

Deploy CloudTrail Logging

In the last step, you set up IAM Access Analyzer to continuously audit permissions. Now you'll tackle the third Zero Trust principle: Assume Breach.

When a security incident happens, the first question is always "what happened?" CloudTrail records every API call made in your AWS account. By sending those logs to an encrypted, locked-down S3 bucket, you create a tamper-resistant audit trail that investigators can trust.

In this step, get ready to:
  • Create an encrypted S3 bucket with all public access blocked.
  • Deploy a CloudTrail trail logging all management events across all regions.
  • Verify CloudTrail is active and delivering logs in the AWS Console.
Create the encrypted S3 bucket for CloudTrail logs

CloudTrail needs a destination to store its log files. You'll create an S3 bucket with three layers of protection: a public access block (prevents accidental exposure), server-side encryption (protects data at rest), and a strict bucket policy (only allows CloudTrail to write).

  • In main.tf, scroll to the bottom of the file (below the Access Analyzer resource) and add the following S3 bucket infrastructure:
# =============================================================================
# ASSUME BREACH - CloudTrail Logging
# =============================================================================

resource "aws_s3_bucket" "cloudtrail_logs" {
  bucket        = "${var.project_name}-cloudtrail-logs-${data.aws_caller_identity.current.account_id}"
  force_destroy = true

  tags = {
    Project   = var.project_name
    Principle = "AssumeBreach"
  }
}

resource "aws_s3_bucket_public_access_block" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

What does this code do?

These three resources work together to create a hardened S3 bucket:

  • aws_s3_bucket creates the bucket. The name includes your AWS account ID to guarantee global uniqueness. force_destroy = true lets Terraform delete the bucket even when it contains logs (useful for cleanup later).
  • aws_s3_bucket_public_access_block applies four layers of public access prevention. Even if someone misconfigures a policy, the block overrides it.
  • aws_s3_bucket_server_side_encryption_configuration encrypts every object written to the bucket using AES256, protecting log data at rest.
  • Save main.tf.

You won't see a change yet. The next chunk adds the access policy that CloudTrail needs to write logs to this bucket.

  • Still in main.tf, add the bucket policy directly below the encryption configuration you just wrote:
data "aws_iam_policy_document" "cloudtrail_s3_policy" {
  statement {
    sid    = "AWSCloudTrailAclCheck"
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }

    actions   = ["s3:GetBucketAcl"]
    resources = [aws_s3_bucket.cloudtrail_logs.arn]

    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = ["arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"]
    }
  }

  statement {
    sid    = "AWSCloudTrailWrite"
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }

    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"]

    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values   = ["bucket-owner-full-control"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = ["arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"]
    }
  }
}

resource "aws_s3_bucket_policy" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id
  policy = data.aws_iam_policy_document.cloudtrail_s3_policy.json
}

What does this bucket policy do?

This policy grants exactly two permissions to the CloudTrail service, and nothing else:

  • s3:GetBucketAcl lets CloudTrail verify it has permission to write to the bucket before delivering logs.
  • s3:PutObject lets CloudTrail write log files into the AWSLogs/<account-id>/ prefix.
  • The aws:SourceArn condition ensures only YOUR specific trail can write here. No other trail (even from the same service) is allowed.

The s3:x-amz-acl condition with value bucket-owner-full-control ensures you retain full control over the log objects CloudTrail writes.

  • Save main.tf.

The bucket is now fully configured. The next chunk creates the CloudTrail trail that sends logs here.

Seeing red squiggles in your editor?

If your editor shows syntax warnings, make sure every opening brace { has a matching closing brace }. The policy document block has deeply nested braces that are easy to misalign.

Help me find the issue

Create the CloudTrail trail

With the bucket ready, you can now create the trail itself. This resource tells AWS to start recording every management API call across all regions and deliver the logs to your secured bucket.

  • In main.tf, add the CloudTrail resource directly below the bucket policy you just wrote:
resource "aws_cloudtrail" "main" {
  depends_on = [aws_s3_bucket_policy.cloudtrail_logs]

  name                          = "${var.project_name}-trail"
  s3_bucket_name                = aws_s3_bucket.cloudtrail_logs.id
  include_global_service_events = true
  is_multi_region_trail         = true
  enable_log_file_validation    = true

  tags = {
    Project   = var.project_name
    Principle = "AssumeBreach"
  }
}

What does this code do?

  • depends_on ensures Terraform creates the bucket policy before the trail. Without this, CloudTrail would try to validate bucket access before the policy exists and fail.
  • include_global_service_events = true captures IAM, STS, and other global service calls that don't belong to a single region.
  • is_multi_region_trail = true logs API activity in every AWS region. An attacker could spin up resources in a region you never use. This catches that.
  • enable_log_file_validation = true delivers digest files that let you cryptographically verify logs haven't been tampered with.
  • Save main.tf.
  • Preview your changes by running:
terraform plan

You should see Terraform planning to add 6 new resources (the S3 bucket, public access block, encryption config, policy document, bucket policy, and CloudTrail trail).

Plan shows errors?

If you see a syntax error, check that every resource and data block in your file has matching opening and closing braces. Also confirm you have a blank line between each resource block.

If you see a provider error, make sure you previously ran terraform init in this directory.

Help me debug my terraform plan errors

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf and your plan shows 6 resources to add.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point:

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# =============================================================================
# NEVER TRUST, ALWAYS VERIFY - MFA-Enforced IAM Policy
# =============================================================================

resource "aws_iam_policy" "mfa_enforce_destructive_actions" {
  name        = "${var.project_name}-mfa-enforce-destructive-actions"
  description = "Denies EC2 Stop/Terminate actions without MFA authentication"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowAllEC2Actions"
        Effect = "Allow"
        Action = "ec2:*"
        Resource = "*"
      },
      {
        Sid    = "DenyStopAndTerminateWithoutMFA"
        Effect = "Deny"
        Action = [
          "ec2:StopInstances",
          "ec2:TerminateInstances"
        ]
        Resource = "*"
        Condition = {
          BoolIfExists = {
            "aws:MultiFactorAuthPresent" = "false"
          }
        }
      }
    ]
  })
}

resource "aws_iam_group" "zero_trust_ec2_admins" {
  name = "${var.project_name}-ec2-admins"
}

resource "aws_iam_group_policy_attachment" "mfa_enforce_attachment" {
  group      = aws_iam_group.zero_trust_ec2_admins.name
  policy_arn = aws_iam_policy.mfa_enforce_destructive_actions.arn
}

# =============================================================================
# LEAST PRIVILEGE - IAM Access Analyzer
# =============================================================================

resource "aws_accessanalyzer_analyzer" "account_analyzer" {
  analyzer_name = "${var.project_name}-access-analyzer"
  type          = "ACCOUNT"

  tags = {
    Project   = var.project_name
    Principle = "LeastPrivilege"
  }
}

# =============================================================================
# ASSUME BREACH - CloudTrail Logging
# =============================================================================

resource "aws_s3_bucket" "cloudtrail_logs" {
  bucket        = "${var.project_name}-cloudtrail-logs-${data.aws_caller_identity.current.account_id}"
  force_destroy = true

  tags = {
    Project   = var.project_name
    Principle = "AssumeBreach"
  }
}

resource "aws_s3_bucket_public_access_block" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

data "aws_iam_policy_document" "cloudtrail_s3_policy" {
  statement {
    sid    = "AWSCloudTrailAclCheck"
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }

    actions   = ["s3:GetBucketAcl"]
    resources = [aws_s3_bucket.cloudtrail_logs.arn]

    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = ["arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"]
    }
  }

  statement {
    sid    = "AWSCloudTrailWrite"
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }

    actions   = ["s3:PutObject"]
    resources = ["${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"]

    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values   = ["bucket-owner-full-control"]
    }

    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = ["arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"]
    }
  }
}

resource "aws_s3_bucket_policy" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id
  policy = data.aws_iam_policy_document.cloudtrail_s3_policy.json
}

resource "aws_cloudtrail" "main" {
  depends_on = [aws_s3_bucket_policy.cloudtrail_logs]

  name                          = "${var.project_name}-trail"
  s3_bucket_name                = aws_s3_bucket.cloudtrail_logs.id
  include_global_service_events = true
  is_multi_region_trail         = true
  enable_log_file_validation    = true

  tags = {
    Project   = var.project_name
    Principle = "AssumeBreach"
  }
}
Deploy and verify in the AWS Console

Before applying, you'll add two outputs so Terraform surfaces the trail name and bucket name after deployment.

  • Open outputs.tf and add these two outputs below the existing ones:
output "cloudtrail_name" {
  description = "Name of the CloudTrail trail"
  value       = aws_cloudtrail.main.name
}

output "cloudtrail_s3_bucket" {
  description = "S3 bucket storing CloudTrail logs"
  value       = aws_s3_bucket.cloudtrail_logs.id
}

What do these outputs do?

Outputs surface key resource identifiers after terraform apply finishes. You'll see the trail name and bucket name printed in your terminal, making it easy to reference them later without digging through the AWS Console.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved outputs.tf before running apply.

ⓧ I'd like to double check the full code

Here is the complete outputs.tf at this point:

output "mfa_policy_arn" {
  description = "ARN of the MFA-enforced IAM policy"
  value       = aws_iam_policy.mfa_enforce_destructive_actions.arn
}

output "access_analyzer_arn" {
  description = "ARN of the IAM Access Analyzer"
  value       = aws_accessanalyzer_analyzer.account_analyzer.arn
}

output "cloudtrail_name" {
  description = "Name of the CloudTrail trail"
  value       = aws_cloudtrail.main.name
}

output "cloudtrail_s3_bucket" {
  description = "S3 bucket storing CloudTrail logs"
  value       = aws_s3_bucket.cloudtrail_logs.id
}
  • Deploy all the CloudTrail resources by running:
terraform apply
  • When Terraform asks you to confirm, type yes and press Enter.

Terraform will create 6 new resources. After it finishes, you'll see your outputs printed in the terminal including cloudtrail_name and cloudtrail_s3_bucket.

Apply failed with an access denied error?

Make sure your AWS CLI credentials have permissions to create S3 buckets, bucket policies, and CloudTrail trails. If you're using an IAM user, it needs the AmazonS3FullAccess and AWSCloudTrailFullAccess managed policies (or equivalent).

If you see a bucket name conflict, your account may already have a bucket with the same name. Run terraform destroy and try again, or change the project_name variable in variables.tf.

Help me diagnose the error

  • Open the AWS Console in your browser and navigate to the CloudTrail service.
  • In the left navigation pane, click Trails.
  • Click your trail name (zero-trust-trail) to open its details page.

You should see the trail with logging enabled, the S3 bucket name displayed, and multi-region set to Yes. CloudTrail starts delivering log files within about 5 minutes of creation.

Why multi-region logging?

An attacker could launch resources in a region you never use, hoping you won't notice. Multi-region logging captures API activity in every AWS region, not just the one you deployed to. This is a best practice from NIST SP 800-207.

Your Zero Trust environment is now fully deployed. All three NIST principles are covered: MFA enforcement, least-privilege auditing, and breach-aware logging. Next up, you'll push this code to GitHub to create a portfolio-ready repository.

Push to GitHub

Your Zero Trust environment is fully deployed on AWS. Every API call is being logged, permissions are being audited, and destructive actions require MFA.

Now it's time to package this project for your portfolio. A clean GitHub repository with a professional README shows hiring managers you can implement cloud security as infrastructure-as-code.

In this step, get ready to:
  • Create a README explaining the Zero Trust principles your project implements.
  • Add a .gitignore and push your code to GitHub.
  • Verify your repository looks clean and professional.
Create the README

A good README is the front door of your repository. It tells anyone landing on your project what it does, which principles it implements, and how to deploy it themselves.

  • Create a new file called README.md in your zero-trust project folder, then paste in the following content:
# Zero Trust Environment on AWS with Terraform

This project deploys a Zero Trust security baseline on AWS using Terraform, implementing the three core principles from NIST SP 800-207.

## Zero Trust Principles Implemented

| Principle | Implementation | AWS Service |
|-----------|---------------|-------------|
| Never Trust, Always Verify | MFA required for destructive EC2 actions | IAM Policy with `BoolIfExists` condition |
| Least Privilege | Continuous access auditing | IAM Access Analyzer |
| Assume Breach | Full API logging with encrypted storage | CloudTrail + S3 |

## Prerequisites

- AWS Account with CLI configured
- Terraform >= 1.5.0
- Git

## Deploy

```bash
terraform init
terraform plan
terraform apply
```

## Clean Up

```bash
terraform destroy
```

What does this README cover?

  • The principles table maps each NIST Zero Trust tenet to a specific AWS implementation in your project.
  • The Prerequisites and Deploy sections let anyone clone and run your project with three commands.
  • The Clean Up section ensures people know how to tear down resources when they are done.
  • Save README.md.
Create the .gitignore and push to GitHub

Terraform generates local state files and a .terraform/ directory that should never be committed. These files contain sensitive information about your infrastructure and are specific to your machine.

  • Create a new file called .gitignore in your zero-trust project folder, then paste in the following content:
# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
.terraform.lock.hcl

# OS
.DS_Store
Thumbs.db

# IDE
.vscode/
.idea/

What does this .gitignore exclude?

  • Terraform state files (*.tfstate) contain resource IDs and sometimes sensitive outputs. They should never be in version control.
  • The .terraform/ directory holds downloaded provider binaries. These are large and machine-specific.
  • OS and IDE files are personal to your machine and irrelevant to collaborators.
  • Save .gitignore.

Now you need a GitHub repository to push your code to.

  • Open your browser and go to github.com.
  • Click the + icon in the top-right corner, then select New repository.
  • Enter zero-trust-aws-terraform as the repository name.
  • Leave the repository set to Public.
  • Do NOT check any of the initialization boxes (no README, no .gitignore, no license). You already have these files locally.
  • Click Create repository.

Why skip the initialization options?

GitHub offers to create a README and .gitignore for you, but you already have both locally. If you let GitHub create them, you will have conflicting histories that make the first push harder.

GitHub will show you a page with setup instructions. You will use the commands for pushing an existing repository.

  • Stage and commit all your files by running these commands:
git add .
git commit -m "Deploy Zero Trust environment with Terraform"
  • Connect your local repository to GitHub and push by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/zero-trust-aws-terraform.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local repo to the GitHub repository you just created.
  • git branch -M main renames your default branch to main (the GitHub standard).
  • git push -u origin main uploads all your commits to GitHub and sets main as the default upstream branch.
  • You should see output confirming your branch was pushed, ending with a line showing the remote URL.

Push rejected or authentication failed?

  • If you see an authentication error, make sure you are logged into GitHub in your terminal. GitHub requires a personal access token or SSH key for pushing.
  • If you see "failed to push some refs," you may have accidentally initialized the GitHub repo with a README. Delete the repo on GitHub, recreate it without any initialization, and try the push again.
  • help me fix a git push authentication or rejection error
Verify the repository on GitHub

A professional repository should show your Terraform files clearly and render the README on the landing page.

  • Refresh your GitHub repository page in the browser.
  • Confirm you see these files listed: main.tf, variables.tf, providers.tf, outputs.tf, README.md, and .gitignore.
  • Scroll down and verify that your README renders with the Zero Trust Principles table visible.
  • Confirm that .terraform/ and any .tfstate files are NOT listed. Your .gitignore is keeping them out.

Check the file count

You should see exactly 6 files in the repository. If you see the .terraform/ folder or state files, your .gitignore was not in place before the commit. Run git rm -r --cached .terraform then commit and push again.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files and that your GitHub repository shows 6 files with the README rendered at the bottom.

ⓧ I'd like to double check the full code

Here is the complete README.md:

# Zero Trust Environment on AWS with Terraform

This project deploys a Zero Trust security baseline on AWS using Terraform, implementing the three core principles from NIST SP 800-207.

## Zero Trust Principles Implemented

| Principle | Implementation | AWS Service |
|-----------|---------------|-------------|
| Never Trust, Always Verify | MFA required for destructive EC2 actions | IAM Policy with `BoolIfExists` condition |
| Least Privilege | Continuous access auditing | IAM Access Analyzer |
| Assume Breach | Full API logging with encrypted storage | CloudTrail + S3 |

## Prerequisites

- AWS Account with CLI configured
- Terraform >= 1.5.0
- Git

## Deploy

```bash
terraform init
terraform plan
terraform apply
```

## Clean Up

```bash
terraform destroy
```

Here is the complete .gitignore:

# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
.terraform.lock.hcl

# OS
.DS_Store
Thumbs.db

# IDE
.vscode/
.idea/

Secret mission

Add Password Policy and Destructive Action Alerts

Your Zero Trust baseline detects overly-permissive access and logs all API activity. But what happens when someone actually performs a destructive action? In this mission, you'll add a strict password policy and a real-time alerting pipeline that notifies you the moment a destructive API call fires.

Clean Up Your Resources

Clean Up Your Resources

All resources in this project are free-tier eligible. IAM, IAM Access Analyzer, and the first CloudTrail trail per account cost nothing. S3 storage for CloudTrail logs is negligible during a short project. Decide whether to keep your resources running or delete them entirely.

Resources you used:

  • IAM policy, IAM group, and group policy attachment.
  • IAM Access Analyzer.
  • S3 bucket with CloudTrail logs.
  • CloudTrail trail.
  • GitHub repository.
  • Local project folder on your Desktop.

Keep everything running

No action needed. These resources represent security best practices you would want on any AWS account. IAM policies, Access Analyzer, and CloudTrail are all free-tier eligible with no ongoing compute costs.

  • Your Terraform state file tracks everything. You can come back and modify resources any time.
  • Your GitHub repository stays available for your portfolio.

Pause - I'll come back to this later

There are no ongoing compute costs to pause. All resources in this project are free-tier eligible, so "Pause" and "Keep" are effectively the same.

  • Leave everything as-is. You can return and run terraform plan at any time to see the current state.

Delete - I don't want to use this again

Remove all project resources and start fresh. Terraform handles the teardown of all AWS resources in one command.

  • Open your terminal and navigate to your project folder on the Desktop (the folder containing main.tf).
  • Destroy all AWS resources by running this command:
terraform destroy

Terraform will show you a plan of all resources it will delete. Type yes when prompted to confirm.

What about the S3 bucket?

The S3 bucket has force_destroy = true set in the Terraform code. This means Terraform automatically empties the bucket before deleting it. No manual cleanup needed.

  • Delete your GitHub repository by navigating to your repository page on GitHub.
  • Click Settings at the top of the repository page.
  • Scroll down to the Danger Zone section at the bottom.
  • Click Delete this repository and confirm by typing the repository name.
  • Delete your local project folder by running these commands:
cd ~/Desktop
rm -rf zero-trust-terraform

Adjust the folder name if needed

Replace zero-trust-terraform with whatever you named your project folder in Step 1.

Nice Work!

Nice Work!

Nice work! You just built and deployed a complete Zero Trust security baseline on AWS, defined entirely in Terraform code.

You've learned how to:

  • Deploy MFA-enforced IAM policies that deny destructive EC2 actions without multi-factor authentication, implementing the NIST Zero Trust principle of "Never Trust, Always Verify."
  • Set up IAM Access Analyzer to continuously audit your AWS account for overly-permissive access, enforcing Least Privilege.
  • Configure CloudTrail logging to an encrypted, locked-down S3 bucket and manage the entire environment as infrastructure-as-code pushed to GitHub.
  • Secret Mission: Added a strict account password policy and real-time CloudWatch alerts that notify you when destructive actions are detected.

Ready to quiz yourself?