<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ finops - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ finops - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 26 Jun 2026 04:50:03 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/finops/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ The 2026 FinOps Roadmap: From Cost-Blind Engineer to Cloud Financial Manager ]]>
                </title>
                <description>
                    <![CDATA[ My first AWS bill was $23,000. I had been working at the company for three weeks. Nobody told me. The bill just grew quietly in the background while I was proud of the feature I shipped. A Lambda func ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-2026-finops-roadmap-from-cost-blind-engineer-to-cloud-financial-manager/</link>
                <guid isPermaLink="false">6a30894af07f26c8d93079b8</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ finops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Roadmap ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Mon, 15 Jun 2026 23:22:50 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/365d29dc-738d-4c21-a9a5-8f818c36cc95.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>My first AWS bill was $23,000. I had been working at the company for three weeks.</p>
<p>Nobody told me. The bill just grew quietly in the background while I was proud of the feature I shipped. A Lambda function that called an external enrichment API on every user event. Clean code. Solid tests. Thirty-two million events that month. At $0.0007 per API call.</p>
<p>My engineering manager forwarded the invoice with two words: "Please explain."</p>
<p>That was the moment I discovered FinOps — not from a conference talk or a certification course, but from the specific shame of having written expensive code and not knowing it until the damage was done.</p>
<p>This roadmap is what I needed that day. A complete, honest guide to transforming from an engineer who builds things that work into an engineer who builds things that work <em>and</em> cost what they should. By the end of this guide, you'll have the skills, the scripts, and the vocabulary to talk about cloud spend the way a CFO and a CTO both want to hear.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-four-stages-overview">The Four Stages Overview</a></p>
</li>
<li><p><a href="#heading-stage-1-the-cost-aware-engineer-months-1-to-3">Stage 1: The Cost-Aware Engineer — Months 1 to 3</a></p>
</li>
<li><p><a href="#heading-stage-2-the-optimisation-specialist-months-4-to-8">Stage 2: The Optimisation Specialist — Months 4 to 8</a></p>
</li>
<li><p><a href="#heading-stage-3-the-automation-architect-months-9-to-15">Stage 3: The Automation Architect — Months 9 to 15</a></p>
</li>
<li><p><a href="#heading-stage-4-the-cloud-financial-manager-months-16-to-24">Stage 4: The Cloud Financial Manager — Months 16 to 24</a></p>
</li>
<li><p><a href="#heading-essential-tools-and-certifications">Essential Tools and Certifications</a></p>
</li>
<li><p><a href="#heading-your-90-day-action-plan">Your 90-Day Action Plan</a></p>
</li>
<li><p><a href="#heading-best-practices-summary">Best Practices Summary</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>How to read your AWS bill as an engineer, not as a passive observer</p>
</li>
<li><p>The exact tagging strategy that makes cost attribution possible</p>
</li>
<li><p>How to right-size EC2 and RDS instances using CloudWatch data you already have</p>
</li>
<li><p>The correct sequence for purchasing Savings Plans — and why sequence matters more than the discount percentage</p>
</li>
<li><p>How to build automated cleanup systems for orphaned resources</p>
</li>
<li><p>How to present cloud cost findings to engineering leadership with data that drives decisions</p>
</li>
<li><p>The chargeback and showback models that make cost accountability stick</p>
</li>
</ul>
<p>Let's begin.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following this roadmap, you should have some skills and tools ready to go.</p>
<p><strong>Knowledge:</strong></p>
<ul>
<li><p>You can deploy an application to AWS (EC2, Lambda, or containers)</p>
</li>
<li><p>You understand basic AWS services: S3, RDS, EC2, VPC, IAM</p>
</li>
<li><p>You're comfortable reading Python and writing simple bash scripts</p>
</li>
<li><p>You know what a pull request is and have gone through at least one code review</p>
</li>
</ul>
<p><strong>Access:</strong></p>
<ul>
<li><p>Read-only access to your AWS billing console and Cost Explorer</p>
</li>
<li><p>AWS CLI v2 configured with at least <code>ReadOnlyAccess</code> policy attached</p>
</li>
<li><p>Python 3.9 or later for running the audit scripts in this guide</p>
</li>
</ul>
<p><strong>Mindset:</strong> You don't need to be a finance expert. But you do need to be willing to look at numbers that might be uncomfortable. Every engineer I've worked with who became excellent at FinOps had one thing in common: they were willing to be the person who asked "but what does this cost?" in a room where nobody else wanted to.</p>
<p><strong>Estimated time:</strong> This roadmap covers 24 months of deliberate skill-building. You can absorb the reading in a few evenings. The practice is the 24 months.</p>
<h2 id="heading-the-four-stages-overview">The Four Stages Overview</h2>
<p>Before going deep, here's the complete picture of where you're going:</p>
<pre><code class="language-plaintext">Stage 1 — Cost-Aware Engineer (Months 1–3)
├── Read your cloud bill and understand it
├── Tag every resource with meaningful metadata
├── Identify your top 5 cost drivers
└── Block your first expensive PR with cost justification

Stage 2 — Optimisation Specialist (Months 4–8)
├── Right-size every over-provisioned resource
├── Implement storage lifecycle policies
├── Move non-production to Spot instances
└── Purchase your first Savings Plan in the right order

Stage 3 — Automation Architect (Months 9–15)
├── Build automated cleanup for orphaned resources
├── Add cost estimation to your CI/CD pipeline
├── Create cost-aware auto-scaling triggers
└── Deploy a self-service FinOps dashboard

Stage 4 — Cloud Financial Manager (Months 16–24)
├── Lead monthly FinOps reviews with engineering leadership
├── Build chargeback models for departments
├── Negotiate enterprise agreements with AWS
└── Forecast cloud spend within 5% variance
</code></pre>
<p>The reason this is a 24-month journey and not a weekend project: each stage builds on the previous one. Engineers who jump straight to Savings Plans without rightsizing first end up paying discounted prices for waste. Engineers who build dashboards before tagging get beautiful charts with no actionable data. The sequence isn't arbitrary.</p>
<h2 id="heading-stage-1-the-cost-aware-engineer-months-1-to-3">Stage 1: The Cost-Aware Engineer — Months 1 to 3</h2>
<h3 id="heading-11-reading-the-bill-like-an-engineer-not-an-accountant">1.1 Reading the Bill Like an Engineer, Not an Accountant</h3>
<p>The default AWS Cost Explorer view shows you service-level totals. That's accounting. What you need is engineering-level decomposition: which specific resources cost money, what business function they serve, and whether each dollar is justified.</p>
<p>Start by pulling a proper breakdown:</p>
<pre><code class="language-bash"># Pull last month's cost breakdown grouped by service
# Run this before touching any optimisation — this is your baseline
aws ce get-cost-and-usage \
  --time-period Start=\((date -d 'last month' +%Y-%m-01),End=\)(date +%Y-%m-01) \
  --granularity MONTHLY \
  --group-by Type=DIMENSION,Key=SERVICE \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Groups[*].{Service:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
  --output table | sort -k3 -rn
</code></pre>
<p>Save the output. Name the file <code>aws-baseline-YYYY-MM.txt</code>. You'll compare every future month against this number. Without a baseline, you can't measure progress — and without measurable progress, you can't make the case to leadership that the work is worth engineering time.</p>
<h4 id="heading-three-questions-for-every-service-in-your-top-5">Three questions for every service in your top 5:</h4>
<p>Most engineers stop at "what is this service?" and never reach the useful question. Here's the framework I use when I first audit an account:</p>
<p>The first question is whether you know what specific business function this service is performing. Not the product name, the function. "S3" isn't an answer. "Storing unprocessed video uploads that sit for 90 days before anyone watches them" is an answer.</p>
<p>The second question is whether the cost is growing, stable, or shrinking when you look at the past three months. A stable \(12,000/month is a different problem from a \)12,000/month line that was $4,000 six months ago.</p>
<p>The third question is what percentage of your total bill this service represents. Optimising a 1% line item while a 40% line item runs unchecked is a common time-wasting trap.</p>
<h3 id="heading-12-the-tagging-strategy-that-actually-survives">1.2 The Tagging Strategy That Actually Survives</h3>
<p>Here's the honest truth about tagging: most tagging strategies die within six months because they're designed for reporting rather than for engineers. Engineers don't tag things well when they're moving fast. The solution isn't to demand more discipline. Instead, it's to make tagging enforced at the infrastructure layer.</p>
<p>Here's the minimal viable tag set (the six tags that cover 90% of attribution needs):</p>
<pre><code class="language-yaml"># These six tags enable cost attribution, accountability, and automated remediation
# Add these to every resource in your AWS account — EC2, RDS, S3, Lambda, everything

Environment: "production" | "staging" | "dev"
Team: "platform" | "backend" | "data" | "ml"
Service: "payment-api" | "fraud-detection" | "user-service"
Owner: "ayo@cloudfrugal.com"     # Person responsible for this resource
CostCenter: "engineering"         # For chargeback reporting
AutoShutdown: "true" | "false"    # Enables automated remediation
</code></pre>
<p>Enforce tags at the Terraform level so they can't be skipped:</p>
<pre><code class="language-hcl"># variables.tf
# Add this to your Terraform root module
# Any plan that creates a resource without these tags will fail validation

variable "required_tags" {
  description = "Tags required on every resource in this account"
  type = map(string)
  
  validation {
    condition = contains(keys(var.required_tags), "Environment") &amp;&amp;
                contains(keys(var.required_tags), "Team") &amp;&amp;
                contains(keys(var.required_tags), "Owner")
    error_message = "required_tags must include Environment, Team, and Owner."
  }
}

# Apply in every resource
resource "aws_instance" "app_server" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.medium"

  tags = merge(var.required_tags, {
    Name    = "app-server-${var.environment}"
    Service = "payment-api"
  })
}
</code></pre>
<p>Find everything that's currently untagged:</p>
<pre><code class="language-bash"># List EC2 instances missing the Team tag
# Run this weekly until you hit zero results
aws ec2 describe-instances \
  --query "Reservations[].Instances[?!not_null(Tags[?Key=='Team'].Value | [0])].[InstanceId, InstanceType, State.Name]" \
  --output table
</code></pre>
<p>Once you start finding untagged resources, you'll discover a pattern: the oldest resources in the account are the least tagged, and they're often the most expensive. An EC2 instance from 2021 that predates your tagging policy is exactly the kind of thing that generates a $3,000/month line item nobody can explain.</p>
<h3 id="heading-13-the-cost-aware-code-review">1.3 The Cost-Aware Code Review</h3>
<p>The most underused FinOps practice in engineering teams is reviewing code changes for cost implications before they merge. It takes thirty seconds per PR once you build the habit, and it prevents the kind of problem that opened this guide: the expensive feature that nobody priced before shipping.</p>
<p>Add this section to your PR template:</p>
<pre><code class="language-markdown">## Cost Impact (required for infrastructure and data changes)

- [ ] This change does not affect cloud resource usage
- [ ] New API calls introduced: estimated cost per call $______, calls/month ______
- [ ] New data storage: estimated monthly delta $______
- [ ] Cross-region data transfer introduced: yes / no
- [ ] New external service dependency with per-call pricing: yes / no

If any box other than the first is checked, add a cost estimate before requesting review.
</code></pre>
<p>The discipline is in making cost estimation a first-class review concern, not an afterthought that gets caught by the finance team on the 15th of the month.</p>
<h3 id="heading-stage-1-outcomes">Stage 1 Outcomes</h3>
<p>By the end of month 3, you should have a baseline cost breakdown on file, 100% tag coverage on active resources, identified your top 5 cost drivers with specific reduction targets, and blocked at least one expensive PR with a cost justification that held up in review.</p>
<h2 id="heading-stage-2-the-optimisation-specialist-months-4-to-8">Stage 2: The Optimisation Specialist — Months 4 to 8</h2>
<h3 id="heading-21-right-sizing-the-8020-of-cloud-savings">2.1 Right-Sizing: The 80/20 of Cloud Savings</h3>
<p>The single most reliable source of cloud waste I find in every account I audit is over-provisioned compute.</p>
<p>The pattern is consistent: an engineer provisions an instance at a size that handles their anticipated peak load, the peak never quite materialises at the expected scale, and nobody revisits the instance size because there's no automatic signal that says "this machine is 75% empty."</p>
<p>Make sure you verify actual utilisation before changing anything:</p>
<pre><code class="language-python"># rightsize_analyzer.py
# Finds EC2 instances running below 20% average CPU for 14 days
# These are right-sizing candidates — not automatic deletions

import boto3
from datetime import datetime, timedelta

def find_oversized_instances(region='us-east-1'):
    """
    Returns instances with average CPU below 20% for the last 14 days.
    Low CPU alone doesn't mean right-size — check memory too if CW agent installed.
    """
    ec2 = boto3.client('ec2', region_name=region)
    cw  = boto3.client('cloudwatch', region_name=region)

    reservations = ec2.describe_instances(
        Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]
    )['Reservations']

    candidates = []

    for r in reservations:
        for inst in r['Instances']:
            iid  = inst['InstanceId']
            itype = inst['InstanceType']
            tags = {t['Key']: t['Value'] for t in inst.get('Tags', [])}

            # Pull 14-day average CPU from CloudWatch
            stats = cw.get_metric_statistics(
                Namespace='AWS/EC2',
                MetricName='CPUUtilization',
                Dimensions=[{'Name': 'InstanceId', 'Value': iid}],
                StartTime=datetime.utcnow() - timedelta(days=14),
                EndTime=datetime.utcnow(),
                Period=1209600,   # One 14-day period
                Statistics=['Average']
            )['Datapoints']

            avg_cpu = stats[0]['Average'] if stats else 0.0

            if avg_cpu &lt; 20.0:
                candidates.append({
                    'instance_id':  iid,
                    'instance_type': itype,
                    'avg_cpu_pct':  round(avg_cpu, 1),
                    'environment':  tags.get('Environment', 'unknown'),
                    'owner':        tags.get('Owner', 'unknown'),
                    'team':         tags.get('Team', 'unknown'),
                })

    return sorted(candidates, key=lambda x: x['avg_cpu_pct'])

if __name__ == '__main__':
    results = find_oversized_instances()
    print(f"\nFound {len(results)} right-sizing candidates:\n")
    for r in results:
        print(f"  {r['instance_id']} ({r['instance_type']}) — "
              f"{r['avg_cpu_pct']}% avg CPU — "
              f"owner: {r['owner']}")
</code></pre>
<p>A word of caution: CPU utilisation below 20% is a signal, not a verdict. Some workloads are memory-intensive or I/O-bound and will show low CPU while being correctly sized. Before acting on any right-sizing recommendation, check memory utilisation (requires the CloudWatch agent) and network I/O patterns alongside CPU.</p>
<h3 id="heading-22-storage-tiering-stop-paying-retail-for-cold-data">2.2 Storage Tiering: Stop Paying Retail for Cold Data</h3>
<p>S3 Standard costs \(0.023 per GB per month. S3 Glacier Deep Archive costs \)0.00099 per GB per month. The difference is a factor of 23. If you have data that you last accessed six months ago and you're keeping it in S3 Standard because nobody set up lifecycle policies, you're paying 23x more than necessary.</p>
<p><strong>The complete S3 lifecycle policy for engineering teams:</strong></p>
<pre><code class="language-json">{
  "Rules": [
    {
      "ID": "application-logs-lifecycle",
      "Status": "Enabled",
      "Filter": {"Prefix": "logs/"},
      "Transitions": [
        {"Days": 30,  "StorageClass": "STANDARD_IA"},
        {"Days": 90,  "StorageClass": "GLACIER_IR"},
        {"Days": 365, "StorageClass": "DEEP_ARCHIVE"}
      ],
      "Expiration": {"Days": 2555},
      "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
    },
    {
      "ID": "training-checkpoints-lifecycle",
      "Status": "Enabled",
      "Filter": {"Prefix": "ml-checkpoints/"},
      "Transitions": [
        {"Days": 7,  "StorageClass": "STANDARD_IA"},
        {"Days": 30, "StorageClass": "GLACIER_IR"}
      ],
      "Expiration": {"Days": 90}
    }
  ]
}
</code></pre>
<pre><code class="language-bash"># Apply the lifecycle policy to a bucket
aws s3api put-bucket-lifecycle-configuration \
  --bucket your-logs-bucket \
  --lifecycle-configuration file://lifecycle.json

# Verify it applied correctly
aws s3api get-bucket-lifecycle-configuration \
  --bucket your-logs-bucket
</code></pre>
<h3 id="heading-23-savings-plans-the-sequence-is-everything">2.3 Savings Plans: The Sequence Is Everything</h3>
<p>A Savings Plan is a commitment to spend a minimum dollar amount per hour on AWS compute for one or three years, in exchange for discounts of 30–70% off On-Demand rates. The discount is real. The trap is buying before optimising.</p>
<p><strong>The wrong order:</strong> You have a \(50,000/month EC2 bill. You buy a Savings Plan covering \)35,000/hour. Then you implement right-sizing and Spot instances — and your actual spend drops to \(22,000/month. You've committed to paying \)35,000/month for 12 months against a need of \(22,000. You're paying \)13,000/month for compute you don't use, at a 30% discount. Congratulations on your discounted waste.</p>
<p><strong>The right order:</strong></p>
<pre><code class="language-plaintext">Month 1-2: Right-size all instances using VPA and CloudWatch data
Month 3:   Move staging and development to Spot instances
Month 4:   Migrate compatible workloads to Graviton (20% cheaper)
Month 5:   Add VPC endpoints to eliminate NAT Gateway charges
Month 6:   THEN look at your steady-state On-Demand spend
Month 6+:  Purchase Savings Plans covering 70% of that optimised baseline
</code></pre>
<p><strong>Calculate what to commit to:</strong></p>
<pre><code class="language-bash"># Get your On-Demand EC2 spend for the last 30 days
# This is your rightsized baseline — the number to commit against
aws ce get-cost-and-usage \
  --time-period Start=\((date -d '30 days ago' +%Y-%m-%d),End=\)(date +%Y-%m-%d) \
  --granularity DAILY \
  --filter '{
    "And": [
      {"Dimensions": {"Key": "SERVICE",       "Values": ["Amazon Elastic Compute Cloud - Compute"]}},
      {"Dimensions": {"Key": "PURCHASE_TYPE", "Values": ["On-Demand"]}}
    ]
  }' \
  --metrics UnblendedCost \
  --query 'ResultsByTime[*].{Date:TimePeriod.Start,Cost:Total.UnblendedCost.Amount}' \
  --output table

# Get AWS's own recommendation for what to commit
aws savingsplans get-savings-plans-purchase-recommendation \
  --savings-plans-type COMPUTE_SP \
  --term-in-years ONE_YEAR \
  --payment-option NO_UPFRONT \
  --lookback-period-in-days THIRTY_DAYS
</code></pre>
<h2 id="heading-stage-3-the-automation-architect-months-9-to-15">Stage 3: The Automation Architect — Months 9 to 15</h2>
<h3 id="heading-31-the-orphaned-resource-problem-and-why-it-never-fixes-itself">3.1 The Orphaned Resource Problem — And Why It Never Fixes Itself</h3>
<p>Orphaned resources are the cloud equivalent of a gym membership you forgot to cancel. They exist, they charge you, but nobody notices until the annual audit.</p>
<p>The root cause isn't laziness. It's the absence of lifecycle management at the infrastructure layer. When an engineer spins up an EC2 instance for a one-week experiment and then leaves the company, there's no automatic signal that the instance is now orphaned. It sits there, billing $140/month, until someone hunts it down.</p>
<p>The fix is a weekly automated audit that surfaces candidates for deletion and notifies the registered owner, not a process change that depends on engineers remembering to clean up.</p>
<pre><code class="language-python"># orphan_reporter.py
# Runs every Sunday via EventBridge → Lambda
# Posts a Slack report of orphaned resources for human review
# DOES NOT auto-delete — deletion requires a human decision

import boto3
import json
import urllib.request
from datetime import datetime, timedelta, timezone

SLACK_WEBHOOK = 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
UNATTACHED_VOLUME_AGE_DAYS = 14
SNAPSHOT_AGE_DAYS = 90


def find_orphaned_resources():
    ec2 = boto3.client('ec2')
    report = {'monthly_waste_usd': 0, 'items': []}

    # Unattached EBS volumes
    for vol in ec2.describe_volumes(
        Filters=[{'Name': 'status', 'Values': ['available']}]
    )['Volumes']:
        age = (datetime.now(timezone.utc) - vol['CreateTime']).days
        if age &gt;= UNATTACHED_VOLUME_AGE_DAYS:
            cost = round(vol['Size'] * 0.08, 2)  # gp3 rate
            tags = {t['Key']: t['Value'] for t in vol.get('Tags', [])}
            report['items'].append({
                'type':  'Unattached EBS Volume',
                'id':    vol['VolumeId'],
                'detail': f"{vol['Size']}GB {vol['VolumeType']} — {age} days old",
                'owner': tags.get('Owner', 'unknown'),
                'monthly_cost_usd': cost,
            })
            report['monthly_waste_usd'] += cost

    # Unassociated Elastic IPs
    for addr in ec2.describe_addresses()['Addresses']:
        if 'AssociationId' not in addr:
            report['items'].append({
                'type':  'Unassociated Elastic IP',
                'id':    addr.get('AllocationId', addr['PublicIp']),
                'detail': addr['PublicIp'],
                'owner': 'unknown',
                'monthly_cost_usd': 3.60,
            })
            report['monthly_waste_usd'] += 3.60

    # Old snapshots
    cutoff = (datetime.now(timezone.utc) - timedelta(days=SNAPSHOT_AGE_DAYS)).isoformat()
    for snap in ec2.describe_snapshots(OwnerIds=['self'])['Snapshots']:
        if snap['StartTime'].isoformat() &lt; cutoff:
            cost = round(snap.get('VolumeSize', 0) * 0.05, 2)
            report['items'].append({
                'type':  f'Snapshot ({SNAPSHOT_AGE_DAYS}+ days old)',
                'id':    snap['SnapshotId'],
                'detail': f"Created {snap['StartTime'].strftime('%Y-%m-%d')}",
                'owner': 'unknown',
                'monthly_cost_usd': cost,
            })
            report['monthly_waste_usd'] += cost

    return report


def post_to_slack(report):
    lines = [
        f":money_with_wings: *Weekly Orphaned Resource Report*",
        f"Found *{len(report['items'])} orphaned resources* "
        f"costing *${report['monthly_waste_usd']:.2f}/month*\n",
    ]
    for item in report['items'][:20]:  # Cap at 20 lines to stay readable
        lines.append(
            f"• `{item['type']}` {item['id']} — {item['detail']} "
            f"— *${item['monthly_cost_usd']:.2f}/mo* — owner: {item['owner']}"
        )
    lines.append("\nReview and delete anything no longer needed.")

    req = urllib.request.Request(
        SLACK_WEBHOOK,
        data=json.dumps({'text': '\n'.join(lines)}).encode(),
        headers={'Content-Type': 'application/json'}
    )
    urllib.request.urlopen(req)


def lambda_handler(event, context):
    report = find_orphaned_resources()
    post_to_slack(report)
    return {
        'items_found': len(report['items']),
        'monthly_waste': report['monthly_waste_usd'],
    }
</code></pre>
<h3 id="heading-32-cost-estimation-in-your-cicd-pipeline">3.2 Cost Estimation in Your CI/CD Pipeline</h3>
<p>The goal is to catch expensive infrastructure changes at the PR stage — before they deploy and before they generate a billing surprise.</p>
<pre><code class="language-yaml"># .github/workflows/cost-check.yml
# Runs on any PR that touches infrastructure files
# Uses Infracost to estimate the monthly cost delta

name: Infrastructure Cost Check

on:
  pull_request:
    paths:
      - 'terraform/**'
      - 'infrastructure/**'
      - '*.tf'

jobs:
  cost-estimate:
    name: Estimate monthly cost change
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Infracost
        uses: infracost/actions/setup@v3
        with:
          api-key: ${{ secrets.INFRACOST_API_KEY }}

      - name: Generate cost estimate
        run: |
          infracost breakdown \
            --path terraform/ \
            --format json \
            --out-file /tmp/infracost.json

      - name: Post cost diff to PR
        uses: infracost/actions/comment@v3
        with:
          path: /tmp/infracost.json
          behavior: update

      - name: Block if monthly increase exceeds threshold
        run: |
          MONTHLY_DELTA=$(cat /tmp/infracost.json | \
            jq '.projects[0].diff.totalMonthlyCost' | tr -d '"')

          echo "Estimated monthly cost change: \$$MONTHLY_DELTA"

          # Fail the PR if this change adds more than $500/month
          python3 -c "
          import sys
          delta = float('$MONTHLY_DELTA')
          if delta &gt; 500:
              print(f'PR blocked: estimated +\\({delta:.2f}/month exceeds \\)500 threshold')
              sys.exit(1)
          else:
              print(f'Cost check passed: estimated +\${delta:.2f}/month')
          "
</code></pre>
<h2 id="heading-stage-4-the-cloud-financial-manager-months-16-to-24">Stage 4: The Cloud Financial Manager — Months 16 to 24</h2>
<h3 id="heading-41-leading-finops-reviews-with-executives">4.1 Leading FinOps Reviews with Executives</h3>
<p>By month 16, you have the data. What changes at Stage 4 is the audience. You're no longer presenting to engineers who understand instance types and NAT Gateway pricing. You're presenting to a CTO who wants to know if the infrastructure investment is proportional to the business value it produces, and a CFO who wants to know when the line will stop going up.</p>
<p>The vocabulary shift is simple but important. You stop saying "we right-sized our EC2 instances" and start saying "we reduced our infrastructure unit cost by 28% while maintaining the same request throughput." You stop saying "we eliminated NAT Gateway charges" and start saying "we closed a $6,400/month gap between what we were paying and what was necessary."</p>
<p>The metric that anchors every executive FinOps conversation is cost per business unit. Not total bill (cost per API call, cost per user, cost per transaction, cost per model inference). That ratio tells the story of whether your infrastructure efficiency is improving as the business scales.</p>
<pre><code class="language-python"># unit_economics.py
# Calculate cost per transaction — the metric that matters to leadership

import boto3
from datetime import datetime, timedelta

def calculate_cost_per_transaction(service_name, transaction_count, days_back=30):
    """
    Returns cost per transaction for a given service over the last N days.
    transaction_count: total transactions for the same period (from your metrics)
    """
    ce = boto3.client('ce')

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d'),
            'End':   datetime.now().strftime('%Y-%m-%d'),
        },
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        Filter={
            'Tags': {
                'Key':    'Service',
                'Values': [service_name]
            }
        }
    )

    total_cost = sum(
        float(period['Total']['UnblendedCost']['Amount'])
        for period in response['ResultsByTime']
    )

    cost_per_txn = total_cost / transaction_count if transaction_count &gt; 0 else 0

    return {
        'service':           service_name,
        'period_days':       days_back,
        'total_cost_usd':    round(total_cost, 2),
        'transactions':      transaction_count,
        'cost_per_txn_usd':  round(cost_per_txn, 6),
    }


# Example: payment service processed 4.2M transactions this month
result = calculate_cost_per_transaction('payment-api', 4_200_000)
print(f"Cost per transaction: ${result['cost_per_txn_usd']:.6f}")
print(f"Total infrastructure cost: ${result['total_cost_usd']:,.2f}")
</code></pre>
<h3 id="heading-42-the-chargeback-and-showback-models">4.2 The Chargeback and Showback Models</h3>
<p>Chargeback means actually billing departments for their cloud usage. Showback means showing departments their usage costs without the internal billing transfer. Both create the same outcome: engineers start caring about what they consume because someone they work with is paying attention to it.</p>
<pre><code class="language-python"># showback_report.py
# Generates monthly cost-by-team report for distribution to engineering leads

import boto3
from datetime import datetime

def generate_team_showback():
    ce = boto3.client('ce')

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': datetime.now().replace(day=1).strftime('%Y-%m-%d'),
            'End':   datetime.now().strftime('%Y-%m-%d'),
        },
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        GroupBy=[
            {'Type': 'TAG',       'Key': 'Team'},
            {'Type': 'DIMENSION', 'Key': 'SERVICE'},
        ]
    )

    by_team = {}
    for group in response['ResultsByTime'][0].get('Groups', []):
        team    = group['Keys'][0].replace('Team$', '') or 'untagged'
        service = group['Keys'][1]
        cost    = float(group['Metrics']['UnblendedCost']['Amount'])

        if team not in by_team:
            by_team[team] = {'total': 0, 'services': {}}
        by_team[team]['total'] += cost
        by_team[team]['services'][service] = round(cost, 2)

    # Print sorted by total cost descending
    print(f"\n{'='*52}")
    print(f"  Month-to-Date Cloud Spend by Team")
    print(f"  Generated: {datetime.now().strftime('%Y-%m-%d')}")
    print(f"{'='*52}\n")

    for team, data in sorted(by_team.items(), key=lambda x: x[1]['total'], reverse=True):
        print(f"  {team:&lt;20} ${data['total']:&gt;10,.2f}/month")
        top_services = sorted(data['services'].items(), key=lambda x: x[1], reverse=True)[:3]
        for svc, cost in top_services:
            print(f"    └─ {svc:&lt;30} ${cost:&gt;8,.2f}")
    print()

generate_team_showback()
</code></pre>
<h2 id="heading-essential-tools-and-certifications">Essential Tools and Certifications</h2>
<p>The tools that matter at each stage of this roadmap:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Tool</th>
<th>Why It Matters</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>AWS Cost Explorer</td>
<td>Free, built-in, the starting point for all cost analysis</td>
</tr>
<tr>
<td>1</td>
<td>AWS CLI <code>ce</code> commands</td>
<td>Scriptable cost queries — dashboards can't be automated</td>
</tr>
<tr>
<td>2</td>
<td>AWS Compute Optimizer</td>
<td>ML-powered rightsizing recommendations for EC2 and RDS</td>
</tr>
<tr>
<td>2</td>
<td>VPA (Kubernetes)</td>
<td>Pod-level rightsizing recommendations using actual usage</td>
</tr>
<tr>
<td>3</td>
<td>Infracost</td>
<td>PR-level cost estimation for Terraform changes</td>
</tr>
<tr>
<td>3</td>
<td>AWS Budgets</td>
<td>Proactive alerts — catches problems before the monthly invoice</td>
</tr>
<tr>
<td>4</td>
<td>AWS Cost and Usage Report + Athena</td>
<td>SQL-level billing analysis at any granularity</td>
</tr>
<tr>
<td>4</td>
<td>CloudHealth or Vantage</td>
<td>Multi-account, multi-cloud cost management</td>
</tr>
</tbody></table>
<p><strong>The one certification worth your time:</strong> FinOps Certified Practitioner from the FinOps Foundation. It takes 20 hours to prepare and $300 to sit. It signals to hiring managers and clients that you understand the discipline formally — which matters when you're the person leading FinOps conversations at the executive level.</p>
<h2 id="heading-your-90-day-action-plan">Your 90-Day Action Plan</h2>
<h3 id="heading-month-1-foundation">Month 1 — Foundation:</h3>
<p>Enable Cost Explorer if it isn't already on. Pull the baseline command from Section 1.1 and save the output. Run the untagged resource query from Section 1.2 and document how many resources are missing tags. Find your top three cost drivers. Present the findings to your engineering manager — not as a problem, but as an opportunity with a dollar figure attached.</p>
<h3 id="heading-month-2-quick-wins">Month 2 — Quick Wins:</h3>
<p>Run the rightsizing analyser from Section 2.1 on your EC2 fleet. Downsize the three highest-confidence candidates. Apply S3 lifecycle policies to your two largest buckets. Create VPC endpoints for S3, ECR, and DynamoDB. Estimate the savings from each action and document them against your baseline.</p>
<h3 id="heading-month-3-automation-and-habits">Month 3 — Automation and Habits:</h3>
<p>Deploy the orphan reporter Lambda on a Sunday schedule. Add the cost check GitHub Action to your infrastructure repository. Start a monthly FinOps review meeting — even if it's just you and one other engineer. Build the habit before you need the audience.</p>
<h2 id="heading-best-practices-summary">Best Practices Summary</h2>
<p>✅ <strong>Do:</strong> Establish a cost baseline before any optimisation. The number is meaningless without a comparison point.</p>
<p>✅ <strong>Do:</strong> Right-size before buying Savings Plans. Always. The sequence changes the outcome.</p>
<p>✅ <strong>Do:</strong> Enforce tagging at the infrastructure layer — Terraform or CloudFormation — not as a process reminder.</p>
<p>✅ <strong>Do:</strong> Move staging and development to Spot instances. The interruption rate is manageable, while the 70% cost difference is not.</p>
<p>✅ <strong>Do:</strong> Add VPC endpoints for S3, ECR, and DynamoDB before reviewing data transfer costs. It's a 30-minute fix for a multi-thousand-dollar line item.</p>
<p>✅ <strong>Do:</strong> Present cost findings as cost-per-business-metric, not as total bill. "We reduced cost per transaction from \(0.0021 to \)0.0013" is a business result. "$38,000/month reduction" is an accounting result.</p>
<p>❌ <strong>Don't:</strong> Buy Savings Plans on an unoptimised baseline. You'll lock in discounted waste.</p>
<p>❌ <strong>Don't:</strong> Build FinOps dashboards before tagging is complete. Beautiful charts with no attribution data answer no questions.</p>
<p>❌ <strong>Don't:</strong> Run orphaned resource cleanup without human review first. Run in report-only mode for two weeks, verify the candidates are genuinely orphaned, then add deletion logic.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://www.finops.org/framework/"><strong>FinOps Foundation Framework</strong></a> — The practitioner framework that defines the Inform, Optimise, and Operate cycle this roadmap is built on</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/cost-management/latest/APIReference/API_GetCostAndUsage.html"><strong>AWS Cost Explorer API Reference</strong></a> — Full reference for the cost query commands used throughout this guide</p>
</li>
<li><p><a href="https://aws.amazon.com/compute-optimizer/"><strong>AWS Compute Optimizer</strong></a> — AWS's own rightsizing recommendation service; complements the manual analysis in Stage 2</p>
</li>
<li><p><a href="https://www.infracost.io/docs/"><strong>Infracost Documentation</strong></a> — Setup guide for the PR-level cost estimation tool in Stage 3</p>
</li>
<li><p><a href="https://learn.finops.org/path/finops-certified-practitioner"><strong>FinOps Certified Practitioner Exam</strong></a> — The certification referenced in the tools section</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/savingsplans/latest/userguide/what-is-savings-plans.html"><strong>AWS Savings Plans Documentation</strong></a> — The authoritative reference on commitment types, coverage rules, and purchase strategy</p>
</li>
<li><p><a href="https://github.com/aayostem"><strong>Companion Repository</strong></a> — All scripts from this guide, including the rightsizing analyser, orphan reporter, and showback report generator</p>
</li>
</ul>
<p><a href="https://github.com/aayostem"><em>Ayobami Adejumo</em></a> <em>is a senior platform engineer and FinOps consultant. He has audited AWS infrastructure for 20+ Series A and Series B companies. He is an active FinOps Foundation Supporter</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The AWS FinOps Guide for Series A Startups: The 8 Cost Patterns That Appear After Product-Market Fit ]]>
                </title>
                <description>
                    <![CDATA[ You raised your Series A. Engineering hired fast. Features shipped faster. And somewhere between month six and month twelve, someone forwarded you an AWS Cost Explorer screenshot with a line that only ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-aws-finops-guide-for-series-a-startups/</link>
                <guid isPermaLink="false">6a1f046fcf96043972a575f0</guid>
                
                    <category>
                        <![CDATA[ startup ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ finops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Tue, 02 Jun 2026 16:27:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e4bbaeaf-810e-4ebb-9c81-d2183cac6df6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You raised your Series A. Engineering hired fast. Features shipped faster. And somewhere between month six and month twelve, someone forwarded you an AWS Cost Explorer screenshot with a line that only goes up.</p>
<p>That line isn't random. It follows a pattern. The same eight patterns, at the same growth stage, at almost every company I've audited.</p>
<p>This guide names all eight, shows you exactly where to look, and gives you the fix for each one. By the time you finish reading, you'll know which leaks are draining your runway — and what to do about them this week.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-who-this-guide-is-for">Who This Guide Is For</a></p>
</li>
<li><p><a href="#heading-before-you-start-establish-your-baseline">Before You Start: Establish Your Baseline</a></p>
</li>
<li><p><a href="#heading-pattern-1-the-new-hire-experiment-tax">Pattern 1: The New Hire Experiment Tax</a></p>
</li>
<li><p><a href="#heading-pattern-2-staging-environment-proliferation">Pattern 2: Staging Environment Proliferation</a></p>
</li>
<li><p><a href="#heading-pattern-3-the-nat-gateway-tax">Pattern 3: The NAT Gateway Tax</a></p>
</li>
<li><p><a href="#heading-pattern-4-the-savings-plan-timing-mistake">Pattern 4: The Savings Plan Timing Mistake</a></p>
</li>
<li><p><a href="#heading-pattern-5-cross-az-data-transfer">Pattern 5: Cross-AZ Data Transfer</a></p>
</li>
<li><p><a href="#heading-pattern-6-the-gp2-volume-trap">Pattern 6: The gp2 Volume Trap</a></p>
</li>
<li><p><a href="#heading-pattern-7-the-infinite-log-trap">Pattern 7: The Infinite Log Trap</a></p>
</li>
<li><p><a href="#heading-pattern-8-the-orphaned-resource-collector">Pattern 8: The Orphaned Resource Collector</a></p>
</li>
<li><p><a href="#heading-the-full-savings-summary">The Full Savings Summary</a></p>
</li>
<li><p><a href="#heading-what-to-do-this-week">What to Do This Week</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-who-this-guide-is-for">Who This Guide Is For</h2>
<p>This guide is written for engineers, CTOs, and technical co-founders at Series A companies — typically 15 to 80 engineers, AWS bills between \(20,000 and \)150,000 per month, and a finance team that has recently started paying attention to the infrastructure line.</p>
<p>You don't need a dedicated FinOps team. You need one engineer, one afternoon per week, and the eight patterns in this guide.</p>
<p><strong>What you should have before starting:</strong></p>
<ul>
<li><p>AWS account access with Cost Explorer enabled</p>
</li>
<li><p>AWS CLI v2 configured (<code>aws configure</code>)</p>
</li>
<li><p>Basic familiarity with EC2, RDS, EBS, and S3</p>
</li>
<li><p>A Cost Explorer bookmark — you will use it constantly</p>
</li>
</ul>
<p><strong>Estimated time to complete all fixes:</strong> 8–20 engineering hours spread across two sprints. The reading takes around 20 minutes. The highest-ROI fix (Pattern 3) takes about 30 minutes.</p>
<h2 id="heading-before-you-start-establish-your-baseline">Before You Start: Establish Your Baseline</h2>
<p>Don't skip this step. Optimization without a baseline is just guessing. Run this command before touching anything:</p>
<pre><code class="language-bash"># Pull last month's AWS cost breakdown by service
# This becomes your before number — save it somewhere
aws ce get-cost-and-usage \
  --time-period Start=\((date -d 'last month' +%Y-%m-01),End=\)(date +%Y-%m-01) \
  --granularity MONTHLY \
  --group-by Type=DIMENSION,Key=SERVICE \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Groups[*].{Service:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
  --output table | sort -k3 -rn
</code></pre>
<p>Then screenshot the output. Name the file <code>aws-baseline-YYYY-MM.png</code>. You'll compare against this after each fix to verify actual savings.</p>
<p>The typical breakdown at Series A looks like this:</p>
<table>
<thead>
<tr>
<th>AWS Service</th>
<th>% of Bill</th>
<th>Waste Potential</th>
</tr>
</thead>
<tbody><tr>
<td>EC2 (compute)</td>
<td>45–55%</td>
<td>High</td>
</tr>
<tr>
<td>Data Transfer</td>
<td>15–20%</td>
<td>Very High</td>
</tr>
<tr>
<td>RDS</td>
<td>10–15%</td>
<td>Medium</td>
</tr>
<tr>
<td>EBS</td>
<td>8–12%</td>
<td>Medium</td>
</tr>
<tr>
<td>CloudWatch</td>
<td>3–6%</td>
<td>Medium</td>
</tr>
<tr>
<td>Load Balancers</td>
<td>3–5%</td>
<td>Low</td>
</tr>
</tbody></table>
<p>Now let's go through each pattern.</p>
<h2 id="heading-pattern-1-the-new-hire-experiment-tax">Pattern 1: The New Hire Experiment Tax</h2>
<p>Every engineering hire needs a development environment. This is expected. What's not expected is what happens after the feature ships: nothing.</p>
<p>The environment keeps running. At \(0.192/hour for an m5.xlarge, a forgotten dev environment costs \)138/month. Ten engineers who each forgot one environment is $1,380/month — for infrastructure that's doing precisely nothing.</p>
<p>This pattern accelerates after a Series A because hiring moves fast. A new engineer joins on Monday, spins up an EC2, an RDS, and a namespace in the dev cluster, ships the feature by Friday, and moves to the next ticket. The environment isn't on anyone's radar. There's no off-boarding process for dev resources.</p>
<p><strong>What the waste looks like:</strong></p>
<pre><code class="language-text">Dev environment for Alice (feature/payment-flow):
  EC2 m5.xlarge — last CPU activity: 23 days ago
  RDS db.t3.medium — last connection: 19 days ago
  EKS namespace — last pod scheduled: 15 days ago
  Monthly cost: $187
  Status: running
</code></pre>
<p><strong>Finding it:</strong></p>
<pre><code class="language-bash"># Find EC2 instances with average CPU below 5% for the last 14 days
# These are idle instances — candidates for shutdown or termination
aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 \
  --metric-name CPUUtilization \
  --period 1209600 \
  --statistics Average \
  --start-time $(date -d '14 days ago' --iso-8601=seconds) \
  --end-time $(date --iso-8601=seconds) \
  --dimensions Name=InstanceId,Value=YOUR_INSTANCE_ID \
  --query 'Datapoints[*].{Average:Average}' \
  --output table
</code></pre>
<h3 id="heading-the-fix-an-automatic-idle-instance-stopper">The Fix — an Automatic Idle Instance Stopper:</h3>
<p>The Lambda below runs every night at 22:00. It checks every EC2 instance tagged <code>Environment=dev</code> for CPU utilisation over the past seven days. Any instance averaging below 5% gets stopped automatically. An SNS notification goes to the engineer's email before the stop happens, giving them a chance to override it by adding a <code>KeepAlive=true</code> tag.</p>
<pre><code class="language-python"># idle_environment_stopper.py
# Deploy as a Lambda function triggered by EventBridge on schedule: cron(0 22 * * ? *)
# This stops idle dev environments before they run through the night and weekend

import boto3
from datetime import datetime, timedelta, timezone

ec2 = boto3.client('ec2')
cloudwatch = boto3.client('cloudwatch')
sns = boto3.client('sns')

IDLE_CPU_THRESHOLD = 5.0      # Stop instances below this average CPU %
IDLE_DAYS = 7                  # Look back 7 days of CloudWatch data
SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:YOUR_ACCOUNT:dev-environment-alerts'

def get_average_cpu(instance_id):
    """Return the 7-day average CPU utilisation for an EC2 instance."""
    response = cloudwatch.get_metric_statistics(
        Namespace='AWS/EC2',
        MetricName='CPUUtilization',
        Dimensions=[{'Name': 'InstanceId', 'Value': instance_id}],
        StartTime=datetime.now(timezone.utc) - timedelta(days=IDLE_DAYS),
        EndTime=datetime.now(timezone.utc),
        Period=604800,  # One 7-day period
        Statistics=['Average']
    )
    datapoints = response.get('Datapoints', [])
    return datapoints[0]['Average'] if datapoints else 0.0

def lambda_handler(event, context):
    """Stop idle dev instances and notify their owners."""
    
    # Find all running dev instances
    response = ec2.describe_instances(
        Filters=[
            {'Name': 'instance-state-name', 'Values': ['running']},
            {'Name': 'tag:Environment', 'Values': ['dev', 'development']},
        ]
    )

    stopped = []
    skipped = []

    for reservation in response['Reservations']:
        for instance in reservation['Instances']:
            instance_id = instance['InstanceId']
            tags = {t['Key']: t['Value'] for t in instance.get('Tags', [])}

            # Skip instances explicitly marked to keep alive
            if tags.get('KeepAlive', '').lower() == 'true':
                skipped.append(instance_id)
                continue

            avg_cpu = get_average_cpu(instance_id)

            if avg_cpu &lt; IDLE_CPU_THRESHOLD:
                # Notify the owner before stopping
                owner = tags.get('Owner', 'unknown')
                sns.publish(
                    TopicArn=SNS_TOPIC_ARN,
                    Subject=f'Dev environment stopped: {instance_id}',
                    Message=(
                        f'Instance {instance_id} (Owner: {owner}) had {avg_cpu:.1f}% average CPU '
                        f'over {IDLE_DAYS} days and has been stopped.\n\n'
                        f'To prevent this, add the tag: KeepAlive=true\n'
                        f'To restart: aws ec2 start-instances --instance-ids {instance_id}'
                    )
                )
                ec2.stop_instances(InstanceIds=[instance_id])
                stopped.append({'id': instance_id, 'owner': owner, 'avg_cpu': avg_cpu})

    print(f"Stopped {len(stopped)} idle instances. Skipped {len(skipped)} keep-alive instances.")
    return {'stopped': stopped, 'skipped': skipped}
</code></pre>
<p><strong>Monthly savings:</strong> \(1,000–\)2,000 depending on team size and how long the pattern has been running.</p>
<h2 id="heading-pattern-2-staging-environment-proliferation">Pattern 2: Staging Environment Proliferation</h2>
<p>Staging starts as one environment. Then the frontend team needs their own because the backend team keeps breaking theirs. Then the ML team needs isolated compute. Then QA needs a stable environment for integration tests.</p>
<p>Before anyone noticed, you have four staging environments running 24/7 — each one idle for 16 hours of every day.</p>
<p>The waste isn't in the existence of the environments. It's in the schedule. Staging environments don't need to run at 3am.</p>
<p><strong>What the waste looks like:</strong></p>
<pre><code class="language-text">staging-frontend:   $250/month   Used: Mon-Fri 09:00-18:00
staging-backend:    $250/month   Used: Mon-Fri 09:00-18:00
staging-ml:         $250/month   Used: Mon-Fri 10:00-17:00
staging-qa:         $250/month   Used: Mon-Fri 09:00-17:00
Total:            $1,000/month   Running: 24 hours/day, 7 days/week
Actual usage:        ~35%        You are paying 100%
</code></pre>
<p><strong>Finding it:</strong></p>
<pre><code class="language-bash"># Find EKS node groups tagged as staging with their current status
aws eks list-nodegroups --cluster-name your-cluster-name --output table

# Check EC2 instances tagged staging and their launch time
# Any instance running &gt; 30 days with no weekend stop schedule is a candidate
aws ec2 describe-instances \
  --filters "Name=tag:Environment,Values=staging" "Name=instance-state-name,Values=running" \
  --query 'Reservations[*].Instances[*].{ID:InstanceId,Type:InstanceType,Launch:LaunchTime}' \
  --output table
</code></pre>
<h3 id="heading-the-fix-scheduled-start-and-stop-with-aws-instance-scheduler">The Fix — Scheduled Start and Stop with AWS Instance Scheduler:</h3>
<pre><code class="language-bash"># Option 1: Tag-based scheduling with AWS Instance Scheduler (CloudFormation solution)
# Add these tags to your staging EC2 instances and RDS clusters:
# Schedule: office-hours
# This starts instances at 08:00 and stops them at 20:00 Mon-Fri
# Weekend: completely off

# Option 2: Quick Lambda-based solution — stop all staging at 20:00 weekdays
aws events put-rule \
  --schedule-expression "cron(0 20 ? * MON-FRI *)" \
  --name stop-staging-environments \
  --state ENABLED

# The stop Lambda — same pattern as Pattern 1 but targets staging tag
# Add a corresponding start rule at 07:30 Mon-Fri
</code></pre>
<h3 id="heading-consolidation-in-addition-to-scheduling">Consolidation in Addition to Scheduling</h3>
<p>If frontend and backend share a database schema, consolidate them into one shared staging environment with namespace-level isolation. The combined cost is lower than two separate environments:</p>
<pre><code class="language-yaml"># One shared staging cluster with namespace isolation
# frontend-staging and backend-staging share nodes via Karpenter
# but are isolated by namespace-level network policies
apiVersion: v1
kind: Namespace
metadata:
  name: staging-frontend
  labels:
    environment: staging
    team: frontend
---
apiVersion: v1
kind: Namespace
metadata:
  name: staging-backend
  labels:
    environment: staging
    team: backend
</code></pre>
<p><strong>The math:</strong></p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Monthly cost</th>
</tr>
</thead>
<tbody><tr>
<td>Before: 4 environments, always on</td>
<td>$1,000</td>
</tr>
<tr>
<td>After: 2 consolidated environments, office hours only</td>
<td>$290</td>
</tr>
<tr>
<td>Monthly savings</td>
<td>$710</td>
</tr>
</tbody></table>
<h2 id="heading-pattern-3-the-nat-gateway-tax">Pattern 3: The NAT Gateway Tax</h2>
<p>NAT Gateway is the most consistently underestimated line item on every AWS bill I've audited. It charges $0.045 per GB of data processed — and in EKS clusters, a staggering amount of traffic flows through it by default.</p>
<p>Every pod that pulls a container image from ECR goes through NAT Gateway. Every Lambda that writes to S3 goes through NAT Gateway. Every service that polls SQS, queries DynamoDB, or calls the Secrets Manager API goes through NAT Gateway — unless you have configured VPC endpoints.</p>
<p>VPC endpoints create a private connection between your VPC and the AWS service. Traffic routes through the AWS backbone instead of NAT Gateway. The data transfer becomes free.</p>
<p><strong>What the waste looks like:</strong></p>
<pre><code class="language-bash"># Run this to see your current NAT Gateway data processing bill
aws ce get-cost-and-usage \
  --time-period Start=\((date -d 'last month' +%Y-%m-01),End=\)(date +%Y-%m-01) \
  --granularity MONTHLY \
  --filter '{
    "Dimensions": {
      "Key": "USAGE_TYPE",
      "Values": ["NatGateway-Bytes", "NatGateway-Hours"]
    }
  }' \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Total.UnblendedCost.Amount' \
  --output text
</code></pre>
<p>If this number is above \(200, you have a NAT Gateway problem. At most Series A companies running EKS, it is between \)800 and $6,000.</p>
<h3 id="heading-the-fix-vpc-endpoints-for-the-four-highest-traffic-aws-services">The Fix — VPC Endpoints for the Four Highest-traffic AWS Services:</h3>
<pre><code class="language-bash"># Get your VPC ID and route table ID first
VPC_ID=$(aws ec2 describe-vpcs \
  --filters "Name=tag:Name,Values=your-vpc-name" \
  --query 'Vpcs[0].VpcId' --output text)

ROUTE_TABLE_ID=$(aws ec2 describe-route-tables \
  --filters "Name=vpc-id,Values=$VPC_ID" "Name=association.main,Values=true" \
  --query 'RouteTables[0].RouteTableId' --output text)

# S3 gateway endpoint — free to create, eliminates all S3 NAT charges
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --service-name com.amazonaws.us-east-1.s3 \
  --route-table-ids $ROUTE_TABLE_ID

# DynamoDB gateway endpoint — also free
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --service-name com.amazonaws.us-east-1.dynamodb \
  --route-table-ids $ROUTE_TABLE_ID

# ECR API endpoint — eliminates NAT charges on every container pull
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --vpc-endpoint-type Interface \
  --service-name com.amazonaws.us-east-1.ecr.api \
  --subnet-ids $(aws ec2 describe-subnets \
    --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Tier,Values=private" \
    --query 'Subnets[*].SubnetId' --output text)

# ECR Docker endpoint — required alongside ECR API for image pulls
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --vpc-endpoint-type Interface \
  --service-name com.amazonaws.us-east-1.ecr.dkr \
  --subnet-ids $(aws ec2 describe-subnets \
    --filters "Name=vpc-id,Values=$VPC_ID" "Name=tag:Tier,Values=private" \
    --query 'Subnets[*].SubnetId' --output text)
</code></pre>
<p>When explaining this to your CFO, call it the NAT tax. They understand taxes. "We're paying a $0.045/GB tax on internal network traffic that we can eliminate in 30 minutes" lands better than "data processing bytes."</p>
<p><strong>Monthly savings:</strong> \(2,000–\)8,000 depending on your container pull frequency and S3 usage.</p>
<h2 id="heading-pattern-4-the-savings-plan-timing-mistake">Pattern 4: The Savings Plan Timing Mistake</h2>
<p>A Savings Plan is a commitment to spend a fixed dollar amount per hour on AWS compute for one or three years in exchange for a 30–70% discount. The math is attractive. The timing is where teams go wrong.</p>
<p>When the bill gets large, the instinct is to commit. Buy the Savings Plan, reduce the bill, show the CFO. The problem: if you haven't rightsized first, you're committing to pay for waste at a discount. When you rightsize later, your actual spend drops below your commitment — and you pay for compute you're not using.</p>
<p><strong>What wrong order looks like:</strong></p>
<pre><code class="language-text">Step 1: AWS bill is $100,000/month
Step 2: Buy $70,000/hour Savings Plan commitment
Step 3: Rightsize instances — actual spend drops to $60,000
Step 4: Savings Plan covers \(70,000 but you only use \)60,000
Step 5: You pay $28,000/month for compute you do not use
         (Savings Plan discount applied to the overage)
         
Net result: You locked in waste for 12 months
</code></pre>
<p><strong>What right order looks like:</strong></p>
<pre><code class="language-text">Step 1: Rightsize instances — spend drops from \(100,000 to \)60,000
Step 2: Add Spot for staging — spend drops from \(60,000 to \)45,000
Step 3: Migrate compatible workloads to Graviton — spend drops to $36,000
Step 4: NOW buy a Savings Plan covering $25,000/month (70% of steady-state)
Step 5: Effective monthly cost: \(12,500 for committed + \)11,000 on-demand = $23,500

Net result: $76,500/month saved versus the original bill
</code></pre>
<p>How to check what you should commit to:</p>
<pre><code class="language-bash"># View your last 30 days of EC2 On-Demand spend
# This is your rightsized baseline — what you actually use after optimisation
aws ce get-cost-and-usage \
  --time-period Start=\((date -d '30 days ago' +%Y-%m-%d),End=\)(date +%Y-%m-%d) \
  --granularity DAILY \
  --filter '{
    "And": [
      {"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Elastic Compute Cloud - Compute"]}},
      {"Dimensions": {"Key": "PURCHASE_TYPE", "Values": ["On-Demand"]}}
    ]
  }' \
  --metrics UnblendedCost \
  --query 'ResultsByTime[*].{Date:TimePeriod.Start,Cost:Total.UnblendedCost.Amount}' \
  --output table

# Get AWS's own Savings Plan recommendation based on your usage
aws savingsplans get-savings-plans-purchase-recommendation \
  --savings-plans-type COMPUTE_SP \
  --term-in-years ONE_YEAR \
  --payment-option NO_UPFRONT \
  --lookback-period-in-days THIRTY_DAYS
</code></pre>
<p>As a rule, commit to 60–70% of your steady-state On-Demand spend after optimisation. Leave 30–40% flexible. Never commit on the unoptimised baseline.</p>
<p><strong>Monthly savings:</strong> \(5,000–\)15,000 depending on compute spend. This is the pattern with the highest single-action ROI when sequenced correctly.</p>
<h2 id="heading-pattern-5-cross-az-data-transfer">Pattern 5: Cross-AZ Data Transfer</h2>
<p>AWS charges \(0.01 per GB in each direction when data crosses an Availability Zone boundary. \)0.01 sounds negligible. It's not — because AZ boundaries are crossed constantly in distributed systems, and the charge is bidirectional.</p>
<p>The most common scenario: your application pods are scheduled across multiple AZs (as they should be for resilience), but your database is pinned to one AZ. Every database query from a pod in a different AZ costs \(0.01/GB going to the database and \)0.01/GB coming back. At 100GB of database traffic per day, that's \(60/month. At 1TB per day, it is \)600/month.</p>
<p><strong>What the waste looks like:</strong></p>
<pre><code class="language-bash"># Check current cross-AZ data transfer charges
aws ce get-cost-and-usage \
  --time-period Start=\((date -d 'last month' +%Y-%m-01),End=\)(date +%Y-%m-01) \
  --granularity MONTHLY \
  --filter '{"Dimensions": {"Key": "USAGE_TYPE", "Values": ["DataTransfer-Regional-Bytes"]}}'  \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Total.UnblendedCost.Amount' \
  --output text
</code></pre>
<p>How to find which pods are causing the cross-AZ traffic:</p>
<pre><code class="language-bash"># Check which AZ your database RDS instance is in
aws rds describe-db-instances \
  --query 'DBInstances[*].{ID:DBInstanceIdentifier,AZ:AvailabilityZone}' \
  --output table

# Check which AZs your application pods are running in
kubectl get pods -o wide -n production | awk '{print $7}' | sort | uniq -c
</code></pre>
<p>If your RDS is in <code>us-east-1a</code> and 60% of your pods are in <code>us-east-1b</code> and <code>us-east-1c</code>, you have a cross-AZ traffic problem.</p>
<h3 id="heading-the-fix-topology-aware-routing">The Fix — Topology-aware Routing:</h3>
<pre><code class="language-yaml"># topology-aware-routing.yaml
# This tells Kubernetes to prefer scheduling pods in the same AZ
# as the node making the request — keeping traffic local

apiVersion: v1
kind: Service
metadata:
  name: payment-api
  namespace: production
  annotations:
    # Route traffic to pods in the same AZ as the caller when possible
    service.kubernetes.io/topology-mode: "Auto"
spec:
  selector:
    app: payment-api
  ports:
  - port: 8080
    targetPort: 8080
</code></pre>
<pre><code class="language-yaml"># For pods themselves — spread across AZs but prefer local
# topologySpreadConstraints ensures even distribution
# while topology-aware routing keeps traffic within AZs

spec:
  topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: payment-api
</code></pre>
<p>For database traffic specifically, consider migrating from single-AZ RDS to Aurora, which handles AZ routing internally. Your application connects to one endpoint and Aurora routes internally — no cross-AZ charge from the application layer.</p>
<p><strong>Monthly savings:</strong> \(500–\)6,000 depending on database query volume and AZ distribution of your pods.</p>
<h2 id="heading-pattern-6-the-gp2-volume-trap">Pattern 6: The gp2 Volume Trap</h2>
<p>In 2014, AWS launched gp2 EBS volumes. In 2020, they launched gp3 — cheaper, faster, and with better baseline performance. In 2026, most Series A companies are still running gp2.</p>
<p>The difference: gp2 costs \(0.10/GB/month and provides 3 IOPS per GB (100 IOPS minimum). gp3 costs \)0.08/GB/month and provides 3,000 IOPS baseline regardless of size. gp3 is 20% cheaper and 10x faster on IOPS for most volume sizes. The migration is online — it runs while the volume is attached and in use.</p>
<p><strong>Finding all your gp2 volumes:</strong></p>
<pre><code class="language-bash"># List every gp2 volume in your account with its size and monthly cost
aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[*].{
    ID:VolumeId,
    Size:Size,
    State:State,
    MonthlyCost_USD:Size
  }' \
  --output table

# Count the total: number of volumes and combined GB
aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'length(Volumes)' --output text

aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'sum(Volumes[*].Size)' --output text
</code></pre>
<h3 id="heading-the-fix-migrate-all-gp2-to-gp3-in-one-script">The Fix — Migrate All gp2 to gp3 in One Script:</h3>
<pre><code class="language-bash">#!/bin/bash
# migrate_gp2_to_gp3.sh
# Migrates all gp2 volumes to gp3. Online operation — no downtime.
# Each modification runs asynchronously; the volume stays available throughout.

echo "Starting gp2 to gp3 migration..."

# Get all gp2 volume IDs
VOLUMES=$(aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[*].VolumeId' \
  --output text)

COUNT=0
for VOL_ID in $VOLUMES; do
  echo "Migrating $VOL_ID to gp3..."
  aws ec2 modify-volume \
    --volume-id $VOL_ID \
    --volume-type gp3 \
    --no-cli-pager
  COUNT=$((COUNT + 1))
done

echo "Migration initiated for $COUNT volumes."
echo "Modifications run online — no downtime. Monitor progress:"
echo "aws ec2 describe-volumes-modifications --query 'VolumesModifications[*].{ID:VolumeId,State:ModificationState}'"
</code></pre>
<p><strong>Verify completion:</strong></p>
<pre><code class="language-bash"># Check that no gp2 volumes remain
aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'length(Volumes)' \
  --output text
# Expected: 0
</code></pre>
<p><strong>Monthly savings:</strong> 20% of your total EBS spend. At \(10,000/month in EBS, that's \)2,000 saved for 30 minutes of work.</p>
<h2 id="heading-pattern-7-the-infinite-log-trap">Pattern 7: The Infinite Log Trap</h2>
<p>CloudWatch log groups have a default retention policy of "Never expire." Every log group created without an explicit retention setting accumulates logs indefinitely. For a busy Series A company, this means you're storing debug logs from 2022 that nobody has opened since the sprint review they were created for.</p>
<p>The cost compounds quietly. CloudWatch charges \(0.03/GB/month for log storage and \)0.50/GB for log ingestion. A cluster generating 50GB of logs per day ingests \(25/day — \)750/month — and then stores those logs forever at an increasing monthly cost.</p>
<p><strong>Finding log groups with no retention policy:</strong></p>
<pre><code class="language-bash"># List all log groups with their retention settings
# Any group showing "retentionInDays: null" is infinite — it never expires
aws logs describe-log-groups \
  --query 'logGroups[*].{Name:logGroupName,RetentionDays:retentionInDays,StoredBytes:storedBytes}' \
  --output table | grep -E "(None|null)"

# Count how many log groups have no retention set
aws logs describe-log-groups \
  --query 'length(logGroups[?retentionInDays==`null`])' \
  --output text
</code></pre>
<h3 id="heading-the-fix-set-retention-policies-in-bulk">The Fix — Set Retention Policies in Bulk:</h3>
<p>Different log types have different compliance requirements. Debug logs don't need to be kept. Audit logs might need 365 days. The table below gives sensible defaults:</p>
<table>
<thead>
<tr>
<th>Log Type</th>
<th>Recommended Retention</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td>Application debug logs</td>
<td>14 days</td>
<td>Only useful for active debugging</td>
</tr>
<tr>
<td>Application error logs</td>
<td>90 days</td>
<td>Post-incident investigation window</td>
</tr>
<tr>
<td>Access logs</td>
<td>30 days</td>
<td>Security review window</td>
</tr>
<tr>
<td>CloudTrail audit logs</td>
<td>365 days</td>
<td>SOC2 evidence requirement</td>
</tr>
<tr>
<td>VPC Flow Logs</td>
<td>90 days</td>
<td>Security investigation window</td>
</tr>
</tbody></table>
<pre><code class="language-bash">#!/bin/bash
# set_log_retention.sh
# Sets 30-day retention on all log groups that have no policy set
# Adjust the retention period per log group type as needed

echo "Setting retention policies on log groups with no expiry..."

# Get all log groups with no retention
aws logs describe-log-groups \
  --query 'logGroups[?retentionInDays==`null`].logGroupName' \
  --output text | tr '\t' '\n' | while read LOG_GROUP; do

  # Skip CloudTrail logs — these need longer retention for SOC2
  if echo "$LOG_GROUP" | grep -qi "cloudtrail"; then
    echo "Skipping CloudTrail log group: $LOG_GROUP"
    aws logs put-retention-policy \
      --log-group-name "$LOG_GROUP" \
      --retention-in-days 365
    continue
  fi

  # Set 30-day retention on all other log groups
  echo "Setting 30-day retention on: $LOG_GROUP"
  aws logs put-retention-policy \
    --log-group-name "$LOG_GROUP" \
    --retention-in-days 30
done

echo "Done. Logs older than their retention period will be deleted automatically by CloudWatch."
</code></pre>
<p><strong>Monthly savings:</strong> \(500–\)2,000 on storage costs. The ingestion cost reduction kicks in immediately when noisy debug logging is reduced. The storage cost reduction compounds over 30–90 days as old logs expire.</p>
<h2 id="heading-pattern-8-the-orphaned-resource-collector">Pattern 8: The Orphaned Resource Collector</h2>
<p>Every departed engineer leaves a trail. An EBS volume attached to a terminated instance. An Elastic IP allocated but not associated. A load balancer fronting a service that was deprecated in Q3. Old snapshots from an RDS instance that was replaced. None of these are intentional, but all of them are billed.</p>
<p>The fix is a weekly audit. Not a manual investigation — an automated script that runs every Sunday night, finds orphaned resources, and sends a Slack message with a list of candidates for deletion.</p>
<p><strong>Finding the orphans:</strong></p>
<pre><code class="language-bash"># Unattached EBS volumes — you are paying for storage with nothing in it
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[*].{
    ID:VolumeId,
    Size:Size,
    Created:CreateTime,
    MonthlyCost:Size
  }' \
  --output table

# Unassociated Elastic IPs — $3.60/month each when not attached to a running instance
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==`null`].[PublicIp,AllocationId]' \
  --output table

# Old snapshots — created more than 90 days ago, no longer needed
aws ec2 describe-snapshots \
  --owner-ids self \
  --query "Snapshots[?StartTime&lt;='$(date -d '90 days ago' --iso-8601=seconds)'].[SnapshotId,StartTime,VolumeSize]" \
  --output table

# Idle load balancers — active but routing zero traffic
aws elbv2 describe-load-balancers \
  --query 'LoadBalancers[*].{ARN:LoadBalancerArn,DNS:DNSName,State:State.Code}' \
  --output table
</code></pre>
<p><strong>The weekly cleanup Lambda:</strong></p>
<pre><code class="language-python"># orphan_resource_reporter.py
# Runs every Sunday at 20:00 via EventBridge
# Reports orphaned resources to Slack — does NOT auto-delete
# Deletion requires a human decision. The Lambda surfaces the candidates.

import boto3
import json
import urllib.request
from datetime import datetime, timedelta, timezone

SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'

def get_orphaned_resources():
    """Collect all orphaned AWS resources and their estimated monthly costs."""
    ec2 = boto3.client('ec2')
    elbv2 = boto3.client('elbv2')
    report = {'total_monthly_waste': 0, 'resources': []}

    # Unattached EBS volumes ($0.08/GB/month for gp3)
    volumes = ec2.describe_volumes(
        Filters=[{'Name': 'status', 'Values': ['available']}]
    )['Volumes']
    for vol in volumes:
        monthly_cost = round(vol['Size'] * 0.08, 2)
        report['resources'].append({
            'type': 'Unattached EBS Volume',
            'id': vol['VolumeId'],
            'detail': f"{vol['Size']}GB {vol['VolumeType']}",
            'monthly_cost': monthly_cost
        })
        report['total_monthly_waste'] += monthly_cost

    # Unassociated Elastic IPs ($3.60/month each)
    addresses = ec2.describe_addresses()['Addresses']
    for addr in addresses:
        if 'AssociationId' not in addr:
            report['resources'].append({
                'type': 'Unassociated Elastic IP',
                'id': addr['AllocationId'],
                'detail': addr['PublicIp'],
                'monthly_cost': 3.60
            })
            report['total_monthly_waste'] += 3.60

    # Snapshots older than 90 days
    cutoff = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()
    snapshots = ec2.describe_snapshots(OwnerIds=['self'])['Snapshots']
    old_snapshots = [s for s in snapshots if s['StartTime'].isoformat() &lt; cutoff]
    for snap in old_snapshots:
        monthly_cost = round(snap.get('VolumeSize', 0) * 0.05, 2)
        report['resources'].append({
            'type': 'Old Snapshot (90+ days)',
            'id': snap['SnapshotId'],
            'detail': f"Created {snap['StartTime'].strftime('%Y-%m-%d')}",
            'monthly_cost': monthly_cost
        })
        report['total_monthly_waste'] += monthly_cost

    return report

def post_to_slack(report):
    """Send the orphaned resource report to Slack."""
    resource_lines = '\n'.join([
        f"• {r['type']} `{r['id']}` — {r['detail']} — *${r['monthly_cost']}/month*"
        for r in report['resources']
    ])

    message = {
        'text': (
            f":money_with_wings: *Weekly Orphaned Resource Report*\n\n"
            f"Found *{len(report['resources'])} orphaned resources* "
            f"costing *${report['total_monthly_waste']:.2f}/month*\n\n"
            f"{resource_lines}\n\n"
            f"Review and delete resources that are no longer needed."
        )
    }
    
    req = urllib.request.Request(
        SLACK_WEBHOOK_URL,
        data=json.dumps(message).encode(),
        headers={'Content-Type': 'application/json'}
    )
    urllib.request.urlopen(req)

def lambda_handler(event, context):
    report = get_orphaned_resources()
    post_to_slack(report)
    return {
        'resources_found': len(report['resources']),
        'monthly_waste': report['total_monthly_waste']
    }
</code></pre>
<p><strong>Monthly savings:</strong> \(500–\)2,000. Every departed engineer typically leaves \(50–\)200 in orphaned resources. At a team of 30 with 30% annual turnover, that compounds quickly.</p>
<h2 id="heading-the-full-savings-summary">The Full Savings Summary</h2>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Monthly Saving</th>
<th>Time to Fix</th>
<th>Difficulty</th>
</tr>
</thead>
<tbody><tr>
<td>1. New hire experiment tax</td>
<td>\(1,000–\)2,000</td>
<td>2 hours (Lambda)</td>
<td>Medium</td>
</tr>
<tr>
<td>2. Staging proliferation</td>
<td>\(600–\)800</td>
<td>3 hours (scheduling)</td>
<td>Low</td>
</tr>
<tr>
<td>3. NAT Gateway tax</td>
<td>\(2,000–\)8,000</td>
<td>30 minutes</td>
<td>Low</td>
</tr>
<tr>
<td>4. Savings Plan timing</td>
<td>\(5,000–\)15,000</td>
<td>One decision</td>
<td>Low</td>
</tr>
<tr>
<td>5. Cross-AZ data transfer</td>
<td>\(500–\)6,000</td>
<td>2 hours</td>
<td>Medium</td>
</tr>
<tr>
<td>6. gp2 volume trap</td>
<td>\(1,000–\)5,000</td>
<td>30 minutes (script)</td>
<td>Low</td>
</tr>
<tr>
<td>7. Infinite log trap</td>
<td>\(500–\)2,000</td>
<td>1 hour (script)</td>
<td>Low</td>
</tr>
<tr>
<td>8. Orphaned resources</td>
<td>\(500–\)2,000</td>
<td>2 hours (Lambda)</td>
<td>Low</td>
</tr>
<tr>
<td><strong>Total potential</strong></td>
<td><strong>\(11,100–\)40,800/month</strong></td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h2 id="heading-what-to-do-this-week">What to Do This Week</h2>
<p>Don't fix all eight this week. Prioritise by ROI per hour of engineering time:</p>
<p><strong>Day 1 (30 minutes):</strong> Pattern 3 — NAT Gateway endpoints. Highest ROI per minute of any fix in this guide. One command creates the S3 endpoint. Done.</p>
<p><strong>Day 2 (30 minutes):</strong> Pattern 6 — gp2 to gp3 migration. Run the script. Check the output. Done.</p>
<p><strong>Day 3 (1 hour):</strong> Pattern 7 — log retention policies. Run the bulk retention script. Done.</p>
<p><strong>Day 4 (2 hours):</strong> Patterns 1 and 8 — deploy both Lambdas. They run automatically from here.</p>
<p><strong>Next sprint:</strong> Pattern 2 (staging schedule), Pattern 5 (topology-aware routing), and Pattern 4 (run the rightsizing cycle first, then evaluate Savings Plans).</p>
<p>Open Cost Explorer after each fix. Compare against your baseline screenshot from the start of this guide. The line should start going down.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://www.finops.org/framework/"><strong>FinOps Foundation Framework</strong></a> — The practitioner framework this guide contributes to, covering Inform, Optimize, and Operate phases of cloud cost management</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/cost-management/latest/APIReference/API_GetCostAndUsage.html"><strong>AWS Cost Explorer API Reference</strong></a> — Full reference for the <code>get-cost-and-usage</code> command used throughout this guide</p>
</li>
<li><p><a href="https://aws.amazon.com/compute-optimizer/"><strong>AWS Compute Optimizer</strong></a> — AWS's own rightsizing recommendation service, used alongside the patterns in this guide for EC2 and EBS recommendations</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints.html"><strong>AWS VPC Endpoints Documentation</strong></a> — Complete list of available VPC endpoints for Pattern 3</p>
</li>
<li><p><a href="https://aws.amazon.com/solutions/implementations/instance-scheduler-on-aws/"><strong>AWS Instance Scheduler Solution</strong></a> — The AWS-maintained CloudFormation solution for Pattern 2 environment scheduling</p>
</li>
<li><p><a href="https://karpenter.sh/docs/"><strong>Karpenter Documentation</strong></a> — For teams ready to go beyond these 8 patterns into dynamic node provisioning and Spot diversification</p>
</li>
<li><p><a href="https://www.finops.org/resources/"><strong>FinOps Foundation Asset Library</strong></a> — The community asset library where practical scripts like the ones in this guide are contributed and maintained by practitioners</p>
</li>
</ul>
<p><a href="https://github.com/aayostem"><em>Ayobami Adejumo</em></a> <em>is a senior platform engineer and FinOps specialist. He has audited AWS infrastructure for 30+ Series A companies and contributes practical tooling to the FinOps Foundation Asset Library.</em></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
