Security Monitoring System with Terraform
Deploy a threat detection and alerting pipeline on AWS using Terraform.
Introduction
30 Second Summary
Every cloud account is a potential target. Without monitoring in place, a breach could go unnoticed for weeks.
In this project, you will build a security monitoring system on AWS that detects threats, logs all API activity, and sends you email alerts when something suspicious happens. You will define the entire system as Terraform code, making it repeatable and version-controlled.
What You'll Build
A complete security monitoring pipeline on AWS covering threat detection, audit logging, and observability, all defined as Infrastructure as Code with Terraform.
By the end of this project, you'll have:
- A working AWS GuardDuty detector monitoring your account for threats, with an EventBridge rule routing formatted alerts to your email via SNS.
- An AWS CloudTrail trail logging every API call in your account to an S3 bucket for audit and forensic investigation.
- A CloudWatch dashboard visualizing your security posture at a glance, with metrics tracking GuardDuty findings and alert volume.
- Secret Mission: Add severity-based filtering so only high-priority threats trigger your inbox alerts.
Are there any prerequisites?
You need an AWS account with admin-level IAM access and an access key configured. Step 1 walks you through installing Terraform and the AWS CLI if you do not already have them.
Install and Configure Your Tools
Before you can define any infrastructure as code, you need two command-line tools installed on your machine.
Terraform is the tool that reads your configuration files and translates them into real AWS resources. The AWS CLI is what lets Terraform authenticate with your AWS account. In this step, you will install both tools and verify they can talk to AWS.
In this step, get ready to:
- Install Terraform and verify it is working.
- Install and configure the AWS CLI with your credentials.
- Verify that Terraform can authenticate with your AWS account.
Install Terraform
Terraform is the Infrastructure as Code engine that will deploy all of your security monitoring resources. You need version 1.0 or higher installed.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check if Terraform is already installed by running this command:
terraform version
✔️ I see version 1.0 or higher
Terraform is ready to go. Skip ahead to the next substep.
ⓧ I see an older version
You need version 1.0 or higher for this project. Upgrade Terraform using the instructions for your operating system below.
macOS
- Upgrade Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
- Verify the upgrade by running:
terraform version
Windows
- Upgrade Terraform by running:
choco install terraform
- Verify the upgrade by running:
terraform version
ⓧ Command not found
Terraform is not installed yet. Follow the instructions for your operating system below.
macOS
- Install Terraform using Homebrew by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
- Verify the installation by running:
terraform version
You should see a version number like Terraform v1.15.3 in the output.
Windows
- Install Terraform using Chocolatey by running:
choco install terraform
- Verify the installation by running:
terraform version
You should see a version number like Terraform v1.15.3 in the output.
Terraform command still not found?
Close your terminal and open a new one. Package managers sometimes need a fresh terminal session to update the PATH.
On macOS, if you do not have Homebrew installed, visit brew.sh and follow the install instructions first. On Windows, if you do not have Chocolatey, visit chocolatey.org/install for setup steps.
help me troubleshoot my Terraform installation
Install and configure the AWS CLI
The AWS CLI is what allows Terraform to authenticate with your AWS account. Terraform uses the credentials stored by the AWS CLI to create and manage resources on your behalf.
- Check if the AWS CLI is already installed by running:
aws --version
✔️ I see aws-cli/2.x.x
AWS CLI v2 is already installed. Skip ahead to the configuration step below.
ⓧ I see aws-cli/1.x.x
You have AWS CLI version 1 installed. This project requires version 2. Follow the instructions for your operating system to upgrade.
macOS
- Download and install the AWS CLI v2 by running:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
- Verify the upgrade by running:
aws --version
You should see aws-cli/2.x.x in the output.
Windows
- Download and run the AWS CLI v2 MSI installer by running:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
- Close and reopen your terminal after the installer completes.
- Verify the upgrade by running:
aws --version
You should see aws-cli/2.x.x in the output.
ⓧ Command not found
AWS CLI is not installed. Follow the instructions for your operating system below.
macOS
- Download and install the AWS CLI v2 by running:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
- Verify the installation by running:
aws --version
You should see something like aws-cli/2.x.x in the output.
Windows
- Download and run the AWS CLI v2 MSI installer by running:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
- Close and reopen your terminal after the installer completes.
- Verify the installation by running:
aws --version
You should see something like aws-cli/2.x.x in the output.
Now configure the AWS CLI with your access credentials. The aws configure command will prompt you for four values.
- Run the configuration wizard:
aws configure
What will this prompt ask for?
The wizard asks for four values:
- AWS Access Key ID and AWS Secret Access Key from your IAM user's access key.
- Default region name should be us-east-1 (or your preferred region).
- Default output format can be left blank (press Enter to accept the default).
- Enter your AWS Access Key ID when prompted.
- Enter your AWS Secret Access Key when prompted.
- Enter us-east-1 for the default region (or the region you prefer to deploy in).
- Press Enter to accept the default output format.
Don't have an access key?
You can create one in the AWS Console. Go to IAM, then Users, select your user, open the Security credentials tab, and click Create access key. Choose Command Line Interface (CLI) as the use case.
help me create an AWS access key for CLI use
Verify AWS access
The final check confirms that your AWS credentials are working correctly. This is the same authentication path Terraform will use when deploying resources.
- Verify your identity by running:
aws sts get-caller-identity
You should see a JSON response containing your UserId, Account, and Arn.
What does this command do?
The aws sts get-caller-identity command asks AWS "who am I?" using whatever credentials are configured. If this returns your account details, Terraform will also be able to authenticate successfully.
Seeing an error about invalid credentials?
Double-check that you pasted your Access Key ID and Secret Access Key correctly when you ran aws configure. A single extra space or missing character will cause authentication to fail.
You can re-run aws configure at any time to overwrite your stored credentials with the correct values.
help me fix my AWS CLI credentials
Your tools are ready to go. Next up, you will create the Terraform project structure and configure the AWS provider.
Set Up the Terraform Project Structure
Every Terraform project starts with a clear file structure. Each file has a specific job: one defines providers and resources, one declares variables, one sets output values, and one holds your actual configuration values.
In this step, you'll create the project folder and four core configuration files. Then you'll initialize Terraform so it downloads the AWS provider plugin and validates your setup.
In this step, get ready to:
- Create the project directory and four configuration files.
- Write the AWS provider configuration and variable definitions.
- Initialize Terraform to download the required provider plugin.
Create the project directory and files
Terraform reads all .tf files in a directory as a single configuration. You need a dedicated folder for your security monitoring project with files for each concern: providers, variables, outputs, and runtime values.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir security-monitoring
cd security-monitoring
- Create the four configuration files by running this command:
touch main.tf variables.tf outputs.tf terraform.tfvars
- Confirm the files exist by running:
ls
You should see main.tf, variables.tf, outputs.tf, and terraform.tfvars listed in the output.
Don't see all four files?
Make sure you ran the touch command from inside the security-monitoring folder. If you're in the wrong directory, run cd ~/Desktop/security-monitoring and try the touch command again.
help me find my project files
Configure the AWS provider
The provider configuration tells Terraform which cloud platform to talk to and which version of the provider plugin to use. You'll also define the variables that keep your configuration flexible across environments.
- Open the security-monitoring folder in your code editor.
- Open main.tf and paste the following code:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
What does this code do?
- The terraform block pins the AWS provider to version ~> 6.0 (any 6.x release) and requires Terraform 1.0 or higher.
- The provider "aws" block configures AWS with a region pulled from a variable. This keeps the region flexible instead of hardcoded.
- The two data sources look up your current AWS account ID and region at runtime. Later resources reference these values dynamically.
- Save main.tf.
You won't see any visible change yet. The terraform init command in the next substep validates this file.
- Open variables.tf and paste the following code:
variable "aws_region" {
description = "AWS region to deploy resources in"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "security-monitor"
}
variable "alert_email" {
description = "Email address to receive security alerts"
type = string
}
What does this code do?
- aws_region sets which AWS region to deploy into. It defaults to us-east-1 if no value is provided.
- project_name is a prefix used to name all resources consistently. It defaults to security-monitor.
- alert_email has no default. Terraform will require you to provide it, because this is where security alerts get sent.
- Save variables.tf.
- Open terraform.tfvars and paste the following code:
aws_region = "us-east-1"
alert_email = "[[ALERT_EMAIL=\"your-email@example.com\"]]"
What does this code do?
This file supplies actual values for the variables you just defined. Terraform reads terraform.tfvars automatically and uses these values when building your infrastructure.
- Replace the placeholder email address with your real email address. This is where security alert notifications will be sent.
- Save terraform.tfvars.
What about outputs.tf?
Leave outputs.tf empty for now. You'll add output definitions in later steps as you create resources that produce useful values like ARNs and IDs.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full content of each file at this point.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
variable "aws_region" {
description = "AWS region to deploy resources in"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "security-monitor"
}
variable "alert_email" {
description = "Email address to receive security alerts"
type = string
}
aws_region = "us-east-1"
alert_email = "your-email@example.com"
Initialize Terraform
Initializing downloads the AWS provider plugin and prepares your working directory. This is the first command you run in any new Terraform project.
- Download the AWS provider plugin by running this command:
terraform init
What does this command do?
terraform init reads your required_providers block, downloads the specified provider plugin from the Terraform Registry, and stores it in a .terraform/ folder inside your project. It also creates a lock file to pin the exact provider version.
You should see a message that says Terraform has been successfully initialized! at the end of the output.
Seeing an error instead?
- If you see a syntax error, double-check that your main.tf has matching curly braces and that every string is wrapped in double quotes.
- If you see a provider not found error, check that source = "hashicorp/aws" is spelled exactly right in your required_providers block.
- If you see a credentials error, make sure your AWS CLI is configured. Run aws sts get-caller-identity to verify.
help me fix my terraform init error
Your project structure is ready and Terraform can talk to AWS. Next up, you'll use this foundation to deploy GuardDuty for real-time threat detection.
Deploy GuardDuty Threat Detection
Your Terraform project is initialized and ready to provision real AWS infrastructure. The first pillar of your security monitoring system is threat detection.
GuardDuty is AWS's managed threat detection service. It continuously analyzes CloudTrail logs, VPC Flow Logs, and DNS queries to identify suspicious activity in your account. In this step, you will define a GuardDuty detector as Terraform code and deploy it to AWS.
In this step, get ready to:
- Write the GuardDuty detector resource in Terraform.
- Deploy the detector with terraform plan and terraform apply.
- Verify GuardDuty is active in the AWS Console.
Write the GuardDuty detector resource
A GuardDuty detector is the core resource that enables threat monitoring in a region. Once enabled, it starts analyzing data sources for suspicious behavior automatically.
- Open main.tf in your code editor.
- Find the two data source lines at the bottom of the file:
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
- Add the following GuardDuty resource block below those lines:
# =============================================================================
# GuardDuty - Threat Detection
# =============================================================================
resource "aws_guardduty_detector" "main" {
enable = true
finding_publishing_frequency = "FIFTEEN_MINUTES"
datasources {
s3_logs {
enable = true
}
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
enable = true activates the GuardDuty detector so it starts analyzing your account's activity immediately.
finding_publishing_frequency = "FIFTEEN_MINUTES" sets how often GuardDuty publishes findings to EventBridge. This is the most frequent option available, giving you near-real-time alerts.
The datasources block enables S3 log monitoring so GuardDuty can detect threats targeting your S3 buckets. The tags block labels the resource for easy identification.
- Save main.tf.
You won't see a change in AWS yet. The next chunk adds an output, then you will deploy both together.
Seeing a syntax error in your editor?
Make sure the resource block sits outside and below the existing data blocks. If your editor highlights an error, check for missing closing braces on the datasources or tags blocks.
help me fix a syntax error in my Terraform GuardDuty resource block
Next, add a Terraform output so you can reference the detector ID later.
- Open outputs.tf and add the following output block:
output "guardduty_detector_id" {
description = "The ID of the GuardDuty detector"
value = aws_guardduty_detector.main.id
}
What does this output do?
Terraform outputs let you extract values from your deployed infrastructure. After applying, Terraform prints the detector ID to your terminal so you can reference it in other tools or scripts without digging through the AWS Console.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved both main.tf and outputs.tf.
ⓧ I'd like to double check the full code
Here is the full main.tf at this point:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# GuardDuty - Threat Detection
# =============================================================================
resource "aws_guardduty_detector" "main" {
enable = true
finding_publishing_frequency = "FIFTEEN_MINUTES"
datasources {
s3_logs {
enable = true
}
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
Here is the full outputs.tf:
output "guardduty_detector_id" {
description = "The ID of the GuardDuty detector"
value = aws_guardduty_detector.main.id
}
Deploy with Terraform
Terraform uses a two-step deployment workflow. First you preview the changes with terraform plan, then you apply them with terraform apply.
- Preview the changes Terraform will make by running:
terraform plan
What does terraform plan do?
terraform plan compares your configuration to the current state (empty right now, since nothing is deployed). It shows exactly what Terraform will create, modify, or destroy without making any changes. You should see a plan to create 1 resource (the GuardDuty detector).
- You should see output ending with Plan: 1 to add, 0 to change, 0 to destroy. confirming one new resource will be created.
- Deploy the GuardDuty detector by running:
terraform apply
What happens during terraform apply?
terraform apply generates a fresh plan and then prompts you for confirmation before making any changes. It displays the same plan output you saw previously, then asks you to approve.
- Terraform will prompt you with a confirmation. Type yes and press **Enter** to approve the deployment.
- You should see output ending with Apply complete! Resources: 1 added, 0 changed, 0 destroyed. followed by the guardduty_detector_id output value.
Seeing an error about a detector already existing?
GuardDuty only allows one detector per region per account. If you previously enabled GuardDuty in this region (even through the console), Terraform cannot create a second one.
To fix this, either disable the existing detector in the GuardDuty console (navigate to **Settings**, then choose **Disable GuardDuty**), or import the existing detector into Terraform state using terraform import aws_guardduty_detector.main DETECTOR_ID.
help me resolve a GuardDuty detector already exists error in Terraform
Verify in the AWS Console
The Terraform output confirms the resource was created, but let's verify directly in the AWS Console.
- Open the AWS Console in your browser and navigate to the GuardDuty service (search for GuardDuty in the top search bar).
- Make sure you are in the same region you configured in terraform.tfvars (us-east-1 by default). Check the region selector in the top-right corner of the console.
- You should see the GuardDuty **Summary** dashboard, confirming the detector is active and monitoring your account.
What is GuardDuty's 30-day free trial?
GuardDuty offers a 30-day free trial per region when first enabled. During this trial you will not be charged for any GuardDuty analysis. After 30 days, costs are based on the volume of data analyzed.
You will destroy all resources at the end of this project to avoid any charges.
Your account now has an active threat detector watching for suspicious activity. Next up, you will enable CloudTrail audit logging so every API call in your account is recorded for investigation.
Enable CloudTrail Audit Logging
GuardDuty is now actively watching your account for threats. The next pillar of your security monitoring system is audit logging — knowing exactly what happened in your account and when.
CloudTrail is AWS's audit logging service. It records every API call made in your account, creating a forensic trail you can review after a security incident. In this step, you'll define the CloudTrail infrastructure in Terraform and deploy it alongside your existing GuardDuty setup.
In this step, get ready to:
- Create an S3 bucket to store CloudTrail log files.
- Define a bucket policy granting CloudTrail write permissions.
- Deploy a CloudTrail trail that captures API activity across all regions.
Create an S3 bucket for CloudTrail logs
CloudTrail needs a storage destination for its log files. You'll create a dedicated S3 bucket for this purpose, then add a bucket policy that gives CloudTrail permission to write into it.
- In main.tf, add the CloudTrail S3 bucket resource below your GuardDuty resource by pasting this code:
# =============================================================================
# CloudTrail - Audit Logging
# =============================================================================
resource "aws_s3_bucket" "cloudtrail_logs" {
bucket = "${var.project_name}-cloudtrail-${data.aws_caller_identity.current.account_id}"
force_destroy = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
This creates an S3 bucket with a globally unique name built from your project name and AWS account ID.
The force_destroy = true setting lets Terraform delete the bucket even when it contains log files. Without this, terraform destroy would fail because S3 normally refuses to delete non-empty buckets.
You won't see a change yet. The next chunk adds the bucket policy that CloudTrail requires before it can write logs.
- Add the bucket policy directly below the S3 bucket resource in main.tf:
resource "aws_s3_bucket_policy" "cloudtrail_logs" {
bucket = aws_s3_bucket.cloudtrail_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSCloudTrailAclCheck"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.cloudtrail_logs.arn
Condition = {
StringEquals = {
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
},
{
Sid = "AWSCloudTrailWrite"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
}
]
})
}
What does this bucket policy do?
CloudTrail requires two specific permissions before it can deliver logs to an S3 bucket:
- s3:GetBucketAcl lets CloudTrail verify it owns the bucket before writing.
- s3:PutObject lets CloudTrail write log files into the AWSLogs/ path.
The Condition blocks ensure only YOUR specific trail can use this bucket. Without this policy, CloudTrail would fail to deliver logs silently.
- Save main.tf.
- Verify your configuration is syntactically valid by running:
terraform validate
You should see Success! The configuration is valid. in the terminal output.
Seeing a validation error?
- Check for missing closing braces } or brackets ] in the bucket policy. The nested JSON structure has multiple levels.
- Make sure the bucket policy resource is placed after the S3 bucket resource. Terraform needs to resolve aws_s3_bucket.cloudtrail_logs references.
- Confirm you have both data "aws_caller_identity" "current" {} and data "aws_region" "current" {} at the top of your file from the previous step.
help me fix a Terraform validation error in my bucket policy
Create the CloudTrail trail
With the bucket and its policy in place, you can now define the CloudTrail trail itself. This resource tells AWS to start recording API calls and delivering them to your S3 bucket.
- Add the CloudTrail resource below the bucket policy in main.tf:
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
enable_logging = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
- depends_on forces Terraform to create the bucket policy before the trail. CloudTrail validates permissions when the trail is created, so the policy must exist first.
- is_multi_region_trail = true captures API calls from every AWS region, not just your primary one.
- include_global_service_events = true records events from global services like IAM and CloudFront.
- enable_log_file_validation = true adds integrity hashes so you can verify logs haven't been tampered with.
- Save main.tf.
You won't see a change yet. The next substep deploys everything together.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tf before moving on.
ⓧ I'd like to double check the full code
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# GuardDuty - Threat Detection
# =============================================================================
resource "aws_guardduty_detector" "main" {
enable = true
finding_publishing_frequency = "FIFTEEN_MINUTES"
datasources {
s3_logs {
enable = true
}
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
# =============================================================================
# CloudTrail - Audit Logging
# =============================================================================
resource "aws_s3_bucket" "cloudtrail_logs" {
bucket = "${var.project_name}-cloudtrail-${data.aws_caller_identity.current.account_id}"
force_destroy = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_policy" "cloudtrail_logs" {
bucket = aws_s3_bucket.cloudtrail_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSCloudTrailAclCheck"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.cloudtrail_logs.arn
Condition = {
StringEquals = {
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
},
{
Sid = "AWSCloudTrailWrite"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
}
]
})
}
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
enable_logging = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
Deploy and verify audit logging
Before deploying, you'll add two outputs so Terraform reports the CloudTrail ARN and bucket name after apply.
- Open outputs.tf and add the following outputs below the existing guardduty_detector_id output:
output "cloudtrail_arn" {
description = "The ARN of the CloudTrail trail"
value = aws_cloudtrail.main.arn
}
output "cloudtrail_s3_bucket" {
description = "The S3 bucket storing CloudTrail logs"
value = aws_s3_bucket.cloudtrail_logs.id
}
What do these outputs do?
Outputs print useful values to your terminal after every terraform apply. You'll see the CloudTrail ARN and the exact S3 bucket name without needing to open the AWS Console.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved outputs.tf before deploying.
ⓧ I'd like to double check the full code
output "guardduty_detector_id" {
description = "The ID of the GuardDuty detector"
value = aws_guardduty_detector.main.id
}
output "cloudtrail_arn" {
description = "The ARN of the CloudTrail trail"
value = aws_cloudtrail.main.arn
}
output "cloudtrail_s3_bucket" {
description = "The S3 bucket storing CloudTrail logs"
value = aws_s3_bucket.cloudtrail_logs.id
}
- Deploy the CloudTrail resources by running:
terraform apply
- Terraform will show a plan with 3 new resources to create (S3 bucket, bucket policy, CloudTrail trail). Type yes and press Enter to confirm.
You should see Apply complete! Resources: 3 added along with your two new outputs printed in the terminal.
Seeing an access denied error?
- Make sure the IAM user or role you configured in Step 1 has admin-level permissions. CloudTrail creation requires cloudtrail:CreateTrail and s3:CreateBucket permissions.
- If you see an error about the S3 bucket already existing, another trail or account may have used the same bucket name. Check if a bucket with security-monitor-cloudtrail- prefix already exists in S3.
help me fix a Terraform apply error for CloudTrail
- Open the AWS Console and navigate to the CloudTrail service.
- Click Trails in the left sidebar.
- Confirm you see a trail named security-monitor-trail with a status of Logging.
What is CloudTrail actually recording?
From this moment forward, every API call in your AWS account is being logged. Creating an EC2 instance, modifying a security group, or even viewing S3 objects generates a CloudTrail event.
If GuardDuty detects a threat, your CloudTrail logs provide the forensic evidence to investigate what happened before and after the event.
Your security monitoring system now has both threat detection and a complete audit trail. Next up, you'll build the alert pipeline that notifies you when GuardDuty actually finds something suspicious.
Create the SNS Alert Pipeline
You now have GuardDuty detecting threats and CloudTrail logging every API call in your account. The next challenge is making sure you actually find out when something suspicious happens.
Amazon SNS (Simple Notification Service) lets you send messages to subscribers through email, SMS, or other protocols. In this step, you'll create an SNS topic that acts as the central channel for security alerts, then subscribe your email to it.
In this step, get ready to:
- Create an SNS topic for security alerts.
- Add an email subscription to receive notifications.
- Deploy the resources and confirm the subscription in your inbox.
Create the SNS topic
An SNS topic is a communication channel that can fan out messages to multiple subscribers at once. You'll create a topic specifically for security alerts, then grant EventBridge permission to publish to it (you'll wire up EventBridge in the next step).
- In main.tf, scroll to the bottom of the file (below your CloudTrail resources).
- Add the following SNS topic resource:
# =============================================================================
# SNS - Alert Notifications
# =============================================================================
resource "aws_sns_topic" "security_alerts" {
name = "${var.project_name}-security-alerts"
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
This creates an SNS topic named security-monitor-security-alerts (the project_name variable prefixes the name).
Think of a topic like a mailing list. Anyone subscribed to the topic receives every message published to it.
- Below the topic resource, add the topic policy that grants EventBridge permission to publish messages:
resource "aws_sns_topic_policy" "security_alerts" {
arn = aws_sns_topic.security_alerts.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowEventBridgePublish"
Effect = "Allow"
Principal = {
Service = "events.amazonaws.com"
}
Action = "SNS:Publish"
Resource = aws_sns_topic.security_alerts.arn
}
]
})
}
Why add a topic policy now?
By default, only the topic owner can publish to an SNS topic. In the next step, you'll connect EventBridge to this topic so it can forward GuardDuty findings. This policy pre-authorizes the events.amazonaws.com service to publish messages.
Without this policy, EventBridge would fail silently when it tries to deliver alerts.
- Save main.tf.
Add an email subscription
A topic without subscribers is like a mailing list with no members. You need to subscribe your email address so you actually receive the security alerts.
- In main.tf, add the following subscription resource between the aws_sns_topic and aws_sns_topic_policy resources:
resource "aws_sns_topic_subscription" "email_alert" {
topic_arn = aws_sns_topic.security_alerts.arn
protocol = "email"
endpoint = var.alert_email
}
What does this code do?
This subscribes the email address stored in your alert_email variable to the SNS topic. The protocol field tells SNS how to deliver messages.
AWS requires email subscribers to confirm their subscription before receiving messages. You'll handle that confirmation after deploying.
- Now add an output so you can easily reference the topic ARN later. Open outputs.tf and add this at the bottom:
output "sns_topic_arn" {
description = "The ARN of the SNS security alerts topic"
value = aws_sns_topic.security_alerts.arn
}
- Save both main.tf and outputs.tf.
Update your email before applying
Open terraform.tfvars and replace your-email@example.com with your real email address. This is where AWS sends the subscription confirmation.
✔️ Awesome, I've got everything!
Great. Make sure you've saved all three files before continuing.
ⓧ I'd like to double check the full code
Here is the full main.tf at this point:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# GuardDuty - Threat Detection
# =============================================================================
resource "aws_guardduty_detector" "main" {
enable = true
finding_publishing_frequency = "FIFTEEN_MINUTES"
datasources {
s3_logs {
enable = true
}
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
# =============================================================================
# CloudTrail - Audit Logging
# =============================================================================
resource "aws_s3_bucket" "cloudtrail_logs" {
bucket = "${var.project_name}-cloudtrail-${data.aws_caller_identity.current.account_id}"
force_destroy = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_policy" "cloudtrail_logs" {
bucket = aws_s3_bucket.cloudtrail_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSCloudTrailAclCheck"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.cloudtrail_logs.arn
Condition = {
StringEquals = {
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
},
{
Sid = "AWSCloudTrailWrite"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
}
]
})
}
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
enable_logging = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
# =============================================================================
# SNS - Alert Notifications
# =============================================================================
resource "aws_sns_topic" "security_alerts" {
name = "${var.project_name}-security-alerts"
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_sns_topic_subscription" "email_alert" {
topic_arn = aws_sns_topic.security_alerts.arn
protocol = "email"
endpoint = var.alert_email
}
resource "aws_sns_topic_policy" "security_alerts" {
arn = aws_sns_topic.security_alerts.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowEventBridgePublish"
Effect = "Allow"
Principal = {
Service = "events.amazonaws.com"
}
Action = "SNS:Publish"
Resource = aws_sns_topic.security_alerts.arn
}
]
})
}
And here is outputs.tf:
output "guardduty_detector_id" {
description = "The ID of the GuardDuty detector"
value = aws_guardduty_detector.main.id
}
output "cloudtrail_arn" {
description = "The ARN of the CloudTrail trail"
value = aws_cloudtrail.main.arn
}
output "cloudtrail_s3_bucket" {
description = "The S3 bucket storing CloudTrail logs"
value = aws_s3_bucket.cloudtrail_logs.id
}
output "sns_topic_arn" {
description = "The ARN of the SNS security alerts topic"
value = aws_sns_topic.security_alerts.arn
}
Apply and confirm the subscription
Time to deploy the SNS resources and confirm that your email subscription is active.
- Preview the changes Terraform will make by running:
terraform plan
- You should see 3 resources to add: the SNS topic, the email subscription, and the topic policy.
- Deploy the resources by running:
terraform apply
- Type yes when prompted to confirm.
What should I see?
Terraform creates all three resources and displays Apply complete! Resources: 3 added along with the sns_topic_arn output value.
- Check your email inbox (the address you set in terraform.tfvars).
- Open the email with the subject AWS Notification - Subscription Confirmation.
- Click the Confirm subscription link in the email.
Why do I need to confirm?
AWS requires email subscribers to opt in before receiving messages. This prevents someone from subscribing a random email address to a high-volume topic. Until you confirm, the subscription stays in a pending state and no alerts will be delivered.
Don't see the confirmation email?
- Check your spam or junk folder.
- Verify the email address in terraform.tfvars is correct. If you need to fix it, update the file, run terraform apply again, and check your inbox for a new confirmation email.
- The email can take 1-2 minutes to arrive.
Help me troubleshoot my SNS subscription confirmation email
Your alert pipeline is ready to receive messages. Next up, you'll connect GuardDuty to this topic using EventBridge so that security findings are automatically routed to your inbox.
Connect GuardDuty to SNS with EventBridge
You have GuardDuty detecting threats and an SNS topic ready to deliver alerts to your inbox. The missing piece is connecting these two services so that findings flow automatically from detection to notification.
Amazon EventBridge is an event routing service that listens for events from AWS services and routes them to targets. In this step, you will create an EventBridge rule that captures every GuardDuty finding and sends a formatted alert message to your SNS topic.
In this step, get ready to:
- Create an EventBridge rule that captures all GuardDuty findings.
- Add an input transformer that formats raw JSON into human-readable alert messages.
- Deploy and verify the alert pipeline end-to-end.
Create the EventBridge rule
EventBridge uses rules to match incoming events based on patterns. You need a rule that listens specifically for GuardDuty finding events.
- In main.tf, add the following EventBridge section after your SNS resources:
# =============================================================================
# EventBridge - Route GuardDuty Findings to SNS
# =============================================================================
resource "aws_cloudwatch_event_rule" "guardduty_findings" {
name = "${var.project_name}-guardduty-findings"
description = "Capture all GuardDuty findings and route to SNS"
event_pattern = jsonencode({
source = ["aws.guardduty"]
detail-type = ["GuardDuty Finding"]
})
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
What does this code do?
The event_pattern is the filter that tells EventBridge which events to capture. It matches on two fields:
- source set to aws.guardduty means only events published by GuardDuty match.
- detail-type set to GuardDuty Finding narrows it to actual threat findings (not service health events).
Why does Terraform use aws_cloudwatch_event_rule?
EventBridge is the evolution of CloudWatch Events. The Terraform AWS provider still uses the legacy resource names aws_cloudwatch_event_rule and aws_cloudwatch_event_target, but the underlying service is EventBridge.
You will not see a change yet. The next chunk adds a target that tells EventBridge where to send matching events.
Create the EventBridge target with input transformer
A rule without a target does nothing. The target defines where matched events go and how they are formatted before delivery.
- In main.tf, add the following resource directly below the EventBridge rule you just created:
resource "aws_cloudwatch_event_target" "sns_target" {
rule = aws_cloudwatch_event_rule.guardduty_findings.name
target_id = "send-to-sns"
arn = aws_sns_topic.security_alerts.arn
input_transformer {
input_paths = {
severity = "$.detail.severity"
type = "$.detail.type"
description = "$.detail.description"
account = "$.detail.accountId"
region = "$.detail.region"
title = "$.detail.title"
}
input_template = "\"GuardDuty Finding in account <account> (<region>)\\nSeverity: <severity>\\nType: <type>\\nTitle: <title>\\nDescription: <description>\""
}
}
What does the input transformer do?
Without an input transformer, you would receive the raw JSON event from GuardDuty. That JSON is hundreds of lines long and difficult to read in an email. The transformer extracts only the fields you care about and arranges them into a readable message.
- input_paths maps variable names to paths in the JSON event. For example, $.detail.severity pulls the severity score from the finding.
- input_template defines the output format using <variable> placeholders. The final email will show the account, region, severity, type, title, and description on separate lines.
You will not see a change yet. The next substep adds the output and deploys everything.
Deploy and verify the EventBridge connection
For EventBridge to deliver messages to your SNS topic, the topic needs a resource policy that grants EventBridge permission to publish. You already added this policy in the previous step when you created the aws_sns_topic_policy resource with events.amazonaws.com as the allowed principal.
Now add an output so you can easily reference the EventBridge rule ARN later.
- In outputs.tf, add the following output at the bottom of the file:
output "eventbridge_rule_arn" {
description = "The ARN of the EventBridge rule"
value = aws_cloudwatch_event_rule.guardduty_findings.arn
}
What does this output do?
Terraform outputs display useful information after each apply. This output prints the EventBridge rule ARN so you can quickly reference it in the AWS console or other automation tools.
- Save both main.tf and outputs.tf.
- Deploy the EventBridge resources by running:
terraform apply
- Type yes when prompted to confirm the deployment.
What should I see?
Terraform will show 2 resources to add (the EventBridge rule and target). After applying, you should see Apply complete! Resources: 2 added along with the eventbridge_rule_arn output value.
- Open the AWS console and navigate to the EventBridge service.
- Click Rules in the left sidebar.
- Confirm that security-monitor-guardduty-findings appears in the list with a status of Enabled.
Don't see the rule in EventBridge?
- Make sure you are viewing the correct AWS region in the console. The region selector in the top-right corner should match the region in your terraform.tfvars file.
- In EventBridge, ensure you are looking at rules on the default event bus, not a custom bus.
- Check your terminal for errors during terraform apply. A common issue is a syntax error in the event_pattern JSON.
Help me troubleshoot my EventBridge rule not appearing in the AWS console.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full main.tf file up to this point:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# GuardDuty - Threat Detection
# =============================================================================
resource "aws_guardduty_detector" "main" {
enable = true
finding_publishing_frequency = "FIFTEEN_MINUTES"
datasources {
s3_logs {
enable = true
}
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
# =============================================================================
# CloudTrail - Audit Logging
# =============================================================================
resource "aws_s3_bucket" "cloudtrail_logs" {
bucket = "${var.project_name}-cloudtrail-${data.aws_caller_identity.current.account_id}"
force_destroy = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_policy" "cloudtrail_logs" {
bucket = aws_s3_bucket.cloudtrail_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSCloudTrailAclCheck"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.cloudtrail_logs.arn
Condition = {
StringEquals = {
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
},
{
Sid = "AWSCloudTrailWrite"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
}
]
})
}
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
enable_logging = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
# =============================================================================
# SNS - Alert Notifications
# =============================================================================
resource "aws_sns_topic" "security_alerts" {
name = "${var.project_name}-security-alerts"
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_sns_topic_subscription" "email_alert" {
topic_arn = aws_sns_topic.security_alerts.arn
protocol = "email"
endpoint = var.alert_email
}
resource "aws_sns_topic_policy" "security_alerts" {
arn = aws_sns_topic.security_alerts.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowEventBridgePublish"
Effect = "Allow"
Principal = {
Service = "events.amazonaws.com"
}
Action = "SNS:Publish"
Resource = aws_sns_topic.security_alerts.arn
}
]
})
}
# =============================================================================
# EventBridge - Route GuardDuty Findings to SNS
# =============================================================================
resource "aws_cloudwatch_event_rule" "guardduty_findings" {
name = "${var.project_name}-guardduty-findings"
description = "Capture all GuardDuty findings and route to SNS"
event_pattern = jsonencode({
source = ["aws.guardduty"]
detail-type = ["GuardDuty Finding"]
})
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_cloudwatch_event_target" "sns_target" {
rule = aws_cloudwatch_event_rule.guardduty_findings.name
target_id = "send-to-sns"
arn = aws_sns_topic.security_alerts.arn
input_transformer {
input_paths = {
severity = "$.detail.severity"
type = "$.detail.type"
description = "$.detail.description"
account = "$.detail.accountId"
region = "$.detail.region"
title = "$.detail.title"
}
input_template = "\"GuardDuty Finding in account <account> (<region>)\\nSeverity: <severity>\\nType: <type>\\nTitle: <title>\\nDescription: <description>\""
}
}
Here is the full outputs.tf file up to this point:
output "guardduty_detector_id" {
description = "The ID of the GuardDuty detector"
value = aws_guardduty_detector.main.id
}
output "cloudtrail_arn" {
description = "The ARN of the CloudTrail trail"
value = aws_cloudtrail.main.arn
}
output "cloudtrail_s3_bucket" {
description = "The S3 bucket storing CloudTrail logs"
value = aws_s3_bucket.cloudtrail_logs.id
}
output "sns_topic_arn" {
description = "The ARN of the SNS security alerts topic"
value = aws_sns_topic.security_alerts.arn
}
output "eventbridge_rule_arn" {
description = "The ARN of the EventBridge rule"
value = aws_cloudwatch_event_rule.guardduty_findings.arn
}
Your alert pipeline is now complete. When GuardDuty detects a threat, EventBridge captures the finding, formats it into a readable message, and publishes it to SNS which delivers it to your inbox. Next up, you will build a CloudWatch dashboard to visualize your security posture at a glance.
Build a CloudWatch Security Dashboard
Your alert pipeline is live. When GuardDuty detects suspicious activity, EventBridge routes a formatted notification straight to your inbox.
Detection and alerting are covered, but you still need a way to see your security posture at a glance. A CloudWatch dashboard gives you a visual overview of what's happening across your entire monitoring stack.
In this step, get ready to:
- Create a CloudWatch dashboard resource with security-focused widgets.
- Configure metric widgets that track GuardDuty findings and alert activity.
- Deploy the dashboard and view it in the AWS Console.
Create the dashboard resource
CloudWatch dashboards are JSON-defined layouts of widgets. Each widget can display a metric graph, a text block, or other visualizations.
- Add the CloudWatch Dashboard section to main.tf by pasting the following code at the bottom of the file, after your EventBridge resources:
# =============================================================================
# CloudWatch Dashboard - Security Observability
# =============================================================================
resource "aws_cloudwatch_dashboard" "security" {
dashboard_name = "${var.project_name}-dashboard"
dashboard_body = jsonencode({
widgets = [
{
type = "text"
x = 0
y = 0
width = 24
height = 2
properties = {
markdown = "# Security Monitoring Dashboard\nThis dashboard provides an overview of your AWS account security posture. GuardDuty findings indicate potential threats. CloudTrail tracks all API activity."
}
},
{
type = "metric"
x = 0
y = 2
width = 12
height = 6
properties = {
metrics = [
["AWS/Events", "TriggeredRules", "RuleName", "${var.project_name}-guardduty-findings"]
]
period = 300
stat = "Sum"
region = var.aws_region
title = "GuardDuty Findings (EventBridge Triggers)"
view = "timeSeries"
}
},
{
type = "metric"
x = 12
y = 2
width = 12
height = 6
properties = {
metrics = [
["AWS/SNS", "NumberOfMessagesPublished", "TopicName", "${var.project_name}-security-alerts"]
]
period = 300
stat = "Sum"
region = var.aws_region
title = "Security Alerts Sent (SNS)"
view = "timeSeries"
}
},
{
type = "metric"
x = 0
y = 8
width = 12
height = 6
properties = {
metrics = [
["AWS/S3", "NumberOfObjects", "BucketName", "${var.project_name}-cloudtrail-${data.aws_caller_identity.current.account_id}", "StorageType", "AllStorageTypes"]
]
period = 86400
stat = "Average"
region = var.aws_region
title = "CloudTrail Log Objects in S3"
view = "singleValue"
}
}
]
})
}
What does this code do?
The aws_cloudwatch_dashboard resource defines a dashboard as a JSON body containing an array of widgets. Each widget has coordinates (x, y) and dimensions (width, height) that control its position on the dashboard grid.
- The first widget is a text widget that renders a Markdown header describing the dashboard's purpose.
- The second widget tracks TriggeredRules from the AWS/Events namespace. This shows how often your EventBridge rule fires for GuardDuty findings.
- The third widget tracks NumberOfMessagesPublished from the AWS/SNS namespace. This shows how many alert emails were sent.
- The fourth widget tracks NumberOfObjects in your CloudTrail S3 bucket. This confirms logs are accumulating over time.
- Save main.tf.
Add a GuardDuty findings widget
The dashboard uses CloudWatch metrics that AWS services publish automatically. The GuardDuty findings widget deserves a closer look because it connects directly to the EventBridge rule you created in the previous step.
How do the metric widgets work?
Each metric widget references a namespace (like AWS/Events), a metric name (like TriggeredRules), and a dimension that filters the metric to a specific resource.
The GuardDuty widget uses RuleName as the dimension to filter for your specific EventBridge rule. Every time the rule fires for a GuardDuty finding, the metric increments. The period = 300 setting aggregates data in 5-minute intervals.
The S3 widget uses period = 86400 (24 hours) and singleValue view because object count is a daily metric that's best shown as a single number rather than a time series.
Now add an output so Terraform reports the dashboard ARN after deployment.
- Open outputs.tf and add the following output at the bottom of the file:
output "dashboard_arn" {
description = "The ARN of the CloudWatch security dashboard"
value = aws_cloudwatch_dashboard.security.dashboard_arn
}
What does this output do?
The dashboard_arn output prints the dashboard's Amazon Resource Name after each apply. You can use this ARN to reference the dashboard in other tools or share a direct link.
- Save outputs.tf.
Deploy and view the dashboard
With the dashboard resource and output defined, you can deploy everything and see your security dashboard in the AWS Console.
- Deploy the dashboard by running:
terraform apply -auto-approve
What should I see?
Terraform will show 1 resource to add (the dashboard). After the apply completes, you should see the dashboard_arn output printed alongside your existing outputs.
- Open the AWS Console in your browser.
- Navigate to the CloudWatch service.
- In the left navigation pane, click Dashboards.
- Click on security-monitor-dashboard to open your dashboard.
You should see a Markdown header at the top and three metric widgets below it. The graphs will be empty initially because no GuardDuty findings have triggered yet.
Don't see your dashboard?
- Make sure the terraform apply completed without errors. Check the output for any JSON formatting issues in the dashboard body.
- Verify you're looking at the correct AWS region in the console. Your dashboard is deployed to the region set in terraform.tfvars.
- If the dashboard appears but widgets show "No data available," that's expected. The metrics will populate once GuardDuty findings occur.
help me troubleshoot my CloudWatch dashboard not appearing after terraform apply
✔️ Awesome, I've got everything!
Great. Double check you've saved both main.tf and outputs.tf.
ⓧ I'd like to double check the full code
Here's the complete main.tf at this point:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.0"
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# =============================================================================
# GuardDuty - Threat Detection
# =============================================================================
resource "aws_guardduty_detector" "main" {
enable = true
finding_publishing_frequency = "FIFTEEN_MINUTES"
datasources {
s3_logs {
enable = true
}
}
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
# =============================================================================
# CloudTrail - Audit Logging
# =============================================================================
resource "aws_s3_bucket" "cloudtrail_logs" {
bucket = "${var.project_name}-cloudtrail-${data.aws_caller_identity.current.account_id}"
force_destroy = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_policy" "cloudtrail_logs" {
bucket = aws_s3_bucket.cloudtrail_logs.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSCloudTrailAclCheck"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.cloudtrail_logs.arn
Condition = {
StringEquals = {
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
},
{
Sid = "AWSCloudTrailWrite"
Effect = "Allow"
Principal = {
Service = "cloudtrail.amazonaws.com"
}
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
Condition = {
StringEquals = {
"s3:x-amz-acl" = "bucket-owner-full-control"
"aws:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-trail"
}
}
}
]
})
}
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
enable_logging = true
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
# =============================================================================
# SNS - Alert Notifications
# =============================================================================
resource "aws_sns_topic" "security_alerts" {
name = "${var.project_name}-security-alerts"
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_sns_topic_subscription" "email_alert" {
topic_arn = aws_sns_topic.security_alerts.arn
protocol = "email"
endpoint = var.alert_email
}
resource "aws_sns_topic_policy" "security_alerts" {
arn = aws_sns_topic.security_alerts.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowEventBridgePublish"
Effect = "Allow"
Principal = {
Service = "events.amazonaws.com"
}
Action = "SNS:Publish"
Resource = aws_sns_topic.security_alerts.arn
}
]
})
}
# =============================================================================
# EventBridge - Route GuardDuty Findings to SNS
# =============================================================================
resource "aws_cloudwatch_event_rule" "guardduty_findings" {
name = "${var.project_name}-guardduty-findings"
description = "Capture all GuardDuty findings and route to SNS"
event_pattern = jsonencode({
source = ["aws.guardduty"]
detail-type = ["GuardDuty Finding"]
})
tags = {
Project = var.project_name
ManagedBy = "terraform"
}
}
resource "aws_cloudwatch_event_target" "sns_target" {
rule = aws_cloudwatch_event_rule.guardduty_findings.name
target_id = "send-to-sns"
arn = aws_sns_topic.security_alerts.arn
input_transformer {
input_paths = {
severity = "$.detail.severity"
type = "$.detail.type"
description = "$.detail.description"
account = "$.detail.accountId"
region = "$.detail.region"
title = "$.detail.title"
}
input_template = "\"GuardDuty Finding in account <account> (<region>)\\nSeverity: <severity>\\nType: <type>\\nTitle: <title>\\nDescription: <description>\""
}
}
# =============================================================================
# CloudWatch Dashboard - Security Observability
# =============================================================================
resource "aws_cloudwatch_dashboard" "security" {
dashboard_name = "${var.project_name}-dashboard"
dashboard_body = jsonencode({
widgets = [
{
type = "text"
x = 0
y = 0
width = 24
height = 2
properties = {
markdown = "# Security Monitoring Dashboard\nThis dashboard provides an overview of your AWS account security posture. GuardDuty findings indicate potential threats. CloudTrail tracks all API activity."
}
},
{
type = "metric"
x = 0
y = 2
width = 12
height = 6
properties = {
metrics = [
["AWS/Events", "TriggeredRules", "RuleName", "${var.project_name}-guardduty-findings"]
]
period = 300
stat = "Sum"
region = var.aws_region
title = "GuardDuty Findings (EventBridge Triggers)"
view = "timeSeries"
}
},
{
type = "metric"
x = 12
y = 2
width = 12
height = 6
properties = {
metrics = [
["AWS/SNS", "NumberOfMessagesPublished", "TopicName", "${var.project_name}-security-alerts"]
]
period = 300
stat = "Sum"
region = var.aws_region
title = "Security Alerts Sent (SNS)"
view = "timeSeries"
}
},
{
type = "metric"
x = 0
y = 8
width = 12
height = 6
properties = {
metrics = [
["AWS/S3", "NumberOfObjects", "BucketName", "${var.project_name}-cloudtrail-${data.aws_caller_identity.current.account_id}", "StorageType", "AllStorageTypes"]
]
period = 86400
stat = "Average"
region = var.aws_region
title = "CloudTrail Log Objects in S3"
view = "singleValue"
}
}
]
})
}
And here's the complete outputs.tf:
output "guardduty_detector_id" {
description = "The ID of the GuardDuty detector"
value = aws_guardduty_detector.main.id
}
output "cloudtrail_arn" {
description = "The ARN of the CloudTrail trail"
value = aws_cloudtrail.main.arn
}
output "cloudtrail_s3_bucket" {
description = "The S3 bucket storing CloudTrail logs"
value = aws_s3_bucket.cloudtrail_logs.id
}
output "sns_topic_arn" {
description = "The ARN of the SNS security alerts topic"
value = aws_sns_topic.security_alerts.arn
}
output "eventbridge_rule_arn" {
description = "The ARN of the EventBridge rule"
value = aws_cloudwatch_event_rule.guardduty_findings.arn
}
output "dashboard_arn" {
description = "The ARN of the CloudWatch security dashboard"
value = aws_cloudwatch_dashboard.security.dashboard_arn
}
Secret mission
Filter Alerts by Severity
Your alert pipeline currently fires on every GuardDuty finding, regardless of severity. In a real environment, low-priority informational findings would flood your inbox and cause alert fatigue. Can you add severity-based filtering so only the findings that matter reach your email?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS services that are free during the GuardDuty 30-day trial, but may incur charges after the trial ends.
Cost warning
GuardDuty offers a 30-day free trial per region. After the trial ends, GuardDuty charges are based on the volume of CloudTrail events and VPC Flow Logs analyzed. For a personal account with minimal activity, this is typically a few dollars per month.
CloudTrail, SNS, EventBridge, and CloudWatch dashboards (up to 3) have no cost at this scale. If you are done with the project, run terraform destroy to avoid any charges after the trial.
Resources you used:
- AWS GuardDuty detector (threat detection).
- AWS CloudTrail trail and S3 log bucket (audit logging).
- AWS SNS topic and email subscription (alert delivery).
- AWS EventBridge rule and target (event routing).
- AWS CloudWatch dashboard (security observability).
- Local Terraform project files (main.tf, variables.tf, outputs.tf, terraform.tfvars, .terraform directory, and state files).
Keep everything running
No action needed. Choose this if you are still actively building or want to continue monitoring your account.
- Your GuardDuty detector will continue monitoring your account for threats.
- CloudTrail will keep logging API calls to your S3 bucket.
- After 30 days, GuardDuty charges begin based on your account activity volume.
- Terraform and AWS CLI remain installed on your machine for future projects.
Pause - I'll come back to this later
Disable GuardDuty detection to stop charges while keeping your infrastructure definition intact. You can re-enable it later by flipping a single value.
- Open main.tf and find the aws_guardduty_detector resource.
- Change enable = true to enable = false.
- Run terraform apply to apply the change.
- The detector remains configured but stops analyzing logs. No GuardDuty charges accrue while disabled.
- To resume monitoring later, set enable = true and run terraform apply again.
Delete - I don't want to use this again
Remove all AWS resources and local project files. This is the safest option if you are done with the project.
- Navigate to your project folder in the terminal.
- Destroy all AWS resources by running:
terraform destroy
- Terraform will show you a plan of all resources it will delete. Type yes to confirm.
- Remove the local project folder by running:
cd ~/Desktop
rm -rf security-monitor
Windows users
If you are on Windows, use this command instead to remove the project folder:
cd ~\Desktop
rmdir /s /q security-monitor
- (Optional) Uninstall Terraform by running:
brew uninstall hashicorp/tap/terraform
- (Optional) Uninstall AWS CLI by running:
sudo rm /usr/local/bin/aws
sudo rm -rf /usr/local/aws-cli
- (Optional) Remove your AWS credentials file if you no longer need it:
rm -rf ~/.aws
That's a Wrap!
That's a Wrap!
Nice work! You just built a comprehensive security monitoring system on AWS, defined entirely as Terraform code.
You've learned how to:
- Deployed GuardDuty threat detection and connected it to EventBridge for automated alert routing to your email via SNS.
- Enabled CloudTrail audit logging to capture every API call across your AWS account for forensic investigation.
- Built a CloudWatch dashboard to visualize your security posture and managed the entire infrastructure lifecycle with Infrastructure as Code.
- Secret Mission: Added severity-based filtering to your EventBridge rule so only high-priority findings trigger alerts.
Ready to quiz yourself?