Build an AWS Cloud Cost Tracker

Monitor AWS spending with a live S3 dashboard and automated alerts.

Introduction

30 Second Summary

Ever checked your bank account and wondered where all your money went? The same thing happens with cloud spending, where small services silently add up until your monthly bill arrives.

In this project, you will build an automated AWS cost tracker that monitors your spending, alerts you when costs cross a threshold, and publishes a live HTML dashboard you can check anytime.

What You'll Build

A fully automated cost monitoring system that combines six AWS services to query your spending data, generate a styled dashboard, and email you when costs get too high.

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

  • A CloudWatch billing alarm connected to SNS that emails you when your estimated charges exceed a threshold.
  • A Lambda function that queries the Cost Explorer API for spending by service and forecasted month-end costs.
  • An S3 bucket configured as a static website hosting your live cost dashboard, with an EventBridge Scheduler schedule triggering weekly updates.
  • Secret Mission: Add a month-over-month cost comparison with color-coded indicators showing spending trends.

Are there any prerequisites?

You need an AWS account with billing access and the AWS CLI installed and configured. If you do not have these yet, set them up before starting Step 1.

Enable Billing Access and Create the Alert Pipeline

Before you can track AWS costs programmatically, you need to turn on the services that collect and report billing data.

In this step, you will enable Cost Explorer, turn on billing alerts, create an SNS notification topic, and wire up a CloudWatch alarm that emails you when your estimated charges cross a threshold.

In this step, get ready to:
  • Enable Cost Explorer and CloudWatch billing alerts in your AWS account.
  • Create an SNS topic and subscribe your email to receive billing notifications.
  • Create a CloudWatch billing alarm that triggers when estimated charges exceed $5.
Enable Cost Explorer and billing alerts

AWS does not expose billing data by default. You need to explicitly enable Cost Explorer (so your Lambda function can query spending later) and turn on CloudWatch billing alerts (so CloudWatch can monitor your estimated charges).

What is Cost Explorer?

Cost Explorer is an AWS tool that lets you view and analyze your spending over time. Your Lambda function will use the Cost Explorer API later to pull spending data for the dashboard.

Each API call costs $0.01. The console UI itself is free to use.

  • In the left navigation pane, click Cost Explorer.
  • If you see a Welcome to Cost Explorer page, click Launch Cost Explorer.
  • If Cost Explorer is already active, you will see your cost dashboard instead.

Why might I not see cost data yet?

After enabling Cost Explorer for the first time, AWS needs up to 24 hours to prepare your billing data. This is normal and will not block the rest of this project.

Now enable CloudWatch billing alerts so that CloudWatch can receive billing metric data from AWS.

  • In the left navigation pane, click Billing Preferences.
  • Find the Alert preferences section and click Edit.
  • Select Receive CloudWatch Billing Alerts.
  • Click Save preferences.

Why do I need to enable this separately?

CloudWatch billing metrics are disabled by default. Once you enable them, AWS begins sending estimated charge data to CloudWatch every 6 hours. This is what your billing alarm will monitor.

After you enable billing alerts, you cannot disable data collection. You can only delete alarms you create later.

Don't see Billing Preferences?

Make sure you are signed in with the root account or an IAM user that has billing access enabled. If you are using an IAM user, the root account must first grant billing access under Account settings in the Billing console.

Help me get access to billing preferences in my AWS account.

Create an SNS topic and subscribe your email

Your billing alarm needs somewhere to send notifications. Amazon SNS (Simple Notification Service) acts as the delivery channel. You will create a topic (a named message channel) and subscribe your email address so alarm notifications arrive in your inbox.

  • Confirm your region is set to US East (N. Virginia) in the region dropdown at the top right of the console.
  • In the top search bar, type SNS and select Simple Notification Service.
  • In the left navigation pane, click Topics.
  • Click Create topic.
  • For Type, select Standard.
  • For Name, enter billing-alerts.
  • Scroll down and click Create topic.

You should see the topic details page with the topic name and its ARN displayed.

What is an SNS topic?

An SNS topic is a named message channel. When something publishes a message to it (like a CloudWatch alarm), SNS fans that message out to every subscriber (like your email). One topic can notify many endpoints at once.

Now subscribe your email address to the topic.

  • On the topic details page, click Create subscription.
  • For Protocol, choose Email.
  • For Endpoint, enter your email address.
  • Click Create subscription.
  • Check your email inbox for a message from AWS Notifications.
  • Click Confirm subscription in the email.
  • Return to the SNS console. Your subscription status should now show Confirmed.

Don't see the confirmation email?

Check your spam or junk folder. The email comes from a no-reply AWS address and may be filtered.

If it still does not arrive after a few minutes, double-check the email address you entered. You can delete the subscription and create a new one with the correct address.

Help me confirm my SNS email subscription.

Create a CloudWatch billing alarm

CloudWatch is AWS's monitoring service. You will create an alarm that watches the EstimatedCharges billing metric and triggers your SNS topic when costs exceed $5.

Why must billing alarms be in us-east-1?

AWS stores billing metric data exclusively in the US East (N. Virginia) region, regardless of where your other resources run. You must create billing alarms in us-east-1 or they will not see any data.

  • In the top search bar, type CloudWatch and select CloudWatch.
  • Confirm your region is still US East (N. Virginia) in the top-right region dropdown.
  • In the left navigation pane, click Alarms.
  • Click All alarms.
  • Click Create alarm.
  • Click Select metric.
  • Choose Billing.
  • Choose Total Estimated Charge.
  • Select the checkbox for EstimatedCharges (Currency: USD).
  • Click Select metric.

Now configure the alarm threshold.

  • Under Statistic, confirm it is set to Maximum.
  • Under Period, confirm it is set to 6 hours.
  • Under Conditions, select Greater than.
  • Enter 5 as the threshold value (this means $5).
  • Click Next.

Now connect the alarm to your SNS topic.

  • Under Notification, confirm In alarm is selected as the trigger state.
  • For Send a notification to, select Select an existing SNS topic.
  • Choose your billing-alerts topic from the dropdown.
  • Click Next.
  • Enter a name for the alarm, such as billing-alarm-5-usd.
  • Click Next.
  • Review your settings, then click Create alarm.

Your alarm now appears in the Alarms list. It will likely show Insufficient data initially. This is expected because billing metrics only update every 6 hours.

What does Insufficient data mean?

CloudWatch has not received enough billing metric data points to evaluate your alarm yet. After one or two 6-hour billing cycles, the alarm will transition to either OK (costs under $5) or In alarm (costs over $5).

Don't see the Billing metric category?

Make sure you are in the US East (N. Virginia) region. Billing metrics only appear in us-east-1.

If you just enabled billing alerts moments ago, billing metrics may take up to 15 minutes to appear in CloudWatch. Wait a few minutes and refresh the page.

Help me find the billing metric in CloudWatch.

Your alert pipeline is live. When your AWS charges cross $5, CloudWatch will fire the alarm and SNS will email you. Next up, you will create the S3 bucket that hosts your cost dashboard.

Create the S3 Dashboard Bucket

Your billing alarm is active and watching for unexpected charges. Now it's time to build the visual side of your cost tracker: a live dashboard with its own URL.

Amazon S3 can host static websites directly from a bucket, no web server required. In this step, you'll create an S3 bucket, configure it for public website hosting, and verify the URL works.

In this step, get ready to:
  • Create an S3 bucket with static website hosting enabled.
  • Configure public access with a bucket policy.
  • Upload and verify a placeholder page.
Create the S3 bucket and enable static website hosting

Your cost dashboard will be an HTML page that lives in an S3 bucket. Enabling static website hosting gives the bucket its own public URL that serves web pages.

  • In the AWS Management Console, search for S3 in the top search bar and open the S3 service.
  • In the left navigation pane, click General purpose buckets.
  • Click Create bucket.
  • Enter a globally unique bucket name. Use the format my-cost-dashboard- followed by your AWS account ID (e.g., my-cost-dashboard-123456789012).
  • Leave the AWS Region and all other settings at their defaults.
  • Click Create bucket.

Your bucket appears in the bucket list. Now enable website hosting on it.

  • Click your new bucket name to open it.
  • Click the Properties tab.
  • Scroll down to Static website hosting and click Edit.
  • Select Enable.
  • In the Index document field, enter index.html.
  • Click Save changes.

After saving, you'll see a Bucket website endpoint URL at the bottom of the Static website hosting section. Copy this URL and save it somewhere. You'll need it later.

What is static website hosting?

When you enable static website hosting on an S3 bucket, AWS gives the bucket its own HTTP endpoint. Any HTML files you upload to the bucket become accessible as web pages at that URL.

The endpoint follows this format: http://bucket-name.s3-website-region.amazonaws.com. Your Lambda function will later upload a styled dashboard to this bucket, and anyone with the URL can view it.

Don't see the endpoint URL?

Make sure you clicked Save changes after enabling hosting. The endpoint only appears after the save completes. Scroll to the very bottom of the Static website hosting section on the Properties tab to find it.

Help me find my S3 website endpoint URL

Disable Block Public Access and add the bucket policy

S3 buckets block public access by default. For your dashboard to be viewable by anyone with the URL, you need to remove this restriction and add a policy that explicitly allows public reads.

  • Click the Permissions tab.
  • Under Block public access (bucket settings), click Edit.
  • Uncheck Block all public access.
  • Click Save changes.
  • A confirmation dialog appears. Type confirm in the text field and click Confirm.

Now add a bucket policy that grants public read access.

  • Still on the Permissions tab, scroll down to Bucket policy and click Edit.
  • Paste the following policy into the editor, replacing the bucket name with your own:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::[[BUCKET_NAME="my-cost-dashboard-youraccountid"]]/*"
            ]
        }
    ]
}
  • Click Save changes.

What does this policy do?

This policy tells S3 to allow anyone (Principal: "*") to read objects (s3:GetObject) from your bucket. Without it, visitors would get an Access Denied error when trying to view your dashboard page.

Seeing an Access Denied error when saving the policy?

This means Block Public Access is still enabled. Go back to the Block public access (bucket settings) section and confirm all four checkboxes are unchecked. You must disable Block Public Access before adding a public bucket policy.

Also double-check that the bucket name in the Resource field exactly matches your actual bucket name. A typo here causes a mismatch error.

Help me fix my S3 bucket policy error

Upload a placeholder page and verify the website endpoint

With hosting configured and public access enabled, you can now upload an HTML file and confirm the website endpoint serves it correctly.

  • Create a file on your computer called index.html with the following content:
<!DOCTYPE html>
<html>
<head>
    <title>AWS Cost Dashboard</title>
</head>
<body>
    <h1>AWS Cost Dashboard</h1>
    <p>Dashboard coming soon!</p>
</body>
</html>

Why a placeholder?

This temporary page lets you confirm that static website hosting, the bucket policy, and the endpoint URL all work together. In the next step, your Lambda function will replace this file with a styled dashboard that shows real cost data.

  • In the S3 console, click the Objects tab of your bucket.
  • Click Upload.
  • Click Add files and select the index.html file you just created.
  • Click Upload.
  • After the upload completes, click Close.

Now test your website endpoint.

  • Click the Properties tab.
  • Scroll to the bottom and find the Bucket website endpoint URL under Static website hosting.
  • Click the endpoint URL to open it in a new browser tab.

You should see a page with the heading "AWS Cost Dashboard" and the text "Dashboard coming soon!" displayed.

Seeing a 404 Not Found or Access Denied?

If you see a 404 error, confirm that your uploaded file is named exactly index.html (lowercase, no extra spaces). The file name must match the index document you configured earlier.

If you see Access Denied, revisit the Permissions tab and confirm that Block Public Access is disabled and the bucket policy is saved with your correct bucket name in the Resource field.

Help me troubleshoot my S3 website endpoint

Your dashboard has a home on the web. Next up, you'll build the Lambda function that queries your AWS spending data and generates a real cost report to replace this placeholder.

Create the Lambda Function with IAM Role

Your billing alarm and dashboard bucket are in place. Now you need the engine that pulls your cost data from AWS and turns it into a visible report.

In this step, you will create a Lambda function that queries the Cost Explorer API for your spending data, generates a styled HTML dashboard, and uploads it to your S3 bucket. First, you will set up the IAM role that grants the function permission to do all of this.

In this step, get ready to:
  • Create an IAM role with Cost Explorer and S3 permissions for your Lambda function.
  • Create a Lambda function with the Python 3.12 runtime and configure its settings.
  • Deploy the function code and test it to generate your live dashboard.
Create an IAM role for Lambda

Your Lambda function needs permission to read cost data and write files to S3. You will create a dedicated IAM role that grants exactly these permissions.

  • Open the IAM console by searching for IAM in the AWS search bar and selecting it.
  • In the left navigation pane, click **Policies**, then click **Create policy**.
  • Select the JSON tab in the policy editor.
  • Delete any existing content and paste the following policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ce:GetCostAndUsage",
                "ce:GetCostForecast"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::my-cost-dashboard-*/index.html"
        }
    ]
}

What does this policy do?

The first statement grants permission to call ce:GetCostAndUsage and ce:GetCostForecast. Cost Explorer actions require Resource: "*" because they do not support resource-level permissions.

The second statement grants s3:PutObject scoped to your dashboard bucket. This ensures the function can only write to that one bucket.

  • Click Next.
  • For Policy name, enter cost-dashboard-lambda-policy.
  • Click Create policy.

Now create the role that uses this policy.

  • In the left navigation pane, click Roles, then click Create role.
  • For Trusted entity type, select AWS service.
  • For Service or use case, choose Lambda.
  • Click Next.
  • In the permissions policy search box, search for AWSLambdaBasicExecutionRole and check the box next to it.
  • Clear the search, then search for cost-dashboard-lambda-policy and check the box next to it.
  • Click Next.
  • For Role name, enter cost-dashboard-lambda-role.
  • Click Create role.

Why two policies?

AWSLambdaBasicExecutionRole is an AWS managed policy that grants your function permission to write logs to CloudWatch. Without it, you would not be able to see any execution logs or error messages.

The custom policy you created grants the specific Cost Explorer and S3 permissions your function needs. Keeping permissions scoped to exactly what is required is an IAM best practice.

You should see a green success banner confirming the role was created.

Don't see your custom policy in the search?

Make sure you created the policy first before creating the role. If you already started the role wizard, close it, go back to Policies, confirm cost-dashboard-lambda-policy exists, then restart the role creation.

Help me troubleshoot my IAM role setup for Lambda.

Create the Lambda function

With the role ready, you can create the Lambda function itself and attach the role to it.

  • Open the Lambda console by searching for Lambda in the AWS search bar and selecting it.
  • Click Create function.
  • Select Author from scratch.
  • For Function name, enter cost-dashboard-generator.
  • For Runtime, select Python 3.12.
  • Expand Change default execution role.
  • Select Use an existing role.
  • In the Existing role dropdown, select cost-dashboard-lambda-role.
  • Click Create function.

Lambda creates your function and takes you to its configuration page. Now configure the timeout and environment variable.

  • Click the Configuration tab.
  • Select General configuration from the left sidebar, then click Edit.
  • Set Timeout to 0 min and 30 sec.
  • Click Save.

Why increase the timeout?

The default Lambda timeout is 3 seconds. Cost Explorer API calls can take 5-10 seconds to return data. Setting the timeout to 30 seconds gives the function enough time to complete both API calls and upload the HTML file to S3.

  • Still in the Configuration tab, select Environment variables from the left sidebar.
  • Click Edit, then click Add environment variable.
  • For Key, enter DASHBOARD_BUCKET.
  • For Value, enter your S3 bucket name (e.g. my-cost-dashboard-123456789012).
  • Click Save.

Why use an environment variable?

Storing the bucket name in an environment variable keeps your code portable. If you ever change buckets, you only update the variable instead of editing the source code.

Don't see your role in the dropdown?

IAM roles can take a few seconds to propagate. Wait 10-15 seconds, then refresh the page and try again. Make sure you are in the same AWS region where you created the role.

Help me find my IAM role when creating a Lambda function.

Deploy the Lambda function code and test it

Your function is configured but still has the default placeholder code. You will replace it with the cost dashboard generator that queries Cost Explorer and builds an HTML report.

  • Click the Code tab to open the inline code editor.
  • Select all the default code in lambda_function.py and delete it.
  • Paste the following code into the editor:
import boto3
import os
import json
import logging
from datetime import datetime, timedelta

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ce_client = boto3.client('ce', region_name='us-east-1')
s3_client = boto3.client('s3')

BUCKET_NAME = os.environ.get('DASHBOARD_BUCKET')


def get_cost_by_service():
    today = datetime.utcnow()
    start_of_month = today.replace(day=1).strftime('%Y-%m-%d')
    end_date = today.strftime('%Y-%m-%d')

    response = ce_client.get_cost_and_usage(
        TimePeriod={
            'Start': start_of_month,
            'End': end_date
        },
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'SERVICE'
            }
        ]
    )
    return response['ResultsByTime']


def get_cost_forecast():
    today = datetime.utcnow()
    start_date = today.strftime('%Y-%m-%d')
    # Forecast to end of next month
    if today.month == 12:
        end_date = today.replace(year=today.year + 1, month=2, day=1).strftime('%Y-%m-%d')
    else:
        end_month = today.month + 1
        if end_month > 12:
            end_date = datetime(today.year + 1, 1, 1).strftime('%Y-%m-%d')
        else:
            end_date = today.replace(month=end_month, day=1).strftime('%Y-%m-%d')

    try:
        response = ce_client.get_cost_forecast(
            TimePeriod={
                'Start': start_date,
                'End': end_date
            },
            Metric='UNBLENDED_COST',
            Granularity='MONTHLY'
        )
        return response['Total']['Amount']
    except Exception as e:
        logger.warning(f"Could not get forecast: {str(e)}")
        return None


def generate_html(cost_data, forecast_amount):
    today = datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')

    services = []
    total_cost = 0.0

    if cost_data:
        for period in cost_data:
            for group in period.get('Groups', []):
                service_name = group['Keys'][0]
                amount = float(group['Metrics']['UnblendedCost']['Amount'])
                if amount > 0.001:
                    services.append((service_name, amount))
                    total_cost += amount

    services.sort(key=lambda x: x[1], reverse=True)

    service_rows = ''
    for service_name, amount in services:
        service_rows += f'''
        <tr>
            <td>{service_name}</td>
            <td class="cost">${amount:.4f}</td>
        </tr>'''

    forecast_section = ''
    if forecast_amount:
        forecast_section = f'''
        <div class="forecast">
            <h2>Forecasted Month-End Spend</h2>
            <p class="forecast-amount">${float(forecast_amount):.2f}</p>
        </div>'''

    html = f'''<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AWS Cost Dashboard</title>
    <style>
        body {{
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
            color: #333;
        }}
        h1 {{
            color: #232f3e;
            border-bottom: 3px solid #ff9900;
            padding-bottom: 10px;
        }}
        .updated {{
            color: #666;
            font-size: 0.9em;
            margin-bottom: 20px;
        }}
        .total {{
            background: #232f3e;
            color: white;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }}
        .total h2 {{
            margin: 0;
            color: #ff9900;
        }}
        .total .amount {{
            font-size: 2em;
            margin: 10px 0 0 0;
        }}
        .forecast {{
            background: white;
            padding: 20px;
            border-radius: 8px;
            border-left: 4px solid #ff9900;
            margin-bottom: 20px;
        }}
        .forecast h2 {{
            margin: 0 0 10px 0;
            color: #232f3e;
        }}
        .forecast-amount {{
            font-size: 1.5em;
            color: #ff9900;
            margin: 0;
        }}
        table {{
            width: 100%;
            border-collapse: collapse;
            background: white;
            border-radius: 8px;
            overflow: hidden;
        }}
        th {{
            background: #232f3e;
            color: white;
            padding: 12px 15px;
            text-align: left;
        }}
        td {{
            padding: 10px 15px;
            border-bottom: 1px solid #eee;
        }}
        tr:hover {{
            background-color: #f9f9f9;
        }}
        .cost {{
            text-align: right;
            font-family: monospace;
        }}
    </style>
</head>
<body>
    <h1>AWS Cost Dashboard</h1>
    <p class="updated">Last updated: {today}</p>
    <div class="total">
        <h2>Current Month Spend</h2>
        <p class="amount">${total_cost:.2f}</p>
    </div>
    {forecast_section}
    <h2>Cost Breakdown by Service</h2>
    <table>
        <thead>
            <tr>
                <th>Service</th>
                <th style="text-align: right;">Cost</th>
            </tr>
        </thead>
        <tbody>
            {service_rows}
        </tbody>
    </table>
</body>
</html>'''
    return html


def lambda_handler(event, context):
    logger.info("Starting cost dashboard generation")

    cost_data = get_cost_by_service()
    forecast_amount = get_cost_forecast()

    html_content = generate_html(cost_data, forecast_amount)

    s3_client.put_object(
        Bucket=BUCKET_NAME,
        Key='index.html',
        Body=html_content,
        ContentType='text/html'
    )

    logger.info(f"Dashboard uploaded to s3://{BUCKET_NAME}/index.html")

    return {
        'statusCode': 200,
        'body': json.dumps('Dashboard updated successfully')
    }

What does this code do?

The code has four main functions working together:

  • get_cost_by_service() calls the Cost Explorer API with MONTHLY granularity, grouping costs by SERVICE and using the UnblendedCost metric. It queries from the 1st of the current month to today.
  • get_cost_forecast() calls get_cost_forecast with the UNBLENDED_COST metric to predict your month-end total. It wraps in a try/except because new accounts without enough billing history will get an error.
  • generate_html() takes the raw cost data and builds a styled HTML page with your total spend, forecast, and a table of per-service costs sorted highest to lowest.
  • lambda_handler() orchestrates everything. It calls the data functions, generates the HTML, and uploads it to your S3 bucket as index.html.
  • Click Deploy to save and deploy the code.

You should see a green banner saying the function was updated successfully. Now test it.

  • Click Test.
  • In the Configure test event dialog, leave the event name as test and the JSON body as the default {}.
  • Click Save, then click Test again to invoke the function.

After a few seconds, you should see a green Execution result: succeeded banner with a response body of "Dashboard updated successfully".

  • Open your S3 website URL in a new browser tab (the same URL from Step 2).
  • You should now see a styled dashboard showing your current month spend, a forecasted month-end total, and a breakdown table of costs by service.

Don't see a forecast section?

If your AWS account is brand new or has less than two weeks of billing history, the forecast API returns an error. The code handles this gracefully and simply omits the forecast section. Your dashboard will still show the current month spend and service breakdown.

Seeing a timeout or permission error?

If you see a Task timed out error, confirm you set the timeout to 30 seconds under Configuration > General configuration.

If you see an AccessDeniedException, double check that your cost-dashboard-lambda-role has both the cost-dashboard-lambda-policy and AWSLambdaBasicExecutionRole attached. Also confirm Cost Explorer is enabled (it can take up to 24 hours after first enablement for data to appear).

Help me debug my Lambda cost dashboard function.

✔️ Awesome, I've got everything!

Great. Double check that you've deployed the code and that your dashboard URL shows real cost data.

ⓧ I'd like to double check the full code

Here is the complete lambda_function.py for comparison:

import boto3
import os
import json
import logging
from datetime import datetime, timedelta

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ce_client = boto3.client('ce', region_name='us-east-1')
s3_client = boto3.client('s3')

BUCKET_NAME = os.environ.get('DASHBOARD_BUCKET')


def get_cost_by_service():
    today = datetime.utcnow()
    start_of_month = today.replace(day=1).strftime('%Y-%m-%d')
    end_date = today.strftime('%Y-%m-%d')

    response = ce_client.get_cost_and_usage(
        TimePeriod={
            'Start': start_of_month,
            'End': end_date
        },
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'SERVICE'
            }
        ]
    )
    return response['ResultsByTime']


def get_cost_forecast():
    today = datetime.utcnow()
    start_date = today.strftime('%Y-%m-%d')
    # Forecast to end of next month
    if today.month == 12:
        end_date = today.replace(year=today.year + 1, month=2, day=1).strftime('%Y-%m-%d')
    else:
        end_month = today.month + 1
        if end_month > 12:
            end_date = datetime(today.year + 1, 1, 1).strftime('%Y-%m-%d')
        else:
            end_date = today.replace(month=end_month, day=1).strftime('%Y-%m-%d')

    try:
        response = ce_client.get_cost_forecast(
            TimePeriod={
                'Start': start_date,
                'End': end_date
            },
            Metric='UNBLENDED_COST',
            Granularity='MONTHLY'
        )
        return response['Total']['Amount']
    except Exception as e:
        logger.warning(f"Could not get forecast: {str(e)}")
        return None


def generate_html(cost_data, forecast_amount):
    today = datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')

    services = []
    total_cost = 0.0

    if cost_data:
        for period in cost_data:
            for group in period.get('Groups', []):
                service_name = group['Keys'][0]
                amount = float(group['Metrics']['UnblendedCost']['Amount'])
                if amount > 0.001:
                    services.append((service_name, amount))
                    total_cost += amount

    services.sort(key=lambda x: x[1], reverse=True)

    service_rows = ''
    for service_name, amount in services:
        service_rows += f'''
        <tr>
            <td>{service_name}</td>
            <td class="cost">${amount:.4f}</td>
        </tr>'''

    forecast_section = ''
    if forecast_amount:
        forecast_section = f'''
        <div class="forecast">
            <h2>Forecasted Month-End Spend</h2>
            <p class="forecast-amount">${float(forecast_amount):.2f}</p>
        </div>'''

    html = f'''<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AWS Cost Dashboard</title>
    <style>
        body {{
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
            color: #333;
        }}
        h1 {{
            color: #232f3e;
            border-bottom: 3px solid #ff9900;
            padding-bottom: 10px;
        }}
        .updated {{
            color: #666;
            font-size: 0.9em;
            margin-bottom: 20px;
        }}
        .total {{
            background: #232f3e;
            color: white;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }}
        .total h2 {{
            margin: 0;
            color: #ff9900;
        }}
        .total .amount {{
            font-size: 2em;
            margin: 10px 0 0 0;
        }}
        .forecast {{
            background: white;
            padding: 20px;
            border-radius: 8px;
            border-left: 4px solid #ff9900;
            margin-bottom: 20px;
        }}
        .forecast h2 {{
            margin: 0 0 10px 0;
            color: #232f3e;
        }}
        .forecast-amount {{
            font-size: 1.5em;
            color: #ff9900;
            margin: 0;
        }}
        table {{
            width: 100%;
            border-collapse: collapse;
            background: white;
            border-radius: 8px;
            overflow: hidden;
        }}
        th {{
            background: #232f3e;
            color: white;
            padding: 12px 15px;
            text-align: left;
        }}
        td {{
            padding: 10px 15px;
            border-bottom: 1px solid #eee;
        }}
        tr:hover {{
            background-color: #f9f9f9;
        }}
        .cost {{
            text-align: right;
            font-family: monospace;
        }}
    </style>
</head>
<body>
    <h1>AWS Cost Dashboard</h1>
    <p class="updated">Last updated: {today}</p>
    <div class="total">
        <h2>Current Month Spend</h2>
        <p class="amount">${total_cost:.2f}</p>
    </div>
    {forecast_section}
    <h2>Cost Breakdown by Service</h2>
    <table>
        <thead>
            <tr>
                <th>Service</th>
                <th style="text-align: right;">Cost</th>
            </tr>
        </thead>
        <tbody>
            {service_rows}
        </tbody>
    </table>
</body>
</html>'''
    return html


def lambda_handler(event, context):
    logger.info("Starting cost dashboard generation")

    cost_data = get_cost_by_service()
    forecast_amount = get_cost_forecast()

    html_content = generate_html(cost_data, forecast_amount)

    s3_client.put_object(
        Bucket=BUCKET_NAME,
        Key='index.html',
        Body=html_content,
        ContentType='text/html'
    )

    logger.info(f"Dashboard uploaded to s3://{BUCKET_NAME}/index.html")

    return {
        'statusCode': 200,
        'body': json.dumps('Dashboard updated successfully')
    }

Your Lambda function is working and your live cost dashboard is online. Next up, you will automate this entire process so the dashboard refreshes itself every week without you lifting a finger.

Schedule Weekly Updates with EventBridge Scheduler

Your Lambda function generates a polished cost dashboard on demand. The missing piece is automation.

Without a scheduler, you would need to log into the console and manually trigger the function every time you want fresh data. EventBridge Scheduler solves this by invoking your Lambda on a recurring cron schedule, so your dashboard always stays current.

In this step, get ready to:
  • Create an EventBridge Scheduler schedule that triggers your Lambda every Monday at 9:00 UTC.
  • Verify the schedule is active and has the correct execution role permissions.
  • Run a final end-to-end test confirming your entire cost tracking pipeline works together.
Create the EventBridge Scheduler schedule

A cron expression lets you define exactly when your schedule fires. The expression cron(0 9 ? * MON *) means "at minute 0, hour 9, any day of the month, any month, on Monday, any year."

  • Open the Amazon EventBridge Scheduler console at https://console.aws.amazon.com/scheduler/home.
  • On the Schedules page, choose Create schedule.
  • For Schedule name, enter weekly-cost-dashboard.
  • For Schedule group, choose default.
  • In the Schedule pattern section, select Recurring schedule.
  • Choose Cron-based schedule and enter the following expression:
cron(0 9 ? * MON *)

What does this cron expression mean?

EventBridge Scheduler cron format is cron(minutes hours day-of-month month day-of-week year). Your expression breaks down as:

  • 0 = minute 0 (top of the hour).
  • 9 = 9:00 AM UTC.
  • ? = any day of the month.
  • * = every month.
  • MON = Monday only.
  • * = every year.
  • For Flexible time window, choose Off.
  • Choose Next.
  • On the Select target page, choose AWS Lambda Invoke.
  • In the Invoke section, select your cost-dashboard-generator function from the dropdown.
  • Leave the payload field empty. EventBridge Scheduler sends an empty event to invoke the function.
  • Choose Next.
  • On the Settings page, under Schedule state, confirm the Enable schedule toggle is on.
  • Under Permissions, choose Create new role for this schedule.
  • Leave the auto-generated role name as-is. EventBridge Scheduler attaches the lambda:InvokeFunction permission automatically.
  • Choose Next.
  • On the Review and create schedule page, review your settings.
  • Choose Create schedule.

Why EventBridge Scheduler instead of scheduled rules?

Scheduled rules are a legacy feature of EventBridge. AWS recommends EventBridge Scheduler for new implementations because it offers built-in retries, dead-letter queues, and flexible time windows.

Don't see your Lambda in the target dropdown?

Make sure you are in the same AWS region where you created the cost-dashboard-generator function. If you created it in us-east-1, ensure the EventBridge Scheduler console is also set to us-east-1.

help me find my Lambda function in EventBridge Scheduler

Verify the schedule is active

After creation, your schedule should appear on the Schedules page with a status confirming it is ready to run.

  • On the Schedules page, locate your weekly-cost-dashboard schedule.
  • Confirm the Status column shows Enabled.
  • Click the schedule name to open its details.
  • Verify the Schedule expression shows cron(0 9 ? * MON *).
  • Scroll down to the Target section and confirm it points to your cost-dashboard-generator function.
  • Check the Permissions section to confirm an execution role is attached with lambda:InvokeFunction permission.

What does the execution role do?

EventBridge Scheduler needs its own execution role to invoke your Lambda on your behalf. When you chose Create new role for this schedule, the console auto-created a role with the lambda:InvokeFunction permission scoped to your function.

Run a final end-to-end test

Your automation is wired up, but you will not see it fire until next Monday. To confirm everything works together right now, trigger the Lambda manually one more time and verify all the pieces.

  • Navigate to the Lambda console and open your cost-dashboard-generator function.
  • Click the Test tab.
  • Click Test to invoke the function with the existing test event.
  • Confirm the execution result shows statusCode: 200 and the message Dashboard updated successfully.
  • Open a new browser tab and navigate to your S3 website endpoint URL.
  • Verify the dashboard displays your current month spend, forecasted month-end spend, and a cost breakdown by service.
  • Check the "Last updated" timestamp matches the current time (confirming the fresh invocation just ran).
  • Navigate to the CloudWatch console in us-east-1 and open Alarms.
  • Confirm your billing alarm shows a state of OK or INSUFFICIENT_DATA. Both are expected.

Why is my alarm in INSUFFICIENT_DATA?

CloudWatch billing metrics update every 6 hours. If you enabled billing alerts recently, the alarm may not have enough data points yet to evaluate. This is normal and it will transition to OK or ALARM once billing data arrives.

Dashboard not updating after the test?

Check the Lambda execution logs in CloudWatch Logs. Look for errors related to the DASHBOARD_BUCKET environment variable. If it is empty, go back to your Lambda configuration and set it to your S3 bucket name.

help me debug why my cost dashboard Lambda is not uploading to S3

Your cost tracking system is fully automated. Every Monday at 9:00 UTC, EventBridge Scheduler triggers your Lambda, which queries the Cost Explorer API and publishes a fresh dashboard to S3. Meanwhile, your CloudWatch alarm watches for spending spikes and alerts you via SNS email. Six AWS services working together without any manual intervention.

Secret mission

Add Week-Over-Week Cost Comparison

Your dashboard shows what you're spending right now. But how do you know if costs are trending up or down? In this secret mission, you'll add a month-over-month comparison table that highlights which services increased or decreased in spending, with color-coded rows that make cost trends impossible to miss.

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 has a small ongoing cost of approximately $0.02-0.03 per week from Cost Explorer API calls when the schedule is active.

Cost warning

While most services in this project stay within the AWS free tier, the Cost Explorer API charges $0.01 per request. Your Lambda makes 2-3 API calls each week, costing $0.02-0.03 per week. If you do not plan to use the dashboard, disable or delete the EventBridge schedule to stop these charges.

Resources you used:

  • SNS topic billing-alerts with 1 email subscription (us-east-1).
  • CloudWatch billing alarm (us-east-1).
  • S3 bucket my-cost-dashboard-<accountid> with static website hosting.
  • Lambda function cost-dashboard-generator.
  • IAM role cost-dashboard-lambda-role.
  • EventBridge Scheduler schedule weekly-cost-dashboard with execution role.

Keep everything running

No action needed. Choose this if you want your dashboard to keep updating every week and your billing alarm to stay active.

  • Your EventBridge schedule triggers the Lambda every Monday at 9:00 UTC, refreshing your dashboard automatically.
  • The CloudWatch billing alarm continues monitoring your estimated charges and emails you if they exceed your threshold.
  • Ongoing cost is approximately $0.02-0.03 per week for Cost Explorer API calls. All other services remain within the AWS free tier.

Pause - I'll come back to this later

Disable the EventBridge schedule to stop weekly API calls. Your dashboard stays accessible but freezes at its last update.

  • Open the Amazon EventBridge console and select **Schedules** from the left sidebar.
  • Select the weekly-cost-dashboard schedule.
  • Click **Disable** to pause the schedule.
  • Your S3 dashboard URL still works and shows the last generated report. The CloudWatch billing alarm stays active at no extra cost.
  • To resume later, return to the schedule and click **Enable**.

Delete - I don't want to use this again

Remove all project resources to fully clean up. Follow these steps in order.

  • Open the Amazon EventBridge console. Select **Schedules** from the left sidebar, select weekly-cost-dashboard, then click **Delete**.
  • Open the AWS Lambda console. Select the cost-dashboard-generator function, click **Actions**, then click **Delete**.
  • Open the Amazon S3 console. Select your my-cost-dashboard-<accountid> bucket. Click **Empty** to remove all objects first, then click **Delete** to remove the bucket.
  • Open the CloudWatch console in us-east-1. Select **Alarms** from the left sidebar, select your billing alarm, click **Actions**, then click **Delete**.
  • Open the Amazon SNS console in us-east-1. Select **Topics**, select billing-alerts, then click **Delete**.
  • Open the IAM console. Select **Roles** from the left sidebar, search for cost-dashboard-lambda-role, select it, then click **Delete**.
  • Still in the IAM **Roles** page, search for the EventBridge Scheduler execution role (its name starts with Amazon_EventBridge_Scheduler), select it, then click **Delete**.

What about Cost Explorer?

Once you enable CloudWatch billing alerts, you cannot disable the data collection. However, there is no cost for having billing alerts enabled. You can safely leave this setting on.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully automated AWS cost tracking system that monitors your spending, alerts you when costs spike, and publishes a live dashboard you can check from any browser.

You've learned how to:

  • Created a CloudWatch billing alarm connected to SNS that emails you when your AWS estimated charges exceed a defined threshold.
  • Built a Lambda function that queries the Cost Explorer API for spending data grouped by service and a forecasted month-end total, then generates a styled HTML dashboard.
  • Hosted the dashboard on S3 as a static website with a public URL, and automated weekly refreshes using EventBridge Scheduler.
  • Secret Mission: Added a month-over-month cost comparison with color-coded indicators showing which services increased or decreased in spending.

Ready to quiz yourself?