Home Resume
Homeβ€Ί Blogβ€Ί EBS Savings Dashboard β€” Phase 1: Turning CUR 2.0 B…
AWS Terraform

EBS Savings Dashboard β€” Phase 1: Turning CUR 2.0 Billing Data Into a Live Cost Intelligence Platform

A permanent, serverless dashboard that reads AWS Cost & Usage Report Parquet data, surfaces EBS savings opportunities across hundreds of accounts, and costs less than a coffee per month to run.

AWS Platform Engineering Lab Β· EBS Savings Dashboard β€” Phase 1

Why β€” The Problem This Solves

EBS volumes are one of the most reliably wasteful line items in any AWS bill β€” not because engineers are careless, but because the cost is invisible until someone actively looks for it.

In a typical enterprise AWS org with 500 accounts:

  • Unattached volumes keep billing after the EC2 instance they served is terminated
  • gp2 volumes never migrated to gp3 cost 20% more for identical throughput
  • io1 volumes provisioned for peak workloads sit idle at $0.125/GB-month
  • Snapshots pile up automatically and are never reviewed
The Gap in Existing Tooling

QuickSight and Cost Explorer show you what you spent β€” not what you could stop spending. They cannot cross-reference which volumes are unattached right now, or project savings if you take action today. QuickSight also requires per-user licensing starting at $18/user/month.

I wanted a single dashboard that answers three questions at once:

  1. How much are we spending on EBS across the entire org right now?
  2. Which accounts and regions are the biggest offenders?
  3. What is the projected saving if we clean up unused volumes and migrate gp2 β†’ gp3?
Who Benefits

FinOps teams get a monthly savings target with account-level breakdown. Platform teams get evidence to justify cleanup automation. Engineers get out of the loop β€” the data is always current, no one needs to run a script to answer a cost question.

Choosing the Right Approach

There are several ways to build cost visibility tooling on AWS. Here is how the main options compare:

πŸ“ˆ AWS Cost Explorer / QuickSight

Shows billing history well. Cannot cross-reference live EC2 inventory or project savings. QuickSight licensing starts at $18/user/month.

πŸ’΅ Third-Party Tools (CloudHealth, Apptio)

Comprehensive but $10k–$100k+/year. Adds external vendor dependency with data egress. Overkill for a focused EBS workflow.

βœ… CUR 2.0 β†’ Athena β†’ Lambda β†’ React

Reads your own billing data from your own S3 bucket. No licensing. Permanent CloudFront URL. ~$0–2/month. Fully customisable. Phase 2 adds live EC2 inventory on top.

πŸ“Š Custom QuickSight Dashboard

Billing data only β€” cannot call EC2 APIs to check attachment status. Per-user licensing still applies.

The CUR 2.0 β†’ Athena path wins because the data is already in your S3 bucket (AWS delivers it daily), Athena charges only for columns scanned ($5/TB), and the whole pipeline is serverless β€” nothing running when nobody is using it.

Phase 1 Scope

This post covers Phase 1 only β€” CUR billing data, no live EC2 API calls. Phase 2 will add cross-account EC2:DescribeVolumes via STS AssumeRole to show live attachment status per volume.

Architecture β€” How It Fits Together

EBS Savings Dashboard Phase 1 Architecture Three swim lanes: Data Pipeline, API Layer, Frontend. Services connected left to right with arrows. Ξ» β‘  DATA PIPELINE CUR 2.0 Parquet Β· Daily Amazon S3 CUR bucket AWS Glue Data Catalog Amazon Athena Engine v3 Β· Cache Amazon S3 Results Β· 3d TTL StartQueryExecution β‘‘ API LAYER API Gateway HTTP Β· /ebs-savings AWS Lambda handler_phase1.py assumes IAM Role ebsp1-lambda-role β‘’ FRONTEND Amazon S3 React app Β· OAC CloudFront d2gop1c7y82ckf HTTPS Browser Engineer / FinOps Legend β€” Services AWS Billing Β· CUR 2.0 Amazon S3 (storage) AWS Glue (catalog) Amazon Athena (query engine) Amazon API Gateway AWS Lambda (compute) IAM Role (temp STS creds) Amazon CloudFront (CDN) Browser (user) Data / service flow IAM role assumption Storage Compute Analytics Security

Three swim lanes, entirely serverless, no persistent compute:

  • β‘  Data Pipeline β€” CUR 2.0 Parquet lands in S3. Glue Catalog provides the schema. Athena queries the data. Results cached in a second S3 bucket for 3 days.
  • β‘‘ API Layer β€” API Gateway triggers Lambda on every dashboard load. Lambda runs as an IAM Role with least-privilege permissions β€” no IAM users, no access keys anywhere.
  • β‘’ Frontend β€” React app compiled and synced to a private S3 bucket. CloudFront serves it globally via OAC. Permanent HTTPS URL, free *.cloudfront.net certificate.

Step 1 β€” Enable CUR 2.0 Export

Everything starts here. CUR 2.0 is the billing export AWS delivers daily to an S3 bucket in Parquet format. It must be enabled from the management (payer) account β€” not a member account. Go to Billing β†’ Data Exports β†’ Create export.

AWS CUR 2.0 export creation screen showing Daily granularity, Parquet format, and Amazon Athena integration checked
CUR 2.0 export setup β€” key selections: Daily granularity, Include Resource IDs, Create new file (not overwrite), Amazon Athena checked, Parquet format

The critical selections:

  • Include Resource IDs β€” required. This is the EBS volume ID (vol-xxxxxxxx)
  • Time granularity: Daily β€” not Hourly (24x more rows with zero benefit for monthly savings tracking)
  • File versioning: Create new file β€” not Overwrite. You want historical months preserved so the trend chart has data to show
  • Amazon Athena checked β€” AWS auto-creates the Glue Catalog table and Hive partitions. No Glue Crawler needed in production
  • Parquet format β€” columnar, Snappy-compressed. Athena scans only the columns your SQL touches, not all 116
All 116 columns? Yes β€” keep them all

Athena only charges for columns it actually scans. Your SQL selects ~8 columns. The other 108 cost nothing. Removing columns now means you can't add them back later without losing historical data.

Step 2 β€” Data Pipeline: S3, Glue, Athena

Once CUR is configured, AWS delivers one Parquet file per billing period to your S3 bucket. For Phase 1 testing I generated 18 months of synthetic data using generate_mock_cur.py β€” simulating 500 accounts, 6 regions, and ~5,000 EBS volumes. This lets the full pipeline be validated before waiting 24 hours for real CUR delivery.

S3 bucket showing 18 Parquet files named cur_2025-01.parquet through cur_2026-06.parquet
S3 bucket with 18 synthetic Parquet files β€” one per month, Jan 2025 to Jun 2026 β€” totalling ~7 MB compressed

A Glue Crawler reads the bucket and registers the schema in the Glue Data Catalog. Athena then uses that catalog to query the files with SQL β€” without needing to know the physical file paths.

Athena query editor showing SELECT * FROM ebsp1_cur_db.synthetic_report LIMIT 5 with column names and sample rows
Athena query editor β€” the synthetic_report table is visible in the left panel. Running a LIMIT 5 query shows the actual column names and data your Lambda reads
CUR schema is universal

Column names like line_item_usage_account_id, line_item_unblended_cost, and product_regioncode are standardised by AWS β€” identical across every company, every account, every region worldwide. Write the SQL once and it works in any AWS org.

The Lambda filters to EBS-only rows using:

SQL β€” EBS filter in Athena
WHERE line_item_product_code = 'AmazonEC2'
  AND line_item_usage_type LIKE '%EBS:VolumeUsage%'

This skips EC2 instances, RDS, and every other service in the same CUR file β€” returning only EBS volume cost rows.

Athena Query Result Reuse

CUR data only updates once per day. Running a fresh Athena scan every time someone opens the dashboard is wasteful. Athena's Query Result Reuse returns cached results at $0 cost if the same query ran within the last 7 days. The first user each day pays ~$0.01 for the scan. Everyone else that day gets sub-second responses from cache.

Step 3 β€” Identity & Security (No IAM Users)

One design principle held throughout: no IAM users anywhere. Every action is performed by IAM Roles with temporary STS credentials that auto-expire in 15 minutes.

1

Browser Opens URL

User navigates to the CloudFront URL. No AWS identity, no login, no VPN required.

2

React App Calls API

The React app calls fetch() to API Gateway. Still no AWS identity β€” just an HTTPS request from the browser.

3

Lambda Assumes IAM Role via STS

API Gateway invokes Lambda. Lambda automatically assumes ebsp1-lambda-role β€” 15-minute temporary credentials, issued at runtime, never stored anywhere.

4

Athena Queries CUR Data

Lambda calls Athena with those temp credentials. Athena reads S3. Results written to the results bucket. No human credential ever touched the data.

5

Full Auditability for Free

CloudTrail records every Athena query attributed to the Lambda role ARN. No credential management overhead, no keys to rotate when someone leaves.

Phase 1 identity and data flow diagram showing Browser to CloudFront to API Gateway to Lambda to Athena to S3 CUR to Dashboard
Full request flow β€” left to right: Browser opens URL (no AWS identity) β†’ CloudFront serves React app β†’ Browser calls API Gateway β†’ Lambda runs as IAM Role (no IAM user, no keys) β†’ Athena queries CUR Parquet β†’ JSON savings data returned to Dashboard

The IAM role policy is scoped to exactly what Phase 1 needs:

IAM Policy β€” Phase 1 (CUR only)
Athena:   StartQueryExecution, GetQueryExecution, GetQueryResults
S3 CUR:   GetObject, ListBucket  (read-only)
S3 Res:   PutObject, GetObject, DeleteObject
Glue:     GetTable, GetDatabase, GetPartitions
# No EC2, no STS AssumeRole, no Organizations β€” CUR data only

Step 4 β€” Lambda & API Gateway

Lambda (handler_phase1.py) is the engine of the dashboard. It receives the API Gateway request, builds a parameterised Athena SQL query, polls for results, and returns a structured JSON payload to the browser.

Key design decisions in the Lambda:

  • Region filter β€” ?region=us-east-1 appended to the fetch URL threads through to a {region_filter} placeholder in the SQL WHERE clause
  • Months selector β€” 3/6/12/18 months controls how far back the Athena query looks
  • Split-month logic β€” divides data into pre-cleanup and post-cleanup periods to calculate savings delta
  • Division-by-zero guard β€” returns zero savings (not an error) when there is no pre-cleanup data

API Gateway is configured as an HTTP API (not REST) with CORS open for Phase 1 testing. Before connecting real CUR data at work, add IAM authorization or an API key so the endpoint is not publicly callable.

Step 5 β€” React Dashboard on CloudFront

The React app (AWS CloudScape Design System) is compiled with npm run build and synced to a private S3 bucket. CloudFront sits in front of it via OAC (Origin Access Control) β€” the S3 bucket has no public access at all. Every request is served over HTTPS from the free *.cloudfront.net certificate.

Deploying a new version is three commands:

Deploy frontend
npm run build
aws s3 sync dist/ s3://ebsp1-frontend-684346483786/ --delete --profile personal
aws cloudfront create-invalidation \
  --distribution-id E3W08TQPVK17E8 \
  --paths "/*" --profile personal

The CloudFront URL is permanent β€” it does not change when you redeploy. Share it once with your FinOps team and it always points to the latest version.

What the Dashboard Shows

The dashboard has four tabs, all responding to a global region filter and months selector (3 / 6 / 12 / 18 months).

EBS Savings Dashboard overview tab showing KPI strip with monthly savings and trend chart
Overview tab β€” KPI strip at top (monthly spend before/after, savings/month, projected annual saving, volumes cleaned up) and 18-month trend chart with cleanup reference line
By Account tab showing bar chart ranking accounts by EBS spend
By Account tab β€” ranks every account by EBS spend, instantly showing which account teams to contact first for cleanup
By Region tab showing horizontal bars with spend per AWS region
By Region tab β€” spend distribution across us-east-1, eu-west-1, ap-southeast-1, and other regions
By Volume Type tab comparing gp2, gp3, io1, and st1 spend
By Volume Type tab β€” compares gp2 vs gp3 vs io1 vs st1 spend, making the gp2 β†’ gp3 migration opportunity visible as a real dollar figure

Challenges & Real Bugs Fixed

1
volumes_deleted showed 301,884 β€” 18Γ— the real number

The bug: summing volume_count across all 18 months instead of taking first month count minus last month count. Cross-month summation inflated the number by 18Γ—.

βœ“ Fix

volumes_deleted = first_month_count βˆ’ last_month_count

2
By Region bars all showed $0

When cleanup_start was an empty string, all months landed in the "post" bucket β€” zero pre-cleanup data, zero calculated savings delta per region.

βœ“ Fix

Added a midpoint fallback: when cleanup_start is empty, use the halfway point of the date range as the split.

3
Savings showed βˆ’$16,550/month (negative)

Mock data churn rate was 3% per month. Over 18 months this deleted more volumes than plausibly existed, making post-cleanup cost higher than pre-cleanup cost.

βœ“ Fix

Reduced churn rate to 0.5% β€” realistic for a managed enterprise environment.

4
Region filter dropdown had no effect on charts

The selected region value was not being appended to the API URL, so Lambda received no region parameter and returned all-region data regardless of selection.

βœ“ Fix

Append ?region={selectedRegion} to the fetch call and thread it to a {region_filter} placeholder in the Lambda SQL.

Cost to Run

ServiceUsageMonthly Cost
Lambda~30 invocations Β· 512 MB Β· 15s avg$0.00 (free tier)
Athena~5 queries/day Β· 400 KB scanned (Parquet)<$0.01
S3 (3 buckets)~8 MB total stored<$0.01
CloudFront<1 GB transfer Β· PriceClass_100$0.00 (free tier)
API Gateway<1 million requests$0.00 (free tier)
Glue Crawler1 run/month Β· <1 min$0.00 (free tier)
Total~$0–2/month

At enterprise scale (real CUR, 500 accounts, 50 engineers opening the dashboard daily), Athena cost rises to ~$2–5/month due to larger Parquet files. Everything else stays the same.

Production caching pattern

CUR updates once per day. The right pattern is: EventBridge daily pre-warm at 06:00 UTC (Lambda warms the Athena cache before anyone opens the dashboard) + Athena Query Result Reuse (7-day cache, $0 for repeat queries) + CloudFront API cache (5-min TTL). First user each day pays ~$0.01. Everyone else gets sub-second responses.

Switching to Real CUR Data (3 Steps)

Phase 1 runs on synthetic data. When your platform team enables real CUR, only three things change. Lambda, API Gateway, CloudFront, and the React dashboard are untouched.

1

Enable CUR 2.0 Export (Management Account)

Ask your FinOps or Platform team: Billing β†’ Data Exports β†’ Create export with the selections above. This requires management account access β€” not a member account.

2

Update Two Lambda Env Vars

In Terraform: ATHENA_DATABASE β†’ the Glue database AWS auto-creates (athenacurcfn) and CUR_TABLE β†’ your export report name. Run terraform apply.

3

Update SQL Column Names

Swap synthetic names for real CUR column names in handler_phase1.py: line_item_usage_account_id, line_item_unblended_cost, product_regioncode, etc. These are identical across every AWS org worldwide.

What Phase 2 Adds

Phase 1 answers "how much are we spending." Phase 2 will answer "which specific volumes should we delete today."

Phase 2 adds cross-account EC2 inventory via STS AssumeRole. Lambda will temporarily assume an EBSDashboardReadRole in each member account and call EC2:DescribeVolumes to pull live attachment status, volume state, last-attached time, and tags.

A CloudFormation StackSet (deployed via Terraform) automatically creates EBSDashboardReadRole in every member account β€” including new accounts added to the org in future. The trust policy uses a PrincipalOrgID condition so only Lambda inside the same AWS Organization can assume the role. No IAM users, no long-lived credentials β€” same security model as Phase 1.

This unlocks a fifth tab: Volume Inventory β€” a sortable table of every unattached volume with size, type, region, account, last-attached date, and projected monthly saving if deleted.

Key Takeaways

CUR schema is universal β€” write SQL once, run anywhere

Column names are standardised by AWS and identical across every company, every account, every region. The SQL you write against synthetic data works against your employer's real CUR without modification β€” only the S3 path and table name change.

No IAM users = simpler security posture

Lambda runs as an IAM Role with temporary STS credentials. No keys to rotate, no credentials to leak, no access to revoke when someone leaves the team. CloudTrail gives full auditability for free.

Validate with synthetic data before touching production billing

Building and debugging the entire pipeline against mock data meant no risk of accidentally querying real billing data, no waiting 24 hours for CUR delivery, and full control over the data shape. Every tab, filter, and edge case was proven before connecting real data.

What’s Next

  • Add API Gateway authorization (IAM auth or API key) β€” current endpoint is open for testing only
  • Add CloudWatch alarms for Lambda error rate and p99 duration
  • Add EventBridge daily pre-warm schedule (06:00 UTC)
  • Connect real CUR 2.0 export from management account
  • Phase 2: StackSet deployment + live Volume Inventory tab

Full Terraform, Lambda handler, React dashboard, and architecture draw.io diagrams are in the GitHub repo.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent