<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Cost Explorer | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/cost-explorer/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/cost-explorer/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Wed, 23 Sep 2026 06:49:49 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Cost Explorer | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/cost-explorer/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>The Real Cost of CloudWatch: Logs, Metrics, Retention and Cardinality</title>
		<link>https://john-nessime.com/blog/cloud-computing/cloudwatch-costs-logs-metrics-cardinality/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 23 Sep 2026 06:44:01 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Amazon Data Firehose]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cardinality]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Cost and Usage Report]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Embedded Metric Format]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Logs Insights]]></category>
		<category><![CDATA[Metric Filters]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Runaway Costs]]></category>
		<category><![CDATA[Showback]]></category>
		<category><![CDATA[VPC Flow Logs]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=785</guid>

					<description><![CDATA[<p>CloudWatch rarely fails loudly, it accumulates. This is a breakdown of the four independent meters behind the bill: ingestion, storage, Logs Insights scans and custom metric cardinality. Why the same log line gets billed three times, why log groups never expire by default, how a single dimension can multiply your metrics bill without your traffic changing, and how to attribute the spend to specific log groups before you start deleting things.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudwatch-costs-logs-metrics-cardinality/">The Real Cost of CloudWatch: Logs, Metrics, Retention and Cardinality</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone in finance forwards the AWS invoice with a single line highlighted. AmazonCloudWatch, sitting uncomfortably close to your compute spend. Nobody launched anything. Nobody enabled a new feature. The number just kept climbing, in a slope gentle enough that nobody noticed until it was big enough to be somebody&#8217;s problem.</p>



<p class="wp-block-paragraph">That slope is the signature. CloudWatch rarely fails loudly, it accumulates: a log group created automatically by a service you enabled once, retention left at the default, a metric filter with a dimension nobody thought about, and a dashboard quietly calling the API every few seconds forever.</p>



<p class="wp-block-paragraph">This post breaks down where CloudWatch costs actually come from, why the same log line gets charged more than once, how cardinality multiplies your bill without your traffic changing, and how to attribute spend to specific log groups before you delete anything. It is organised by cost lever rather than by feature, because that is the order you work in when the invoice lands.</p>



<h2 class="wp-block-heading">The four meters behind your CloudWatch bill</h2>



<p class="wp-block-paragraph">CloudWatch is not one product with one price. It is a set of independent meters that happen to share a console. Working out which meter is running is most of the job, because the fix for each is completely different.</p>



<ul class="wp-block-list">
<li><strong>Ingestion.</strong> Per GB of log data accepted into a log group. Usually the largest component on log-heavy accounts.</li>

<li><strong>Storage.</strong> Per GB-month for what is retained afterwards. Cheap per unit, but it compounds because it never stops unless you tell it to.</li>

<li><strong>Analysis.</strong> Logs Insights bills on data scanned by a query, not on rows returned and not per query.</li>

<li><strong>Metrics and API calls.</strong> Custom metrics bill per unique time series per month. Separately, calls like GetMetricData bill per metric requested, which is how third-party dashboards end up on an AWS invoice.</li>
</ul>



<p class="wp-block-paragraph">The thing worth internalising: a single log line can be billed three times. Once when it arrives, again every month it sits in storage, and again every time a query scans the block it lives in. None of that is hidden, but nothing in the console surfaces it either.</p>



<h2 class="wp-block-heading">Ingestion is the meter that actually hurts</h2>



<h3 class="wp-block-heading">Uncompressed in, compressed out</h3>



<p class="wp-block-paragraph">Ingestion is metered on the uncompressed volume of events you send. Storage is billed on the compressed size CloudWatch keeps. Text logs compress well, so the stored figure can be a small fraction of what you were charged to get the data in.</p>



<p class="wp-block-paragraph">That asymmetry is why people look at a log group holding a few gigabytes, decide it is harmless, and miss that it cost several times that to arrive. The <code>storedBytes</code> value shown on a log group is the compressed number. It tells you about your storage line, not your ingestion line.</p>



<h3 class="wp-block-heading">The log groups you never created</h3>



<p class="wp-block-paragraph">Most runaway ingestion comes from things switched on with a checkbox. VPC Flow Logs pointed at CloudWatch. EKS control plane logging with every type enabled. Lambda functions emitting a full structured payload per invocation. CloudTrail delivering to a log group as well as S3. RDS general query logs left on after a debugging session.</p>



<p class="wp-block-paragraph">None of those feel like a decision at the time, and all of them scale with traffic rather than headcount. That is why the slope keeps rising while the team stays the same size.</p>



<p class="wp-block-paragraph">The most effective lever here is not a CloudWatch setting at all. It is cutting verbosity at the source, before the bytes leave your application. Dropping debug-level logging in production, sampling health check lines, trimming repeated stack frames. Filtering after ingestion does nothing for this meter, because you already paid.</p>



<h2 class="wp-block-heading">Retention: the one-line fix almost nobody applies</h2>



<p class="wp-block-paragraph">Log groups default to <strong>Never Expire</strong>. Not thirty days, not a year. Forever. Every log group created implicitly by another AWS service inherits that default, so an account accumulates hundreds of them without a single deliberate retention decision.</p>



<p class="wp-block-paragraph">Find the offenders first. This lists every log group with no retention policy, alongside its compressed size:</p>



<pre class="wp-block-code"><code>aws logs describe-log-groups 
  --query 'logGroups[?!retentionInDays].[logGroupName,storedBytes]' 
  --output table</code></pre>



<p class="wp-block-paragraph">The <code>?!retentionInDays</code> filter selects entries where the field is absent, which is how the API represents indefinite retention. There is no explicit &#8220;never&#8221; value to match on.</p>



<pre class="wp-block-code"><code>aws logs put-retention-policy 
  --log-group-name /aws/lambda/order-processor 
  --retention-in-days 30</code></pre>



<p class="wp-block-paragraph">Three things are worth knowing before running that across an account.</p>



<ol class="wp-block-list">
<li><strong>The value is an enum, not a free integer.</strong> Accepted values run 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731 and upward to ten years. Asking for 45 days is rejected.</li>

<li><strong>Deletion is not instant.</strong> Expired events are marked for deletion and typically removed within about three days. They stop contributing to your storage charge once marked, and are excluded from the reported <code>storedBytes</code>, so the billing effect lands before the data physically goes.</li>

<li><strong>Reverting means removing the policy, not setting zero.</strong> To go back to indefinite retention you call <code>delete-retention-policy</code> rather than passing a zero value.</li>
</ol>



<p class="wp-block-paragraph">Dry run before you bulk-apply. This prints what would change without touching anything:</p>



<pre class="wp-block-code"><code>aws logs describe-log-groups 
  --query 'logGroups[?!retentionInDays].logGroupName' 
  --output text | tr 't' 'n' | while read -r lg; do
    echo "would set 30-day retention on: $lg"
  done</code></pre>



<p class="wp-block-paragraph">Retention is where compliance and cost pull against each other, and cost should lose. If an auditor needs seven years of logs, seven years is the answer. The move is to export them to S3 and apply lifecycle rules there, rather than paying CloudWatch rates for archival data you will query twice a decade.</p>



<h2 class="wp-block-heading">Cardinality: the multiplier nobody sees coming</h2>



<p class="wp-block-paragraph">This is the part that surprises people, because it breaks the intuition that cost tracks traffic. Cardinality can multiply your metrics bill by an order of magnitude while request volume stays flat.</p>



<p class="wp-block-paragraph">CloudWatch treats every unique combination of namespace, metric name and dimension values as a separate metric. Not a variant of one metric. A separate, separately billed metric. Publish <code>OrderLatency</code> with a <code>Region</code> dimension holding three values and you have three metrics. Add a <code>CustomerId</code> dimension and you have three times however many customers you have.</p>



<p class="wp-block-paragraph">The dangerous dimension values are the ones that look like identifiers: request IDs, trace IDs, pod names, session tokens, customer IDs, IP addresses, URL paths with IDs embedded in them. Each new value is a new billable series that persists and accrues.</p>



<h3 class="wp-block-heading">Metric filters have a guard rail, and it bites</h3>



<p class="wp-block-paragraph">Metrics extracted from logs by a metric filter bill as custom metrics. A filter can carry up to three dimensions, and AWS documents an explicit safety mechanism: CloudWatch Logs disables a metric filter that generates a thousand distinct dimension name/value pairs within a period.</p>



<p class="wp-block-paragraph">That guard rail is useful, but read what it means operationally. Hitting it does not just cost money, it silently stops the filter, so the metric you built an alarm on stops receiving data. If that alarm treats missing data as not breaching, it goes quiet rather than firing. You lose the alert and keep the bill.</p>



<p class="wp-block-paragraph">Audit what exists before adding more, and count what a namespace actually produces:</p>



<pre class="wp-block-code"><code>aws logs describe-metric-filters 
  --log-group-name /aws/lambda/order-processor

aws cloudwatch list-metrics 
  --namespace MyApp/Orders 
  --query 'length(Metrics)'</code></pre>



<p class="wp-block-paragraph">If that count is materially larger than the number of distinct things you consciously measure, you have found your multiplier.</p>



<h3 class="wp-block-heading">EMF does exactly what you tell it</h3>



<p class="wp-block-paragraph">Embedded Metric Format is a good design: you emit structured events, CloudWatch extracts metrics from declared fields, and you get high-cardinality context in the logs with low-cardinality metrics for alarming. That split is the point.</p>



<p class="wp-block-paragraph">The failure mode is promoting a high-cardinality field into the dimension set. AWS documents this plainly: declare something like a request ID as a dimension and EMF will, by design, create a custom metric for every unique combination. Keep identifiers in the log body as properties, and keep dimensions to values you would put on a dashboard axis.</p>



<h3 class="wp-block-heading">Anomaly detection alarms cost more than they look</h3>



<p class="wp-block-paragraph">An anomaly detection alarm does not evaluate one metric. It evaluates the source metric plus the upper and lower bounds of the predicted band, and those bounds are metrics too. That is roughly a threefold multiple against a standard threshold alarm, per alarm.</p>



<p class="wp-block-paragraph">It earns its keep on genuinely seasonal signals where a static threshold produces constant noise. Switching an entire alarm estate to it &#8220;to be safe&#8221; triples a line item for very little detection benefit.</p>



<h2 class="wp-block-heading">Query cost spikes exactly when you need it most</h2>



<p class="wp-block-paragraph">Logs Insights bills on the volume a query scans. Not rows returned. Not query count. Scanned bytes. A query returning twelve lines that scans a terabyte costs the same as one returning a million lines that scans a terabyte.</p>



<p class="wp-block-paragraph">During an incident, an engineer runs a dozen exploratory queries across a wildcard log group selection and a wide time window. The pricing model charges you hardest at the moment the tool is most valuable, which is an uncomfortable incentive. Two habits reduce it without reducing your ability to debug:</p>



<ul class="wp-block-list">
<li><strong>Narrow the time window first, then the filter.</strong> Scanning is bounded by the selected range across the selected groups. Starting at fifteen minutes and widening is far cheaper than starting at a week and narrowing.</li>

<li><strong>Select specific log groups, not patterns.</strong> Querying across every Lambda log group multiplies scanned bytes by the number of functions when you only care about one.</li>
</ul>



<p class="wp-block-paragraph">Watch for scheduled queries too. A dashboard widget or automation running an Insights query every few minutes against a large log group is a fixed monthly cost nobody remembers creating.</p>



<h2 class="wp-block-heading">Finding out where your CloudWatch costs come from</h2>



<p class="wp-block-paragraph">Attribute before you cut. Deleting log groups because they look big is how teams lose the one log they needed three weeks later.</p>



<ol class="wp-block-list">
<li><strong>Split the bill by meter.</strong> In Cost Explorer, filter service to CloudWatch and group by usage type. The region-prefixed usage types map to the meters directly: <code>DataProcessing-Bytes</code> is log ingestion, <code>TimedStorage-ByteHrs</code> is log storage, <code>DataScanned-Bytes</code> is Logs Insights, and <code>CW:Requests</code> and <code>CW:GMD-Metrics</code> cover API calls.</li>

<li><strong>Rank log groups by ingestion.</strong> The <code>IncomingBytes</code> metric in the <code>AWS/Logs</code> namespace, dimensioned by log group, is the per-group ingestion figure. Graph it with the Sum statistic. One documented gotcha: use an <em>absolute</em> range covering exactly thirty days, because a relative range or a different length produces a number that does not correspond to a billing month.</li>

<li><strong>Check stored bytes separately.</strong> A log group can be small in ingestion but large in storage because it has accumulated since the account was created. That is a retention problem, not a verbosity problem, and the fix differs.</li>

<li><strong>Count custom metrics per namespace.</strong> Compare the count against your mental model of what you measure. The gap is cardinality.</li>

<li><strong>Look inside the noisiest group.</strong> Once you know which log group dominates, find out which streams are responsible before changing any application code.</li>
</ol>



<p class="wp-block-paragraph">For that last step, a Logs Insights query over a short window is enough. Keep the range tight, because this query is itself billed on what it scans:</p>



<pre class="wp-block-code"><code>stats count(*) as events, sum(strlen(@message)) as approx_bytes by @logStream
| sort approx_bytes desc
| limit 20</code></pre>



<p class="wp-block-paragraph">That approximates byte volume per stream, which is usually enough to identify the chatty component. It is an approximation, not a billing figure, because it counts message characters rather than the metered event size.</p>



<p class="wp-block-paragraph">If you manage several accounts and want attribution done for you, cost platforms such as Vantage and CloudZero break CloudWatch spend down by usage type and resource without you building dashboards. Worth the spend if client reporting is part of the job. For one account and one engineer, Cost Explorer plus the steps above gets the same answer.</p>



<h2 class="wp-block-heading">Cheaper destinations, and what they cost you</h2>



<p class="wp-block-paragraph">Once you know your ingestion is dominated by logs you rarely read, there are three real options. None is free of trade-offs.</p>



<ul class="wp-block-list">
<li><strong>The Infrequent Access log class.</strong> Set at the log group level, it ingests at a materially lower per-GB rate in exchange for a reduced feature set. Historically that meant no metric filters, no alarming from logs and no Live Tail, though the capability list has expanded since launch. Check the current log classes documentation rather than assuming, and note that a log group you extract metrics from is not a candidate.</li>

<li><strong>Subscription filter to S3 via Amazon Data Firehose.</strong> Sends log data to object storage where lifecycle rules and Athena queries apply. You trade Insights and the console experience for much cheaper long-term retention. Good for audit and forensic logs, poor for anything you troubleshoot weekly.</li>

<li><strong>A separate log stack.</strong> Grafana Cloud, or self-hosted Loki and Prometheus on a VPS from a provider like Contabo or InterServer, moves cost from per-GB metering to a fixed monthly box you administer. The economics can be dramatically better at volume. The price is that you now own retention, backups, upgrades and the availability of your own observability during an outage, which is exactly when you least want to be debugging it.</li>
</ul>



<p class="wp-block-paragraph">The honest version: CloudWatch is expensive partly because it is operated for you and wired into everything. At a few dozen gigabytes a month, migrating away costs more engineering time than it saves. At terabytes, the arithmetic flips hard.</p>



<h2 class="wp-block-heading">Troubleshooting a bill that will not go down</h2>



<p class="wp-block-paragraph"><strong>You set retention everywhere and storage barely moved.</strong> Check whether storage was ever the dominant cost. If your usage type breakdown was mostly <code>DataProcessing-Bytes</code>, retention was never going to fix it.</p>



<p class="wp-block-paragraph"><strong>Custom metric count grows with no code changes.</strong> Something is emitting a dimension whose value set is open-ended. Look for deployment identifiers, container IDs, or a version string appended to a dimension.</p>



<p class="wp-block-paragraph"><strong>An alarm went quiet and the bill jumped the same week.</strong> Suspect a disabled metric filter. Check the filter still exists and is producing data, and verify the alarm&#8217;s missing data treatment. Alarms treating missing data as not breaching fail silently by design.</p>



<p class="wp-block-paragraph"><strong>API request charges you cannot place.</strong> External dashboards are the usual answer. A Grafana instance polling CloudWatch, or any tool calling GetMetricData on a short interval, bills per metric requested. Widening the refresh interval on dashboards nobody watches live is a quick win.</p>



<p class="wp-block-paragraph"><strong>Costs dropped, then came back.</strong> Look at your infrastructure as code. If Terraform or CloudFormation creates log groups without a retention argument, every apply restores the default. Fixing it in the console fixes it until the next deploy.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating storage as the problem because it is the number the console shows. Ingestion is usually larger and invisible in the log group list.</li>

<li>Adding a dimension because it would be useful to filter by, without checking how many distinct values it can take.</li>

<li>Filtering logs after they reach CloudWatch. The ingestion charge already applied.</li>

<li>Deleting log groups to cut cost without checking whether anything alarms on them.</li>

<li>Setting retention in the console while the IaC that owns the log group has none configured.</li>

<li>Enabling every EKS control plane log type when only the audit log is consumed.</li>
</ul>



<h2 class="wp-block-heading">Best practices for controlling CloudWatch costs</h2>



<ul class="wp-block-list">
<li><strong>Make retention a required argument.</strong> If log groups come from Terraform or CloudFormation, treat a missing retention setting as a policy violation and fail the plan. Checkov and similar scanners have rules for exactly this.</li>

<li><strong>Decide verbosity per environment, not per service.</strong> Debug in staging, info in production, with a documented way to raise it temporarily during an incident.</li>

<li><strong>Keep dimensions bounded, identifiers in the message body.</strong> If you cannot enumerate a dimension&#8217;s possible values on a whiteboard, it does not belong in the dimension set.</li>

<li><strong>Alarm on the bill itself.</strong> A billing alarm on estimated charges, or an anomaly monitor scoped to CloudWatch, turns a quarterly surprise into a same-week signal.</li>

<li><strong>Review the top five log groups by ingestion monthly.</strong> Five minutes with the <code>IncomingBytes</code> graph catches new noise before it compounds.</li>

<li><strong>Route archival logs out deliberately.</strong> Anything kept for compliance rather than operations belongs in S3 with lifecycle rules from the start.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Why is my CloudWatch bill so high when my traffic has not changed?</h3>



<p class="wp-block-paragraph">Two causes account for most cases. Storage accumulating because log groups default to indefinite retention, so the bill grows every month even at constant ingestion. Or metric cardinality, where a dimension with an open-ended value set keeps creating new billable series. Neither tracks request volume, which is why the bill and the traffic graph disagree.</p>



<h3 class="wp-block-heading">Does deleting a log group reduce my bill immediately?</h3>



<p class="wp-block-paragraph">It stops future storage charges for that data and nothing else. Ingestion already billed is spent, and deleting the group does not stop whatever was writing to it, which will simply recreate it. Turn off the source first, then delete.</p>



<h3 class="wp-block-heading">What counts as one custom metric for billing?</h3>



<p class="wp-block-paragraph">One unique combination of namespace, metric name and dimension values. The same metric name published with three different dimension value sets is three billable metrics. This is the single most important sentence in CloudWatch pricing and the easiest one to skim past.</p>



<h3 class="wp-block-heading">Should I switch log groups to the Infrequent Access class?</h3>



<p class="wp-block-paragraph">For logs you keep but rarely read, it is usually a straightforward saving on ingestion. For any group you extract metrics from, alarm on, or stream live, it is not a candidate. The feature gap between the classes has narrowed since launch, so check current documentation rather than ruling it out on old information.</p>



<h3 class="wp-block-heading">Is Logs Insights charged per query or per gigabyte?</h3>



<p class="wp-block-paragraph">Per gigabyte scanned. Rows returned are irrelevant. A tightly filtered query over a wide range across many log groups is expensive; a broad query over fifteen minutes on one log group is cheap. Narrow the range before you narrow the filter.</p>



<h3 class="wp-block-heading">Do VPC Flow Logs have to go to CloudWatch?</h3>



<p class="wp-block-paragraph">No. Flow logs can be delivered to S3 instead, avoiding CloudWatch ingestion and storage entirely, at the cost of losing Insights queries over them. If you analyse flow logs occasionally with Athena rather than continuously in the console, S3 is the better destination.</p>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">CloudWatch costs are not one number with one cause. They are four meters running independently, and each has a different fix: cut verbosity at the source for ingestion, set retention for storage, narrow scope for queries, bound your dimensions for metrics. Applying the wrong fix to the wrong meter is why so many cost-reduction efforts produce a bill that barely moves.</p>



<p class="wp-block-paragraph">So attribute first. Split by usage type, rank log groups by <code>IncomingBytes</code>, count custom metrics per namespace. Ten minutes of that tells you which lever you are actually pulling, and stops you deleting the log group that turns out to be the only place a production alarm was getting its data.</p>



<h2 class="wp-block-heading">Need a hand with your CloudWatch spend?</h2>



<p class="wp-block-paragraph">This is well-defined work with a measurable outcome, which makes it a good fit for a short engagement. Things I can help with:</p>



<ul class="wp-block-list">
<li>Breaking your CloudWatch bill down by meter and by log group, so you know what you are paying for before anything changes</li>

<li>Auditing custom metric cardinality and identifying which dimensions are multiplying your series count</li>

<li>Building a retention policy that satisfies your compliance requirement without paying CloudWatch rates for archival data</li>

<li>Enforcing retention and log class in Terraform or CloudFormation so the fix survives the next deployment</li>

<li>Designing a subscription filter and S3 archive path for logs you need to keep but rarely query</li>

<li>Reviewing alarms and dashboards for anomaly detection and API polling costs that are not earning their keep</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me your Cost Explorer breakdown grouped by usage type, or the output of <code>describe-log-groups</code> for the account. That is usually enough to say where the money is going before we talk about anything else.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudwatch-costs-logs-metrics-cardinality/">The Real Cost of CloudWatch: Logs, Metrics, Retention and Cardinality</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</title>
		<link>https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 15 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Config]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[Backfill]]></category>
		<category><![CDATA[Cost Allocation Tags]]></category>
		<category><![CDATA[Cost and Usage Report]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Multi-Account Strategy]]></category>
		<category><![CDATA[Service Control Policies]]></category>
		<category><![CDATA[Showback]]></category>
		<category><![CDATA[Tag Policies]]></category>
		<category><![CDATA[Terraform]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=519</guid>

					<description><![CDATA[<p>Tagging a resource and activating a cost allocation tag are two different operations, and AWS never tells you when you have only done the first. A practical guide to the activation gap, what backfill can and cannot repair, the five places coverage leaks, and a retrofit order that actually converges.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/">AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You tagged everything. The Terraform provider block carries <code>default_tags</code> with <code>Project</code> and <code>Environment</code>, every module inherits them, and you can open the console right now and see those tags sitting on the instances.</p>



<p class="wp-block-paragraph">Then someone in finance asks what the reporting pipeline costs. You open Cost Explorer, group by <code>Project</code>, and over half the bill lands in a bucket called &#8220;No tag key&#8221;.</p>



<p class="wp-block-paragraph">Nothing is broken. The tags are there. They just aren&#8217;t on the bill, because tagging a resource and activating a cost allocation tag are two separate operations, and AWS never warns you when you&#8217;ve done only the first.</p>



<p class="wp-block-paragraph">This post covers getting AWS cost allocation tags to cover a messy account: the activation gap, what backfill will and won&#8217;t repair, where coverage leaks even with decent discipline, and a retrofit order that converges.</p>



<h2 class="wp-block-heading">A resource tag and a cost allocation tag are not the same thing</h2>



<p class="wp-block-paragraph">A resource tag is metadata on the resource. A cost allocation tag is a billing dimension. Getting the second means taking a key that already exists on your resources and explicitly switching it on in the Billing and Cost Management console.</p>



<p class="wp-block-paragraph">Two details make this bite. First, activation lives in the management account. From a member account, the page tells you tags are managed at the payer level and that&#8217;s the end of it. Plenty of teams have a good tagging standard and no access to the one screen that makes it count. Related trap: an account moving between organizations has its activated keys reset to inactive.</p>



<p class="wp-block-paragraph">Second, the lag. AWS documents up to 24 hours for a newly applied key to appear on the activation page, and up to another 24 hours after you activate before it takes effect. The loop between &#8220;we agreed a standard&#8221; and &#8220;I can see it in Cost Explorer&#8221; is two days at best.</p>



<p class="wp-block-paragraph">Past a handful of keys, drive it from the API. Listing the inactive ones is the fastest way to find what you forgot:</p>



<pre class="wp-block-code"><code># Every key AWS knows about that is NOT currently billing-active
aws ce list-cost-allocation-tags --status Inactive

# Switch a set of them on
aws ce update-cost-allocation-tags-status --cli-input-json '{
  "CostAllocationTagsStatus": [
    {"TagKey": "Project",     "Status": "Active"},
    {"TagKey": "Environment", "Status": "Active"},
    {"TagKey": "Owner",       "Status": "Active"}
  ]
}'</code></pre>



<p class="wp-block-paragraph">User-defined keys appear in the report with a <code>user:</code> prefix and AWS-generated ones carry the reserved <code>aws:</code> prefix, so they never collide. There&#8217;s a cap on active keys, raisable through Service Quotas, which you almost certainly don&#8217;t need to raise.</p>



<h2 class="wp-block-heading">What the backfill repairs, and what it quietly doesn&#8217;t</h2>



<p class="wp-block-paragraph">Activation used to be strictly forward-looking, so activating late permanently orphaned everything before it. That&#8217;s no longer true. The management account can ask AWS to reapply current activation status across up to twelve months of historical cost data.</p>



<pre class="wp-block-code"><code># Must be the first day of a month, no earlier than twelve months back
aws ce start-cost-allocation-tag-backfill 
  --backfill-from 2025-01-01T00:00:00Z</code></pre>



<ul class="wp-block-list">
<li>Start date must be the first day of a month, and can&#8217;t reach back more than twelve months.</li>

<li>One request per 24 hours, and you can&#8217;t submit another while one is processing.</li>

<li>It backfills <em>activation status</em>, not tag values.</li>
</ul>



<p class="wp-block-paragraph">That last point catches people. If the resource wasn&#8217;t carrying the tag when the cost was incurred, backfill gives you nothing for those months. It fixes &#8220;I forgot to activate.&#8221; It does not fix &#8220;I forgot to tag.&#8221; A tag applied in June and activated in November backfills to June and no further, whatever start date you hand it.</p>



<p class="wp-block-paragraph">It also cuts both ways. Backfill applies the <em>current</em> status of every key, so a key sitting inactive right now gets stripped out of historical data too. If you&#8217;re pruning an overgrown tag list, backfill before you deactivate the old keys, not after, or you&#8217;ll erase the history you were trying to keep.</p>



<h2 class="wp-block-heading">Where AWS cost allocation tags actually leak coverage</h2>



<p class="wp-block-paragraph">With activation sorted and every Terraform-managed resource tagged, a stubborn slice of the bill still refuses to attribute. It matters which slice, because the fixes have nothing in common.</p>



<h3 class="wp-block-heading">Charges no resource can carry</h3>



<p class="wp-block-paragraph">Support charges, refunds, credits, tax, some Marketplace subscriptions. There&#8217;s no resource to hang a tag on, so tagging discipline never touches them. On accounts with a large support plan or heavy credit activity, that&#8217;s a visible fraction of the invoice.</p>



<p class="wp-block-paragraph">The answer is account tags. AWS added support for using AWS Organizations account tags as a cost allocation dimension, applying to all metered usage inside the tagged account and flowing into Cost Explorer, Budgets, Cost Anomaly Detection and Cost Categories. Because attribution happens at the account level, it picks up exactly what resource tags can&#8217;t reach.</p>



<p class="wp-block-paragraph">This is recent enough that most tagging guides predate it, and it&#8217;s the highest-value item here if your accounts already map roughly onto teams.</p>



<h3 class="wp-block-heading">Resources created by other resources</h3>



<p class="wp-block-paragraph">This is the quiet one, and usually the biggest surprise gap in an otherwise well-run account. Terraform&#8217;s provider-level <code>default_tags</code> applies to resources Terraform creates. It does not reach the EC2 instances an Auto Scaling group launches, because Terraform didn&#8217;t create those, the ASG did. Your ASG is tagged. Your instances aren&#8217;t. Same for volumes attached at launch, ENIs, snapshots and AMIs.</p>



<pre class="wp-block-code"><code>data "aws_default_tags" "current" {}

resource "aws_autoscaling_group" "app" {
  # ... other config ...

  # Push provider default tags down to launched instances
  dynamic "tag" {
    for_each = data.aws_default_tags.current.tags
    content {
      key                 = tag.key
      value               = tag.value
      propagate_at_launch = true
    }
  }
}

resource "aws_launch_template" "app" {
  # ... other config ...

  tag_specifications {
    resource_type = "instance"
    tags          = data.aws_default_tags.current.tags
  }

  tag_specifications {
    resource_type = "volume"
    tags          = data.aws_default_tags.current.tags
  }
}</code></pre>



<p class="wp-block-paragraph">For a standalone instance, <code>volume_tags</code> on <code>aws_instance</code> covers its attached volumes. General rule: whenever one AWS resource provisions another on your behalf, assume tags don&#8217;t propagate until you&#8217;ve checked the created resource in the console, not the plan output.</p>



<h3 class="wp-block-heading">Shared infrastructure that belongs to everyone</h3>



<p class="wp-block-paragraph">NAT gateway data processing, VPC interface endpoints, a Transit Gateway, a shared load balancer, the central logging bucket. You can tag all of these perfectly and the tag says <code>Project=platform</code>, which is accurate and useless for chargeback.</p>



<p class="wp-block-paragraph">Transit Gateway is worth calling out because it supports tagging the gateway resource itself, so data processing and transfer charges pick up the tag rather than only attachment hours. That gap used to swallow a lot of network spend.</p>



<p class="wp-block-paragraph">Beyond that, stop chasing tags and do allocation math instead. Cost Categories support split charge rules that redistribute a shared bucket across your other categories, using proportional splits, fixed percentages or an even division. Proportional is the sane default; fixed percentages are for when finance already agreed a number and the report has to match it.</p>



<h3 class="wp-block-heading">Container spend hiding behind one instance tag</h3>



<p class="wp-block-paragraph">A shared EKS or ECS cluster is one tagged compute footprint running fifteen teams&#8217; workloads. The instance tag gives you the total and nothing about who caused it. Split cost allocation data is the native answer: opt in and the cost and usage export gains rows breaking cluster cost down to individual pods or tasks. It won&#8217;t account for idle headroom you&#8217;re deliberately carrying, but it beats one line item labelled &#8220;the cluster&#8221;.</p>



<h3 class="wp-block-heading">Case drift and near-duplicate keys</h3>



<p class="wp-block-paragraph">Keys and values are case-sensitive. <code>Environment</code>, <code>environment</code> and <code>ENV</code> are three distinct keys producing three columns, each holding a partial and misleading picture. Tag Policies in AWS Organizations exist for this: define the key including its capitalization, define permitted values, and get an org-wide compliance report. Cheapest governance on this list, because it costs nobody a deploy.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A retrofit order that converges</h2>



<p class="wp-block-paragraph">Retrofits stall because people start at the bottom, sweeping untagged resources by hand while the creation path keeps producing more. This order avoids that.</p>



<ol class="wp-block-list">
<li><strong>Measure first.</strong> Group Cost Explorer by your intended key and record what percentage of spend falls into &#8220;No tag key&#8221;. That&#8217;s your baseline and your only honest progress metric.</li>

<li><strong>Cut the key set down.</strong> Three to five keys: owner, environment, and a project or cost centre. Coverage on five beats partial coverage on thirty.</li>

<li><strong>Activate immediately, even with terrible coverage.</strong> Both clocks start now: the activation delay and the rolling twelve-month backfill window. There&#8217;s no downside to activating a key that&#8217;s barely applied.</li>

<li><strong>Rank the gap by spend, not resource count.</strong> Sort untagged line items by cost descending. It&#8217;s usually a few NAT gateways, a couple of oversized databases and one forgotten bucket. Ten thousand untagged security groups cost nothing.</li>

<li><strong>Fix the creation path before the backlog.</strong> IaC defaults, launch template tag specifications, ASG propagation. Sweeping first means sweeping again next month.</li>

<li><strong>Sweep the stragglers in bulk.</strong> The Resource Groups Tagging API works across services and regions, which beats going service by service.</li>

<li><strong>Backfill, then re-measure.</strong> Compare against step one. If the number barely moved, you fixed the wrong resources.</li>
</ol>



<pre class="wp-block-code"><code># Find what already carries a Project tag, to diff against inventory
aws resourcegroupstaggingapi get-resources --tag-filters Key=Project

# Apply tags to a specific set of ARNs
aws resourcegroupstaggingapi tag-resources 
  --resource-arn-list arn:aws:rds:eu-west-1:111122223333:db:reporting-01 
  --tags Project=reporting,Environment=prod</code></pre>



<p class="wp-block-paragraph">Run it against a couple of ARNs and confirm in the console before looping over a list. Bulk tagging is trivially easy to point at the wrong account.</p>



<h2 class="wp-block-heading">Enforcing tags without becoming the tag police</h2>



<p class="wp-block-paragraph">Four mechanisms, in increasing order of blast radius. Most teams need the first three and should be careful with the fourth.</p>



<ul class="wp-block-list">
<li><strong>IaC defaults.</strong> Provider-level default tags plus required module variables. Cheapest, catches the majority, nobody thinks about it.</li>

<li><strong>CI validation.</strong> A linter rule that fails the pull request on missing required tags. The failure lands on the person who can fix it, when they can fix it.</li>

<li><strong>Detective controls.</strong> The AWS Config managed rule for required tags flags non-compliant resources without blocking anything. Tells the truth, breaks nothing.</li>

<li><strong>Preventive controls.</strong> A service control policy denying creation when a required key is absent, keyed on a null check against the request tag.</li>
</ul>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRDSCreateWithoutProjectTag",
      "Effect": "Deny",
      "Action": "rds:CreateDBInstance",
      "Resource": "*",
      "Condition": {
        "Null": {
          "aws:RequestTag/Project": "true"
        }
      }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">The honest trade-off: service control policies are where tagging programmes go to die. Not every API supports tag-on-create, and not every legitimate workflow passes tags through the way you expect. Roll a broad deny across the org on day one and you break somebody&#8217;s deployment on day two, the ticket lands on you, and the policy is gone by day three along with everyone&#8217;s appetite for the project.</p>



<p class="wp-block-paragraph">Scope it to one service at a time, starting in sandbox. I&#8217;d rather run Config plus Tag Policies for a quarter and fix the top three offenders in the IaC. The deny is a finishing move, not an opening one.</p>



<h2 class="wp-block-heading">Measuring coverage so it doesn&#8217;t rot</h2>



<p class="wp-block-paragraph">Cost Explorer gives the headline number. For a per-service breakdown, query the cost and usage export in Athena. One caveat: tag column naming differs between export versions, older ones giving a flattened column per activated key and newer formats a map column. Run a <code>DESCRIBE</code> first rather than guessing.</p>



<pre class="wp-block-code"><code>-- Confirm the actual column names first
DESCRIBE your_cur_database.your_cur_table;

-- Then: untagged spend, ranked by service
SELECT   line_item_product_code,
         SUM(line_item_unblended_cost) AS untagged_cost
FROM     your_cur_database.your_cur_table
WHERE    your_project_tag_column = ''
  AND    line_item_unblended_cost &gt; 0
GROUP BY 1
ORDER BY 2 DESC
LIMIT    20;</code></pre>



<p class="wp-block-paragraph">That ranked list is your work queue; everything below the top twenty is noise. The list call also returns a last-used timestamp per key, the cleanest way to find keys nobody has applied to anything in months.</p>



<p class="wp-block-paragraph">If you&#8217;d rather not maintain the dashboard yourself, this is a fair place to buy rather than build. Grafana Cloud sits on Athena comfortably if you already run Grafana, and platforms like CloudZero, Vantage and nOps handle allocation modelling out of the box. Their real value is the shared-cost and commitment maths. Their real cost is another vendor between you and your billing data, and several price against a percentage of the spend they watch, so run that number first.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<ul class="wp-block-list">
<li><strong>The key isn&#8217;t in the activation list.</strong> It must exist on at least one resource first, and propagation takes up to a day. Past that, check you&#8217;re in the management account and the key matches exactly, including case.</li>

<li><strong>Activated, but everything still shows untagged.</strong> Give it a full billing cycle. A key switched on mid-month leaves the first half of that month untagged until you backfill.</li>

<li><strong>Tags stopped working after an account move.</strong> Activation doesn&#8217;t survive a move between organizations. Reactivate from the new management account, then backfill.</li>

<li><strong>Cluster costs still land on one team.</strong> Instance tags can&#8217;t split a shared cluster. Opt into split cost allocation data, or treat the cluster as a shared cost in a split charge rule.</li>

<li><strong>You&#8217;ve hit the active key limit.</strong> Almost always too many keys rather than a real need for a quota increase. Use last-used timestamps to find dead ones, and backfill before deactivating.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Assuming a tagged resource is a tagged cost. It isn&#8217;t until you activate the key.</li>

<li>Waiting for good coverage before activating. Activate first, improve coverage second, backfill third.</li>

<li>Deactivating stale keys and then backfilling, which wipes their history out of the data.</li>

<li>Measuring coverage by resource count instead of by spend.</li>

<li>Rolling out a broad tag-enforcing SCP in production before anyone has read a compliance report.</li>

<li>Trusting <code>default_tags</code> to reach resources Terraform didn&#8217;t directly create.</li>

<li>Putting anything sensitive in a tag value. Tags surface in billing exports that finance and third-party tools read.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep the mandatory set to three to five keys, with defined allowed values rather than free text.</li>

<li>Pin casing in the standard and enforce it with Tag Policies, because AWS won&#8217;t do it for you.</li>

<li>Tag accounts as well as resources. Account tags cover charges resource tags structurally cannot.</li>

<li>Treat coverage as a tracked percentage of spend with an owner and a monthly trend, not a project that finishes.</li>

<li>Accept an irreducible remainder. Chasing the last few percent by hand costs more than it returns.</li>

<li>Put allocation rules in Cost Categories rather than inventing tag values that encode a split.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Are AWS cost allocation tags retroactive?</h3>



<p class="wp-block-paragraph">Partly. Activation applies going forward, but the management account can request a backfill reapplying current activation status across up to twelve months. The catch is that the resource must have carried the tag at the time, so backfill recovers late activation, never late tagging.</p>



<h3 class="wp-block-heading">Why don&#8217;t my tags appear in Cost Explorer?</h3>



<p class="wp-block-paragraph">In order of likelihood: the key was never activated in the Billing console; you&#8217;re looking from a member account; the activation delay hasn&#8217;t elapsed; or the key differs in case from the one you activated.</p>



<h3 class="wp-block-heading">How many cost allocation tags can I activate?</h3>



<p class="wp-block-paragraph">There&#8217;s a documented per-account cap on active keys, raisable through Service Quotas, plus a separate per-resource cap on total tags. Check current numbers in the AWS billing quotas docs. In practice the binding limit is human: past roughly a dozen keys, consistency collapses.</p>



<h3 class="wp-block-heading">Should I use account tags or resource tags?</h3>



<p class="wp-block-paragraph">Both, for different jobs. Account tags handle top-level attribution and cover untaggable charges like support, credits and refunds. Resource tags handle granularity inside an account. If your accounts already map cleanly to teams, account tags are the cheaper win and worth doing first.</p>



<h3 class="wp-block-heading">Is this worth doing on a small account?</h3>



<p class="wp-block-paragraph">Scale the effort to the shape of the bill. If you run a handful of services under one account and can name every resource from memory, account tags plus two resource keys is plenty. Worth noticing too that a fixed-price VPS from a provider like Contabo or InterServer has trivially perfect cost allocation, because the bill is one predictable line. Elastic infrastructure buys flexibility and pays for it in attribution work.</p>



<h3 class="wp-block-heading">How do I allocate shared costs like NAT gateways?</h3>



<p class="wp-block-paragraph">Not with tags. Put them in a Cost Categories split charge rule and redistribute proportionally across consuming categories. Tag the shared resource to identify the bucket, then let the rule do the allocation.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">AWS cost allocation tags fail silently, and that&#8217;s what makes them frustrating. Nothing errors. No alarm fires. Your resources look correctly tagged in every console you check, the bill quietly disagrees, and you find out when someone asks a question you can&#8217;t answer.</p>



<p class="wp-block-paragraph">So check one thing now: open the cost allocation tags page in your management account and see whether the keys you&#8217;ve been diligently applying are actually switched on. If they aren&#8217;t, activate them today, then backfill. That twelve-month window rolls, and every month you wait is history you can&#8217;t recover.</p>



<p class="wp-block-paragraph">Everything else here is incremental. Coverage is a percentage you improve, not a state you reach.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help getting your AWS bill to explain itself?</h2>



<p class="wp-block-paragraph">Cost attribution is unglamorous and easy to postpone until the quarter someone actually needs the numbers. If your account is already messy, it goes faster with someone who has untangled one before. I help with:</p>



<ul class="wp-block-list">
<li>Auditing tag coverage as a percentage of spend, broken down by service, so you know what you&#8217;re actually fixing</li>

<li>Designing a minimal tag schema and the Tag Policies to keep it consistent across an organization</li>

<li>Closing propagation gaps in Terraform and CloudFormation, around Auto Scaling groups, launch templates and attached storage</li>

<li>Setting up Cost Categories with split charge rules so shared infrastructure stops distorting every team&#8217;s number</li>

<li>Building cost and usage export pipelines in Athena, partitioned so the dashboard isn&#8217;t expensive to load</li>

<li>Planning a staged tag enforcement rollout that doesn&#8217;t break anyone&#8217;s deployment pipeline</li>
</ul>



<p class="wp-block-paragraph">Send me a Cost Explorer screenshot grouped by your main tag key, or the output of a list call against your active tags, and I&#8217;ll tell you where the gap actually is.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/aws-cost-allocation-tags-coverage/">AWS Cost Allocation Tags: Getting Real Coverage on a Messy Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cut AWS Costs Without Breaking Production: A Blast-Radius Playbook</title>
		<link>https://john-nessime.com/blog/devops/cut-aws-costs-without-breaking-production/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 26 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon EBS]]></category>
		<category><![CDATA[Amazon EC2]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[NAT Gateway]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Reserved Instances]]></category>
		<category><![CDATA[Savings Plans]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[VPC]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=264</guid>

					<description><![CDATA[<p>Most AWS cost work goes wrong because it starts with the biggest line item, which is also the riskiest. Order the work by blast radius instead: free networking and storage fixes first, performance envelopes one workload at a time, and commitments last. Includes the commands to find the waste and the cuts that look harmless and are not.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/cut-aws-costs-without-breaking-production/">Cut AWS Costs Without Breaking Production: A Blast-Radius Playbook</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The message usually arrives on the third or fourth of the month, and it is never phrased as a question. Someone from finance pastes a screenshot of the AWS bill into a channel, adds a number and a percentage, and waits. Nobody says &#8220;please cause an outage.&#8221; What they say is &#8220;can we get this down.&#8221;</p>



<p class="wp-block-paragraph">That is where most cost work goes wrong. The pressure is on the total, so people attack the biggest line item first. The biggest line item is almost always compute, and compute is the one place where a wrong guess shows up as latency at peak. Three weeks later the bill is lower and the p99 is worse, and nobody connects the two, because the change that caused it was a Tuesday afternoon instance swap that &#8220;went fine.&#8221;</p>



<p class="wp-block-paragraph">This post is about how to cut AWS costs without breaking production by ordering the work differently: not by how much each change saves, but by how far the damage travels if the change is wrong. I will go through the levers in that order, with the traps in each one, the commands I use to find the waste, and the handful of cuts that look harmless and are not.</p>



<h2 class="wp-block-heading">The cuts that break production are rarely the ones you fear</h2>



<p class="wp-block-paragraph">The line items that look most alarming on a bill are usually networking and storage overhead: NAT Gateway processing, public IPv4 hours, log ingestion, snapshots nobody owns. They feel dangerous to touch because they sit in the middle of the VPC. In practice, almost all of them can be removed or rerouted with zero behavioural change to your application. A gateway endpoint for S3 does not alter a single byte your code sees. Deleting an Elastic IP associated with nothing cannot affect anything, by definition.</p>



<p class="wp-block-paragraph">The changes that feel safe are the ones with real blast radius. Dropping an instance one size. Switching a volume type. Moving objects to a colder storage class. Each is a single API call, reversible on paper, and each quietly narrows a performance envelope you were relying on without knowing it. The failure does not show up at deploy time. It shows up the next time you hit the load level that used to fit inside the old headroom.</p>



<p class="wp-block-paragraph">So the rule I work by: <strong>sequence cost work by blast radius, not by savings size</strong>. Take everything production cannot possibly notice first. Then the things that move a performance envelope, one workload at a time, with a metric to watch. Then, last, the financial commitments, which cannot break your uptime at all but can lock you into paying for architecture you are about to throw away.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">First, find out what you are actually paying for</h2>



<p class="wp-block-paragraph">The default Cost Explorer view groups by service, which is close to useless here. &#8220;EC2 &#8211; Other&#8221; is not a thing you can act on. It is a bucket holding EBS volumes, snapshots, NAT Gateway hours, NAT Gateway data processing, data transfer and public IPv4 charges, all mixed together.</p>



<h3 class="wp-block-heading">Group by usage type instead</h3>



<p class="wp-block-paragraph">Usage type names the actual mechanism being billed. Strings like <code>NatGateway-Bytes</code>, <code>NatGateway-Hours</code>, <code>PublicIPv4:InUseAddress</code> and <code>EBS:VolumeUsage.gp2</code> tell you what to go fix. This pulls last month&#8217;s usage types sorted by cost:</p>



<pre class="wp-block-code"><code>aws ce get-cost-and-usage 
  --time-period Start=$(date -d '1 month ago' +%Y-%m-01),End=$(date +%Y-%m-01) 
  --granularity MONTHLY 
  --metrics UnblendedCost 
  --group-by Type=DIMENSION,Key=USAGE_TYPE 
  --output json 
  | jq -r '.ResultsByTime[0].Groups[] | [.Keys[0], .Metrics.UnblendedCost.Amount] | @tsv' 
  | sort -k2 -gr | head -30</code></pre>



<p class="wp-block-paragraph">That uses GNU <code>date</code>, so run it on Linux unless you have coreutils on macOS. Cost Explorer API calls are themselves billed per request, which surprises people the first time they wrap this in a cron job.</p>



<h3 class="wp-block-heading">Billing data cannot tell you where your bytes went</h3>



<p class="wp-block-paragraph">This is the most useful thing to internalise about networking spend. Cost data knows how many gigabytes crossed the NAT Gateway. It has no idea whether they were headed to S3, to ECR, to CloudWatch or to a third-party API. There is no destination breakdown in billing data.</p>



<p class="wp-block-paragraph">VPC Flow Logs are the missing half. Turn them on for the private subnets, then query them grouped by destination address with Athena or CloudWatch Logs Insights and map the ranges back to AWS services. Now you have the split: this much to S3, this much to ECR, this much genuinely to the internet. That is what tells you which endpoint is worth adding. Flow Logs cost money to ingest, so scope them tightly and turn them off once you have the answer. A week on the right subnets is enough.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Tier zero: savings production cannot notice</h2>



<p class="wp-block-paragraph">Everything in this tier changes billing without changing behaviour. Do all of it before you touch a single instance type.</p>



<h3 class="wp-block-heading">The orphan sweep</h3>



<p class="wp-block-paragraph">AWS charges for allocated resources, not used ones. A volume attached to nothing bills at full rate. An Elastic IP attached to nothing bills by the hour, and has done ever since AWS began charging for public IPv4 addresses whether or not they are in use.</p>



<pre class="wp-block-code"><code># Elastic IPs associated with nothing
aws ec2 describe-addresses 
  --query 'Addresses[?AssociationId==`null`].[PublicIp,AllocationId]' 
  --output text

# EBS volumes in the "available" state, meaning attached to no instance
aws ec2 describe-volumes --filters Name=status,Values=available 
  --query 'Volumes[].[VolumeId,Size,VolumeType,CreateTime]' 
  --output text</code></pre>



<p class="wp-block-paragraph">Run the same pass over load balancers with no healthy targets, snapshots whose source volume is gone, and old AMIs still holding snapshots behind them. Snapshot a volume before deleting it. The snapshot costs a fraction of the volume and buys you a rollback if something did care after all.</p>



<h3 class="wp-block-heading">NAT Gateway is usually the largest risk-free win</h3>



<p class="wp-block-paragraph">A NAT Gateway bills three separate ways: an hourly charge for existing, a per-gigabyte charge for every byte it processes, and then whatever data transfer applies to where that traffic actually went. Teams look at the hourly rate, decide it is a rounding error, and never look at the processing line. The processing line is the one that grows with traffic.</p>



<p class="wp-block-paragraph">The part that stings: traffic from a private subnet to S3 or DynamoDB in the same region is charged NAT processing even though it never leaves the AWS network. Gateway endpoints for those two services are free, with no hourly and no per-gigabyte charge. If your private subnets talk to S3 through a NAT Gateway, this is the best ratio of saving to risk available anywhere on the platform.</p>



<pre class="wp-block-code"><code>aws ec2 create-vpc-endpoint 
  --vpc-id vpc-0123456789abcdef0 
  --service-name com.amazonaws.eu-west-1.s3 
  --vpc-endpoint-type Gateway 
  --route-table-ids rtb-0123456789abcdef0</code></pre>



<p class="wp-block-paragraph">A gateway endpoint works by adding a prefix-list route to the route tables you name, so the main failure mode is naming the wrong tables and leaving traffic on the old path. Nothing breaks; you just do not save anything. The real caveat is policy: if you have bucket policies or endpoint policies that restrict access by source, check them first, because requests arriving through an endpoint present differently.</p>



<p class="wp-block-paragraph">Interface endpoints for other services (ECR, CloudWatch Logs, Secrets Manager, SSM) are a different calculation. They charge per hour per availability zone plus per gigabyte, so they only pay off above a certain volume for that one service. Use the Flow Logs breakdown to decide. Pulling container images from ECR through NAT on every scale-out is the case that usually justifies one.</p>



<h3 class="wp-block-heading">Log groups that never expire</h3>



<p class="wp-block-paragraph">CloudWatch Logs bills mostly on ingestion and secondarily on stored volume. A log group created without an explicit retention setting keeps data forever, so every debug group someone created during an incident years ago is still accruing storage.</p>



<pre class="wp-block-code"><code># Log groups with no retention policy set
aws logs describe-log-groups 
  --query 'logGroups[?!retentionInDays].[logGroupName,storedBytes]' 
  --output text

aws logs put-retention-policy 
  --log-group-name /aws/lambda/my-function 
  --retention-in-days 30</code></pre>



<p class="wp-block-paragraph">Retention is the safe half. Reducing ingestion is a tier-one change, because you are removing evidence you might need during an incident. If you need a long retention window cheaply, exporting to S3 with a lifecycle rule beats paying CloudWatch storage rates indefinitely, and a log platform like Grafana Loki or a hosted equivalent gives you a cheaper long tail while CloudWatch keeps the recent window.</p>



<h3 class="wp-block-heading">Incomplete multipart uploads</h3>



<p class="wp-block-paragraph">Failed large uploads leave parts behind in S3. Those parts are billed as storage and do not appear in a normal object listing, which is why some buckets bill for far more than they seem to contain. One lifecycle rule ends it:</p>



<pre class="wp-block-code"><code>{
  "Rules": [
    {
      "ID": "abort-incomplete-multipart",
      "Status": "Enabled",
      "Filter": {},
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">Apply it with <code>aws s3api put-bucket-lifecycle-configuration</code>. That call replaces the entire lifecycle configuration on the bucket, so fetch the existing one and merge rather than overwrite.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Tier one: savings that move a performance envelope</h2>



<p class="wp-block-paragraph">Now a wrong call has consequences. Everything here goes one workload at a time, with a named metric you watch for at least a full traffic cycle afterwards.</p>



<h3 class="wp-block-heading">gp2 to gp3, and the throughput trap</h3>



<p class="wp-block-paragraph">This is presented everywhere as a free win, and for most volumes it is. gp3 costs less per gibibyte and gives every volume a baseline of 3,000 IOPS regardless of size, where gp2 scales at 3 IOPS per GiB and only reaches 3,000 sustained at 1 TiB. For a small database volume quietly burning through burst credits, gp3 is a straight upgrade in both cost and stability.</p>



<p class="wp-block-paragraph">The trap is throughput, not IOPS. gp3&#8217;s baseline throughput is 125 MiB/s. A large gp2 volume can deliver up to 250 MiB/s. Migrate a big sequential-read volume to gp3 with the defaults and you halve its throughput ceiling while the cost report still shows a saving. Log processing, backup jobs and analytics scratch space are exactly the workloads that hit this, and exactly the ones where the symptom is &#8220;the nightly job got slower&#8221; rather than an alert.</p>



<p class="wp-block-paragraph">Check <code>VolumeReadBytes</code> and <code>VolumeWriteBytes</code> in CloudWatch first, and provision throughput explicitly where the volume needs it:</p>



<pre class="wp-block-code"><code>aws ec2 modify-volume --volume-id vol-0123456789abcdef0 
  --volume-type gp3 --iops 3000 --throughput 250

aws ec2 describe-volumes-modifications --volume-id vol-0123456789abcdef0 
  --query 'VolumesModifications[].[ModificationState,Progress]' 
  --output text</code></pre>



<p class="wp-block-paragraph">Elastic Volumes changes the type in place with the instance running, so there is no downtime. Two things to watch anyway. If the root volume is declared inline in a CloudFormation block device mapping, changing its type there can replace the instance rather than modify the volume; modify through the EC2 API instead. And there is a cooldown before a volume can be modified again, so plan on getting it right rather than iterating. On RDS the same change also runs without downtime, but the instance then enters a storage optimization state during which further storage changes are blocked.</p>



<h3 class="wp-block-heading">Rightsizing without guessing</h3>



<p class="wp-block-paragraph">AWS Compute Optimizer reads CloudWatch metrics and returns recommendations with a confidence rating. Better than eyeballing CPU graphs, with one important gap: without the CloudWatch agent installed it cannot see memory. A JVM service sitting at 12% CPU and 88% heap looks like an obvious downsize candidate and is not.</p>



<pre class="wp-block-code"><code>aws compute-optimizer get-ec2-instance-recommendations 
  --query 'instanceRecommendations[?finding==`OVER_PROVISIONED`].[instanceArn,currentInstanceType]' 
  --output text</code></pre>



<p class="wp-block-paragraph">My working rules:</p>



<ul class="wp-block-list">
<li>Look at a window that includes your worst week, not a rolling average. Averages hide the peak that justifies the size.</li>

<li>Change one dimension at a time, family or size, so you know what caused any regression.</li>

<li>Treat burstable families as a separate decision. A T-family instance that exhausts CPU credits under sustained load either throttles or silently bills you for unlimited mode. Neither is what you wanted.</li>

<li>Moving to Graviton usually saves more than downsizing x86, but it is an architecture change. Images need to be multi-arch and every compiled dependency needs to build. That belongs in a sprint, not in a cost-cutting afternoon.</li>

<li>Write down the metric you expect to move and the threshold at which you revert, before you make the change.</li>
</ul>



<h3 class="wp-block-heading">S3 storage classes and the small-object trap</h3>



<p class="wp-block-paragraph">Intelligent-Tiering is the low-thought option and it works well for what it was designed for: objects of meaningful size with access patterns you cannot predict. It charges a small per-object monitoring fee, moves objects between tiers automatically, and applies no retrieval fee on the automatic access tiers.</p>



<p class="wp-block-paragraph">Where it fails is object count. Objects below the eligibility threshold of 128 KB are not auto-tiered at all. They sit in the frequent access tier permanently while still paying monitoring. A bucket holding hundreds of millions of thumbnails, metadata records or small JSON documents can pay for monitoring that produces no saving whatsoever. Check average object size first, from S3 Storage Lens or an inventory report: total bytes divided by object count.</p>



<p class="wp-block-paragraph">Lifecycle transitions have their own trap, since each transition is a billed request. Moving a billion small objects to a colder class can cost more in transition requests than the storage saving returns in a year. Transitions pay off on size, not on count. Expiry is the exception: deleting costs nothing and removes the storage entirely. While you are in there, check non-current versions in versioned buckets, which accumulate invisibly and need their own rule. And if your workload is genuinely egress-heavy, an object store with no egress charge such as Cloudflare R2 changes the arithmetic enough to model, in exchange for cross-provider latency and an operational split.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Tier two: commitments, which break budgets rather than uptime</h2>



<p class="wp-block-paragraph">Savings Plans and Reserved Instances cannot cause an outage. They are billing constructs. What they can do is hold you to a spend rate for one or three years after the architecture that justified it has changed. What I would want anyone to know before signing:</p>



<ul class="wp-block-list">
<li>Savings Plans cannot be cancelled during the term; AWS documentation is explicit. There is a limited return path with eligibility conditions and quotas per billing family, but plan as though there is no exit.</li>

<li>Standard Reserved Instances can be listed on the Reserved Instance Marketplace, which is a real exit but not a guaranteed one, since somebody has to buy. Convertible RIs cannot be sold, only exchanged for a different configuration.</li>

<li>Commit after rightsizing, never before. A plan applied to an oversized fleet locks in the waste and removes the incentive to fix it.</li>

<li>Savings Plans do not reserve capacity. If you need a guarantee that instances will be available in a specific zone, that is an On-Demand Capacity Reservation, a separate mechanism the discount can then apply to.</li>

<li>Savings Plans do not apply to Spot usage or to usage already covered by an RI. If much of your fleet is Spot, your committable baseline is smaller than the bill suggests.</li>

<li>Compute Savings Plans survive architectural drift: family changes, size changes and moves between EC2, Fargate and Lambda stay covered. EC2 Instance Savings Plans and Standard RIs discount more deeply and tolerate drift poorly. Choose based on how stable your architecture actually is, not how stable you would like it to be.</li>
</ul>



<p class="wp-block-paragraph">Most teams land on a layered shape: commit at the baseline you are confident will still exist in a year, leave the variable top of the curve on demand or on Spot. Flat usage and a frozen architecture justify deeper, longer commitments. Mid-migration, mid-container-adoption or mid-anything, take the smaller discount for the shorter term.</p>



<p class="wp-block-paragraph">One honest question worth asking here: does the workload need to be on AWS at all? Steady, non-elastic things like build runners, staging environments and internal tooling are often cheaper on a plain VPS from a provider such as Contabo or InterServer, while genuinely elastic workloads are often cheaper on AWS. The trade is operational, because you take back patching, backups and capacity planning that AWS was doing for you.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting: the bill went down and something broke</h2>



<p class="wp-block-paragraph">The hard part of diagnosing a cost regression is the delay. The change lands on Tuesday, the symptom appears at month-end batch time, and by then three other things have shipped. Work backwards from the symptom:</p>



<ul class="wp-block-list">
<li><strong>Latency got worse but CPU is fine.</strong> Look at storage. <code>VolumeQueueLength</code> climbing with throughput flat at a round number means you are sitting on a provisioned ceiling, which is the gp3 default-throughput signature. On gp2, a falling <code>BurstBalance</code> is the tell, and it usually starts hours before anything is user-visible.</li>

<li><strong>Intermittent timeouts after rightsizing.</strong> Check whether the new instance type has lower network or EBS bandwidth, not just fewer vCPUs. Baseline bandwidth scales with size, and smaller sizes burst their network the way T instances burst CPU.</li>

<li><strong>Something stopped reaching a service.</strong> If you added VPC endpoints, check whether an endpoint policy is denying it. Interface endpoints also need the endpoint&#8217;s own security group to allow inbound from your workload, which is the most common reason a new endpoint appears to break connectivity.</li>

<li><strong>Objects are slow or missing from an application path.</strong> Check whether a lifecycle rule moved them into a class with retrieval latency. The Glacier tiers inside Intelligent-Tiering are opt-in for exactly this reason, and enabling them turns instant reads into restore requests.</li>
</ul>



<p class="wp-block-paragraph">Keep a change log for cost work specifically: a dated line per change, what you expected to save, what metric you were watching. Five minutes each, and it is the difference between diagnosing this in an hour and in a week.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Starting with the biggest number.</strong> Compute is the biggest line and the riskiest to touch. It is the last tier, not the first.</li>

<li><strong>Committing before rightsizing.</strong> This locks in the exact waste you were trying to remove, for years.</li>

<li><strong>Accepting gp3 defaults on high-throughput volumes.</strong> Cheaper storage, halved throughput ceiling, no alert.</li>

<li><strong>Enabling Intelligent-Tiering on buckets full of tiny objects.</strong> Monitoring fees on objects that will never move a tier.</li>

<li><strong>Turning off monitoring to save on monitoring.</strong> Understandable at 2am on a budget deadline, indefensible during the next incident. Cut cardinality and retention, not coverage.</li>

<li><strong>Doing it once.</strong> Cost drifts back within two quarters unless something structural stops it.</li>
</ul>



<h2 class="wp-block-heading">Best practices that make the savings stick</h2>



<ul class="wp-block-list">
<li><strong>Make cost visible before it is a crisis.</strong> AWS Budgets with alerts at a percentage of forecast, plus Cost Anomaly Detection, catch the runaway job on day two rather than on the invoice. Platforms like Vantage, CloudZero or Kubecost for per-namespace Kubernetes attribution earn their place once several teams share an account structure.</li>

<li><strong>Tag at creation, in the IaC.</strong> Tags applied afterwards do not backfill historical cost data. If it was not tagged when it was created, that spend is unattributable forever.</li>

<li><strong>Put non-production on a schedule.</strong> Dev and staging running nights and weekends is the most common waste in any account, and stopping them has no production blast radius at all.</li>

<li><strong>Make the cheap thing the default in the module.</strong> gp3, sensible log retention, gateway endpoints and lifecycle rules belong in your Terraform or OpenTofu modules, so the next environment is born correct rather than remediated later.</li>

<li><strong>Review commitment coverage on a schedule.</strong> Coverage and utilisation are different numbers and both matter. High coverage with low utilisation means you over-committed.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is the fastest way to cut AWS costs without breaking production?</h3>



<p class="wp-block-paragraph">Add gateway endpoints for S3 and DynamoDB, delete unassociated Elastic IPs and unattached EBS volumes, and set retention on log groups that have none. All four are billing-only changes with no behavioural effect on your application, and together they often account for a large share of the surprise on a bill.</p>



<h3 class="wp-block-heading">Why is my &#8220;EC2 &#8211; Other&#8221; line item so large?</h3>



<p class="wp-block-paragraph">It is a mixed bucket: EBS volumes and snapshots, NAT Gateway hours and data processing, data transfer, and public IPv4 address charges. Regroup Cost Explorer by usage type rather than by service to see which of those is driving it.</p>



<h3 class="wp-block-heading">Is migrating from gp2 to gp3 always cheaper?</h3>



<p class="wp-block-paragraph">No. gp3 has a lower per-gibibyte price, but IOPS above the included baseline and throughput above the included baseline are billed separately. A volume needing high provisioned throughput can cost more on gp3 than it did on gp2. Model the volumes that genuinely need extra performance instead of assuming a flat saving across the fleet.</p>



<h3 class="wp-block-heading">Can I cancel a Savings Plan if my usage drops?</h3>



<p class="wp-block-paragraph">Assume no. AWS documents Savings Plans as non-cancellable for the term. There is a limited return mechanism with eligibility rules and quotas, and no secondary marketplace of the kind that exists for Standard Reserved Instances. Size the commitment to the baseline you are confident about, not the peak you hope for.</p>



<h3 class="wp-block-heading">Should I use Spot Instances to cut costs?</h3>



<p class="wp-block-paragraph">For fault-tolerant, interruptible work such as CI runners, batch jobs and stateless workers behind a queue, the discount is hard to ignore. For anything holding state in memory or serving a request that cannot be retried, the interruption notice is short and the failure is abrupt. Spot is an architecture decision, not a billing toggle.</p>



<h3 class="wp-block-heading">How do I find out where my NAT Gateway traffic is going?</h3>



<p class="wp-block-paragraph">Billing data cannot tell you. Enable VPC Flow Logs on the relevant subnets, query them with Athena or CloudWatch Logs Insights grouped by destination address, and map the results back to AWS service ranges. That gives you the split between S3, ECR, other AWS services and the genuine internet, which is what determines whether an endpoint pays for itself.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">If you take one idea from this, take the ordering. You cut AWS costs without breaking production by sorting the work by blast radius rather than by savings size, and by accepting that the frightening-looking networking and storage line items are usually the safe ones, while the innocuous single-API-call changes are the ones that bite.</p>



<p class="wp-block-paragraph">Do the tier-zero sweep first and completely. It is free, it is fast, and it buys you the standing to say &#8220;not yet&#8221; when someone asks you to shrink a production database on a Thursday. Then move one performance envelope at a time, with a metric and a revert threshold. Commit last, at the baseline you actually believe in.</p>



<h2 class="wp-block-heading">Need a second pair of eyes on your AWS bill?</h2>



<p class="wp-block-paragraph">This is work I do regularly, and it goes faster with someone who has no attachment to how the account got this way. Things I can help with:</p>



<ul class="wp-block-list">
<li>A usage-type level breakdown of your bill, with savings ranked by blast radius, so you know what is safe this week and what needs a change window.</li>

<li>NAT Gateway and data transfer analysis from VPC Flow Logs, ending in a specific list of which endpoints to add and what each is worth.</li>

<li>EBS and S3 review: gp3 candidates with throughput checked properly, lifecycle rules, version cleanup, and storage class decisions based on your real object size distribution.</li>

<li>CloudWatch and logging spend reduction that keeps the signal you need during incidents, including moving long-tail retention off CloudWatch storage rates.</li>

<li>Savings Plan and Reserved Instance modelling against your actual baseline, including what happens to coverage if the migration you are planning goes ahead.</li>

<li>Guardrails so it stays fixed: Budgets, anomaly alerting, tagging enforced in Terraform or OpenTofu, and cheap defaults baked into your modules.</li>
</ul>



<p class="wp-block-paragraph">If you want a concrete starting point, send me a Cost Explorer export grouped by usage type, or the output of the orphan sweep commands above, and I will tell you what I would touch first and what I would leave alone.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/cut-aws-costs-without-breaking-production/">Cut AWS Costs Without Breaking Production: A Blast-Radius Playbook</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The AWS Account Health Check I Run Before Quoting a Project</title>
		<link>https://john-nessime.com/blog/cloud-computing/aws-account-health-check/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 24 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Regions]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Confused Deputy]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Credential Report]]></category>
		<category><![CDATA[Cross-Account Access]]></category>
		<category><![CDATA[Drift Detection]]></category>
		<category><![CDATA[External ID]]></category>
		<category><![CDATA[Freelancing]]></category>
		<category><![CDATA[GuardDuty]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Access Analyzer]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Project Scoping]]></category>
		<category><![CDATA[Root Access Management]]></category>
		<category><![CDATA[Terraform]]></category>
		<category><![CDATA[Trusted Advisor]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=552</guid>

					<description><![CDATA[<p>Before I quote AWS work, I run a read-only account health check: six passes covering identity, logging, region sprawl, infrastructure code, cost shape and support escalation. Here are the commands, the pass tests, and the findings that change the number.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/aws-account-health-check/">The AWS Account Health Check I Run Before Quoting a Project</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">&#8220;It&#8217;s a small AWS account, nothing complicated, we just need someone to tidy it up.&#8221; That sentence has preceded more scope disasters than any other I know of in freelance cloud work, and it&#8217;s almost never a lie. The person saying it believes it, because the parts of an AWS account that make work expensive are the parts nobody looks at.</p>



<p class="wp-block-paragraph">The expensive stuff is not the running instances. It&#8217;s the access keys nobody can attribute, the CloudTrail trail that quietly stopped delivering months ago, the Terraform state that no longer matches reality, and the region somebody spun a proof of concept up in two years ago. None of that comes up on a kickoff call. All of it comes up in week three, after you&#8217;ve quoted.</p>



<p class="wp-block-paragraph">So I run an AWS account health check before I give anyone a number. It&#8217;s read-only, it takes a couple of hours, and it&#8217;s the cheapest insurance in consulting. This post is the actual check: the six passes, the commands, what each finding does to the estimate, and what to do when the client won&#8217;t give you the access to run it.</p>



<h2 class="wp-block-heading">Why the AWS account health check comes before the quote</h2>



<p class="wp-block-paragraph">A quote is a bet on how much unknown there is. You&#8217;re pricing the work in the brief plus everything you&#8217;ll find while doing it, and the second half is invisible until you look. Two accounts can look identical and differ by a factor of three in effort. The difference is nearly always archaeology: how much has to be reverse engineered before you can safely change anything.</p>



<p class="wp-block-paragraph">There&#8217;s a second reason that matters more than pricing. If you touch an account that already had a compromised key sitting in it, the incident becomes yours by proximity. Establishing what the account looked like before you arrived isn&#8217;t paranoia. It&#8217;s the only version of events you&#8217;ll be able to evidence later.</p>



<h2 class="wp-block-heading">The access I ask for, and what a refusal tells me</h2>



<p class="wp-block-paragraph">I ask for a cross-account IAM role that the client creates and my account assumes. Not an IAM user. Not keys in an email. A role, with an external ID the client generates.</p>



<p class="wp-block-paragraph">The external ID solves one specific problem. Without it, a trust policy that names my account can be used by anyone who talks me into assuming a role on their behalf, and I end up acting inside a third party&#8217;s account without knowing it. AWS calls this the confused deputy problem. It&#8217;s the piece of cross-account setup people skip most often.</p>



<p class="wp-block-paragraph">On that role I ask for two AWS managed policies: <code>SecurityAudit</code> and <code>ViewOnlyAccess</code>. The pairing is the point. <code>ViewOnlyAccess</code> lets you enumerate resources across services, so you can see what exists. <code>SecurityAudit</code> lets you read configuration and policy documents, so you can see how it&#8217;s wired together. Neither alone gives you the picture, and neither lets you change anything. Billing is separate again: Cost Explorer read access has to be added explicitly and the feature has to be enabled in the account.</p>



<p class="wp-block-paragraph">The reply to that request tells you as much as the check does.</p>



<ul class="wp-block-list">
<li>&#8220;Here&#8217;s the role ARN and external ID&#8221; means somebody there understands AWS identity. Good sign.</li>

<li>&#8220;I&#8217;ll just make you an admin user, easier&#8221; means nobody is managing access. Expect that looseness everywhere else.</li>

<li>&#8220;Our previous developer set it up and we can&#8217;t reach him&#8221; means you&#8217;re inheriting credentials nobody can account for. Price the rotation work as its own line.</li>

<li>&#8220;We&#8217;d rather not grant access before signing&#8221; is fair. Offer a paid discovery engagement. If they won&#8217;t pay for two hours of assessment, they won&#8217;t pay for the fix.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The six passes</h2>



<p class="wp-block-paragraph">Each pass answers one question. Everything here is read-only, and the order matters, because each pass changes how you read the next.</p>



<h3 class="wp-block-heading">Pass 1: Who can do what</h3>



<p class="wp-block-paragraph">Confirm where you actually are first. That sounds trivial until you&#8217;re in an organization with a dozen similarly named accounts.</p>



<pre class="wp-block-code"><code>aws sts get-caller-identity
aws iam list-account-aliases
aws organizations describe-organization</code></pre>



<p class="wp-block-paragraph">If the third command fails because the account isn&#8217;t in an organization, that&#8217;s a finding by itself. Standalone means no service control policies, no organization trail, and no central place to revoke anything.</p>



<p class="wp-block-paragraph">Then the credential surface. The summary gives counts; the credential report gives per-principal detail, including when each password and key was last used.</p>



<pre class="wp-block-code"><code>aws iam get-account-summary

aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 --decode &gt; credential-report.csv</code></pre>



<p class="wp-block-paragraph">The report generates asynchronously, so the second call may need a retry the first time. What I read from it: how many IAM users exist at all, how many have keys that have never been used, and how many have keys last used a long time ago that are still active.</p>



<p class="wp-block-paragraph">An unused key is worse than a busy one. A key in daily use is attributable to something. A key last used eighteen months ago and still enabled belongs to a person or script nobody remembers, which means nobody will notice when someone else starts using it.</p>



<p class="wp-block-paragraph">Then check whether anyone has ever asked this question before you:</p>



<pre class="wp-block-code"><code>aws accessanalyzer list-analyzers</code></pre>



<p class="wp-block-paragraph">IAM Access Analyzer offers external access analyzers, which flag resources shared outside a zone of trust you define, and unused access analyzers, which surface unused roles, keys and passwords. Unused access analysis is billed per IAM role and user analyzed per month, so it isn&#8217;t free, but it answers the least privilege question far faster than reading policy documents by hand. An empty list means nobody has ever asked this account who can reach what.</p>



<p class="wp-block-paragraph">Root closes the pass. In an AWS Organizations setup, centralized root access management lets the management account or a delegated IAM administrator remove root credentials from member accounts entirely, so there&#8217;s no root password, key or MFA device to manage per account. Live root credentials on every member account is a real finding, and one of the few security fixes that&#8217;s genuinely quick.</p>



<p class="wp-block-paragraph"><em>Pass test: you can name every principal that can reach production, and say when each last did.</em></p>



<h3 class="wp-block-heading">Pass 2: Would anyone notice</h3>



<p class="wp-block-paragraph">This is the pass that most often changes my number, because it&#8217;s the failure that stays invisible until it isn&#8217;t. Logging that exists but has silently stopped working is more dangerous than no logging, since everybody believes they&#8217;re covered.</p>



<pre class="wp-block-code"><code>aws cloudtrail describe-trails 
  --query 'trailList[].{Name:Name,MultiRegion:IsMultiRegionTrail,Org:IsOrganizationTrail}'

aws cloudtrail get-trail-status --name TRAIL_NAME_HERE

aws configservice describe-configuration-recorder-status</code></pre>



<p class="wp-block-paragraph">The first command tells you a trail is configured. The second tells you whether it&#8217;s delivering, which is the part that matters. A trail whose last delivery failed because someone edited the destination bucket policy still shows up in the console as a configured trail. The status call is where the truth is.</p>



<p class="wp-block-paragraph">Multi-region matters for the same reason region sprawl does: a single-region trail records nothing about the region where somebody is quietly running compute. Check GuardDuty too, and how long logs are actually retained. Ninety days of retention against a one-year obligation is a project, not a task, and it belongs in the quote rather than in week four.</p>



<p class="wp-block-paragraph">One naming trap worth knowing: AWS renamed the original Security Hub to Security Hub CSPM and launched a separate, newer service under the Security Hub name. When a client says &#8220;we have Security Hub,&#8221; ask which one, because the findings format and the configuration are not the same thing.</p>



<p class="wp-block-paragraph"><em>Pass test: a trail is delivering right now, covers every region, and its logs are kept for at least as long as the business needs them.</em></p>



<h3 class="wp-block-heading">Pass 3: How wide the blast radius is</h3>



<p class="wp-block-paragraph">People forget regions. Not occasionally, constantly. A demo in Singapore, a test bucket in Ireland, a Lambda deployed from a tutorial that defaulted to us-east-1.</p>



<pre class="wp-block-code"><code>for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  count=$(aws ec2 describe-instances --region "$r" 
    --query 'length(Reservations[].Instances[])' --output text)
  echo "$r $count"
done</code></pre>



<p class="wp-block-paragraph">Left without <code>--all-regions</code>, <code>describe-regions</code> returns the regions enabled for the account, which is exactly what you want. Adding the flag pulls in disabled regions too, and the per-region calls against those just error out.</p>



<p class="wp-block-paragraph">Run the same loop shape for anything carrying state or cost: RDS instances, load balancers, EBS volumes, NAT gateways. NAT gateways are the classic. They bill hourly whether or not anything routes through them, and a forgotten one in a region nobody visits is pure waste. Then the two exposure checks I never skip:</p>



<pre class="wp-block-code"><code>aws ec2 describe-security-groups 
  --filters Name=ip-permission.cidr,Values=0.0.0.0/0 
  --query 'SecurityGroups[].{Id:GroupId,Name:GroupName,VpcId:VpcId}'

aws s3api list-buckets --query 'Buckets[].Name' --output text</code></pre>



<p class="wp-block-paragraph">For each bucket that matters, check its public access block configuration and its policy. An open security group on 443 in front of a load balancer is fine. An open security group on a database port is a different conversation, and you want that conversation before you own the account.</p>



<p class="wp-block-paragraph"><em>Pass test: you have a list of every region with resources in it, and nothing on that list surprises the client.</em></p>



<h3 class="wp-block-heading">Pass 4: Whether any of this is described in code</h3>



<p class="wp-block-paragraph">The question isn&#8217;t &#8220;do they use Terraform.&#8221; It&#8217;s whether the code still describes the account. Those diverge quietly. Somebody makes an urgent console change during an incident, never brings it back into code, and from then on the repo is fiction that plans cleanly right up until the day it destroys something.</p>



<pre class="wp-block-code"><code>terraform plan -refresh-only

terraform plan -detailed-exitcode</code></pre>



<p class="wp-block-paragraph">The refresh-only plan shows where real infrastructure has drifted from state without proposing to change anything. The detailed exit code version is the one for scripts: it exits 0 for no changes, 1 for an error and 2 when there&#8217;s a diff, so you can gate on drift instead of reading output by eye.</p>



<p class="wp-block-paragraph">Three things I want to know here:</p>



<ul class="wp-block-list">
<li>What fraction of live resources are managed by code? Half a codebase is often worse than none, because it creates false confidence about which half.</li>

<li>Where does state live and who can write to it? Remote state in a versioned, locked S3 bucket is a different risk profile from a state file on a laptop.</li>

<li>Does the pipeline that applies changes still run? Credentials that expired six months ago mean everything since went in by hand.</li>
</ul>



<p class="wp-block-paragraph">Tagging belongs here too. With nothing tagged you can&#8217;t attribute cost, can&#8217;t scope a change safely, and can&#8217;t tell a production resource from an abandoned experiment. Untagged environments add hours to everything that follows.</p>



<p class="wp-block-paragraph"><em>Pass test: a refresh-only plan comes back clean, or the drift is a short list you can read out loud.</em></p>



<h3 class="wp-block-heading">Pass 5: What shape the spend is in</h3>



<p class="wp-block-paragraph">I don&#8217;t care much about the total. I care about the shape: which services dominate, whether the curve is flat or climbing, and whether anything jumped without an explanation.</p>



<pre class="wp-block-code"><code>aws ce get-cost-and-usage 
  --time-period Start=$(date -d '6 months ago' +%Y-%m-01),End=$(date +%Y-%m-01) 
  --granularity MONTHLY 
  --metrics UnblendedCost 
  --group-by Type=DIMENSION,Key=SERVICE</code></pre>



<p class="wp-block-paragraph">The <code>date -d</code> syntax is GNU coreutils; on macOS use the BSD equivalent or a fixed start date. Worth knowing before you loop this: the Cost Explorer API bills per paginated request, unlike the console view, so a wide query across several dimensions is a small but real charge on the client&#8217;s bill. Keep it narrow and mention it in your notes.</p>



<ul class="wp-block-list">
<li>Data transfer in the top three usually means an architecture problem rather than a rate problem. Cross-AZ chatter and NAT gateway egress are the usual suspects.</li>

<li>A step change with no matching launch deserves a direct question. Sometimes it&#8217;s a feature. Sometimes it&#8217;s a runaway job. Occasionally it&#8217;s mining on a stolen key.</li>

<li>No budgets and no anomaly monitors means nobody finds out about a spike until the invoice arrives.</li>

<li>Heavy commitment purchases against workloads you&#8217;re about to re-architect are a constraint on your design, and need to be on the table before you propose anything.</li>
</ul>



<p class="wp-block-paragraph">If the client wants ongoing cost visibility past what Cost Explorer offers, platforms like Vantage or CloudZero sit on the Cost and Usage Report and give per-team allocation and unit economics that native tooling makes awkward. Raise it as a separate line, not part of the engineering work.</p>



<p class="wp-block-paragraph"><em>Pass test: you can explain the top three cost lines in a sentence each, and account for every month-on-month jump.</em></p>



<h3 class="wp-block-heading">Pass 6: What happens at two in the morning</h3>



<p class="wp-block-paragraph">Short pass, about escalation. What support plan is the account on, and would anything wake a human up?</p>



<p class="wp-block-paragraph">Support plan matters more than people expect. On Basic you get the service limits checks and a small subset of Trusted Advisor security and fault tolerance checks, manual refresh only, and no technical support cases at all. The full check library and the Trusted Advisor API need a paid plan. If your engagement depends on opening a case during a cutover, that&#8217;s a prerequisite, not a nice to have.</p>



<p class="wp-block-paragraph">There&#8217;s one date worth carrying in your head, because it&#8217;s a real deprecation rather than a version number. AWS has announced that Developer Support, Business Support and Enterprise On-Ramp are discontinued on 1 January 2027, with Business Support+ as the replacement tier. If you&#8217;re scoping anything that runs past that boundary, check which plan the client is on and whether their support cost is about to move.</p>



<p class="wp-block-paragraph">Then monitoring. CloudWatch alarms wired to an SNS topic with no confirmed subscribers are the same failure as the dead trail: configured, visible, useless. If they run anything beyond alarms, whether that&#8217;s Grafana Cloud, Datadog or a self-hosted Prometheus stack, find out who holds the credentials and whether the routing has been tested this year.</p>



<p class="wp-block-paragraph"><em>Pass test: you can name the human who gets woken up, and the path that reaches them.</em></p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The findings that actually move the number</h2>



<ul class="wp-block-list">
<li><strong>Unattributable credentials.</strong> Each one has to be rotated or removed, and each needs a conversation with whoever might break. That&#8217;s coordination time, which is the hardest kind to compress.</li>

<li><strong>No working audit trail.</strong> No baseline means you can&#8217;t prove what you did or didn&#8217;t change. Add the cost of standing logging up before the real work starts.</li>

<li><strong>Significant drift between code and reality.</strong> Reconciliation is slow, fiddly and carries destruction risk. The single largest multiplier I see.</li>

<li><strong>No tags.</strong> A tax on every subsequent task rather than a task of its own, since every scoping decision becomes an investigation.</li>

<li><strong>Resources in unmentioned regions.</strong> Each is a small parallel environment with its own security groups, its own logging gap and its own bill.</li>

<li><strong>One shared admin identity.</strong> Nothing in CloudTrail is attributable to a person, so your first task is identity work the client didn&#8217;t ask for.</li>
</ul>



<p class="wp-block-paragraph">I don&#8217;t present these as criticism. I present them as line items with an hour estimate against each, and let the client decide what&#8217;s in scope. That conversation is much easier before a contract exists than after.</p>



<h2 class="wp-block-heading">If you only run five checks</h2>



<p class="wp-block-paragraph">Sometimes you get twenty minutes and a screen share.</p>



<ol class="wp-block-list">
<li>Pull the credential report and count active keys that haven&#8217;t been used recently.</li>

<li>Confirm a CloudTrail trail exists, is multi-region, and is currently delivering.</li>

<li>Loop the enabled regions for running instances and NAT gateways.</li>

<li>Run a refresh-only plan against whatever infrastructure code exists.</li>

<li>Pull six months of cost grouped by service and look at the shape.</li>
</ol>



<p class="wp-block-paragraph">Those cover identity, evidence, blast radius, delivery speed and money. The rest of the check refines a picture these five already gave you.</p>



<h2 class="wp-block-heading">Where this check goes wrong</h2>



<ul class="wp-block-list">
<li><strong>Treating it as an audit.</strong> You&#8217;re scoping work, not issuing a compliance report. A twenty page findings document makes a prospect defensive and costs you a day.</li>

<li><strong>Checking one region.</strong> The default region in your profile is not where the problems are.</li>

<li><strong>Trusting the console over the API.</strong> Configured and working are different states, and the console is better at showing the first.</li>

<li><strong>Taking long-lived keys because it&#8217;s faster.</strong> Accept an admin access key by email to save fifteen minutes and you&#8217;ve become part of the problem you were hired to assess.</li>

<li><strong>Running the loops unannounced.</strong> A burst of API calls from an unfamiliar principal is exactly what GuardDuty exists to flag. Tell them you&#8217;re doing it and roughly when.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">How long should an AWS account health check take?</h3>



<p class="wp-block-paragraph">Two to three hours for a single account once you have access, including the write-up. Multi-account organizations take longer but not linearly, because the interesting variation concentrates in one or two accounts and the rest are copies of a landing zone pattern.</p>



<h3 class="wp-block-heading">Should I charge for the assessment?</h3>



<p class="wp-block-paragraph">For a short engagement, folding it into the proposal is fine. For anything substantial, or where the client is shopping several consultants, quote it as a small standalone piece with a written deliverable. It qualifies the client, protects your time, and the findings have value to them even if they hire someone else.</p>



<h3 class="wp-block-heading">What if the client won&#8217;t give me read-only access before signing?</h3>



<p class="wp-block-paragraph">Quote time and materials, or quote fixed price with an explicit assumptions list naming every unknown. Do not quote a fixed price on an account you haven&#8217;t seen. The pattern that works is a small paid discovery phase, then a fixed price set once you know what you&#8217;re dealing with.</p>



<h3 class="wp-block-heading">Is ReadOnlyAccess enough on its own?</h3>



<p class="wp-block-paragraph">It&#8217;s broader than you probably want. <code>ReadOnlyAccess</code> extends to reading data in some services, not just configuration, which is more than you need for scoping and more than many clients will be comfortable granting. The <code>SecurityAudit</code> and <code>ViewOnlyAccess</code> pairing is narrower and easier to justify in writing.</p>



<h3 class="wp-block-heading">Can I automate the whole thing?</h3>



<p class="wp-block-paragraph">The enumeration, yes, and it&#8217;s worth scripting since you&#8217;ll run it repeatedly. The interpretation, no. Whether five unused IAM users are a problem depends entirely on what the client is trying to do, and that judgement is most of what you&#8217;re selling.</p>



<h3 class="wp-block-heading">What if I find something actively compromised?</h3>



<p class="wp-block-paragraph">Stop and tell them immediately, in writing, with timestamps. Don&#8217;t remediate unilaterally: you&#8217;re read-only, and containment steps can destroy evidence. Your job is a clear description of what you saw and when, so they can involve whoever they need to.</p>



<h3 class="wp-block-heading">Does this apply to small accounts too?</h3>



<p class="wp-block-paragraph">Especially to small accounts. Larger organizations usually have some governance, even imperfect. A three year old account at a small business that four different contractors have touched is where the surprises live, and it&#8217;s exactly the account that gets described as simple.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">An AWS account health check isn&#8217;t a security exercise dressed up as sales. It&#8217;s the only reliable way to price uncertainty, and uncertainty is what you&#8217;re actually quoting on.</p>



<p class="wp-block-paragraph">If you take one habit from this, take the second pass. Everything else on the list is something you&#8217;d eventually trip over while doing the work. A logging gap is different, because it stays invisible while it costs you, and by the time it matters you&#8217;re the person who was in the account when it happened. Check the trail is delivering. Then check the rest.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on an AWS account?</h2>



<p class="wp-block-paragraph">I do this for consultancies scoping engagements and for businesses who inherited an account they don&#8217;t fully understand. Usually that looks like:</p>



<ul class="wp-block-list">
<li>A read-only AWS account health check with a written findings list, hour estimates per item, and a clear split between urgent and later.</li>

<li>Cross-account audit role setup, with a trust policy and external ID you can hand to any consultant without granting more than you meant to.</li>

<li>Credential archaeology: working out what every active access key belongs to, then rotating or removing the ones that belong to nothing.</li>

<li>Getting CloudTrail, AWS Config and log retention into a state where they&#8217;re delivering rather than just configured.</li>

<li>Terraform drift reconciliation, including importing unmanaged resources back under code without a destructive plan.</li>

<li>Cost shape reviews: the forgotten NAT gateways, orphaned volumes and idle regions, plus budgets and anomaly alerts that reach a real inbox.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got a credential report, a <code>terraform plan</code> output or a Cost Explorer screenshot you can&#8217;t make sense of, send it over and I&#8217;ll tell you what I&#8217;d look at next.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/aws-account-health-check/">The AWS Account Health Check I Run Before Quoting a Project</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
