Infrastructure Drift Detection System

Detect unauthorized AWS infrastructure changes with Terraform and GitHub Actions.

Introduction

30 Second Summary

Someone changes a security group in the cloud console to fix something urgently. A week later, something breaks and no one knows what changed or when.

In this project, you will build an automated drift detection system that monitors your AWS infrastructure for unauthorized changes and sends you an email alert the moment something drifts from its expected state.

What You'll Build

An automated monitoring pipeline that uses Terraform and GitHub Actions to detect when AWS resources have been modified outside of your infrastructure code.

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

  • A Terraform configuration managing a VPC security group, S3 bucket, and IAM policy as your monitored infrastructure.
  • An SNS email alerting system that notifies you immediately when drift is detected.
  • A scheduled GitHub Actions workflow that authenticates via OIDC and runs terraform plan every 6 hours to catch unauthorized changes.
  • Secret Mission: Add auto-remediation that automatically reverts unauthorized changes with a configurable safety toggle.

Are there any prerequisites?

You'll need an AWS account with admin access and a GitHub account. Basic familiarity with Terraform (writing .tf files and running terraform apply) and GitHub Actions (workflow YAML files) is helpful but not required.

Before We Start

Before We Start...

Set Up the Project and Terraform Backend

Before you can detect drift, you need infrastructure for Terraform to monitor. That infrastructure needs a reliable home for its state file so that scheduled workflows can compare what exists in AWS against what Terraform expects.

In this step, you will create a GitHub repository, set up the directory structure, provision the remote state backend (an S3 bucket and DynamoDB table), and configure Terraform to store its state there.

In this step, get ready to:
  • Create a GitHub repository and local project structure.
  • Provision the S3 bucket and DynamoDB table for Terraform state.
  • Configure Terraform to use the remote S3 backend.
Create the GitHub repository and directory structure
  • Open GitHub in your browser and create a new repository named drift-detection.
  • Set the repository to public (so GitHub Actions scheduled workflows run for free).
  • Check the box for Add a README file.
  • Click Create repository.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the repository by running this command:
git clone https://github.com/[[GITHUB_ORG="your-github-username"]]/drift-detection.git
cd drift-detection
  • Create the directory structure by running these commands:
mkdir -p terraform
mkdir -p .github/workflows
  • Confirm the directories exist by running:
ls -la
  • You should see terraform/ and .github/ listed alongside the README.md file.

Don't see the .github folder?

Folders starting with a dot are hidden by default. Running ls -la (with the -a flag) shows hidden files and folders. If the folder is missing, re-run the mkdir -p .github/workflows command.

Ask for help: Why can't I see my .github folder in the terminal?

Provision the state backend resources

Terraform needs a place to store its state file remotely so your GitHub Actions workflow can access it. You will create an S3 bucket with versioning (so you can recover a corrupted state file) and a DynamoDB table for state locking (so two processes never write to state simultaneously).

Why S3 and DynamoDB for state?

S3 stores the state file itself. Versioning protects you from accidental overwrites because every change creates a new version you can roll back to.

DynamoDB provides a lock. When Terraform starts modifying state, it writes a lock entry to the table. If another process tries to run at the same time, it sees the lock and waits instead of corrupting the state.

  • Choose a unique name for your state bucket. S3 bucket names are globally unique, so append your initials or a short random string. For example: drift-detection-state-jd2025.
  • Create the S3 bucket by running this command (replace the bucket name with your unique name):
aws s3api create-bucket \
    --bucket [[STATE_BUCKET="drift-detection-state-your-suffix-here"]] \
    --region us-east-1
  • Enable versioning on the bucket by running:
aws s3api put-bucket-versioning \
    --bucket [[STATE_BUCKET="drift-detection-state-your-suffix-here"]] \
    --versioning-configuration Status=Enabled
  • Create the DynamoDB lock table by running:
aws dynamodb create-table \
    --table-name drift-detection-lock \
    --attribute-definitions AttributeName=LockID,AttributeType=S \
    --key-schema AttributeName=LockID,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST \
    --region us-east-1

What do these commands do?

  • The first command creates a new S3 bucket in us-east-1. No --create-bucket-configuration is needed because us-east-1 is the default region for S3.
  • The second command turns on versioning so every state file change is saved as a recoverable version.
  • The third command creates a DynamoDB table named drift-detection-lock with LockID as the partition key. PAY_PER_REQUEST billing means you only pay when the table is actually used (essentially free for this project).
  • Verify the bucket exists by running:
aws s3 ls | grep drift-detection-state
  • You should see your bucket name in the output.

Seeing 'BucketAlreadyExists'?

S3 bucket names are globally unique across all AWS accounts. If someone else already uses your chosen name, pick a different suffix and try again.

Ask for help: My S3 bucket creation failed with a name conflict. How do I choose a unique name?

Now you will create the Terraform configuration files that reference this backend.

  • Create terraform/variables.tf with the project variables by pasting the following code:
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "alert_email" {
  description = "Email address for drift alerts"
  type        = string
}

variable "github_org" {
  description = "GitHub organization or username"
  type        = string
}

variable "github_repo" {
  description = "GitHub repository name"
  type        = string
  default     = "drift-detection"
}

variable "allowed_ssh_cidr" {
  description = "CIDR block allowed for SSH access"
  type        = string
  default     = "203.0.113.0/24"
}

What do these variables do?

  • aws_region sets the AWS region for all resources (defaults to us-east-1).
  • alert_email is where drift alert notifications will be sent.
  • github_org and github_repo identify your repository for the OIDC trust policy.
  • allowed_ssh_cidr restricts SSH access to a specific IP range on the monitored security group.
  • Create terraform/terraform.tfvars.example as a reference file for variable values:
alert_email    = "your-email@example.com"
github_org     = "your-github-username"
github_repo    = "drift-detection"
allowed_ssh_cidr = "203.0.113.0/24"
  • Copy this example file to create your actual terraform.tfvars by running:
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
  • Open terraform/terraform.tfvars in your editor and replace the placeholder values with your actual email address and GitHub username.
  • Save the file.

Keep terraform.tfvars out of version control

Add terraform.tfvars to your .gitignore file. The .example file shows teammates what variables are needed without exposing your personal values.

Configure the remote backend and verify

Now you will create the main Terraform configuration that tells Terraform where to store its state. This connects Terraform to the S3 bucket and DynamoDB table you just created.

  • Create terraform/backend.tf with the following code (replace drift-detection-state-your-suffix-here with your actual bucket name):
terraform {
  required_version = ">= 1.15.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  backend "s3" {
    bucket         = "drift-detection-state-your-suffix-here"
    key            = "drift-detection/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "drift-detection-lock"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region
}

What does this configuration do?

  • required_version = ">= 1.15.0" ensures you are running a compatible Terraform version.
  • required_providers pins the AWS provider to version ~> 6.0 (any 6.x release).
  • The backend "s3" block tells Terraform to store state in your S3 bucket with encryption enabled and to use the DynamoDB table for locking.
  • The provider "aws" block configures the AWS provider to use the region defined in your variables.
  • Initialize Terraform by running this command from within the terraform/ directory:
cd terraform
terraform init

What does terraform init do?

This command downloads the AWS provider plugin and configures the S3 backend. Terraform will connect to your bucket and create the initial state file. You should see a success message confirming the backend was initialized.

  • Confirm the backend works by running:
terraform plan
  • Terraform will prompt you for the alert_email variable if you have not set up terraform.tfvars yet. Enter your email address when prompted.
  • You should see the message: No changes. Your infrastructure matches the configuration. This confirms the remote backend is working and there are no resources to create yet.

Seeing a backend configuration error?

Double-check that the bucket name in your backend.tf matches the exact bucket name you created with the AWS CLI. Also verify the DynamoDB table name is drift-detection-lock (case-sensitive).

Ask for help: Terraform init is failing to configure my S3 backend. What should I check?

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in your terraform/ directory.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point:

terraform {
  required_version = ">= 1.15.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  backend "s3" {
    bucket         = "drift-detection-state-your-suffix-here"
    key            = "drift-detection/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "drift-detection-lock"
    encrypt        = true
  }
}

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

variable "alert_email" {
  description = "Email address for drift alerts"
  type        = string
}

variable "github_org" {
  description = "GitHub organization or username"
  type        = string
}

variable "github_repo" {
  description = "GitHub repository name"
  type        = string
  default     = "drift-detection"
}

variable "allowed_ssh_cidr" {
  description = "CIDR block allowed for SSH access"
  type        = string
  default     = "203.0.113.0/24"
}
alert_email    = "your-email@example.com"
github_org     = "your-github-username"
github_repo    = "drift-detection"
allowed_ssh_cidr = "203.0.113.0/24"

Your Terraform backend is live. Every terraform plan and terraform apply will now read and write state from S3 with DynamoDB locking. Next up, you will provision the actual infrastructure that your drift detection system will monitor.

Provision the Monitored Infrastructure

Your Terraform backend is configured and your state is stored securely in S3. Now you need actual infrastructure for the drift detection system to monitor.

In this step, you will provision a set of AWS resources that represent a realistic production environment. The resources span networking, storage, and identity, giving the drift detection system multiple resource types to watch.

In this step, get ready to:
  • Create a VPC and security group with strict SSH access rules.
  • Provision an S3 bucket with versioning and an IAM policy for read-only access.
  • Apply the configuration and verify resources exist in the AWS Console.
Create the VPC and security group

A VPC gives your security group a network home. The security group represents the kind of resource that should never be modified manually. It allows SSH access from only one specific CIDR block, and any deviation from that rule is drift.

  • Create a new file called main.tf inside your terraform/ folder.
  • Add the VPC resource by pasting this code into terraform/main.tf:
resource "aws_vpc" "monitored" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name      = "drift-detection-vpc"
    ManagedBy = "terraform"
  }
}

What does this code do?

  • aws_vpc.monitored creates a virtual private cloud with a 10.0.0.0/16 address range.
  • The ManagedBy = "terraform" tag is a signal to anyone browsing the AWS Console that this resource should not be changed by hand.
  • Add the security group resource below the VPC in terraform/main.tf:
resource "aws_security_group" "monitored" {
  name        = "drift-detection-sg"
  description = "Monitored security group - do not modify manually"
  vpc_id      = aws_vpc.monitored.id

  ingress {
    description = "SSH from allowed CIDR only"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.allowed_ssh_cidr]
  }

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

  tags = {
    Name      = "drift-detection-sg"
    ManagedBy = "terraform"
  }
}

What does this code do?

  • The ingress block allows inbound SSH (port 22) from only the CIDR defined in var.allowed_ssh_cidr.
  • The egress block allows all outbound traffic, which is a standard default.
  • Later, you will simulate drift by adding an extra inbound rule via the AWS Console. The drift detection system will catch that change.
  • Save terraform/main.tf.

Seeing a syntax error?

  • Make sure the vpc_id line references aws_vpc.monitored.id exactly. A typo in the resource name will cause a reference error.
  • Check that every opening brace { has a matching closing brace }.
  • Ask for help: Why is my Terraform main.tf showing a syntax error when I add the security group?
Add the S3 bucket and IAM policy

The security group covers networking. Now you will add a monitored S3 bucket and an IAM policy to give the drift detection system resource variety across storage and identity.

  • Add the S3 bucket configuration below the security group in terraform/main.tf:
resource "aws_s3_bucket" "monitored" {
  bucket_prefix = "drift-detection-data-"
  force_destroy = true

  tags = {
    Name      = "drift-detection-data"
    ManagedBy = "terraform"
  }
}

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

resource "aws_s3_bucket_public_access_block" "monitored" {
  bucket                  = aws_s3_bucket.monitored.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

What does this code do?

  • aws_s3_bucket.monitored creates a bucket with a randomized suffix (via bucket_prefix) so the name is globally unique.
  • force_destroy = true allows Terraform to delete the bucket even if it contains objects. This makes cleanup easier later.
  • The versioning and public access block resources lock down the bucket so it cannot be made public.
  • Add the IAM policy and outputs below the S3 resources in terraform/main.tf:
resource "aws_iam_policy" "monitored" {
  name        = "drift-detection-read-policy"
  description = "Read-only access to the monitored S3 bucket"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.monitored.arn,
          "${aws_s3_bucket.monitored.arn}/*"
        ]
      }
    ]
  })
}

output "security_group_id" {
  value = aws_security_group.monitored.id
}

output "s3_bucket_name" {
  value = aws_s3_bucket.monitored.bucket
}

What does this code do?

  • The IAM policy grants only s3:GetObject and s3:ListBucket on the monitored bucket. This is a realistic scoped policy that someone might try to widen manually.
  • The output blocks print the security group ID and bucket name after apply. You will use these later to verify the resources exist.
  • Save terraform/main.tf.

Seeing a reference error for the bucket ARN?

  • Confirm that the IAM policy's Resource array uses aws_s3_bucket.monitored.arn and not a hardcoded bucket name.
  • Check that the S3 bucket resource block above is named "monitored" so the reference resolves.
  • Ask for help: Why is Terraform saying it cannot find aws_s3_bucket.monitored?

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete terraform/main.tf file:

resource "aws_vpc" "monitored" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name      = "drift-detection-vpc"
    ManagedBy = "terraform"
  }
}

resource "aws_security_group" "monitored" {
  name        = "drift-detection-sg"
  description = "Monitored security group - do not modify manually"
  vpc_id      = aws_vpc.monitored.id

  ingress {
    description = "SSH from allowed CIDR only"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.allowed_ssh_cidr]
  }

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

  tags = {
    Name      = "drift-detection-sg"
    ManagedBy = "terraform"
  }
}

resource "aws_s3_bucket" "monitored" {
  bucket_prefix = "drift-detection-data-"
  force_destroy = true

  tags = {
    Name      = "drift-detection-data"
    ManagedBy = "terraform"
  }
}

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

resource "aws_s3_bucket_public_access_block" "monitored" {
  bucket                  = aws_s3_bucket.monitored.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_iam_policy" "monitored" {
  name        = "drift-detection-read-policy"
  description = "Read-only access to the monitored S3 bucket"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.monitored.arn,
          "${aws_s3_bucket.monitored.arn}/*"
        ]
      }
    ]
  })
}

output "security_group_id" {
  value = aws_security_group.monitored.id
}

output "s3_bucket_name" {
  value = aws_s3_bucket.monitored.bucket
}
Apply the configuration and verify resources

Your Terraform configuration is complete. Time to provision all the resources in AWS and confirm they exist.

  • Apply the Terraform configuration by running this command from your terraform/ directory:
terraform apply

What happens during apply?

Terraform shows you a plan of what it will create, then asks you to confirm by typing yes. It creates the VPC, security group, S3 bucket (with versioning and access block), and IAM policy.

  • When prompted, type yes and press Enter to confirm.
  • Wait for the apply to complete. You should see output that ends with something like Apply complete! Resources: 6 added, 0 changed, 0 destroyed. followed by your two outputs.
  • Note the security_group_id and s3_bucket_name values from the Terraform output. You will use these to verify the resources in the AWS Console.
  • Verify the resources exist in the AWS Console:
  • Open the VPC service and confirm you see a VPC named drift-detection-vpc.
  • Open the EC2 service, go to Security Groups, and confirm you see drift-detection-sg with one inbound rule for port 22.
  • Open the S3 service and confirm a bucket starting with drift-detection-data- exists.
  • Open the IAM service, go to Policies, and confirm drift-detection-read-policy exists.

Apply failed with a permissions error?

  • Confirm your AWS CLI credentials have admin access or sufficient permissions for VPC, EC2, S3, and IAM operations.
  • If you see a region mismatch, check that terraform.tfvars is present and references the correct region (or the default us-east-1 is being used).
  • Ask for help: Why is my terraform apply failing with a permissions or access denied error?

Your monitored infrastructure is live. Next up, you will set up SNS alerting so the drift detection system can email you when something changes.

Set Up SNS Alerting

Your infrastructure is deployed and tracked by Terraform state. The next challenge is knowing when someone modifies it outside of Terraform.

In this step, you'll create an SNS (Simple Notification Service) topic that delivers email alerts. This is the notification channel your drift detection workflow will use to tell you something changed.

In this step, get ready to:
  • Create an SNS topic and email subscription in Terraform.
  • Confirm your email subscription.
  • Test the alerting pipeline with a manual message.
Create the alerting configuration

SNS topics work like a broadcast channel. You publish a message to the topic, and every subscriber receives it. For this project, you'll subscribe your email address so drift alerts land in your inbox.

  • Create a new file called alerting.tf inside your terraform/ folder.
  • Add the SNS topic, email subscription, and output by pasting the following code:
resource "aws_sns_topic" "drift_alerts" {
  name = "drift-detection-alerts"
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.drift_alerts.arn
  protocol  = "email"
  endpoint  = var.alert_email
}

output "sns_topic_arn" {
  value = aws_sns_topic.drift_alerts.arn
}

What does this code do?

  • aws_sns_topic.drift_alerts creates a named topic called drift-detection-alerts. This is the broadcast channel your workflow will publish to.
  • aws_sns_topic_subscription.email subscribes your email address (from var.alert_email in your terraform.tfvars) to the topic using the email protocol.
  • The sns_topic_arn output exposes the topic's ARN so you can reference it in later steps.
  • Save alerting.tf.

Not sure where to create the file?

The file goes in the same terraform/ folder where main.tf and backend.tf already live. Terraform automatically loads all .tf files in the same directory.

help me find where to create alerting.tf in my project

Apply and confirm the email subscription

Terraform can create the SNS subscription, but it cannot confirm it on your behalf. AWS requires you to click a confirmation link in your email before messages will be delivered.

  • In your terminal, make sure you are in the terraform/ directory.
  • Apply the new configuration by running:
terraform apply
  • Review the plan. Terraform will show 2 resources to add (the SNS topic and the subscription).
  • Type yes and press Enter to confirm.

What should I see?

Terraform will create the SNS topic and subscription. The output will display the sns_topic_arn value. Copy this ARN and save it somewhere. You will need it in the next substep and in later steps.

  • Check your email inbox for a message from no-reply@sns.amazonaws.com.
  • Click the Confirm subscription link in the email to activate your subscription.

Check your spam folder

The confirmation email sometimes lands in spam or promotions. If you don't see it within a minute, check those folders.

  • Verify the subscription is confirmed by opening the AWS Console.
  • Navigate to the SNS service, then click Subscriptions in the left sidebar.
  • You should see your email subscription with a status of Confirmed.

Subscription still showing as PendingConfirmation?

If you missed the confirmation email or the link expired, you can request a new one. In the AWS Console under SNS > Subscriptions, select the pending subscription and choose Request confirmation.

help me confirm my SNS email subscription

Test the alert with a manual message

Before wiring this into your workflow, confirm the full alerting pipeline works end-to-end by publishing a test message manually.

  • Send a test message to your SNS topic by running:
aws sns publish --topic-arn [[SNS_TOPIC_ARN="your SNS topic ARN from the terraform output"]] --message "Test drift alert"

Where do I find the topic ARN?

The ARN was printed as the sns_topic_arn output when you ran terraform apply. It looks like arn:aws:sns:us-east-1:123456789012:drift-detection-alerts. You can also retrieve it by running terraform output sns_topic_arn from the terraform/ directory.

  • Check your email inbox. You should receive a message with the text "Test drift alert" within a few seconds.

Didn't receive the test email?

Make sure your subscription status is Confirmed in the AWS Console (not PendingConfirmation). Messages are only delivered to confirmed subscriptions.

Also check that the --topic-arn value you used matches the ARN shown in terraform output sns_topic_arn. A mismatched ARN will silently fail.

help me troubleshoot why my SNS test message didn't arrive

✔️ Awesome, I've got everything!

Great. Double check you've saved alerting.tf and that your test email arrived.

ⓧ I'd like to double check the full code

Here is the complete terraform/alerting.tf file:

resource "aws_sns_topic" "drift_alerts" {
  name = "drift-detection-alerts"
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.drift_alerts.arn
  protocol  = "email"
  endpoint  = var.alert_email
}

output "sns_topic_arn" {
  value = aws_sns_topic.drift_alerts.arn
}

Your alerting pipeline is live and tested. Next, you'll set up OIDC authentication so your GitHub Actions workflow can securely access AWS without storing any credentials.

Set Up GitHub OIDC Authentication

Your drift detection workflow needs to authenticate with AWS from GitHub Actions. Storing long-lived access keys as repository secrets is risky. If those keys leak, anyone can access your AWS account.

A better approach is OIDC (OpenID Connect) authentication. With OIDC, GitHub Actions requests a short-lived token from AWS each time the workflow runs. No permanent credentials are stored anywhere.

In this step, get ready to:
  • Create an OIDC identity provider that trusts GitHub Actions.
  • Define an IAM role with a trust policy scoped to your repository.
  • Attach a policy granting the role read access to your infrastructure and state.
Create the OIDC provider and IAM role

The OIDC provider tells AWS to trust tokens issued by GitHub. The IAM role defines who can assume it, restricted to your specific repository.

  • Create a new file called oidc.tf inside your terraform/ folder.
  • Add the OIDC provider resource by pasting this code:
resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com"
  ]

  thumbprint_list = [
    "ffffffffffffffffffffffffffffffffffffffff"
  ]
}

What does this code do?

This registers GitHub's token service as a trusted identity provider in your AWS account.

  • url points to GitHub's OIDC token endpoint. AWS will verify tokens signed by this issuer.
  • client_id_list restricts which audience claims are accepted. sts.amazonaws.com is required for AWS STS.
  • thumbprint_list is a certificate fingerprint placeholder. AWS uses it for TLS verification of the provider.
  • Below the OIDC provider resource, add the IAM role with a trust policy that restricts access to your repository:
resource "aws_iam_role" "github_actions" {
  name = "drift-detection-github-actions"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
          }
        }
      }
    ]
  })
}

What does this trust policy do?

The trust policy controls who is allowed to assume this role. It uses two conditions to lock it down.

  • StringEquals on the aud claim ensures only requests intended for AWS STS are accepted.
  • StringLike on the sub claim restricts access to workflows running from your specific GitHub repository. No other repository can assume this role.
  • Save oidc.tf.

You won't see a change yet. The role still needs permissions attached before you apply it.

Attach the drift detection policy

The role exists, but it has no permissions. You need a policy that grants read access to your infrastructure state and the ability to publish SNS alerts.

  • In terraform/oidc.tf, below the aws_iam_role resource, add the inline policy and output:
resource "aws_iam_role_policy" "github_actions" {
  name = "drift-detection-policy"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "TerraformStateAccess"
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:ListBucket"
        ]
        Resource = [
          "arn:aws:s3:::drift-detection-state-*",
          "arn:aws:s3:::drift-detection-state-*/*"
        ]
      },
      {
        Sid    = "DynamoDBLock"
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:DeleteItem"
        ]
        Resource = "arn:aws:dynamodb:${var.aws_region}:*:table/drift-detection-lock"
      },
      {
        Sid    = "ReadInfrastructureState"
        Effect = "Allow"
        Action = [
          "ec2:Describe*",
          "s3:GetBucket*",
          "s3:ListBucket",
          "s3:GetObject",
          "s3:GetPublicAccessBlock",
          "s3:GetBucketVersioning",
          "iam:GetPolicy",
          "iam:GetPolicyVersion",
          "iam:ListPolicyVersions",
          "sns:GetTopicAttributes",
          "sns:ListSubscriptionsByTopic",
          "sns:GetSubscriptionAttributes",
          "iam:GetOpenIDConnectProvider",
          "iam:GetRole",
          "iam:GetRolePolicy",
          "iam:ListRolePolicies",
          "iam:ListAttachedRolePolicies"
        ]
        Resource = "*"
      },
      {
        Sid    = "SNSPublish"
        Effect = "Allow"
        Action = [
          "sns:Publish"
        ]
        Resource = aws_sns_topic.drift_alerts.arn
      }
    ]
  })
}

output "github_actions_role_arn" {
  value = aws_iam_role.github_actions.arn
}

What does this policy grant?

The policy is split into four statement blocks, each with a specific purpose.

  • TerraformStateAccess lets the workflow read and write Terraform state in your S3 backend bucket.
  • DynamoDBLock allows acquiring and releasing the state lock so concurrent runs don't corrupt state.
  • ReadInfrastructureState grants read-only access to all managed resource types. This is what terraform plan needs to compare real infrastructure against your configuration.
  • SNSPublish allows the workflow to send drift alerts to your SNS topic.

Why read-only for drift detection?

The terraform plan command only reads infrastructure state. It never modifies resources. By limiting the role to read-only permissions, you follow the principle of least privilege. Even if someone compromises the workflow, they cannot change your infrastructure.

  • Save oidc.tf.

You won't see a change yet. The next substep applies this configuration to AWS.

✔️ Awesome, I've got everything!

Great. Double check you have saved terraform/oidc.tf with all three resources and the output.

ⓧ I'd like to double check the full code

resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com"
  ]

  thumbprint_list = [
    "ffffffffffffffffffffffffffffffffffffffff"
  ]
}

resource "aws_iam_role" "github_actions" {
  name = "drift-detection-github-actions"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "github_actions" {
  name = "drift-detection-policy"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "TerraformStateAccess"
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:ListBucket"
        ]
        Resource = [
          "arn:aws:s3:::drift-detection-state-*",
          "arn:aws:s3:::drift-detection-state-*/*"
        ]
      },
      {
        Sid    = "DynamoDBLock"
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:DeleteItem"
        ]
        Resource = "arn:aws:dynamodb:${var.aws_region}:*:table/drift-detection-lock"
      },
      {
        Sid    = "ReadInfrastructureState"
        Effect = "Allow"
        Action = [
          "ec2:Describe*",
          "s3:GetBucket*",
          "s3:ListBucket",
          "s3:GetObject",
          "s3:GetPublicAccessBlock",
          "s3:GetBucketVersioning",
          "iam:GetPolicy",
          "iam:GetPolicyVersion",
          "iam:ListPolicyVersions",
          "sns:GetTopicAttributes",
          "sns:ListSubscriptionsByTopic",
          "sns:GetSubscriptionAttributes",
          "iam:GetOpenIDConnectProvider",
          "iam:GetRole",
          "iam:GetRolePolicy",
          "iam:ListRolePolicies",
          "iam:ListAttachedRolePolicies"
        ]
        Resource = "*"
      },
      {
        Sid    = "SNSPublish"
        Effect = "Allow"
        Action = [
          "sns:Publish"
        ]
        Resource = aws_sns_topic.drift_alerts.arn
      }
    ]
  })
}

output "github_actions_role_arn" {
  value = aws_iam_role.github_actions.arn
}
Apply and store the role ARN as a GitHub secret

With the OIDC configuration complete, you can now apply it and store the resulting role ARN so your workflow can reference it.

  • Apply the new OIDC resources by running this command from your terraform/ directory:
terraform apply
  • Review the plan. Terraform will show 3 resources to add (the OIDC provider, the IAM role, and the inline policy).
  • Type yes and press **Enter** to confirm.

After the apply completes, you should see the github_actions_role_arn output with a value like arn:aws:iam::123456789012:role/drift-detection-github-actions.

  • Copy the github_actions_role_arn value from the terminal output. You will need it in the next steps.

Seeing an error about the OIDC provider already existing?

If you already have a GitHub OIDC provider in your AWS account from a different project, Terraform will fail because the provider URL must be unique per account. You can import the existing provider into your state instead.

help me resolve a Terraform error about the OIDC provider already existing in my AWS account

Now store the role ARN as a GitHub repository secret so your workflow can use it.

  • Open your drift-detection repository on GitHub.
  • Click **Settings** in the repository navigation bar.
  • In the **Security** section of the sidebar, select **Secrets and variables**, then click **Actions**.
  • Click the **Secrets** tab.
  • Click **New repository secret**.
  • In the **Name** field, type AWS_ROLE_ARN.
  • In the **Secret** field, paste the role ARN value you copied from the Terraform output.
  • Click **Add secret**.

You should see AWS_ROLE_ARN listed under repository secrets.

Don't see the Settings tab?

You need admin or owner access to the repository to manage secrets. If you created the repository yourself, you already have this access. Try refreshing the page or check that you are signed in to the correct GitHub account.

help me find where to add repository secrets in GitHub

Your GitHub Actions workflow now has a secure, keyless path to authenticate with AWS. Next up, you will build the workflow that uses this role to detect drift.

Create the Drift Detection Workflow

Your infrastructure is provisioned, your alerting is wired up, and your OIDC role is ready to authenticate GitHub Actions securely.

Now you need the automation that ties everything together. In this step, you will create a GitHub Actions workflow that runs terraform plan on a schedule, checks whether anything has drifted, and sends an SNS alert if it has.

In this step, get ready to:
  • Create a scheduled workflow with OIDC permissions.
  • Run terraform plan -detailed-exitcode to detect drift programmatically.
  • Send an SNS alert when drift is detected and verify the workflow end-to-end.
Create the workflow file with triggers and permissions

A GitHub Actions workflow is a YAML file that lives in .github/workflows/. You need two triggers: a cron schedule so the check runs automatically every 6 hours, and a workflow_dispatch trigger so you can run it manually for testing.

  • Create a new file at .github/workflows/drift-check.yml and paste the following code:
name: Drift Detection

on:
  schedule:
    # Runs every 6 hours to check for drift
    - cron: '0 */6 * * *'
  workflow_dispatch:

# OIDC requires id-token:write to request a JWT
permissions:
  id-token: write
  contents: read

# Shared environment variables for all jobs
env:
  AWS_REGION: us-east-1
  TF_WORKING_DIR: terraform

What does this code do?

The schedule trigger uses cron syntax. 0 */6 * * * means "at minute 0 of every 6th hour." The workflow_dispatch trigger lets you click a button in GitHub to run the workflow on demand.

The permissions block sets id-token: write so the workflow can request a JWT from GitHub's OIDC provider. AWS uses this token to authenticate the workflow without long-lived credentials.

  • Save drift-check.yml.

You won't see a change yet. The next chunk adds the job steps that make this workflow actually do something.

Add the job steps for authentication and drift detection

The core of the workflow is a single job that authenticates to AWS via OIDC, initializes Terraform, and runs terraform plan -detailed-exitcode. The -detailed-exitcode flag is what makes drift detection possible. It returns exit code 0 when nothing changed, 1 when there is an error, and 2 when drift is detected.

  • In .github/workflows/drift-check.yml, add the following below the env block:
jobs:
  detect-drift:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_wrapper: false

      - name: Terraform Init
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform init -input=false

      - name: Terraform Plan (Drift Detection)
        id: plan
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          set +e
          terraform plan -detailed-exitcode -input=false -no-color > plan_output.txt 2>&1
          EXIT_CODE=$?
          set -e
          echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
          cat plan_output.txt
          if [ $EXIT_CODE -eq 1 ]; then
            echo "Terraform plan failed with an error"
            exit 1
          fi

What does this code do?

  • actions/checkout@v4 clones your repository so Terraform can read the .tf files.
  • aws-actions/configure-aws-credentials@v6 exchanges the GitHub OIDC token for temporary AWS credentials using the IAM role you created in the previous step.
  • terraform_wrapper: false disables the Terraform wrapper script. Without this, the wrapper can interfere with capturing the raw exit code.
  • set +e prevents the shell from exiting on a non-zero exit code. This is critical because exit code 2 (drift detected) would otherwise kill the step before you can capture it.
  • The exit code is saved to $GITHUB_OUTPUT so later steps can read it with steps.plan.outputs.exit_code.

Why terraform_wrapper: false?

The hashicorp/setup-terraform action includes a wrapper script by default that captures stdout into an output variable. This wrapper can swallow exit codes or add extra output that breaks your exit code logic. Setting terraform_wrapper: false gives you direct access to the real Terraform binary and its raw exit codes.

  • Save drift-check.yml.

You won't see a change yet. The next chunk adds the conditional alerting steps that respond to the exit code.

YAML indentation giving you trouble?

YAML is extremely sensitive to indentation. Each level must be exactly 2 spaces (no tabs). The jobs: key must be at the root level (0 spaces indent), and steps: must be at 6 spaces (under detect-drift).

help me fix the YAML indentation in my drift-check.yml workflow file

Add the conditional alert steps and test the workflow

The final piece is the response logic. When the plan step detects drift (exit code 2), you want an SNS alert sent to your email. When there is no drift (exit code 0), you log a clean confirmation.

  • In drift-check.yml, add these two steps at the end of the steps: list, below the Terraform Plan step:
      - name: Send Drift Alert
        if: steps.plan.outputs.exit_code == '2'
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          DRIFT_SUMMARY=$(head -100 plan_output.txt)
          aws sns publish \
            --topic-arn "${{ secrets.SNS_TOPIC_ARN }}" \
            --subject "DRIFT DETECTED in drift-detection infrastructure" \
            --message "Infrastructure drift detected at $(date -u).

          The following changes were found outside of Terraform:

          $DRIFT_SUMMARY

          Action required: Review the changes and either update your Terraform configuration or run terraform apply to revert."

      - name: Report No Drift
        if: steps.plan.outputs.exit_code == '0'
        run: echo "No drift detected. Infrastructure matches Terraform configuration."

What does this code do?

  • The if: steps.plan.outputs.exit_code == '2' condition means the alert step only runs when drift is found. No drift means no email.
  • head -100 plan_output.txt grabs the first 100 lines of the plan output. This keeps the SNS message under the size limit while still showing you what changed.
  • aws sns publish sends the drift details to your SNS topic, which forwards the message to your confirmed email subscription.
  • The "Report No Drift" step logs a clean message when exit code is 0. This makes it easy to see a healthy run in the GitHub Actions UI.
  • Save drift-check.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before continuing.

ⓧ I'd like to double check the full code

name: Drift Detection

on:
  schedule:
    - cron: '0 */6 * * *'
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  TF_WORKING_DIR: terraform

jobs:
  detect-drift:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_wrapper: false

      - name: Terraform Init
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: terraform init -input=false

      - name: Terraform Plan (Drift Detection)
        id: plan
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          set +e
          terraform plan -detailed-exitcode -input=false -no-color > plan_output.txt 2>&1
          EXIT_CODE=$?
          set -e
          echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
          cat plan_output.txt
          if [ $EXIT_CODE -eq 1 ]; then
            echo "Terraform plan failed with an error"
            exit 1
          fi

      - name: Send Drift Alert
        if: steps.plan.outputs.exit_code == '2'
        working-directory: ${{ env.TF_WORKING_DIR }}
        run: |
          DRIFT_SUMMARY=$(head -100 plan_output.txt)
          aws sns publish \
            --topic-arn "${{ secrets.SNS_TOPIC_ARN }}" \
            --subject "DRIFT DETECTED in drift-detection infrastructure" \
            --message "Infrastructure drift detected at $(date -u).

          The following changes were found outside of Terraform:

          $DRIFT_SUMMARY

          Action required: Review the changes and either update your Terraform configuration or run terraform apply to revert."

      - name: Report No Drift
        if: steps.plan.outputs.exit_code == '0'
        run: echo "No drift detected. Infrastructure matches Terraform configuration."

Now you need to add the SNS topic ARN as a GitHub secret so the workflow can publish alerts. You also need to commit, push, and trigger the workflow to verify it works.

  • Get your SNS topic ARN by running:
cd terraform && terraform output sns_topic_arn
  • Open your drift-detection repository on GitHub.
  • Click **Settings** in the repository navigation bar.
  • In the left sidebar, expand Secrets and variables and click Actions.
  • Click New repository secret.
  • Enter SNS_TOPIC_ARN as the name.
  • Paste the ARN value from the terminal output as the secret value.
  • Click Add secret.

Now commit and push the workflow file so GitHub can see it.

  • Commit and push the workflow by running these commands from your project root:
cd ..
git add .github/workflows/drift-check.yml
git commit -m "Add drift detection workflow"
git push

Why must the file be on the default branch?

The workflow_dispatch trigger only works when the workflow file exists on the default branch (usually main). If you pushed to a feature branch, the Run workflow button will not appear until you merge to the default branch.

  • On GitHub, click Actions in the repository navigation bar.
  • In the left sidebar, click Drift Detection.
  • Click the Run workflow button.
  • Leave the branch as main and click Run workflow again to confirm.
  • Click on the running workflow to see the job details.
  • Wait for the run to complete. You should see a green checkmark and the "Report No Drift" step showing the message: No drift detected. Infrastructure matches Terraform configuration.

Workflow failed?

  • If you see an OIDC error, check that the AWS_ROLE_ARN secret matches the ARN from terraform output github_actions_role_arn. Also confirm the trust policy in oidc.tf references your correct GitHub username and repo name.
  • If Terraform Init fails, verify the S3 state bucket name in backend.tf matches the actual bucket you created. The IAM role also needs s3:GetObject and s3:ListBucket on the state bucket.
  • If the plan step shows exit code 1 (error), expand the step logs in GitHub Actions to see the full error message. Common causes are missing IAM permissions or a mismatched region.

help me debug my drift detection GitHub Actions workflow that is failing

Your drift detection system is live. It will automatically check your infrastructure every 6 hours and alert you if anything changes. Next up, you will intentionally break something and watch the system catch it.

Simulate Drift and Verify Detection

Your drift detection system is deployed and waiting. The real test is whether it catches unauthorized changes when they happen.

In this step, you will play the role of someone making a "quick fix" in the AWS Console. Then you will watch your system detect and report that change automatically.

In this step, get ready to:
  • Introduce drift by manually modifying a security group in the AWS Console.
  • Trigger the workflow and receive an SNS alert email describing the drift.
  • Remediate the drift with Terraform and confirm the system reports no changes.
Introduce drift by modifying the security group

The most common source of infrastructure drift is someone opening the cloud console and making a "quick" change. You are about to do exactly that: add an HTTPS rule to the security group that Terraform does not know about.

  • In the AWS Console, open the VPC service. You can search for VPC in the top search bar.
  • Click Security groups in the left sidebar.
  • Find and click on the security group named drift-detection-sg.
  • Click the Inbound rules tab.
  • Click Edit inbound rules.
  • Click Add rule.
  • Set the Type to HTTPS.
  • Set the Source to 0.0.0.0/0 (Anywhere IPv4).
  • Click Save rules.

You should now see two inbound rules: the original SSH rule and the new HTTPS rule you just added.

Why this simulates real drift

This is exactly how drift happens in the real world. Someone opens the console to "quickly" allow HTTPS traffic, and nobody updates the Terraform configuration.

The result is a gap between what Terraform thinks exists and what actually exists in AWS. Over time, these gaps compound until the infrastructure becomes unmanageable.

Can't find drift-detection-sg?

Make sure you are in the correct AWS region (us-east-1 unless you changed it). Check the region selector in the top right of the console.

If you still cannot find it, try searching for drift-detection-sg in the security groups filter bar. Help me find my security group in the AWS Console.

Trigger the workflow and verify the drift alert

Now that unauthorized drift exists, your detection system should catch it. You will trigger the workflow manually and watch it identify the security group change.

  • Go to your drift-detection repository on GitHub.
  • Click the Actions tab.
  • Click Drift Detection in the left sidebar.
  • Click Run workflow, then click the green Run workflow button to confirm.
  • Wait for the workflow run to complete. Click on the running job to watch its progress.

The workflow should complete with the Terraform Plan (Drift Detection) step showing exit code 2. The Send Drift Alert step should execute successfully.

  • Check your email inbox for a message with the subject line containing DRIFT DETECTED.
  • Read the email. It describes the security group change that Terraform detected, including the HTTPS ingress rule you added manually.

What does exit code 2 mean?

The terraform plan -detailed-exitcode command returns three possible exit codes. Exit code 0 means no changes detected. Exit code 1 means an error occurred. Exit code 2 means changes were detected between the configuration and real infrastructure.

Your workflow uses this exit code to decide whether to send an alert. Only exit code 2 triggers the SNS notification.

Workflow failed or no email received?

If the workflow failed at the Configure AWS Credentials step, double check that your AWS_ROLE_ARN secret is set correctly in your repository settings.

If the workflow succeeded but you did not receive an email, verify that you confirmed your SNS subscription in a previous step. Check your spam folder as well.

If the plan shows exit code 0 (no drift), make sure you saved the inbound rule change in the AWS Console. Help me troubleshoot my drift detection workflow.

Remediate the drift and confirm resolution

Drift remediation means bringing real infrastructure back in line with your Terraform configuration. Running terraform apply will remove the manually added HTTPS rule and restore the security group to its defined state.

  • In your terminal, make sure you are in the terraform/ directory of your drift-detection project.
  • Revert the drift by running this command:
terraform apply -input=false -auto-approve

What does this command do?

This applies your Terraform configuration to AWS, which removes the HTTPS inbound rule you added manually. After this runs, the security group will only allow SSH from the allowed CIDR block, exactly as defined in main.tf.

You should see Terraform output showing that it modified the security group to remove the extra ingress rule.

  • Go back to the AWS Console and refresh the Inbound rules tab for drift-detection-sg.
  • Confirm that only the SSH rule (port 22) remains. The HTTPS rule should be gone.

Now verify the detection system also confirms everything is clean.

  • Go back to GitHub and trigger the Drift Detection workflow again using Run workflow.
  • Wait for the run to complete.
  • Verify that the Terraform Plan (Drift Detection) step now shows exit code 0.
  • Confirm that the Report No Drift step ran instead of the Send Drift Alert step.

Still seeing drift after terraform apply?

If the workflow still reports exit code 2 after you applied, there may be another resource that drifted (for example, tags modified by AWS). Check the plan output in the workflow logs to see exactly which resource and attribute are flagged.

Run terraform plan locally to see the full diff before re-applying. Help me understand why drift is still detected after I applied.

You have completed the full cycle: drift introduced, detected, alerted, and remediated. Your system is production-ready. Next up, extend it with automatic remediation in the Secret Mission.

Secret mission

Add Auto-Remediation with a Safety Toggle

Your drift detection system alerts you when something changes. But what if it could also fix the problem automatically? In this secret mission, you will add a configurable auto-remediation mode that reverts unauthorized changes without human intervention.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

All AWS resources in this project are within free tier or cost less than $0.01/month at zero traffic. The GitHub Actions scheduled workflow consumes minutes from your free monthly allowance.

Cost warning

While costs are negligible, the scheduled workflow runs every 6 hours and consumes GitHub Actions minutes. If you have a private repository, this counts against your monthly minute allowance. Disabling the workflow or deleting the repository stops any future usage.

Resources you used:

  • S3 bucket (Terraform state).
  • S3 bucket (monitored data).
  • DynamoDB table (state lock).
  • VPC and security group.
  • IAM policy (read-only access).
  • IAM role (OIDC for GitHub Actions).
  • OIDC provider.
  • SNS topic and email subscription.
  • GitHub repository (drift-detection).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep monitoring your infrastructure for drift.

  • The scheduled workflow runs every 6 hours at no meaningful cost.
  • AWS resources incur less than $0.01/month at this scale.
  • Your SNS subscription remains active and delivers email alerts when drift is detected.

Pause - I'll come back to this later

Disable the scheduled workflow to stop consuming GitHub Actions minutes. Your AWS resources and Terraform state remain intact for when you return.

  • In your GitHub repository, click the Actions tab.
  • In the left sidebar, select the Drift Detection workflow.
  • Click the three-dot menu and select Disable workflow.
  • Your AWS resources will remain but cost nothing at zero traffic.
  • To resume later, follow the same steps and select Enable workflow.

Delete - I don't want to use this again

Remove all project resources completely. This is irreversible.

  • Open your terminal and navigate to the terraform/ directory inside your project folder.
  • Destroy all AWS resources by running this command:
terraform destroy
  • Type yes when prompted to confirm the destruction of all resources.
  • This removes: both S3 buckets, the DynamoDB table, the VPC, security group, IAM policy, IAM role, OIDC provider, SNS topic, and SNS subscription.

To delete the GitHub repository:

  • Go to your drift-detection repository on GitHub.
  • Click Settings below the repository name.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Type the repository name to confirm, then click I understand the consequences, delete this repository.

To remove local project files:

macOS

rm -rf ~/Desktop/drift-detection

Windows

rmdir /s /q %USERPROFILE%\Desktop\drift-detection

Well Done!

Well Done!

You just built something most portfolios completely miss: a system that monitors infrastructure after it is deployed. This proves you think about Day 2 operations and reliability, not just initial provisioning.

You've learned how to:

  • Built a complete drift detection system using terraform plan -detailed-exitcode that runs on a schedule and programmatically catches unauthorized infrastructure changes.
  • Set up GitHub OIDC authentication for keyless, secure communication between GitHub Actions and AWS, with remote state in S3 and DynamoDB locking for safe concurrent access.
  • Implemented SNS email alerting so drift notifications reach you immediately, then simulated real-world drift and verified the system catches and reports it accurately.
  • Added configurable auto-remediation with a safety toggle to automatically revert drift when enabled.

Ready to quiz yourself?