DevOps ·

Cost Optimization for Cloud Infrastructure: Spotting Waste, Right-Sizing Resources, and Serverless Economics

A practical guide to reducing cloud infrastructure costs without sacrificing reliability. Covers idle resource identification, right-sizing methodology, serverless cost modeling, reserved instance math, and a monthly review process for small teams.

Cost Optimization for Cloud Infrastructure: Spotting Waste, Right-Sizing Resources, and Serverless Economics

A seed-stage startup burning $8,000/month on AWS when $2,500 would cover the same workload is not rare. It is the default outcome when engineers provision infrastructure for peak load projections that never materialize, forget to clean up staging environments, and choose managed services for convenience without modeling the cost at scale.

Cloud waste compounds quietly. A forgotten RDS instance here, a Lambda with 3GB memory allocation for a function that uses 200MB there, an NAT Gateway generating $180/month in data processing fees for a service that should route through a VPC endpoint instead. None of these are individually catastrophic. Together they turn $2,500 of necessary spend into $8,000 of monthly burn, and that difference is weeks of runway.

This guide covers where waste actually hides, how to right-size with real metrics rather than guesses, the serverless cost math that determines when Lambda saves money versus costs more, and how to build a monthly review process that catches drift before it accumulates.

The three biggest sources of waste

Idle and orphaned resources

The most common form of cloud waste is resources that are running but doing no useful work. They accumulate through predictable patterns: a developer spins up an RDS instance for testing, the test concludes, the instance stays. A staging environment mirrors production but runs 24/7 despite being used four hours a day. An EC2 instance was replaced by a Lambda function six months ago and no one deleted it because the billing line item was small enough to ignore individually.

Finding them:

In AWS, the Trusted Advisor “Low utilization Amazon EC2 instances” check flags instances with average CPU below 10% over the trailing 14 days. This misses idle RDS instances, which you need to query separately via CloudWatch’s DatabaseConnections metric. A database with zero connections for 72 hours is orphaned.

import {
  CloudWatchClient,
  GetMetricStatisticsCommand,
} from "@aws-sdk/client-cloudwatch";

async function findIdleRDSInstances(instanceIds: string[]): Promise<string[]> {
  const client = new CloudWatchClient({ region: "us-east-1" });
  const now = new Date();
  const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
  const idle: string[] = [];

  for (const instanceId of instanceIds) {
    const command = new GetMetricStatisticsCommand({
      Namespace: "AWS/RDS",
      MetricName: "DatabaseConnections",
      Dimensions: [{ Name: "DBInstanceIdentifier", Value: instanceId }],
      StartTime: sevenDaysAgo,
      EndTime: now,
      Period: 86400, // 1 day
      Statistics: ["Maximum"],
    });

    const response = await client.send(command);
    const maxConnections = Math.max(
      ...(response.Datapoints?.map((d) => d.Maximum ?? 0) ?? [0])
    );

    if (maxConnections === 0) {
      idle.push(instanceId);
    }
  }

  return idle;
}

An RDS db.t4g.small instance running idle costs $35/month. If your account has four of them sitting in staging environments that run 24/7, that is $140/month, $1,680/year, for nothing.

The fix for staging environments: Schedule them off. AWS Instance Scheduler or a simple EventBridge rule with a Lambda function can stop RDS and EC2 instances at 8pm and start them at 8am on weekdays. A db.t4g.small running 50 hours per week instead of 168 costs $10.40/month instead of $35. Across four staging instances: $140/month becomes $42/month.

Over-provisioned instances

The second category is resources that are actively used but significantly larger than required. The root cause is provisioning for a peak that was estimated rather than measured, and never revisiting the allocation after the system is in production.

A t3.xlarge (4 vCPU, 16GB RAM) running at 8% CPU and 2GB memory utilization is paying for 92% of its compute and 87.5% of its memory to sit idle. Downsizing to a t3.medium (2 vCPU, 4GB RAM) would cover the workload and cut the instance cost from $122/month to $30/month.

Over-provisioned Lambda functions are the serverless equivalent. Lambda charges for memory allocation multiplied by execution duration. A function allocated 3GB of memory that consistently uses 200MB is paying 15x the necessary compute cost per invocation:

3072 MB * 100ms * 1,000,000 invocations = 307,200 GB-seconds
 200 MB * 100ms * 1,000,000 invocations =  20,000 GB-seconds

At $0.0000166667/GB-second, that is $5.12/month versus $0.33/month for the same invocation count. For a Lambda invoked 10 million times per month, the gap is $51.20 versus $3.33.

AWS Lambda Power Tuning is an open-source Step Functions state machine that runs your function at multiple memory configurations and reports the cost-performance tradeoff. Run it against your top 10 Lambdas by invocation count. It takes 15 minutes and typically surfaces 40-60% savings on functions provisioned by default at 1024MB or above.

Forgotten services and data transfer fees

The third category is services that were set up for a specific purpose, that purpose ended, and the service continues accruing charges without appearing in any operational context.

Common examples: Elasticsearch domains provisioned for a search feature that was later powered by Postgres full-text search; ElastiCache clusters from a caching layer that was replaced by application-level caching; CloudFront distributions pointing to deleted origins; Route 53 hosted zones for domains that were never launched.

Less obvious: NAT Gateway data processing fees. A NAT Gateway charges $0.045 per GB of data processed. If your workloads in private subnets make frequent calls to S3 or DynamoDB, routing that traffic through a NAT Gateway generates avoidable fees. S3 and DynamoDB have VPC gateway endpoints that route traffic within the AWS network at no data transfer cost. Switching a workload that processes 2TB/month through S3 from NAT Gateway routing to a VPC endpoint saves $90/month.

Check your Cost Explorer “Service breakdown” view filtered to “Data Transfer” specifically. Data transfer is consistently the most underestimated cost category for teams that have not audited it.

Right-sizing methodology with real metrics

Right-sizing is not guessing a smaller instance. It is a measurement process: establish a baseline, identify the gap between provisioned capacity and actual utilization, and then validate downsizing does not degrade the service’s key metrics.

Step 1: Establish utilization baselines over 14+ days.

Single-day snapshots mislead you. You need to capture the weekly pattern including weekends, monthly batch jobs, and any scheduled traffic spikes. Pull CPU, memory, and network utilization at hourly granularity for the trailing 14 days.

For EC2 instances, CloudWatch provides CPU by default. Memory requires the CloudWatch agent:

// cloudwatch-agent-config.json (simplified)
const agentConfig = {
  metrics: {
    metrics_collected: {
      mem: {
        measurement: ["mem_used_percent"],
        metrics_collection_interval: 60,
      },
    },
  },
};

// Then query with:
async function getMemoryUtilization(instanceId: string): Promise<number[]> {
  const client = new CloudWatchClient({ region: "us-east-1" });
  const command = new GetMetricStatisticsCommand({
    Namespace: "CWAgent",
    MetricName: "mem_used_percent",
    Dimensions: [{ Name: "InstanceId", Value: instanceId }],
    StartTime: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
    EndTime: new Date(),
    Period: 3600,
    Statistics: ["Maximum", "Average"],
  });

  const response = await client.send(command);
  return response.Datapoints?.map((d) => d.Maximum ?? 0) ?? [];
}

Step 2: Apply the right-sizing decision rule.

For stateless services with horizontal scaling: target 60-70% average CPU utilization, provision for a p99 peak that is no more than 2x average. If your p99 CPU is 40% and your average is 12%, you are significantly over-provisioned.

For stateful services (databases, caches): target 70% of the allocated memory, never more than 80% CPU average. Databases degrade non-linearly as they approach memory and CPU ceilings. Right-sizing these requires more headroom than stateless services.

Step 3: Validate with a canary or blue-green switch.

Do not right-size directly in production. Launch the candidate instance type alongside the current one, route a fraction of traffic to it, and measure latency and error rate over 48 hours. Only cut over fully once the smaller instance demonstrates stable metrics.

For RDS, instance type changes require a maintenance window reboot. Schedule them for off-peak hours and have a rollback path ready.

Serverless cost modeling: when it saves money and when it costs more

Serverless billing removes the concept of idle cost. You pay per invocation and per GB-second of execution, not for reserved capacity sitting unused. This makes serverless genuinely cheaper for spiky or low-traffic workloads and genuinely more expensive for sustained high-throughput workloads.

The breakeven model:

For a Lambda function that handles HTTP API traffic, the total cost per month is:

Cost = (invocations * $0.0000002)
     + (invocations * duration_seconds * memory_GB * $0.0000166667)
     + (invocations * $0.000001)  // API Gateway HTTP API

Compare this against a t4g.small EC2 instance behind an ALB ($15/month EC2 + $16/month ALB minimum = $31/month fixed floor).

At what invocation volume does Lambda exceed $31/month?

For a typical API handler: 256MB memory, 50ms average execution:

Per invocation cost = $0.0000002 + (0.05s * 0.25GB * $0.0000166667) + $0.000001
                    = $0.0000002 + $0.000000208 + $0.000001
                    = $0.00000142

$31 / $0.00000142 = 21.8 million invocations per month. That is roughly 700,000 requests per day.

Below 700K requests/day: Lambda is cheaper. Above it: a single t4g.small instance costs less than Lambda plus API Gateway. This is the crossover point for this specific function configuration. Adjust the memory and duration for your actual workload.

When serverless costs more in practice:

Lambda becomes expensive when functions have long initialization times, require VPC attachment (which adds 800-1500ms cold starts), or allocate more memory than the workload needs. These factors compound with high invocation rates.

The VPC case is worth isolating. Teams that add Provisioned Concurrency to eliminate VPC cold starts are paying for always-on capacity, which reintroduces the idle cost model they were trying to avoid. At 10 provisioned concurrent executions of a 512MB Lambda, you pay $42/month before the first real request. Two t4g.nano instances at $3.80/month each with a connection pool often cost less. The better path for database-connected Lambdas is RDS Proxy (pooling connections across Lambda environments) and accepting the VPC cold start, or moving those workloads to containers.

Reserved instances and Savings Plans math

On-demand pricing is a penalty for uncertainty. If you can commit to a baseline level of usage, Reserved Instances and Savings Plans reduce that baseline cost by 30-60%.

Compute Savings Plans are the most flexible option. They apply to Lambda, Fargate, and EC2 across all instance families and regions. The commitment is a dollar amount per hour, not a specific instance type. A 1-year Compute Savings Plan for $0.10/hour reduces your on-demand compute costs by roughly 17% for Lambda and 40-50% for EC2 (depending on instance family).

EC2 Instance Savings Plans apply to a specific instance family in a specific region (e.g., m6g in us-east-1) but cover all sizes within that family. Discounts reach 40-60% for 1-year no-upfront and 60-72% for 3-year no-upfront.

Reserved Instances are the least flexible: specific instance type, region, and tenancy. They offer the deepest discounts (up to 72% for 3-year all-upfront) but lock you in precisely.

For a startup, the practical approach: analyze your trailing 3 months of Cost Explorer data to find stable baseline usage. That baseline is your Reserved Instance or Savings Plan candidate. Variable traffic above the baseline stays on-demand.

A concrete example: your API tier consistently uses two t4g.medium EC2 instances as a stable floor, even at low traffic. On-demand, two t4g.mediums cost $60/month ($720/year). A 1-year no-upfront EC2 Instance Savings Plan for the m6g equivalent drops that to roughly $36/month ($432/year). You save $288/year with zero upfront commitment. For three-year all-upfront, the savings reach $350-400/year. Use AWS’s Cost Explorer Savings Plans recommendations view to see the exact numbers for your account’s usage patterns.

The trap to avoid: Committing to Reserved Instances or Savings Plans before you have three months of stable usage data. Startups that commit heavily to reserved capacity before product-market fit often find that the workload they reserved for was refactored or replaced. The commitment remains.

Cost monitoring and alerting

Detecting waste requires measurement infrastructure. Without it, you find problems when the monthly bill arrives.

AWS Cost Explorer with resource-level granularity shows you spending by service, linked account, tag, and resource ID. Enable Cost Explorer in your account (free) and turn on hourly granularity for the last 14 days (there is a small charge: $0.01/hour of stored data). This is the foundation.

Tag everything. Resources without cost allocation tags become unattributable spend. A minimal tagging policy: env (production, staging, development), service (api, workers, data-pipeline), owner (team or engineer). CloudFormation and Terraform apply tags at resource creation; the problem is manually provisioned resources that bypass IaC.

Infracost runs in CI and shows the cost impact of infrastructure changes before they are applied. For a startup using Terraform, a GitHub Actions integration catches regressions before they ship:

# .github/workflows/infracost.yml
name: Infracost
on:
  pull_request:
    paths:
      - "infrastructure/**"

jobs:
  infracost:
    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: Run Infracost
        run: |
          infracost breakdown --path infrastructure/ \
            --format json \
            --out-file /tmp/infracost.json

      - name: Post Infracost comment
        uses: infracost/actions/comment@v3
        with:
          path: /tmp/infracost.json
          behavior: update

A PR that upgrades a db.t3.medium to a db.t3.xlarge now surfaces a $120/month cost increase as a PR comment. The reviewer sees it before approving the change.

Budget alerts are not optional. Set up three tiers in AWS Budgets:

  1. 80% of expected monthly spend: informational alert to Slack
  2. 100% of expected monthly spend: alert to engineering lead
  3. 120% of expected monthly spend: alert to founders/finance

The Slack integration for AWS Budgets uses SNS with a Lambda subscriber:

export const handler = async (event: SNSEvent) => {
  const message = JSON.parse(event.Records[0].Sns.Message);
  const alertBody = {
    text: `AWS Budget Alert: ${message.budgetName}`,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `*${message.budgetName}* has exceeded ${message.notificationType}\n` +
            `Actual: $${parseFloat(message.actualAmount).toFixed(2)}\n` +
            `Budget: $${parseFloat(message.budgetAmount).toFixed(2)}`,
        },
      },
    ],
  };

  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(alertBody),
  });
};

Monthly cost review process for small teams

A monthly 60-minute review, run by one engineer on rotation, keeps cost drift from compounding. The review has four steps:

Step 1: Top 10 cost drivers (15 minutes). Pull Cost Explorer sorted by service cost for the trailing 30 days. Identify the top 10 line items. For each, note whether it increased, decreased, or stayed flat versus the prior month. Any line item with more than 20% month-over-month increase gets flagged for investigation.

Step 2: Idle resource scan (15 minutes). Run the idle resource checks: EC2 instances with CPU below 10% for 14 days, RDS instances with zero database connections for 7 days, unattached EBS volumes, unused Elastic IPs (billed at $0.005/hour when unattached), and empty S3 buckets with active intelligent-tiering configurations.

AWS Compute Optimizer identifies right-sizing opportunities automatically. Run it quarterly and action any “over-provisioned” flagged resources that have been stable for 30+ days.

Step 3: Data transfer audit (15 minutes). Filter Cost Explorer to “Data Transfer” specifically. This category includes NAT Gateway processing fees, inter-AZ transfer costs, and CloudFront origin transfer. Any month where data transfer exceeds 15% of total compute spend warrants a routing review: are you transferring data through paths that should use VPC endpoints or within-region free paths?

Step 4: Savings Plans coverage (15 minutes). Check Cost Explorer’s Savings Plans utilization report. Coverage below 70% of eligible spend means stable workloads that should be committed. Utilization above 95% on existing plans means you have room to purchase more. Document findings in a shared cost log: what changed, why, what was actioned. After six months, you have enough history to separate signal from noise.

Tradeoffs summary

OptimizationMonthly savings (typical)EffortRisk
Stop idle staging environments (schedule off)$80-200LowNone for dev/staging
Right-size over-provisioned EC2$50-300MediumNeeds traffic validation
Right-size Lambda memory allocation$20-150LowNone with Power Tuning
VPC endpoints for S3/DynamoDB$30-200LowNone
Delete orphaned RDS instances$35-200 per instanceLowRequires ownership confirmation
Reserved Instances / Savings Plans30-60% of covered spendMediumCommit risk if workload changes
Infracost in CIPrevents future wasteMediumNone, preventive only

Production considerations

Tagging enforcement requires policy, not trust. Engineers provision untagged resources under time pressure. Use AWS Config rules to flag untagged resources and Service Control Policies to deny resource creation without required tags in production accounts.

Account separation improves anomaly detection. A single AWS account with dozens of services makes it hard to isolate which service caused a billing spike. AWS Organizations with per-environment accounts and consolidated billing gives granular visibility without extra infrastructure overhead.

Commit to Reserved Instances only after three months of stable usage. Startups that buy reserved capacity before product-market fit often find the workload was refactored before the term expires. Pre-seed is too early. The right time is when traffic patterns are predictable and the architecture is not actively changing.

Load testing costs more on serverless. Lambda charges per invocation during load tests. A 10-million-request load test against a Lambda API costs roughly $14 in Lambda fees plus API Gateway charges. Load testing an EC2-backed API costs nothing additional. Budget for this difference in your performance testing cycles.

Closing

Cloud cost optimization is not a one-time audit. It is a practice. The monthly review process matters more than any single optimization because it catches the next set of idle resources before they have been running unused for a year.

For a seed-stage startup, getting from $8,000/month to $3,000/month in infrastructure spend is not usually one dramatic fix. It is six changes: scheduling staging environments off, right-sizing four EC2 instances, adjusting Lambda memory allocations, adding VPC endpoints for S3, deleting two orphaned RDS instances, and buying a modest Savings Plan for the stable compute baseline. None of those changes is risky. Together they extend runway by weeks.

The infrastructure should be boring. The billing should be predictable. Both outcomes are achievable with 60 minutes of focused attention per month.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.