AWS Cost Monitoring with Terraform

Deploy a cost monitoring system with CloudWatch alarms and SNS alerts.

Introduction

30 Second Summary

Nothing ruins a weekend like an unexpected cloud bill. Costs can creep up fast without proper monitoring in place.

In this project, you will use Terraform to define a complete AWS cost monitoring system as Infrastructure as Code. The system watches your spending and sends you email alerts whenever costs cross your budget thresholds.

What You'll Build

Eight Terraform configuration files that deploy 11 AWS resources for cost monitoring across four services: SNS, CloudWatch, S3, and Cost and Usage Reports.

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

  • Three CloudWatch billing alarms that send email notifications through SNS when your spending hits 50%, 80%, and 100% of your monthly budget.
  • A CloudWatch dashboard visualizing your estimated AWS charges over time.
  • An S3 bucket receiving daily Cost and Usage Reports with detailed billing data.
  • Secret Mission: Extend the system with AWS Budget forecast alerts and per-service billing alarms.

Are there any prerequisites?

You need an AWS account with root access (billing alerts require root-level permissions), the Terraform CLI, and the AWS CLI configured with credentials.

Before We Start

Before We Start...

Set Up the Project and Enable Billing Alerts

AWS doesn't publish billing data to CloudWatch by default. Without flipping that switch first, any billing alarms you create will sit idle with no data to watch.

In this step, you'll enable that setting in the AWS Console, then create your Terraform project with all the configuration files that define your provider and variables.

In this step, get ready to:
  • Enable CloudWatch billing alerts in the AWS Console.
  • Create the Terraform project directory and provider configuration.
  • Define project variables and initialize Terraform.
Enable CloudWatch billing alerts

This is a one-time account-level setting that cannot be managed through Terraform. You need to turn it on manually before any billing alarms will work.

  • Open the AWS Billing and Cost Management console by navigating to this URL in your browser:
https://console.aws.amazon.com/costmanagement/
  • Sign in with your root account credentials if prompted.
  • In the left sidebar, click Billing Preferences.
  • Under Alert preferences, click Edit.
  • Select the Receive CloudWatch Billing Alerts checkbox.
  • Click Save preferences.

You should see a confirmation banner at the top of the page indicating your preferences have been saved.

Why do I need to enable this manually?

AWS treats billing metric publishing as an account-level preference, not an infrastructure resource. Terraform can create alarms that read billing data, but it cannot toggle the setting that makes AWS publish that data in the first place.

After you enable it, billing metric data takes approximately 15 minutes to start appearing in CloudWatch.

Don't see the Alert preferences section?

Billing preferences require root account access. If you're signed in with an IAM user, sign out and sign back in with the root account email and password.

help me find the billing alert preferences in the AWS Console

Create the project directory and provider configuration

Every Terraform project starts with a provider configuration that tells Terraform which cloud platform to work with and which region to target.

  • 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.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir aws-cost-monitor
cd aws-cost-monitor
  • Confirm the folder exists by running:
ls

You should see an empty output since the folder is brand new.

Now open the aws-cost-monitor folder in VS Code (or your preferred text editor).

  • Create a new file called main.tf and add the following code:
# Pin Terraform and provider versions for reproducibility
terraform {
  required_version = ">= 1.5.0"

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

# All billing resources must live in us-east-1
provider "aws" {
  region = var.aws_region
}

# Fetch the current AWS account ID for resource naming
data "aws_caller_identity" "current" {}

What does this code do?

  • The terraform block locks the project to Terraform version 1.5.0 or higher and pins the AWS provider to the 6.x line. This prevents unexpected breaking changes.
  • The provider block tells Terraform to target the AWS region stored in var.aws_region (which you'll define in the next file).
  • The data "aws_caller_identity" block looks up your AWS account ID at runtime. You'll use this later to build globally unique resource names.

Why us-east-1?

AWS stores all billing metric data in us-east-1 regardless of where your other resources live. CloudWatch billing alarms, the Cost and Usage Report service, and billing dashboards all need to target this region.

  • Save main.tf.

Seeing red underlines in your editor?

Your editor might flag var.aws_region as undefined. That's expected because the variable definition file doesn't exist yet. You'll create it in the next substep.

help me fix errors in my main.tf file

✔️ 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_version = ">= 1.5.0"

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

provider "aws" {
  region = var.aws_region
}

data "aws_caller_identity" "current" {}
Create the variables and tfvars files

Terraform uses variables to keep configuration flexible. You define the variable schema in one file and set the actual values in another.

  • Create a new file called variables.tf and add the following code:
# Region for all resources (billing data only lives in us-east-1)
variable "aws_region" {
  description = "AWS region (must be us-east-1 for billing resources)"
  type        = string
  default     = "us-east-1"
}

# Where billing alert emails get sent
variable "alert_email" {
  description = "Email address to receive billing alerts"
  type        = string
}

# Dollar amount that defines your alarm thresholds
variable "monthly_budget" {
  description = "Monthly spending budget in USD"
  type        = number
  default     = 10
}

# Prefix used in every resource name for easy identification
variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "cost-monitor"
}

What does each variable control?

  • aws_region defaults to us-east-1 because that's the only region where AWS publishes billing metrics.
  • alert_email has no default. Terraform will prompt you for it if you don't provide a value in terraform.tfvars.
  • monthly_budget sets the dollar threshold for your alarms. A $10 default is a safe starting point for free tier usage.
  • project_name prefixes every resource name so you can identify them at a glance in the AWS Console.
  • Save variables.tf.

Now you need a terraform.tfvars file to set the actual values for any variables that don't have defaults (or that you want to override).

  • Create a new file called terraform.tfvars and add the following code:
# Your email for billing alert notifications
alert_email    = "[[ALERT_EMAIL=\"your-email@example.com\"]]"
# Monthly budget threshold in USD (adjust to your preference)
monthly_budget = 10

What does this file do?

Terraform automatically loads any file named terraform.tfvars when you run commands. It fills in the variable values defined in variables.tf.

Replace the placeholder email with the address where you want to receive billing alerts. Make sure it's an inbox you can access, because you'll need to confirm a subscription later.

  • Save terraform.tfvars.

Not sure which email to use?

Use a personal email you check regularly. AWS will send a subscription confirmation email that you must click to activate billing alerts. Work email addresses behind strict spam filters may block it.

help me choose the right email for my billing alerts

✔️ Awesome, I've got everything!

Great. Make sure both variables.tf and terraform.tfvars are saved before continuing.

ⓧ I'd like to double check the full code

variable "aws_region" {
  description = "AWS region (must be us-east-1 for billing resources)"
  type        = string
  default     = "us-east-1"
}

variable "alert_email" {
  description = "Email address to receive billing alerts"
  type        = string
}

variable "monthly_budget" {
  description = "Monthly spending budget in USD"
  type        = number
  default     = 10
}

variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "cost-monitor"
}
alert_email    = "your-email@example.com"
monthly_budget = 10

With all three files in place, you can initialize the Terraform project. This downloads the AWS provider plugin so Terraform knows how to talk to AWS.

  • Initialize your Terraform project by running this command in your terminal (make sure you're inside the aws-cost-monitor folder):
terraform init

You should see output ending with Terraform has been successfully initialized! along with a note about the installed AWS provider version.

What did terraform init do?

Terraform read the required_providers block in main.tf and downloaded the hashicorp/aws provider plugin into a hidden .terraform directory. It also created a .terraform.lock.hcl file that locks the exact provider version for reproducibility.

Seeing an error during init?

If you see Error: Failed to query available provider packages, check that your machine has an active internet connection. Terraform needs to download the AWS provider from the Terraform Registry.

If you see a syntax error, double check that your main.tf file matches the code above exactly. Missing braces or quotes are common culprits.

help me fix my terraform init error

Your Terraform project is initialized and ready to build on. Next up, you'll create an SNS topic that delivers billing alert emails to your inbox.

Create the SNS Notification Topic

Your Terraform project is initialized and ready to go. Now it's time to build the notification system that delivers billing alerts straight to your inbox.

You'll create an SNS topic as the central messaging channel for your cost monitoring system. CloudWatch billing alarms need somewhere to send their alerts, and SNS connects them to your email.

In this step, get ready to:
  • Create an SNS topic and attach a policy that lets CloudWatch publish to it.
  • Subscribe your email address to receive billing notifications.
  • Preview the planned resources with terraform plan.
Create the SNS topic and access policy

An SNS topic acts like a mailbox that other AWS services can drop messages into. Before CloudWatch can send billing alerts to this topic, you need an access policy that explicitly grants it permission.

  • Create a new file called sns.tf inside your aws-cost-monitor folder.
  • Add the SNS topic and its access policy by pasting the following code:
# SNS topic that receives billing alert messages
resource "aws_sns_topic" "billing_alerts" {
  name = "${var.project_name}-billing-alerts"

  tags = {
    Project = var.project_name
  }
}

# Policy that allows CloudWatch to publish alarm notifications to this topic
resource "aws_sns_topic_policy" "billing_alerts" {
  arn = aws_sns_topic.billing_alerts.arn

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudWatchAlarms"
        Effect = "Allow"
        Principal = {
          Service = "cloudwatch.amazonaws.com"
        }
        Action   = "SNS:Publish"
        Resource = aws_sns_topic.billing_alerts.arn
      }
    ]
  })
}

What does this code do?

  • aws_sns_topic creates a named topic using your project_name variable. This is the central channel that all billing alerts flow through.
  • aws_sns_topic_policy uses jsonencode() to build an IAM-style policy document. The policy grants the cloudwatch.amazonaws.com service principal permission to publish messages to the topic.
  • Without this policy, CloudWatch alarms would fire but have no way to deliver notifications.
  • Save sns.tf.

You won't see a change yet. The next chunk adds the email subscription, then you'll verify everything with terraform plan.

Seeing red squiggles in your editor?

If your editor highlights HCL syntax errors, check that every opening brace { has a matching closing brace }. The jsonencode() block has several layers of nesting, so a missing brace is easy to miss.

Help me fix a syntax error in my sns.tf file

Add the email subscription

The topic exists, but nobody is listening to it yet. An SNS subscription connects the topic to a delivery endpoint. In this case, the endpoint is your email address.

  • In sns.tf, add the email subscription resource below the topic policy by pasting this code at the bottom of the file:
# Email subscription that delivers billing alerts to your inbox
resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.billing_alerts.arn
  protocol  = "email"
  endpoint  = var.alert_email
}

What does this code do?

  • topic_arn points the subscription at the billing alerts topic you just created.
  • protocol = "email" tells SNS to deliver messages as emails.
  • endpoint reads the email address from your alert_email variable (the one you set in terraform.tfvars).

Email subscriptions need manual confirmation

When you eventually run terraform apply (in a later step), AWS sends a confirmation email to the address you provided. You must click the confirmation link in that email before any alerts can be delivered.

Until you confirm, the subscription stays in PendingConfirmation status. Terraform cannot automate this step because it requires human action in your inbox.

  • Save sns.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved sns.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete sns.tf file. Make sure your code matches this exactly.

resource "aws_sns_topic" "billing_alerts" {
  name = "${var.project_name}-billing-alerts"

  tags = {
    Project = var.project_name
  }
}

resource "aws_sns_topic_policy" "billing_alerts" {
  arn = aws_sns_topic.billing_alerts.arn

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudWatchAlarms"
        Effect = "Allow"
        Principal = {
          Service = "cloudwatch.amazonaws.com"
        }
        Action   = "SNS:Publish"
        Resource = aws_sns_topic.billing_alerts.arn
      }
    ]
  })
}

resource "aws_sns_topic_subscription" "email" {
  topic_arn = aws_sns_topic.billing_alerts.arn
  protocol  = "email"
  endpoint  = var.alert_email
}
Run terraform plan to preview

Before creating any real resources, terraform plan shows you exactly what Terraform intends to do. This is your chance to catch mistakes before they reach AWS.

  • Preview the planned resources by running this command in your terminal:
terraform plan

You should see output ending with Plan: 3 to add, 0 to change, 0 to destroy.

What should I see?

The plan output lists the three resources Terraform will create:

  • aws_sns_topic.billing_alerts is the messaging channel.
  • aws_sns_topic_policy.billing_alerts is the access policy for CloudWatch.
  • aws_sns_topic_subscription.email is your email subscription.

Check that the topic name includes your project name, and that the email endpoint matches the address you entered in terraform.tfvars.

Seeing an error instead of a plan?

  • If you see Error: No valid credential sources found, your AWS CLI credentials are not configured. Run aws configure and enter your access key, secret key, and region.
  • If you see a syntax error, make sure every opening brace { in sns.tf has a matching closing brace }. Use the double-check tab above to compare your code.
  • If you see Error: Could not load plugin, you may need to re-run terraform init to download the AWS provider again.

Help me debug my terraform plan error for SNS resources

Your notification pipeline is planned and ready to deploy. Next up, you'll create CloudWatch billing alarms that use this SNS topic to send alerts when your spending crosses key thresholds.

Create CloudWatch Billing Alarms

Your SNS notification topic is ready to deliver alerts. Now you need something to actually trigger those alerts when your AWS spending climbs.

In this step, you'll create three CloudWatch billing alarms. Each alarm monitors your estimated charges and fires a notification through your SNS topic when spending crosses a threshold.

In this step, get ready to:
  • Define three alarm threshold levels using Terraform locals.
  • Create CloudWatch billing alarms with for_each.
  • Run terraform plan to verify the alarm configuration.
Define alarm thresholds with locals

Terraform's locals block lets you define computed values that you reference throughout your configuration. Instead of writing three nearly identical alarm resources by hand, you'll define a map of threshold configurations that Terraform can loop over.

  • Create a new file called alarms.tf in your aws-cost-monitor folder and add the following code:
# Define threshold levels as a map for use with for_each
locals {
  billing_alarms = {
    # 50% of monthly budget triggers a warning
    warning = {
      threshold   = var.monthly_budget * 0.5
      description = "Estimated charges exceeded 50% of $${var.monthly_budget} monthly budget"
    }
    # 80% of monthly budget triggers an alert
    alert = {
      threshold   = var.monthly_budget * 0.8
      description = "Estimated charges exceeded 80% of $${var.monthly_budget} monthly budget"
    }
    # 100% of monthly budget triggers a critical alarm
    critical = {
      threshold   = var.monthly_budget
      description = "Estimated charges reached 100% of $${var.monthly_budget} monthly budget"
    }
  }
}

What does this code do?

The billing_alarms map defines three escalating alarm levels: warning at 50%, alert at 80%, and critical at 100% of your monthly budget.

Each threshold value is computed from var.monthly_budget, so changing your budget in terraform.tfvars automatically adjusts all three alarms. The $$ in the description string is how HCL produces a literal $ character.

Why for_each with a map instead of count?

Using a map with for_each produces named resources like aws_cloudwatch_metric_alarm.billing["warning"] instead of index-based ones like aws_cloudwatch_metric_alarm.billing[0].

This matters when you add or remove an alarm later. With count, removing item 0 would shift every other resource index and force Terraform to destroy and recreate them. With named keys, each resource is independent.

  • Save alarms.tf.

You won't see a change yet. The next chunk adds the alarm resource that uses these thresholds.

Create the billing alarm resource

AWS publishes the EstimatedCharges billing metric approximately every 6 hours to the AWS/Billing namespace in us-east-1. Your alarm resource will watch this metric and compare it against each threshold.

  • In alarms.tf, add the following resource below the locals block:
resource "aws_cloudwatch_metric_alarm" "billing" {
  # Create one alarm for each entry in the billing_alarms map
  for_each = local.billing_alarms

  alarm_name          = "${var.project_name}-${each.key}"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  # Monitor AWS billing estimates
  metric_name         = "EstimatedCharges"
  namespace           = "AWS/Billing"
  # Billing data updates every 6 hours (21600 seconds)
  period              = 21600
  statistic           = "Maximum"
  threshold           = each.value.threshold
  alarm_description   = each.value.description
  # Send notification to the SNS topic when alarm triggers
  alarm_actions       = [aws_sns_topic.billing_alerts.arn]
  # Avoid false alarms during normal gaps between billing data updates
  treat_missing_data  = "missing"

  dimensions = {
    Currency = "USD"
  }

  tags = {
    Project = var.project_name
    Level   = each.key
  }
}

What does this code do?

  • for_each = local.billing_alarms tells Terraform to create one alarm for each key in the map (warning, alert, critical).
  • each.key is the map key (e.g. "warning") and each.value.threshold is the computed dollar amount for that level.
  • period = 21600 sets the evaluation window to 6 hours, matching how often AWS publishes billing data.
  • treat_missing_data = "missing" prevents false alarms during normal 6-hour gaps between billing data updates.
  • alarm_actions points to your SNS topic so each alarm triggers an email notification.
  • Save alarms.tf.

You'll verify everything looks correct by running terraform plan in the next substep.

✔️ Awesome, I've got everything!

Great. Double check you've saved alarms.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete alarms.tf file for comparison.

locals {
  billing_alarms = {
    warning = {
      threshold   = var.monthly_budget * 0.5
      description = "Estimated charges exceeded 50% of $${var.monthly_budget} monthly budget"
    }
    alert = {
      threshold   = var.monthly_budget * 0.8
      description = "Estimated charges exceeded 80% of $${var.monthly_budget} monthly budget"
    }
    critical = {
      threshold   = var.monthly_budget
      description = "Estimated charges reached 100% of $${var.monthly_budget} monthly budget"
    }
  }
}

resource "aws_cloudwatch_metric_alarm" "billing" {
  for_each = local.billing_alarms

  alarm_name          = "${var.project_name}-${each.key}"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "EstimatedCharges"
  namespace           = "AWS/Billing"
  period              = 21600
  statistic           = "Maximum"
  threshold           = each.value.threshold
  alarm_description   = each.value.description
  alarm_actions       = [aws_sns_topic.billing_alerts.arn]
  treat_missing_data  = "missing"

  dimensions = {
    Currency = "USD"
  }

  tags = {
    Project = var.project_name
    Level   = each.key
  }
}
Run terraform plan to verify

Before deploying anything, you should preview the resources Terraform will create. The terraform plan command shows exactly what will be added, changed, or destroyed.

  • Preview the alarm resources by running this command in your terminal:
terraform plan
  • Check the plan output. You should see 6 to add in the summary line: 3 SNS resources from the previous step, plus 3 new CloudWatch alarm resources.

What should I see?

The plan output lists each resource Terraform will create. Look for three alarm entries with names matching your threshold keys:

  • aws_cloudwatch_metric_alarm.billing["warning"] with a threshold at 50% of your budget.
  • aws_cloudwatch_metric_alarm.billing["alert"] with a threshold at 80% of your budget.
  • aws_cloudwatch_metric_alarm.billing["critical"] with a threshold at 100% of your budget.

If your monthly_budget is set to 10, the threshold values should be 5, 8, and 10.

Seeing an error in the plan output?

  • If you see a syntax error, check that every opening brace { in alarms.tf has a matching closing brace }. The nested maps make it easy to miss one.
  • If you see Reference to undeclared resource for aws_sns_topic.billing_alerts, make sure your sns.tf file is saved in the same aws-cost-monitor folder.
  • If you see a different number of resources to add, compare your alarms.tf against the full code in the double-check tab above.

Still stuck? Help me debug my terraform plan error for CloudWatch billing alarms.

Your billing alarms are configured and ready to deploy. Next up, you'll build a CloudWatch dashboard that visualizes your spending trends so you can see your charges at a glance.

Build the CloudWatch Billing Dashboard

Your billing alarms are configured and ready to trigger notifications through SNS when spending crosses your thresholds.

Numbers are useful, but a visual dashboard makes it much easier to spot spending trends at a glance. In this step, you'll create a CloudWatch dashboard that charts your estimated charges over time and breaks them down by service.

In this step, get ready to:
  • Create a CloudWatch dashboard with a total charges widget.
  • Add per-service charge tracking and an informational text panel.
Create the dashboard with a total charges widget

CloudWatch dashboards are customizable pages of widgets that visualize metrics from your AWS resources. You'll use Terraform's jsonencode() function to define the widget layout as structured HCL that gets converted to the JSON format CloudWatch expects.

  • Create a new file called dashboard.tf in your aws-cost-monitor folder and add the following code:
# Define a CloudWatch dashboard for billing visualization
resource "aws_cloudwatch_dashboard" "billing" {
  dashboard_name = "${var.project_name}-billing"

  # Dashboard layout is defined as JSON using Terraform's jsonencode function
  dashboard_body = jsonencode({
    widgets = [
      {
        # Widget 1: Total estimated charges over time
        type   = "metric"
        x      = 0
        y      = 0
        width  = 12
        height = 6
        properties = {
          title   = "Total Estimated Charges (USD)"
          metrics = [
            ["AWS/Billing", "EstimatedCharges", "Currency", "USD"]
          ]
          view   = "timeSeries"
          period = 21600
          stat   = "Maximum"
          region = "us-east-1"
          yAxis = {
            left = { min = 0, label = "USD" }
          }
        }
      }
    ]
  })
}

What does this code do?

The aws_cloudwatch_dashboard resource creates a named dashboard in CloudWatch. The dashboard_body argument expects a JSON string, so you wrap the entire widget definition in jsonencode() to let Terraform handle the conversion.

The first widget is a time series chart that plots the EstimatedCharges metric from the AWS/Billing namespace. The period of 21600 seconds (6 hours) matches how often AWS publishes billing data.

  • Save dashboard.tf.
  • Verify the syntax is valid by running:
terraform validate

You should see Success! The configuration is valid.

Seeing a validation error?

  • Check that every opening brace { and bracket [ has a matching closing pair. The nested structure of jsonencode() makes it easy to miss one.
  • Make sure the metrics value is a list inside a list (double brackets [[ ]]). The outer list holds all metric definitions. The inner list holds one metric's parameters.
  • Help me debug my dashboard.tf validation error
Add a per-service charges widget and info text

The total charges widget gives you the big picture. Now you'll add a widget that breaks down charges by individual AWS service and a text widget that documents your alarm thresholds.

  • In dashboard.tf, find this closing section at the end of the first widget:
      }
    ]
  })
}
  • Replace that closing section with the following code, which adds a comma after the first widget and includes two new widgets before closing the resource:
      },
      {
        # Widget 2: Charges broken down by individual AWS service
        type   = "metric"
        x      = 12
        y      = 0
        width  = 12
        height = 6
        properties = {
          title = "Estimated Charges by Service"
          metrics = [
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonEC2", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonS3", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonRDS", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AWSLambda", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonCloudWatch", "Currency", "USD"]
          ]
          view   = "timeSeries"
          period = 21600
          stat   = "Maximum"
          region = "us-east-1"
          yAxis = {
            left = { min = 0, label = "USD" }
          }
        }
      },
      {
        # Widget 3: Text panel showing alarm thresholds and billing data notes
        type   = "text"
        x      = 0
        y      = 6
        width  = 24
        height = 3
        properties = {
          markdown = "## Billing Alarm Thresholds\n- **Warning**: $${var.monthly_budget * 0.5} USD (50% of budget)\n- **Alert**: $${var.monthly_budget * 0.8} USD (80% of budget)\n- **Critical**: $${var.monthly_budget} USD (100% of budget)\n\n*Billing data updates approximately every 6 hours. All billing metrics are stored in us-east-1.*"
        }
      }
    ]
  })
}

What do these widgets do?

The second widget tracks charges for five common AWS services: EC2, S3, RDS, Lambda, and CloudWatch. Each service gets its own line on the chart by using the ServiceName dimension. This helps you spot which service is driving your spending.

The third widget is a text panel that renders Markdown. It displays your alarm threshold values so anyone viewing the dashboard can see the budget at a glance. The $$ in HCL produces a literal $ character, while ${...} triggers Terraform interpolation.

How much does this dashboard cost?

The CloudWatch free tier includes 3 custom dashboards with up to 50 metrics each per month. This project uses just 1 dashboard. Additional dashboards beyond the free tier cost $3/month each.

  • Save dashboard.tf.
  • Verify the updated file by running:
terraform validate

You should see Success! The configuration is valid.

  • Preview the dashboard resource by running:
terraform plan

You should see 1 resource to add for the aws_cloudwatch_dashboard.billing resource, along with the 6 resources from your earlier files (3 SNS, 3 alarms).

Seeing unexpected changes in the plan?

  • If you see more than 7 resources to add, check that you did not accidentally modify sns.tf or alarms.tf while working on the dashboard.
  • If the plan shows an error about dashboard_body, double check that the jsonencode() closing brackets match: ] closes the widgets array, }) closes jsonencode, and the final } closes the resource.
  • Help me fix my terraform plan output for the dashboard

✔️ Awesome, I've got everything!

Great. Double check you've saved dashboard.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete dashboard.tf file. Compare it against yours line by line.

resource "aws_cloudwatch_dashboard" "billing" {
  dashboard_name = "${var.project_name}-billing"

  dashboard_body = jsonencode({
    widgets = [
      {
        type   = "metric"
        x      = 0
        y      = 0
        width  = 12
        height = 6
        properties = {
          title   = "Total Estimated Charges (USD)"
          metrics = [
            ["AWS/Billing", "EstimatedCharges", "Currency", "USD"]
          ]
          view   = "timeSeries"
          period = 21600
          stat   = "Maximum"
          region = "us-east-1"
          yAxis = {
            left = { min = 0, label = "USD" }
          }
        }
      },
      {
        type   = "metric"
        x      = 12
        y      = 0
        width  = 12
        height = 6
        properties = {
          title = "Estimated Charges by Service"
          metrics = [
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonEC2", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonS3", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonRDS", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AWSLambda", "Currency", "USD"],
            ["AWS/Billing", "EstimatedCharges", "ServiceName", "AmazonCloudWatch", "Currency", "USD"]
          ]
          view   = "timeSeries"
          period = 21600
          stat   = "Maximum"
          region = "us-east-1"
          yAxis = {
            left = { min = 0, label = "USD" }
          }
        }
      },
      {
        type   = "text"
        x      = 0
        y      = 6
        width  = 24
        height = 3
        properties = {
          markdown = "## Billing Alarm Thresholds\n- **Warning**: $${var.monthly_budget * 0.5} USD (50% of budget)\n- **Alert**: $${var.monthly_budget * 0.8} USD (80% of budget)\n- **Critical**: $${var.monthly_budget} USD (100% of budget)\n\n*Billing data updates approximately every 6 hours. All billing metrics are stored in us-east-1.*"
        }
      }
    ]
  })
}

Your dashboard is ready to visualize billing data as soon as it's deployed. Next up, you'll configure an S3 bucket and a Cost and Usage Report to capture detailed billing data for long-term analysis.

Configure S3 and Cost Usage Reports

Your CloudWatch dashboard is ready to visualize your spending trends. The next piece of your cost monitoring system is long-term billing data storage.

AWS can generate detailed Cost and Usage Reports (CUR) that break down every charge on your account. In this step, you will create an S3 bucket to receive those reports and configure a CUR definition that delivers daily billing data.

In this step, get ready to:
  • Create a locked-down S3 bucket with all public access blocked.
  • Add a bucket policy that authorizes the CUR service to deliver reports.
  • Define a Cost and Usage Report that exports daily billing data to S3.
Create the S3 bucket with security settings

Cost and Usage Reports need a destination to land. You will create an S3 bucket and immediately lock down all public access so billing data stays private.

Why include the account ID in the bucket name?

S3 bucket names must be globally unique across all AWS accounts. Including your account ID in the name guarantees no one else has already claimed it.

  • Create a new file called cur.tf inside your aws-cost-monitor folder.
  • Add the S3 bucket and public access block by pasting the following code into cur.tf:
# S3 bucket for storing Cost and Usage Report data
resource "aws_s3_bucket" "cur_reports" {
  # Use account ID in the name to ensure global uniqueness
  bucket = "${var.project_name}-cur-${data.aws_caller_identity.current.account_id}"

  tags = {
    Project = var.project_name
    Purpose = "cost-usage-reports"
  }
}

# Block all public access to the CUR bucket
resource "aws_s3_bucket_public_access_block" "cur_reports" {
  bucket = aws_s3_bucket.cur_reports.id

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

What does this code do?

The aws_s3_bucket resource creates a bucket named with your project name and AWS account ID. The data.aws_caller_identity.current.account_id reference pulls your account ID from the data source you defined in main.tf.

The aws_s3_bucket_public_access_block resource sets all four public access toggles to true. This ensures no one can accidentally expose your billing data to the internet.

  • Save cur.tf.
  • Confirm the syntax is valid by running this command:
terraform validate

You should see Success! The configuration is valid.

Seeing a validation error?

  • Check that the opening and closing braces match for both resource blocks.
  • Make sure the bucket reference in the public access block uses aws_s3_bucket.cur_reports.id with the correct resource name.
  • Verify that data "aws_caller_identity" "current" {} exists in your main.tf file.

Help me fix my cur.tf validation error

Add the bucket policy for CUR delivery

AWS needs explicit permission to write billing reports into your bucket. You will add a bucket policy that grants the billingreports.amazonaws.com service principal read and write access, scoped tightly to your own account.

  • In cur.tf, add the bucket policy below the public access block you just created:
# Grant the CUR service permission to deliver reports to this bucket
resource "aws_s3_bucket_policy" "cur_reports" {
  bucket = aws_s3_bucket.cur_reports.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCURRead"
        Effect = "Allow"
        Principal = {
          Service = "billingreports.amazonaws.com"
        }
        # Read permissions let AWS verify bucket ownership
        Action = [
          "s3:GetBucketAcl",
          "s3:GetBucketPolicy"
        ]
        Resource = aws_s3_bucket.cur_reports.arn
        Condition = {
          StringEquals = {
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id
            "aws:SourceArn"     = "arn:aws:cur:us-east-1:${data.aws_caller_identity.current.account_id}:definition/*"
          }
        }
      },
      {
        Sid    = "AllowCURWrite"
        Effect = "Allow"
        Principal = {
          Service = "billingreports.amazonaws.com"
        }
        # Write permission lets AWS place report files in the bucket
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.cur_reports.arn}/*"
        Condition = {
          StringEquals = {
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id
            "aws:SourceArn"     = "arn:aws:cur:us-east-1:${data.aws_caller_identity.current.account_id}:definition/*"
          }
        }
      }
    ]
  })

  # Ensure the public access block is applied before adding the policy
  depends_on = [aws_s3_bucket_public_access_block.cur_reports]
}

What does this policy do?

The policy has two statements. AllowCURRead lets the billing reports service check your bucket's ACL and policy to verify ownership. AllowCURWrite lets it place report files into the bucket.

The Condition block on each statement is critical. It restricts access to only your AWS account ID and CUR definition ARN. Without these conditions, any account could potentially write to your bucket.

The depends_on ensures the public access block is in place before the policy is applied. This avoids a race condition during deployment.

  • Save cur.tf.

You won't see a change yet. The next chunk creates the CUR report definition that uses this bucket and policy.

Getting a JSON encoding error?

  • Make sure every opening brace { inside the jsonencode() call has a matching closing brace }.
  • Check that the two Statement blocks are separated by a comma inside the list.
  • Verify the Resource in AllowCURWrite ends with /* to grant access to objects inside the bucket, not just the bucket itself.

Help me fix my S3 bucket policy in Terraform

Create the Cost and Usage Report definition

The bucket and policy are ready. Now you will tell AWS to actually generate billing reports and deliver them to your bucket every day.

  • In cur.tf, add the CUR report definition below the bucket policy:
# Define a daily Cost and Usage Report delivered to the S3 bucket
resource "aws_cur_report_definition" "daily" {
  report_name                = "${var.project_name}-daily-report"
  time_unit                  = "DAILY"
  format                     = "textORcsv"
  compression                = "GZIP"
  # RESOURCES adds line-item resource IDs to each charge
  additional_schema_elements = ["RESOURCES"]
  s3_bucket                  = aws_s3_bucket.cur_reports.id
  s3_prefix                  = "cur-data"
  s3_region                  = "us-east-1"
  additional_artifacts       = []
  report_versioning          = "CREATE_NEW_REPORT"
  refresh_closed_reports     = true

  # Wait for the bucket policy so AWS can write on the first attempt
  depends_on = [aws_s3_bucket_policy.cur_reports]
}

What does this code do?

  • time_unit = "DAILY" tells AWS to generate a new report every day.
  • format = "textORcsv" with compression = "GZIP" produces compressed CSV files to minimize storage costs.
  • additional_schema_elements = ["RESOURCES"] includes individual resource IDs in every line item, so you can trace charges back to specific resources.
  • refresh_closed_reports = true means AWS will update past reports when charges are adjusted retroactively (e.g. refunds or credits).

Why must the CUR service be in us-east-1?

The Cost and Usage Report service is only available in us-east-1. AWS stores all billing data in this region regardless of where your other resources run. That is why the s3_region is hardcoded to us-east-1.

  • Save cur.tf.

Before verifying, double check your full cur.tf file matches the complete code.

✔️ Awesome, I've got everything!

Double check you have saved cur.tf before continuing.

ⓧ I'd like to double check the full code

resource "aws_s3_bucket" "cur_reports" {
  bucket = "${var.project_name}-cur-${data.aws_caller_identity.current.account_id}"

  tags = {
    Project = var.project_name
    Purpose = "cost-usage-reports"
  }
}

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

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

resource "aws_s3_bucket_policy" "cur_reports" {
  bucket = aws_s3_bucket.cur_reports.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCURRead"
        Effect = "Allow"
        Principal = {
          Service = "billingreports.amazonaws.com"
        }
        Action = [
          "s3:GetBucketAcl",
          "s3:GetBucketPolicy"
        ]
        Resource = aws_s3_bucket.cur_reports.arn
        Condition = {
          StringEquals = {
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id
            "aws:SourceArn"     = "arn:aws:cur:us-east-1:${data.aws_caller_identity.current.account_id}:definition/*"
          }
        }
      },
      {
        Sid    = "AllowCURWrite"
        Effect = "Allow"
        Principal = {
          Service = "billingreports.amazonaws.com"
        }
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.cur_reports.arn}/*"
        Condition = {
          StringEquals = {
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id
            "aws:SourceArn"     = "arn:aws:cur:us-east-1:${data.aws_caller_identity.current.account_id}:definition/*"
          }
        }
      }
    ]
  })

  depends_on = [aws_s3_bucket_public_access_block.cur_reports]
}

resource "aws_cur_report_definition" "daily" {
  report_name                = "${var.project_name}-daily-report"
  time_unit                  = "DAILY"
  format                     = "textORcsv"
  compression                = "GZIP"
  additional_schema_elements = ["RESOURCES"]
  s3_bucket                  = aws_s3_bucket.cur_reports.id
  s3_prefix                  = "cur-data"
  s3_region                  = "us-east-1"
  additional_artifacts       = []
  report_versioning          = "CREATE_NEW_REPORT"
  refresh_closed_reports     = true

  depends_on = [aws_s3_bucket_policy.cur_reports]
}
  • Preview all four new resources by running this command:
terraform plan

You should see Plan: 4 to add, 0 to change, 0 to destroy at the bottom of the output. The four resources are the S3 bucket, the public access block, the bucket policy, and the CUR report definition.

Plan shows a different number of resources?

  • If the count is higher than 4, you may have unapplied resources from previous steps. That is normal if you skipped terraform apply earlier.
  • If you see errors about missing references, make sure data "aws_caller_identity" "current" {} exists in main.tf and aws_s3_bucket.cur_reports is spelled consistently across all resource references.

Help me fix my terraform plan output for CUR resources

When will the first report arrive?

The first Cost and Usage Report takes up to 24 hours to be delivered to your S3 bucket after deployment. Daily reports will continue arriving after that. You do not need to wait for it to proceed.

Your S3 bucket and Cost and Usage Report are configured. Next up, you will deploy all 11 resources at once and verify everything is working in the AWS Console.

Deploy and Verify the System

All of your Terraform configuration files are written. You have an SNS topic, billing alarms, a CloudWatch dashboard, and a Cost and Usage Report pipeline defined in code.

Before deploying, you need one last file: outputs.tf. Terraform outputs give you quick access to important values like resource ARNs and console URLs after a deployment finishes.

In this step, get ready to:
  • Create the outputs file and deploy all 11 resources with terraform apply.
  • Confirm the SNS email subscription by clicking the link in your inbox.
  • Verify the dashboard, alarms, and S3 bucket in the AWS Console.
Create the outputs file and deploy

Terraform outputs let you extract key values from your deployed infrastructure. Instead of digging through the AWS Console to find ARNs and URLs, they appear right in your terminal after every terraform apply.

  • Create a new file called outputs.tf inside your aws-cost-monitor folder, then paste the following code:
# Output the SNS topic ARN for reference
output "sns_topic_arn" {
  description = "ARN of the billing alerts SNS topic"
  value       = aws_sns_topic.billing_alerts.arn
}

# Build a direct link to the CloudWatch dashboard
output "dashboard_url" {
  description = "URL to the CloudWatch billing dashboard"
  value       = "https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#dashboards:name=${aws_cloudwatch_dashboard.billing.dashboard_name}"
}

# Output the S3 bucket name for CUR data
output "cur_s3_bucket" {
  description = "S3 bucket for Cost and Usage Report data"
  value       = aws_s3_bucket.cur_reports.id
}

# List all billing alarm names using a for expression
output "alarm_names" {
  description = "Names of the billing alarms"
  value       = [for alarm in aws_cloudwatch_metric_alarm.billing : alarm.alarm_name]
}

What does this code do?

  • sns_topic_arn gives you the Amazon Resource Name of your billing alerts topic, useful for referencing it in other tools or scripts.
  • dashboard_url builds a clickable link directly to your CloudWatch dashboard so you can open it from the terminal output.
  • cur_s3_bucket returns the S3 bucket name where your Cost and Usage Reports will be delivered.
  • alarm_names uses a for expression to loop through all billing alarms and collect their names into a list.
  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you've saved outputs.tf before moving on.

ⓧ I'd like to double check the full code

output "sns_topic_arn" {
  description = "ARN of the billing alerts SNS topic"
  value       = aws_sns_topic.billing_alerts.arn
}

output "dashboard_url" {
  description = "URL to the CloudWatch billing dashboard"
  value       = "https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#dashboards:name=${aws_cloudwatch_dashboard.billing.dashboard_name}"
}

output "cur_s3_bucket" {
  description = "S3 bucket for Cost and Usage Report data"
  value       = aws_s3_bucket.cur_reports.id
}

output "alarm_names" {
  description = "Names of the billing alarms"
  value       = [for alarm in aws_cloudwatch_metric_alarm.billing : alarm.alarm_name]
}

Now it is time to deploy everything. This single command creates all 11 AWS resources defined across your configuration files.

  • Deploy your entire cost monitoring system by running this command:
terraform apply

What happens when you run this?

Terraform first shows a plan listing every resource it will create. You will see a summary like Plan: 11 to add, 0 to change, 0 to destroy.

It then asks Do you want to perform these actions? and waits for your confirmation. You must type yes and press Enter to proceed.

  • Review the plan output, then type yes and press Enter to confirm the deployment.

Terraform creates the resources one by one. After a minute or two, you should see output similar to this:

Apply complete! Resources: 11 added, 0 changed, 0 destroyed.

Outputs:

sns_topic_arn = "arn:aws:sns:us-east-1:123456789012:cost-monitor-billing-alerts"
dashboard_url = "https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#dashboards:name=cost-monitor-billing"
cur_s3_bucket = "cost-monitor-cur-123456789012"
alarm_names = [
  "cost-monitor-alert",
  "cost-monitor-critical",
  "cost-monitor-warning",
]
  • Note the dashboard_url value from your output. You will use this to open your dashboard in the next substep.

Seeing an error during apply?

  • If you see Error: No valid credential sources found, your AWS CLI credentials may have expired or were not configured. Run aws sts get-caller-identity to check.
  • If you see Error: creating S3 Bucket: BucketAlreadyExists, the bucket name must be globally unique. The configuration uses your account ID to ensure this, so check that data.aws_caller_identity.current is defined in main.tf.
  • If you see a syntax error, double check the file it mentions against the complete code above.

Why is my terraform apply failing?

Confirm the SNS subscription

Terraform created the SNS email subscription, but AWS cannot auto-confirm it. The subscription stays in a PendingConfirmation state until you click the confirmation link in the email that AWS sent you.

  • Open the email inbox for the address you entered in terraform.tfvars.
  • Find the email from AWS Notifications with the subject AWS Notification - Subscription Confirmation.
  • Click the Confirm subscription link in the email body.

A browser tab opens with a confirmation message from AWS. Your subscription is now active and will receive billing alert emails.

  • To verify, open the AWS Console and navigate to the SNS service.
  • Click Topics in the left sidebar.
  • Click the cost-monitor-billing-alerts topic.
  • Scroll down to the Subscriptions section. The status should now show Confirmed instead of PendingConfirmation.

Don't see the confirmation email?

  • Check your spam or junk folder. AWS notification emails sometimes get filtered.
  • Verify that the email address in terraform.tfvars is correct. If it is wrong, update the file and run terraform apply again.
  • The email can take a few minutes to arrive. Wait 5 minutes before troubleshooting further.

Why am I not receiving the AWS SNS confirmation email?

Verify resources in the AWS Console

Your resources are deployed and the email subscription is confirmed. Time to verify that the dashboard, alarms, and S3 bucket all look correct in the AWS Console.

  • Copy the dashboard_url from your Terraform output and paste it into your browser.

You should see your cost-monitor-billing dashboard with three widgets: Total Estimated Charges (USD), Estimated Charges by Service, and a text widget showing your alarm thresholds.

Why are the graphs empty?

Billing metric data updates approximately every 6 hours. If you just enabled billing alerts in Step 1, the initial data takes about 15 minutes to appear. The graphs will populate over time as AWS publishes new data points.

  • In the CloudWatch console left sidebar, click All alarms under the Alarms section.

You should see three alarms: cost-monitor-warning, cost-monitor-alert, and cost-monitor-critical. Their state will likely show INSUFFICIENT_DATA.

What does INSUFFICIENT_DATA mean?

This is completely normal. The alarm has not received enough billing data points yet to evaluate whether you have crossed a threshold. Your alarms are configured with treat_missing_data = "missing", which prevents false alarms during these gaps.

Once AWS publishes the next billing data point (within 6 hours), the alarms will transition to either OK or ALARM depending on your current spend.

  • In the AWS Console search bar at the top, type S3 and select S3 to open the S3 console.
  • Find the bucket named cost-monitor-cur- followed by your AWS account ID.

The bucket exists and is empty. The first Cost and Usage Report takes up to 24 hours to be delivered, so you will see data appear in this bucket tomorrow.

Dashboard not loading or alarms missing?

  • Make sure you are viewing the us-east-1 region in the AWS Console. All billing resources are deployed to this region. Check the region selector in the top right corner of the console.
  • If the dashboard URL shows a blank page, try refreshing the page. CloudWatch dashboards can take a few seconds to render after first creation.

Help me troubleshoot my CloudWatch dashboard or billing alarms.

Secret mission

Add an AWS Budget with Forecast Alerts and Per-Service Alarms

Your cost monitoring system catches spending after it happens. What if you could predict overspending before it occurs? In this Secret Mission, you will add AWS Budgets with forecast-based alerts and create per-service billing alarms so you know exactly which service is driving up costs.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Most resources in this project fall within the AWS free tier, so ongoing costs are minimal.

Cost warning

The CloudWatch dashboard and alarms are covered by the free tier (3 dashboards and 10 alarm metrics per month). The S3 bucket storing Cost and Usage Report data accumulates a few cents per month.

If you leave all resources running indefinitely, expect negligible charges. If you want zero ongoing costs, choose the Delete tab below.

Resources you used:

  • 1 SNS topic with 1 email subscription.
  • 3 CloudWatch billing alarms (warning, alert, critical).
  • 1 CloudWatch dashboard.
  • 1 S3 bucket for Cost and Usage Report data.
  • 1 Cost and Usage Report definition.
  • 8 Terraform configuration files on your local machine.

What about the billing alerts setting?

The "Receive CloudWatch Billing Alerts" setting you enabled in Step 1 cannot be disabled. This is completely harmless. It simply means AWS continues publishing billing metric data to CloudWatch, which costs nothing.

Keep everything running

No action needed. Choose this if you want your cost monitoring system to stay active and keep watching your AWS spending.

  • Your 3 CloudWatch alarms continue monitoring your estimated charges and send email alerts if thresholds are exceeded.
  • Your CloudWatch dashboard stays accessible at the URL from your Terraform outputs.
  • Your S3 bucket receives daily Cost and Usage Reports. Storage costs are a few cents per month.
  • All alarms and the dashboard stay within free tier limits (3 dashboards and 10 alarm metrics).

Pause - I'll come back to this later

Since these are passive monitoring resources, there is no compute to shut down. You can reduce costs to zero by removing the CUR report while keeping everything else intact.

  • Leave the CloudWatch alarms and dashboard in place. They cost nothing within the free tier.
  • To stop new CUR data from accumulating in S3, you can delete the report definition in the AWS Cost and Usage Reports console.
  • Keep your local aws-cost-monitor folder and all .tf files. You can run terraform apply again later to recreate anything you removed.
  • Do not delete the terraform.tfstate file. Terraform uses it to track what resources exist in AWS.

Delete - I don't want to use this again

Remove all AWS resources and local project files for a complete cleanup.

  • The S3 bucket must be empty before Terraform can delete it. Open the S3 console, select your cost-monitor-cur-* bucket, click Empty, and confirm.
  • Open your terminal and navigate to your aws-cost-monitor folder.
  • Run the following command to destroy all AWS resources:
terraform destroy
  • Type yes when prompted to confirm the destruction of all 11 resources.
  • Delete the aws-cost-monitor folder from your Desktop to remove all local Terraform files.

Alternative to emptying the bucket manually

You can add force_destroy = true to the aws_s3_bucket resource in cur.tf, run terraform apply, and then run terraform destroy. This tells Terraform to delete the bucket and all its contents in one step.

Mission accomplished

That's a Wrap!

Nice work! You just built a complete AWS cost monitoring system using Terraform. Every resource across four AWS services is defined in code, version-controlled, and reproducible.

You've learned how to:

  • Created an SNS notification pipeline with a topic, access policy, and email subscription that delivers billing alerts to your inbox.
  • Deployed three CloudWatch billing alarms using for_each and locals to monitor spending at configurable warning, alert, and critical thresholds.
  • Built a CloudWatch dashboard with jsonencode and configured an S3 bucket with a Cost and Usage Report for long-term billing data storage.
  • Extended the system with AWS Budgets forecast alerts and per-service billing alarms.

Ready to quiz yourself?