Skip to content

Post-migration optimization: it works, but it is expensive, fragile, and unmonitored

AWS post migration optimization in the order that pays: rightsizing and storage cleanup first, then reliability gaps the lift-and-shift left behind, then the monitoring nobody set up.

Three months after a lift-and-shift, most AWS environments share the same profile: everything works, the bill is 30–60% higher than the estimate, half the workloads are quietly single points of failure, and the monitoring consists of whatever CloudWatch collects by default. Post-migration optimization is the deliberate pass that fixes those three debts in order — cost first because it funds the rest, reliability second, observability third — using a month of real utilization data that did not exist on migration day.

What’s happening

Lift-and-shift migrations produce oversized, over-provisioned environments by design. Target instances were sized from source hardware specs (or padded “to be safe”), disks were copied at their on-premises size regardless of usage, and every replicated server became an always-on EC2 instance even if its on-premises ancestor idled 20 hours a day. None of this was wrong during the migration — conservative sizing reduces cutover risk. It becomes wrong when it persists, because AWS bills for provisioned capacity whether or not it is used.

The reliability debt has the same origin. A migrated server lands in one availability zone because that is what replication produces. The database is a self-managed engine on EC2 because that is what it was on-premises. Nothing about the migration created multi-AZ redundancy, health-checked load balancing, or tested backups — those are post-migration work, and in many projects the budget ran out before anyone did them. The monitoring gap is simply the default state: CloudWatch collects hypervisor metrics automatically, but memory, disk space, application errors, and anything that would page a human all require deliberate setup.

What to check first

  • The bill, grouped by service. One month in Billing and Cost Management > Cost Explorer, grouped by service, tells you where the money goes. EC2 compute, EBS, NAT Gateway data processing, and inter-AZ transfer are the usual post-migration surprises.
  • CPU utilization across the fleet. Instances averaging under 10% CPU are the rightsizing shortlist.
  • Orphaned resources. Unattached EBS volumes, idle Elastic IPs, old MGN staging leftovers, and snapshots with no expiry — migration projects shed debris.
  • gp2 volumes. Anything migrated more than a year ago is likely still on gp2; gp3 is cheaper per GB at equal or better baseline performance.
  • Single-AZ everything. One NAT gateway, single-AZ RDS, an application tier that lives entirely in one subnet.

How to verify

# Where the money went last month, by service
aws ce get-cost-and-usage \
  --time-period Start=2026-06-01,End=2026-07-01 \
  --granularity MONTHLY --metrics UnblendedCost \
  --group-by Type=DIMENSION,Key=SERVICE

# Rightsizing candidates, from AWS's own analysis
aws compute-optimizer get-ec2-instance-recommendations \
  --query 'instanceRecommendations[].[instanceArn,finding,recommendationOptions[0].instanceType]' \
  --output table

# Unattached volumes still billing
aws ec2 describe-volumes --filters Name=status,Values=available \
  --query 'Volumes[].[VolumeId,Size,VolumeType,CreateTime]' --output table

# Elastic IPs allocated but not associated
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]'

# Volumes still on gp2
aws ec2 describe-volumes --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[].[VolumeId,Size]' --output table

Per-instance utilization lives in EC2 > Instances > Monitoring; memory does not appear there without the CloudWatch agent, which is itself a finding. Compute Optimizer needs 14 days of data before it will say anything — details in Compute Optimizer rightsizing.

The approach

  1. Sweep the debris first. Delete unattached volumes (snapshot first if in doubt), release idle Elastic IPs, expire orphaned snapshots, finalize any lingering MGN servers so staging resources release. Zero-risk, same-day savings.
  2. Convert gp2 to gp3. An online operation with no downtime for the volume itself; verify IOPS/throughput needs for the few volumes that relied on gp2’s size-scaled burst behavior.
  3. Rightsize from data, not opinions. Apply Compute Optimizer recommendations to the worst offenders in a maintenance window each. Newer-generation instance families usually cost less per unit of performance than the older families a migration tool defaulted to.
  4. Schedule what does not run 24/7. Dev, test, and batch instances get stop/start schedules. An instance running business hours only costs roughly a quarter of an always-on one — the arithmetic is in the instance-hour pricing on https://calculator.aws/.
  5. Commit only after rightsizing. Once the fleet shape is stable for a month, cover the steady-state baseline with Savings Plans or reserved capacity — the decision framework is in RI vs Savings Plans vs Spot. Committing before rightsizing locks in the oversizing.
  6. Close the reliability gaps. Multi-AZ for the database tier, a second NAT gateway if egress matters, health checks that test the application rather than the port, backups with a tested restore. Prioritize by what actually takes the business down, not by what is easiest.
  7. Install real monitoring. CloudWatch agent for memory and disk, log shipping for the applications, and an alarm baseline that pages a human — the CloudWatch alarm baseline is a working starting set.

When this is the wrong move

Optimization is the wrong project when the workload’s future is short — an application being replaced by SaaS in six months deserves the debris sweep and nothing more. It is also premature inside the first few weeks after cutover, when utilization data still reflects migration noise rather than steady state. And deep replatforming — moving the self-managed database to RDS, containerizing the app tier — is a separate project with its own risk profile; do not smuggle it into “optimization” as if it were a tuning pass.

Common mistakes

  • Buying Savings Plans in week one. Committing one to three years of spend to an environment that is about to shrink 40% is the most expensive way to feel proactive.
  • Rightsizing by CPU alone. Memory-bound and burst-dependent workloads (t-family CPU credits) will hurt after a downsize that looked safe in the CPU graph.
  • Deleting migration debris without provenance. That unattached 500 GB volume might be the pre-cutover copy of the finance server. Snapshot, tag, wait a cycle, then delete.
  • Calling it done after the cost pass. The environment is cheaper and still fragile and blind — the second and third passes are the ones that prevent the 2 a.m. incident.

Keeping it under control

  • Put a monthly cost review on the calendar: bill versus budget, top movers, one action item.
  • Tag everything with owner and environment during the optimization pass; untagged spend becomes unattributable spend within a quarter.
  • Set AWS Budgets alerts so drift is a notification, not a quarterly surprise.
  • Re-run Compute Optimizer quarterly; workloads drift and last year’s right size is this year’s oversize.
  • Treat any new workload request with the sizing discipline the migration skipped: start small, measure, grow.

The migration got you to AWS; the optimization pass is what gets you the environment the business case promised. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.