With the visibility from Part 1 in place, the work turned into a list of concrete changes. Together they reduced the AWS bill by about one-third. No single change did it. Each removed something the platform was paying for but not using.
The approach
flowchart TB
A[Baseline<br/>3 months by service and env] --> B[Candidate list<br/>from reports and scripts]
B --> C[Owner review<br/>keep, resize or remove]
C --> D[Change in small batches<br/>with rollback plan]
D --> E[Measure against baseline<br/>next billing cycle]
E --> F[Guardrails<br/>so it stays down]
Ground rules that kept it safe:
- No deletion without an owner’s agreement. Orphans with no owner were tagged
PendingDeletewith a date and announced; if nobody claimed them, they went. - Snapshot or back up before removing anything that holds data.
- Production last. Non-production first, where mistakes are cheap.
- One change type per batch, so a problem points to one cause.
1. Idle and unused services and VMs
The largest share came from resources that were running but doing nothing useful.
What was found:
- EC2 instances with average CPU below 5 percent and almost no network traffic for weeks: old proof-of-concept servers, a migration helper left behind, duplicate jump hosts.
- Stopped instances that still paid for their EBS volumes and attached addresses.
- Unattached EBS volumes from terminated instances, and snapshots going back years.
- Load balancers and target groups with no healthy targets.
- Services enabled for a trial and never turned off.
What was done:
# 1. snapshot, tag, and stop first (reversible)
aws ec2 create-snapshot --volume-id vol-0abc --description "pre-removal i-0123 $(date +%F)"
aws ec2 create-tags --resources i-0123 --tags Key=PendingDelete,Value=2026-09-30
aws ec2 stop-instances --instance-ids i-0123
# 2. after the waiting period with no objection
aws ec2 terminate-instances --instance-ids i-0123
aws ec2 delete-volume --volume-id vol-0def # unattached leftovers
Stopping first gave a safe trial: if something broke, the instance could be started again in a minute. Where an instance was needed but oversized, it was resized instead, using Compute Optimizer recommendations and a week of real metrics.
2. Unused Elastic IPs and public addresses
Since February 2024, AWS charges for every public IPv4 address, attached or not. Elastic IPs that are allocated but not associated were pure waste.
# list unassociated Elastic IPs in each region
for r in ap-south-1 us-east-1; do
echo "== $r"
aws ec2 describe-addresses --region $r \
--query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' --output text
done
# release after confirming no DNS record or allow-list depends on it
aws ec2 release-address --allocation-id eipalloc-0abc --region ap-south-1
Before releasing, each address was searched for in Route 53 records and in partner allow-lists, because a released address cannot be got back.
Beyond unattached addresses, instances in private subnets that had public IPs for no reason were moved to private-only, reaching the internet through the existing NAT, or through VPC endpoints for AWS services such as S3 (gateway endpoints for S3 and DynamoDB have no hourly charge).
3. S3 bucket policy and lifecycle changes
S3 costs came from three places: storage nobody needed, old object versions, and reads that should not have been happening.
Tighter bucket policies. Some buckets allowed broad read access, so content was fetched directly from S3 by clients, crawlers and scripts, bypassing the CDN. Policies were changed so that only CloudFront (through origin access control) and the application’s IAM role could read:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CloudFrontOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-assets/*",
"Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1ABC" } }
},
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::acme-assets", "arn:aws:s3:::acme-assets/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}
]
}
This reduced request and data-transfer charges and closed a security gap at the same time.
Lifecycle rules on every bucket that lacked them:
{
"Rules": [
{
"ID": "tidy",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 },
"NoncurrentVersionExpiration": { "NoncurrentDays": 30 },
"Transitions": [{ "Days": 30, "StorageClass": "STANDARD_IA" }]
},
{
"ID": "expire-temp",
"Status": "Enabled",
"Filter": { "Prefix": "tmp/" },
"Expiration": { "Days": 7 }
}
]
}
aws s3api put-bucket-lifecycle-configuration --bucket acme-assets \
--lifecycle-configuration file://lifecycle.json
- Incomplete multipart uploads are invisible in the console but billed; the abort rule clears them.
- Noncurrent versions on versioned buckets grow forever without an expiry rule.
- For data with unpredictable access, S3 Intelligent-Tiering moves objects between tiers automatically.
4. Fewer CloudFront distributions
Over time the account had collected distributions for old campaigns, test domains and duplicates pointing at the same origin.
How they were assessed:
aws cloudfront list-distributions \
--query 'DistributionList.Items[].{ID:Id,Domain:DomainName,Aliases:Aliases.Items,Origin:Origins.Items[0].DomainName,Enabled:Enabled}' \
--output table
For each one, request counts over 30 days came from CloudWatch (AWS/CloudFront, Requests, region us-east-1), and its aliases were checked against Route 53.
What was done:
- Distributions with no traffic were disabled, watched for a week, then deleted.
- Distributions serving the same origin were consolidated into one, using path-based cache behaviours (
/assets/*,/media/*) instead of separate distributions. - Price class was reviewed: where users are mainly in India and nearby regions, a lower price class avoids paying for edge locations the audience does not use.
- Cache policies were fixed so static assets are actually cached (long TTLs and versioned file names), which lowers origin requests to S3.
5. A leaner staging environment
Staging had grown into a near-copy of production: the same instance sizes, Multi-AZ databases, multiple replicas, running 24 hours a day for a team that used it during working hours.
Changes:
| Area | Before | After |
|---|---|---|
| Compute | Production-sized instances | Smaller instance types sized from actual staging load |
| Replicas | Same count as production | One per service |
| Databases | Multi-AZ, large class | Single-AZ, smaller class |
| Schedule | Always on | Off at night and at weekends |
| Purpose | Development, testing and demos | Release verification only |
Scheduling with EventBridge Scheduler calling the EC2 and RDS APIs directly:
aws scheduler create-schedule --name staging-stop \
--schedule-expression "cron(0 21 ? * MON-FRI *)" --schedule-expression-timezone "Asia/Kolkata" \
--flexible-time-window Mode=OFF \
--target '{
"Arn": "arn:aws:scheduler:::aws-sdk:ec2:stopInstances",
"RoleArn": "arn:aws:iam::111122223333:role/scheduler-staging",
"Input": "{\"InstanceIds\":[\"i-0aaa\",\"i-0bbb\"]}"
}'
aws scheduler create-schedule --name staging-start \
--schedule-expression "cron(0 9 ? * MON-FRI *)" --schedule-expression-timezone "Asia/Kolkata" \
--flexible-time-window Mode=OFF \
--target '{
"Arn": "arn:aws:scheduler:::aws-sdk:ec2:startInstances",
"RoleArn": "arn:aws:iam::111122223333:role/scheduler-staging",
"Input": "{\"InstanceIds\":[\"i-0aaa\",\"i-0bbb\"]}"
}'
The same pattern stops and starts RDS (rds:stopDBInstance), and for EKS or Auto Scaling groups a scheduled action sets the desired capacity to zero overnight. Running only during working hours cuts compute hours for staging to roughly a third of always-on.
6. A local Ubuntu VM for testing
Shrinking staging only works if developers still have somewhere to test. The answer was a local environment on an Ubuntu VM that runs the whole application stack with Docker Compose, so day-to-day testing no longer needs cloud resources at all.
flowchart LR
DEV[Developer laptop] --> VM[Ubuntu 24.04 VM<br/>Docker Compose]
VM --> APP[API and UI containers]
VM --> DB[MySQL, MongoDB, Redis]
VM --> LS[LocalStack<br/>S3 and SQS]
VM --> MP[Mailpit]
DEV -- release candidates only --> STG[Lean staging on AWS]
# docker-compose.yml on the test VM
services:
api:
image: registry.example.com/shop-api:${TAG:-latest}
env_file: .env.local
depends_on: [mysql, mongo, redis, localstack]
ports: ["3000:3000"]
ui:
image: registry.example.com/shop-ui:${TAG:-latest}
volumes: ["./config.local.json:/usr/share/nginx/html/config.json:ro"]
ports: ["8080:80"]
mysql:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: shop
volumes: ["mysql:/var/lib/mysql", "./seed/mysql:/docker-entrypoint-initdb.d:ro"]
mongo:
image: mongo:8.0
volumes: ["mongo:/data/db"]
redis:
image: redis:8
localstack:
image: localstack/localstack:4
environment: { SERVICES: "s3,sqs" }
volumes: ["./seed/localstack:/etc/localstack/init/ready.d:ro"]
ports: ["4566:4566"]
mailpit:
image: axllent/mailpit:latest
ports: ["8025:8025"]
volumes: { mysql: {}, mongo: {} }
- Seed scripts in
seed/load anonymised sample data and create buckets and queues on start-up, so every developer gets the same starting point. .env.localpoints the API at LocalStack (AWS_ENDPOINT_URL=http://localstack:4566) and Mailpit, with dummy credentials.docker compose up -dgives a working stack in minutes;docker compose down -vresets it.
Cloud staging then only runs for what genuinely needs AWS: integration with managed services, performance checks and release sign-off.
7. Smaller items that added up
- Log retention set on every CloudWatch log group (for example 30 days for application logs, longer only where required).
- gp2 to gp3 for EBS volumes: same or better baseline performance at a lower price per GB, changed in place without downtime.
- Commitments for what remained. After the removals and rightsizing, the steady baseline was covered with Savings Plans, sized below the minimum hourly usage. Buying commitments before cleaning up would have locked in paying for waste.
The result
Measured against the baseline in the following billing cycle, the bill was down by about one-third, with no loss of production capacity. Developers also got a faster test loop, since the local environment starts in minutes and needs no shared cloud resources.
Keeping it down
| Guardrail | Purpose |
|---|---|
| Tag policy plus a monthly untagged-resource report | Every resource has an owner |
| Budgets per project and anomaly alerts | Surprises caught within a day |
| Staging schedules managed in code | Off-hours shutdown survives changes |
| Lifecycle rules required on new buckets | Storage cannot grow unbounded |
| Monthly 30-minute cost review of the top five lines | Creep is spotted early |
| Cost estimate in infrastructure pull requests | New spend is a decision, not a surprise |
The most useful lesson: most cloud waste is forgotten, not chosen. A regular, owner-based review finds it, and simple guardrails stop it from coming back.