What Does This Blog Cost? — A Live Cost Dashboard with AWS Cost Explorer

One question I ask myself with every AWS project: what does this actually cost? For this blog I built the answer directly in — at the bottom of this post you can see live what aws-sensei.cloud has cost in the current month, broken down by AWS service.
The Idea
The goal was not a static screenshot dashboard, but real live data straight from AWS — updated daily, embedded directly in the blog. A Hugo shortcode, one API call, done.
The data source is AWS Cost Explorer, which provides accurate cost data per service. The problem: Cost Explorer charges $0.01 per API call — too expensive for every page view. The solution is caching via SSM Parameter Store.
The Architecture
EventBridge (daily 06:00 UTC)
→ Lambda refresh → Cost Explorer API → SSM Parameter Store
↓
Browser → API Gateway → Lambda read → SSM Parameter Store
Two Lambdas, clearly separated responsibilities:
- sensei-cost-refresh — triggered daily by EventBridge, queries Cost Explorer and writes the result as JSON to SSM Parameter Store
- sensei-cost-read — triggered on every widget call, reads from SSM only — no Cost Explorer call, no additional costs
The SAM Template
Both Lambdas live in the same stack but in separate subdirectories so SAM packages them individually:
apis/cost/
├── src/
│ ├── read/handler.py
│ └── refresh/handler.py
└── template.yaml
The EventBridge trigger is defined directly in the SAM template as a Schedule event:
CostRefreshFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: sensei-cost-refresh
Handler: handler.lambda_handler
CodeUri: src/refresh/
Events:
DailyRefresh:
Type: Schedule
Properties:
Schedule: cron(0 6 * * ? *)
Name: sensei-cost-refresh-daily
No separate EventBridge template, no manual configuration — everything in one file.
The Refresh Lambda
ce = boto3.client("ce", region_name="us-east-1")
ssm = boto3.client("ssm", region_name="eu-central-1")
def lambda_handler(event, context):
today = date.today()
start = today.replace(day=1).isoformat()
end = today.isoformat()
response = ce.get_cost_and_usage(
TimePeriod={"Start": start, "End": end},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
services = {}
total = 0.0
for group in response["ResultsByTime"][0]["Groups"]:
service = group["Keys"][0]
amount = float(group["Metrics"]["UnblendedCost"]["Amount"])
if amount >= 0.0001:
services[service] = round(amount, 4)
total += amount
ssm.put_parameter(
Name="/sensei/blog/cost-data",
Value=json.dumps({
"total": round(total, 4),
"services": services,
"period": {"start": start, "end": end},
"refreshed_at": datetime.now(timezone.utc).isoformat(),
}),
Type="String",
Overwrite=True,
)
One important note: Cost Explorer is only available in us-east-1 — the boto3 client must set this explicitly, regardless of which region the Lambda runs in. The SSM client stays in eu-central-1.
IAM Permissions
The refresh Lambda needs two permissions:
- ce:GetCostAndUsage # query Cost Explorer
- ssm:PutParameter # write result to SSM
The read Lambda only one:
- ssm:GetParameter # read cache from SSM
No wildcard, no * on actions — least privilege as always.
The Widget
The Hugo shortcode calls the read Lambda via API Gateway and renders the results as a list sorted by cost. If the SSM parameter is not yet populated (e.g. right after the first deployment), the widget shows a notice instead of an error.
In Markdown all it takes is:
{{< cost >}}