All articles
DevOps#aws#finops#cost optimisation#cloud

How to reduce your AWS bill without breaking things

A safe, ordered approach to AWS cost optimisation: tagging and visibility, idle resources, rightsizing, Savings Plans, storage tiers, and the NAT gateway and data transfer charges that surprise most teams.

FX

Codestria Engineering

Engineering team

5 min read

AWS bills grow quietly. A test environment nobody turned off, volumes left behind by terminated instances, a NAT gateway carrying gigabytes of traffic to S3, instance sizes chosen during a launch scramble and never revisited. By the time someone asks why the bill doubled, nobody remembers who created what.

The good news is that most savings come from a small number of well-understood moves. The risk is making them in the wrong order, such as committing to a three-year Savings Plan before rightsizing, or deleting a snapshot that turns out to be the only backup. This is the order we work in, from zero-risk visibility to commitments, with each step making the next one safer and more accurate.

Step 1: Make spend visible with tags

You cannot optimise what you cannot attribute. Agree on a small set of tags, such as service, environment, owner and cost-center, and enforce them in infrastructure as code so new resources are tagged by default. Then activate them as cost allocation tags in the Billing console; until you do, they will not appear in Cost Explorer, and they are not applied retroactively.

infra/bin/app.ts (AWS CDK)
import { App, Tags } from "aws-cdk-lib";
import { ApiStack } from "../lib/api-stack";

const app = new App();
const environment = app.node.tryGetContext("env") ?? "dev";

new ApiStack(app, `api-${environment}`, { env: { region: "eu-west-1" } });

// Applied to every taggable resource in the app
Tags.of(app).add("environment", environment);
Tags.of(app).add("service", "api");
Tags.of(app).add("owner", "platform-team");
Tags.of(app).add("managed-by", "cdk");

In Terraform, the AWS provider's default_tags block does the same job. Whichever tool you use, add a policy check in CI or an AWS Organizations tag policy so untagged resources are caught early.

Pair tags with AWS Budgets alerts per account or tag, and turn on Cost Anomaly Detection. Neither saves money directly, but they shorten the time between a mistake and someone noticing it from weeks to days.

Step 2: Delete what nobody is using

Idle resources are the safest savings because removing them changes nothing users depend on, as long as you confirm ownership first. The usual suspects:

  • Unattached EBS volumes left after instances were terminated.
  • Old EBS and RDS snapshots and AMIs with no retention policy. Use Data Lifecycle Manager or AWS Backup to expire them automatically.
  • Public IPv4 addresses. AWS charges for every public IPv4 address, attached or not, so unused Elastic IPs and public IPs on private workloads cost money.
  • Load balancers with no healthy targets and forgotten dev or preview environments.
  • CloudWatch log groups with no retention, which keep data forever by default.
find-idle.sh
#!/usr/bin/env bash
set -euo pipefail

# Unattached EBS volumes
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[].{id:VolumeId,size:Size,type:VolumeType,created:CreateTime}' \
  --output table

# Elastic IPs not associated with anything
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].{ip:PublicIp,alloc:AllocationId}' \
  --output table

# Log groups with no retention policy
aws logs describe-log-groups \
  --query 'logGroups[?retentionInDays==null].logGroupName' \
  --output text | tr '\t' '\n'

Step 3: Rightsize, then modernise

Instances are often sized for a peak that never came. AWS Compute Optimizer analyses CloudWatch metrics and recommends smaller or different instance types for EC2, Auto Scaling groups, EBS, Lambda and ECS on Fargate. Look at memory as well as CPU (install the CloudWatch agent for memory metrics, or the recommendations will be CPU-only), and downsize one step at a time with monitoring in place.

  • Move to Graviton (ARM) instances where your runtime supports it. Node.js, Python, Java and Go workloads in containers usually only need a multi-arch image build.
  • Switch gp2 volumes to gp3. gp3 is cheaper per gigabyte and lets you set IOPS and throughput independently; the change is online.
  • Schedule non-production environments to scale to zero outside working hours.
  • Use Spot for stateless, interruption-tolerant work such as CI runners, batch jobs and some Kubernetes node groups.

Step 4: Fix storage tiers and data transfer

S3 Standard is the right default for hot data and the wrong one for logs and backups nobody reads. Add lifecycle rules that move objects to Infrequent Access or Glacier classes, expire old versions, and abort incomplete multipart uploads. For data with unpredictable access, S3 Intelligent-Tiering moves objects between tiers automatically for a small monitoring fee per object, which is worth it for larger objects and not for millions of tiny ones.

s3-lifecycle.json
{
  "Rules": [
    {
      "ID": "logs-tiering",
      "Filter": { "Prefix": "logs/" },
      "Status": "Enabled",
      "Transitions": [
        { "Days": 30, "StorageClass": "STANDARD_IA" },
        { "Days": 90, "StorageClass": "GLACIER_IR" }
      ],
      "Expiration": { "Days": 365 },
      "NoncurrentVersionExpiration": { "NoncurrentDays": 30 },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}

Data transfer is where bills surprise people. NAT gateways charge for every gigabyte they process, so private subnets pulling container images from ECR or reading from S3 through NAT pay for traffic that could be nearly free. Add gateway endpoints for S3 and DynamoDB (no charge) and interface endpoints for ECR and other heavily used services. Also watch cross-AZ traffic, which is billed in both directions: chatty services, replicas and load balancers spread across zones add a steady per-gigabyte cost. And put CloudFront in front of public assets rather than serving them straight from S3 or EC2.

Step 5: Commit, but only to your baseline

Once usage is cleaned up and rightsized, commit to the steady baseline. Compute Savings Plans discount EC2, Fargate and Lambda in exchange for an hourly spend commitment over one or three years, and they apply across instance families and regions. EC2 Instance Savings Plans give deeper discounts but lock you to a family in a region. Databases and caches use Reserved Instances (RDS, ElastiCache, OpenSearch, Redshift) or their own savings plan options where AWS offers them.

Size the commitment to the lowest sustained usage you are confident about, not the average, and leave peaks on demand or Spot. Cost Explorer's recommendations are a reasonable starting point. Buying in smaller tranches every few months is safer than one large purchase, because it adapts as your architecture changes.

Finally, make cost review a habit rather than a project. A short monthly look at Cost Explorer grouped by service and tag, with owners explaining any unexpected growth, catches most regressions before they become large. Savings from a one-off cleanup tend to erode within months if nobody is watching.

Key takeaways

  • Start with tags, cost allocation tags, budgets and anomaly detection so every dollar has an owner.
  • Remove idle volumes, snapshots, public IPs and log data with no retention, after taking a final snapshot.
  • Rightsize with Compute Optimizer, move to Graviton and gp3, and schedule non-production environments.
  • Use S3 lifecycle rules and VPC endpoints; NAT and cross-AZ data transfer are frequent hidden costs.
  • Buy Savings Plans last, sized to your proven baseline.

Want a structured cost review of your AWS accounts? Our DevOps and cloud team audits spend, fixes architecture-level waste and sets up guardrails. Contact us.

Frequently asked questions

Should I buy Savings Plans or Reserved Instances?

For most teams, Compute Savings Plans are the more flexible choice because they apply across instance families, regions, Fargate and Lambda. Reserved Instances still matter for services like RDS, ElastiCache and OpenSearch.

Why is my NAT gateway so expensive?

NAT gateways charge per hour and per gigabyte processed. Traffic to S3, ECR or other AWS services routed through NAT adds up; gateway and interface VPC endpoints usually reduce it.

How do I see which team or service is spending the most?

Tag resources consistently, activate those tags as cost allocation tags in the Billing console, then group by tag in Cost Explorer. Tags only apply to costs from the time they are activated.

Found this useful? Share it with your team.

LinkedInX

Need help with devops & cloud?

Docker, Kubernetes, CI/CD and AWS: reliable deployments, monitoring and lower cloud bills.

Learn more