Six AWS pairs where the pricier service is sized for a problem you may not have, what drives each bill, and read-only commands to check which side you are on.
The bill has a shape, and it is rarely your workload's shape
Most AWS overspending is not waste in the usual sense. Nothing is idle, nothing was forgotten, every resource is doing exactly what it was asked to do. The problem sits upstream of that. Someone picked a service because a tutorial used it, or because it was the one name they recognised, and that service is built for a problem one or two sizes larger than the one they actually have. The bill then reflects the shape of the larger problem, faithfully, every hour.
The reverse error costs just as much and is harder to see, because it arrives as an incident rather than an invoice. A team runs its own database on an instance to save money, has never tested a restore, and discovers during the outage that the backup script stopped working in March. Nothing about that was cheap.
So the useful question is never which service costs less. It is what the expensive one actually gives you, and whether your workload has the property that makes it worth paying for. Six pairs follow, each with the condition under which the pricier option earns its keep and a command you can run to find out which side of the line you are on. Every command here describes or lists. None of them change anything.
A fleet of single-purpose instances where a few Fargate services would do
The estate that costs the most is rarely one oversized machine. It is the spread: one instance for the worker, two or three behind the load balancer, one for Redis, maybe one more for the cron box nobody wants to touch. Each was sized for its own worst hour, each runs every hour, and the sum is a bill shaped like peak demand on every tier at once.
On ECS with Fargate there is no instance layer at all. AWS describes Fargate as a serverless, pay-as-you-go compute engine where you do not manage servers or handle capacity planning, and each service is billed for the tasks it is actually running rather than for a machine that exists whether or not it is busy.
Scaling is per service, not per fleet, and that is the part that changes the arithmetic. Service Auto Scaling adjusts the desired number of tasks for one service on its own, through target tracking on a metric such as average CPU, step adjustments tied to CloudWatch alarms, scheduled actions, or predictive scaling from historical patterns. The web tier and the worker stop sharing a capacity decision.
The worker is the clearest win. AWS states it plainly: if you want your task count to scale to zero when there is no work to be done, set a minimum capacity of 0. A queue consumer that works for two hours a day runs for two hours a day. The dedicated instance it replaces ran for twenty-four.
One detail decides whether that actually works. Scale the worker on something that still has a value at zero tasks, such as the depth of the queue it reads. ECS service CPU is reported per running task, so with no tasks running there is no data point, and a CPU-based policy has nothing to scale out from.
Redis is the one to think about rather than move on reflex. A cache as a task is fine when it is genuinely a cache, meaning you can lose it and refill it. The moment something in there has to survive a restart, it belongs in a managed service or on a box with storage you control, and the next section but one is about exactly that trade.
Databases stay where they are. This is a replatform of the stateless tiers, not of your data. Leave the database on RDS or wherever it already runs, move the things that can be killed and recreated, and judge the result on the instances you switched off.
# Instances running, with type and age. Several small boxes each doing one
# job is the signature this section is about.
aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=running" \
--query "Reservations[].Instances[].[InstanceId,InstanceType,LaunchTime,Tags[?Key=='Name']|[0].Value]" \
--output table
# Any ECS clusters already in the account. This returns cluster ARNs only;
# add list-services --cluster to see what runs in one.
aws ecs list-clusters
EC2 where Lightsail would do
Lightsail sells a bundle. AWS describes each instance bundle as providing compute power, memory, storage and a data transfer allowance, billed at a fixed hourly rate up to a maximum monthly plan cost. EC2 sells those same things separately and meters each one. The instance is one meter, the EBS volume is another (with gp3 letting you provision IOPS and throughput independently of capacity), the public IPv4 address is another, and data transfer out is another, with only the account-wide 100 GB a month AWS gives every customer.
For a small site whose load barely moves, that bundling is the whole point. The Lightsail allowance counts both inbound and outbound transfer toward the quota, but overage is charged only on data transfer out to the internet or to AWS resources over the public IP, and allowance aggregates across instances of the same bundle in a Region so several small instances pool their transfer. Check two things before you commit. The allowance is halved in the Mumbai, Sydney, Jakarta, Malaysia, Hong Kong and Sao Paulo Regions, and a load balancer's own internet traffic is not counted against your instance allowance while traffic between the load balancer and its targets is.
You are on the EC2 side of the line when you need what Lightsail deliberately leaves out. Auto Scaling groups, mixed instance types, Spot capacity, capacity reservations and placement groups all point to EC2, and Lightsail reaches other AWS services through VPC peering rather than living natively among them.
The decision is reversible, which lowers the stakes. AWS supports exporting a Lightsail instance snapshot to EC2, producing an AMI plus an EBS snapshot, with additional EBS snapshots for any attached block storage disks.
# What is running in Lightsail and on which bundle.
aws lightsail get-instances \
--query "instances[].[name,bundleId,blueprintId,state.name]" --output table
# Thirty days of CPU on a candidate EC2 instance. Flat and low is the
# profile a fixed bundle suits.
# The date flags below are BSD. On Linux use: date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ
aws cloudwatch get-metric-statistics --namespace AWS/EC2 \
--metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=i-0123456789abcdef0 \
--start-time "$(date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--period 3600 --statistics Average Maximum --output table
RDS or Aurora where a database on EC2 would do
This is the pair where the fair answer is most often "keep paying", because the work RDS absorbs does not vanish when you decline to pay for it.
Automated backups run in a window you choose, with a retention period between 0 and 35 days, defaulting to seven from the console and one from the API, and point-in-time recovery anywhere inside it. Patching happens in a weekly 30-minute window covering the guest OS and engine version, with mandatory OS updates carrying an apply date and typically taking about ten minutes. Multi-AZ holds a synchronous standby in another Availability Zone, and AWS puts failover for a Multi-AZ DB instance at typically 60 to 120 seconds, with large transactions or a lengthy recovery pushing it higher. The sub-minute figure people remember belongs to the Multi-AZ DB cluster deployment, which AWS puts at typically under 35 seconds and which also gives you two readable standbys.
Aurora changes the storage side, with a cluster volume copied across three Availability Zones that grows and shrinks on its own and bills only for space in use. It has a second dial too, since Aurora Standard charges per million I/O requests while I/O-Optimized drops that charge, at a crossover AWS puts at a quarter of total Aurora spend.
Two signals say you are paying for the wrapper and declining the contents. A Multi-AZ standby does not serve read traffic, so anyone who enabled it hoping for throughput wants a read replica instead. And a retention period of 0 disables automated backups outright, while Multi-AZ off means no failover at all.
For a small project with no scaling requirement, self-managing Postgres or MySQL on EC2 is defensible and often the cheaper answer by a wide margin. What you take on is written down in the shared responsibility model, namely the guest operating system with its updates and security patches. Budget for your own backups and, more to the point, your own restore drills, because an untested backup is a guess.
One option worth knowing before you decide, though almost nobody has it switched on. Aurora Serverless v2 accepts a minimum capacity of zero ACUs, which pauses the instance after an idle interval you choose between five minutes and a day, stops the instance charge entirely while paused, and resumes in roughly 15 seconds. For a database that is genuinely idle most of the day it changes the comparison, and it is a one-line setting on a cluster you may already be running at a fixed floor. Storage is still billed, and the resume delay rules it out for anything latency-sensitive.
# A managed database with the managed features switched off.
aws rds describe-db-instances --query \
"DBInstances[].[DBInstanceIdentifier,DBInstanceClass,Engine,MultiAZ,BackupRetentionPeriod,AllocatedStorage,StorageType]" \
--output table
# Aurora clusters, storage configuration and any serverless range.
aws rds describe-db-clusters --query \
"DBClusters[].[DBClusterIdentifier,Engine,StorageType,ServerlessV2ScalingConfiguration.MinCapacity,ServerlessV2ScalingConfiguration.MaxCapacity]" \
--output table
ElastiCache where Valkey or Redis on EC2 would do
Start by deciding whether the thing you are calling a cache is a cache. If every key can be recomputed from a source of truth, losing the whole cluster is a cold start and a latency spike. If it holds sessions, queue state or rate-limit counters that exist nowhere else, it is a datastore wearing a cache's name, and managed failover is protecting real data.
On a small estate, Valkey or Redis on an EC2 instance does the job, and it does it well enough that the managed service is mostly buying you something you are not using. ElastiCache earns its keep when the cluster has to scale on its own, when a failover has to happen without anyone awake, or when the sizing is genuinely unknown. None of those describe a cache holding a few hundred megabytes for one application.
The condition attached to that is the whole thing, and it is worth saying plainly rather than in a footnote. Somebody has to manage the box. Kernel and package updates, the Redis or Valkey version itself, memory limits and the eviction policy, persistence if you want any, monitoring that notices before your users do. A team that already runs servers is doing this work anyway and one more instance changes nothing. A team with nobody in that seat is not saving money by declining to pay AWS, they are deferring the work until it arrives as an incident.
Be fair about what the managed service does give you. AWS describes ElastiCache as automatically managing hardware provisioning, monitoring, node replacements and software patching, and that applies even to a single node with no replica, so the comparison is not quite like for like. What a single node does not give you is availability. Multi-AZ requires at least one read replica, and with it enabled a failed primary is replaced by promoting the replica with the least replication lag, typically in a few seconds. Without a replica AWS is blunt about the outcome, namely that if a primary has no replicas and it fails, you lose all of that primary's data.
A couple of details change the arithmetic. Replication is asynchronous, so a failover can lose a small amount of recent data. AOF persistence is not an option to weigh either, since AWS has not supported it for Redis OSS since version 2.8.22, which leaves snapshots as the way to persist a node-based cluster. Valkey 9.0 and higher can be created with durability enabled, which persists committed data in a Multi-AZ transactional log and lets replicas recover from that log rather than resynchronising from the primary. Read the conditions before counting on it, because they rule out the small single node this section is about. It needs cluster mode enabled with at least one replica per shard, it is chosen at creation and cannot be turned off afterwards, it runs only on particular instance families, and the asynchronous mode can still lose up to ten seconds of uncommitted data.
With a node-based cluster you are still doing capacity planning, and AWS says plainly that you are responsible for choosing the type and number of nodes correctly. ElastiCache Serverless removes that job if your sizing is genuinely unknown. A node-based replication group with automatic failover disabled and a snapshot retention limit of zero is the cache equivalent of the RDS tell above.
aws elasticache describe-replication-groups --query \
"ReplicationGroups[].[ReplicationGroupId,CacheNodeType,AutomaticFailover,MultiAZ,SnapshotRetentionLimit]" \
--output table
aws elasticache describe-cache-clusters --query \
"CacheClusters[].[CacheClusterId,Engine,EngineVersion,CacheNodeType,NumCacheNodes]" --output table
An extra volume for backups or media, where S3 belongs
Two habits put files on block storage that should never have been there. The first is a second volume mounted at something like /backups, holding last night's database dump and a few tarballs. The second is a volume holding user uploads, because the application wrote them to disk on day one and nobody revisited it.
Both pay the same way. An EBS volume bills for the capacity you provisioned, not for the bytes you put on it, so a volume at one tenth full costs exactly what a full one costs. S3 bills for what is actually stored. A backup directory that grows slowly means you either over-provision from the start and pay for the headroom, or resize under pressure later.
The backup case has a worse problem than the bill. AWS states that a volume and the instance it attaches to must be in the same Availability Zone, so a dump sitting on a second volume beside the database is in the same zone as the thing it exists to protect. It survives a filesystem mistake. It does not survive the instance, the zone, or the account-level accident that takes both. A copy that fails in the same conditions as the original is a convenience, not a backup.
The media case has a different one. Serving uploads off an instance disk puts the instance in the path of every image request, which means it has to be running, sized and scaled for traffic that has nothing to do with your application logic. It also makes the instance stateful, so replacing it becomes a migration. Objects in S3, with a CDN in front, take that traffic off the instance entirely and make the box replaceable again.
Match the S3 class to how often you read the thing. Standard-IA and One Zone-IA give millisecond access with a retrieval fee and a 30-day minimum, and One Zone-IA sits in a single Availability Zone so it suits only data you can recreate. Glacier Instant Retrieval keeps millisecond access at a 90-day minimum, Glacier Flexible Retrieval shares that minimum but must be restored first, and Deep Archive has a 180-day minimum with retrieval measured in hours.
One trap in the IA classes, and it is not where people look for it. Standard-IA, One Zone-IA and Glacier Instant Retrieval each carry a 128 KB minimum billable object size, so thousands of small log files are billed at 128 KB apiece and anything deleted early still pays the minimum out. A lifecycle rule is not how you get there: since September 2024 the default behaviour prevents objects smaller than 128 KB from transitioning to any class at all, and you have to add an object size filter to override it. The way it actually happens is writing small objects straight into an IA class, usually because a backup tool was configured that way. Aggregate before you archive.
A volume is the right answer when you need a filesystem. Block storage exists for data that needs random access, POSIX semantics, or a database's own file layout. If a process must seek into the file, S3 is the wrong shape. If the file is written once and read whole, it is not.
# Volumes attached to nothing, still billing for every provisioned GiB.
aws ec2 describe-volumes --filters Name=status,Values=available \
--query "Volumes[].[VolumeId,Size,VolumeType,CreateTime]" --output table
# Every attached volume that is not the root device. These are the
# candidates: check what is actually on them.
aws ec2 describe-volumes --query \
"Volumes[?Attachments[0].InstanceId && Attachments[0].Device!='/dev/xvda' && Attachments[0].Device!='/dev/sda1'].[VolumeId,Size,VolumeType,Attachments[0].InstanceId,Attachments[0].Device]" \
--output table
# Does this bucket have any lifecycle rules at all?
aws s3api get-bucket-lifecycle-configuration --bucket my-bucket
A pinned fleet where an Auto Scaling group belongs
Four instances behind a load balancer, sized so the busiest hour of the month is comfortable, is a fleet that is comfortable for every other hour of the month too. You pay for the peak continuously and use it briefly.
An Auto Scaling group is the fix, and it costs nothing to adopt. AWS states there are no additional fees for EC2 Auto Scaling and that you pay only for the resources you use. In return the group holds a minimum, maximum and desired capacity, monitors instance health and replaces impaired instances, balances across the Availability Zones you name, registers and deregisters with the load balancer, supports mixed instance types and a blend of Spot and On-Demand purchase options, and rolls out a new AMI through an instance refresh.
The usual objection is that a steady workload does not need scaling, and it misreads what the group is for. AWS documents maintaining a fixed number of instances as a first-class mode, where a group with no scaling policies keeps its desired capacity and still replaces unhealthy members. Even pinned, you gain automated replacement and a reproducible launch template.
Match the scaling method to the pattern you have. Scheduled scaling suits load that changes on a clock, which AWS frames as useful when you know exactly when to change capacity. Dynamic scaling suits traffic that shifts without warning, and predictive scaling handles recurring daily and weekly patterns alongside dynamic scaling rather than instead of it.
Use launch templates, not launch configurations. AWS has retired the latter in stages, with new EC2 instance types unsupported since January 1, 2023, accounts created on or after June 1, 2023 unable to create them in the console, and accounts created on or after October 1, 2024 unable to create them at all.
# Groups where min equals max are pinned. Groups still on a launch
# configuration are on the deprecated path.
aws autoscaling describe-auto-scaling-groups --query \
"AutoScalingGroups[].[AutoScalingGroupName,MinSize,MaxSize,DesiredCapacity,length(Instances),LaunchTemplate.LaunchTemplateName,LaunchConfigurationName]" \
--output table
aws autoscaling describe-launch-configurations \
--query "LaunchConfigurations[].LaunchConfigurationName" --output text
# Target groups using instance targets, to compare against ASG membership.
aws elbv2 describe-target-groups \
--query "TargetGroups[].[TargetGroupName,TargetType,Protocol,Port]" --output table
Read the account before you change it
Every pair above has a threshold, and the threshold is a property of your workload rather than a rule about the service. Peak divided by trough tells you whether an Auto Scaling group pays for itself in capacity or only in reliability. Whether a cached key can be recomputed tells you whether a cache failover matters. Whether anyone has ever restored one of your backups tells you whether self-managing a database is a saving or a deferred incident.
A few caveats before acting. Migration is work, and a quiet workload nobody touches often costs less to leave alone than to move. An existing Reserved Instance or Savings Plan changes the arithmetic and keeps billing after the resource is gone. And the cheapest service you cannot operate at two in the morning is not cheap, it has moved the cost somewhere the invoice does not show.
Run the describe commands first and let the account answer. The service that is wrong for your workload is usually visible in a single column, whether that is a backup retention period of zero, a minimum that equals a maximum, a volume attached to nothing, or a row of instances each doing one small thing.
If the estate is already large enough that the answer is not obvious from these commands, that is what cloud cost optimization work looks like from the outside: read first, change second. And if the answer to all of this turned out to be Kubernetes, the charges that start on their own once a cluster reaches production are a separate list, in moving EKS to production and the five bills nobody budgets.
Or read how we handle it in AWS Cloud Management.
Related Articles
How to Run Postgres on Kubernetes With Point in Time Recovery
A nightly dump cannot answer the question that actually gets asked after an incident, which is to put the database back the way it was at 10.42, just before the migration ran. Continuous archiving can. This is an operator based setup on Kubernetes with base backups and write ahead log shipped to object storage, plus recovery to a named timestamp. It also flags the configuration change that makes most copied CloudNativePG YAML out of date.
CloudInfrastructure as Code: Terraform vs Pulumi
Compare Terraform and Pulumi for infrastructure as code with real-world examples, state management, testing strategies, and migration considerations.
CloudAWS Cost Optimization: 10 Things You're Probably Overpaying For
Ten common areas where AWS customers overspend, with practical strategies for right-sizing, reserved capacity, storage lifecycle management, and more.