<?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>John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Mon, 14 Sep 2026 15:23:34 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>John Nessime</title>
	<link>https://john-nessime.com/blog/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>AI-Generated IAM Policies: Where the Hard Boundary Belongs</title>
		<link>https://john-nessime.com/blog/devops/ai-generated-iam-policies/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 13 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[AI-Assisted Coding]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloudsplaining]]></category>
		<category><![CDATA[CloudTrail]]></category>
		<category><![CDATA[Generative AI]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[IAM Access Analyzer]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Parliament]]></category>
		<category><![CDATA[Permissions Boundaries]]></category>
		<category><![CDATA[Policy Generation]]></category>
		<category><![CDATA[Policy Validation]]></category>
		<category><![CDATA[Privilege Escalation]]></category>
		<category><![CDATA[Service Control Policies]]></category>
		<category><![CDATA[Trust Policies]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=858</guid>

					<description><![CDATA[<p>A generated policy with no wildcards can still hand over your account. Here are the three failure shapes that survive human review, why the review step is the wrong place to put your defence, and how to build a ceiling that holds regardless of what the model writes.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/ai-generated-iam-policies/">AI-Generated IAM Policies: Where the Hard Boundary Belongs</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 policy looked fine. No action wildcard, no resource wildcard, a Sid on every statement, formatting tidier than anything I write by hand. It came out of a chat window, went into a pull request, and got two approvals in about ninety seconds.</p>



<p class="wp-block-paragraph">It granted <code>iam:PassRole</code> on one role ARN and <code>lambda:CreateFunction</code> in the same document. That is not a wildcard and it does not read as administrative access. It is administrative access anyway, to whatever that role can do, for anyone who can write a Lambda function.</p>



<p class="wp-block-paragraph">This is the shape of the problem with AI-generated IAM policies. The failure is almost never the obvious one. Models have absorbed enough style guidance to avoid <code>"Action": "*"</code>, so what comes back passes the eyeball test and the linter and still hands over more than you meant to give. The dangerous output is the one that looks careful.</p>



<p class="wp-block-paragraph">This post covers what policy generation genuinely does well, the failure shapes that survive human review, and where the hard boundary belongs: not in the review step, which is fallible, but in a mechanism that cannot be talked out of its answer.</p>



<h2 class="wp-block-heading">What AI-generated IAM policies get right</h2>



<p class="wp-block-paragraph">Start with the honest case, because it is a strong one. IAM has thousands of actions across hundreds of services and the naming is inconsistent in ways nobody holds in their head. Some services use <code>Describe</code>, some use <code>Get</code>, some use both for different things. ARN formats vary per service and per resource type inside a service. Condition keys apply to some actions and not others.</p>



<p class="wp-block-paragraph">A model is good at that recall problem, and at shape work: splitting a monolithic statement into per-resource statements, adding conditions, converting an inline policy into Terraform. Paste an <code>AccessDenied</code> message and you get a plausible read of which layer refused. So the argument is not that the tooling is useless. It is that recall and reasoning about consequences are different capabilities, and IAM is a system where the second one protects you.</p>



<h2 class="wp-block-heading">The failure modes that survive review</h2>



<h3 class="wp-block-heading">Permissions that compose into something bigger</h3>



<p class="wp-block-paragraph">Every action is individually defensible. The escalation lives in the combination.</p>



<p class="wp-block-paragraph">The canonical case is <code>iam:PassRole</code> beside any service that runs code: Lambda, EC2, ECS, Glue. Passing a role to a compute service means your code executes with that role&#8217;s permissions, so your ceiling is not your own policy, it is whatever the most powerful passable role can do. Rhino Security Labs catalogued a long list of these chains and every one of them is built from permissions that look unremarkable alone.</p>



<p class="wp-block-paragraph">Another that catches people: <code>iam:CreatePolicyVersion</code> on a policy you are attached to. A new version normally needs to be set as default to take effect, but the create call accepts a flag that makes it default immediately, and that flag does not require <code>iam:SetDefaultPolicyVersion</code>. A permission that reads as &#8220;can update policy documents&#8221; reads to an attacker as &#8220;can write myself an admin policy.&#8221;</p>



<p class="wp-block-paragraph">Ask a model whether a policy grants admin and it will check the statements and say no, correctly, one at a time. Composition is the part it does not reliably do, and it is the part that matters.</p>



<h3 class="wp-block-heading">Actions that do not exist</h3>



<p class="wp-block-paragraph">Plausible action names that no service publishes. <code>s3:ListBucketContents</code> instead of <code>s3:ListBucket</code>. IAM accepts a policy containing an unrecognized action string because the document is syntactically valid, so nothing fails at attach time.</p>



<p class="wp-block-paragraph">What happens instead is worse. The application throws <code>AccessDenied</code> in production, somebody debugging under pressure decides the granular list must be wrong, and the fix is a service wildcard. The invented action does not grant too much. It causes a human to grant too much a week later, and by then nobody connects the two events.</p>



<h3 class="wp-block-heading">Resource ARNs that look constrained and are not</h3>



<p class="wp-block-paragraph">An ARN with real characters in it feels safer than a star. Often it is not. The common versions are a trailing wildcard placed one segment too high, and an action whose resource element the service ignores. <code>s3:ListAllMyBuckets</code> is account-scoped no matter what you put in <code>Resource</code>. Several <code>ec2:Describe</code> actions behave the same way. A policy can read as resource-scoped throughout and be account-wide for the actions that count.</p>



<p class="wp-block-paragraph">The related trap is the missing condition: cross-account trust policies without an external ID, bucket policies without <code>aws:PrincipalOrgID</code>, integration roles without <code>aws:SourceIp</code> where the caller has a stable egress address. Generated policies include conditions when you ask and omit them when you do not, because an absent constraint never produces an error.</p>



<h2 class="wp-block-heading">Four things the model cannot know</h2>



<p class="wp-block-paragraph">Some of this is not a capability gap you can prompt past. It is missing input:</p>



<ul class="wp-block-list"><li><strong>Which resources exist.</strong> A bucket name is a guess unless you supplied it. Guessed names either fail closed or, if the wildcard is loose enough, match something you never intended.</li><li><strong>Which resources are sensitive.</strong> Your backup bucket and your public assets bucket are indistinguishable strings. Sensitivity lives nowhere in the document.</li><li><strong>What else is attached.</strong> Effective permissions come from identity policies, resource policies, boundaries, SCPs and session policies together. Reviewing one document tells you very little.</li><li><strong>What the blast radius is.</strong> &#8220;Can delete objects&#8221; means one thing for a scratch bucket and another for the only copy of a client&#8217;s data.</li></ul>



<p class="wp-block-paragraph">The training material is also skewed. Public IAM examples, including plenty of vendor documentation and countless forum answers, are permissive because permissive examples work on the first try. A model that has read the internet has read a lot of over-broad policies presented as correct.</p>



<h2 class="wp-block-heading">Where the hard boundary belongs</h2>



<p class="wp-block-paragraph">Review is not a boundary. Review is a filter with a pass rate that drops the more policies you look at in a week, and generated policies are cheap to produce, so volume rises exactly when attention falls.</p>



<p class="wp-block-paragraph">The boundary has to be something that says no without reading the document. AWS gives you two mechanisms and they are not interchangeable.</p>



<p class="wp-block-paragraph">A <strong>permissions boundary</strong> is a managed policy attached to a user or role that caps what identity-based policies can grant it. It grants nothing itself. Effective permissions become the intersection of the identity policy and the boundary, so an identity policy allowing <code>iam:*</code> against a boundary that omits IAM produces no IAM access at all. If no boundary is attached, none is evaluated, so this is opt-in per identity.</p>



<p class="wp-block-paragraph">A <strong>service control policy</strong> sets the maximum available permissions for every principal in an account or organizational unit, and also grants nothing. It is the right home for things nobody in that account should ever do, and it applies whether or not somebody remembered to attach a boundary.</p>



<p class="wp-block-paragraph">The division I use: SCPs carry account-wide invariants, such as denying escalation-relevant IAM write actions to everyone outside the break-glass role and denying regions you do not operate in. Boundaries carry the per-workload ceiling, and they are what makes it safe to let a pipeline role create other roles. You can permit that role to create roles while requiring, through a condition on the boundary key, that every role it creates carries your boundary. The pipeline can then generate whatever policy it likes, because the ceiling is enforced at evaluation time rather than at review time.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">If the only thing between a generated policy and production is a person reading JSON, you do not have a boundary. You have a habit.</p>
</blockquote>



<h2 class="wp-block-heading">The gate I would actually run</h2>



<p class="wp-block-paragraph">Mechanical checks in a pipeline, failing the build, not a checklist somebody is supposed to remember.</p>



<ol class="wp-block-list"><li><strong>Lint for nonsense.</strong> Parliament catches unknown action names, resource formats that cannot match the action, and type mismatches. Cheapest way to catch an invented action before it becomes a wildcard.</li><li><strong>Run AWS policy validation.</strong> Access Analyzer knows service-specific rules a generic linter does not.</li><li><strong>Assert the escalation actions are absent.</strong> Name the actions you never want granted and fail if any appear. This is the check that maps to the composition failure.</li><li><strong>Compare against the current policy.</strong> For edits, check whether the proposed document grants access the existing one does not. This one is billed per call, so run it on changed policies rather than the whole repository.</li><li><strong>Scan for known risky patterns.</strong> Cloudsplaining scores policies against categories including privilege escalation, data exfiltration and resource exposure, and produces something you can hand to a client.</li></ol>



<pre class="wp-block-code"><code># 1. Lint. Exit status is non-zero when there are findings.
parliament --file policy.json

# 2. AWS-side validation
aws accessanalyzer validate-policy 
  --policy-document file://policy.json 
  --policy-type IDENTITY_POLICY

# 3. Fail if any escalation-relevant action is granted
aws accessanalyzer check-access-not-granted 
  --policy-document file://policy.json 
  --access actions="iam:PassRole","iam:CreatePolicyVersion","iam:AttachRolePolicy" 
  --policy-type IDENTITY_POLICY

# 4. Does the proposed policy grant more than the current one?
aws accessanalyzer check-no-new-access 
  --existing-policy-document file://current.json 
  --new-policy-document file://proposed.json 
  --policy-type IDENTITY_POLICY

# 5. Risk-categorised report
cloudsplaining scan-policy-file --input-file policy.json</code></pre>



<p class="wp-block-paragraph">Step three is the one to add first if you only add one. It turns &#8220;we reviewed it&#8221; into &#8220;the build fails.&#8221;</p>



<p class="wp-block-paragraph">Attaching the boundary is a single call, and it is the part people skip because the role already works without it:</p>



<pre class="wp-block-code"><code>aws iam put-role-permissions-boundary 
  --role-name app-deploy-role 
  --permissions-boundary arn:aws:iam::123456789012:policy/WorkloadCeiling</code></pre>



<h2 class="wp-block-heading">Generate from observed behavior instead</h2>



<p class="wp-block-paragraph">There is a better source of truth than a model&#8217;s guess about what an application needs, and it is the application. Access Analyzer policy generation reads CloudTrail events for a role over a chosen window and writes a policy from what was actually called. Know its limits before relying on it:</p>



<ul class="wp-block-list"><li>It analyzes up to ninety days of history and needs a trail already logging for the account, so a role that only exercises certain paths quarterly produces an incomplete policy.</li><li>Coverage is per-service. For some services it identifies individual actions; for others it can only tell you the service was used and prompts you to fill in the actions yourself.</li><li>It does not produce action-level detail for data events such as S3 object-level operations.</li><li><code>iam:PassRole</code> is not included in generated policies. If your workload needs it, a human adds it back by hand, so the most escalation-relevant line in the document is the one the generator did not write.</li></ul>



<p class="wp-block-paragraph">Behavior-derived policies also fail in a useful direction. A missing permission produces a denial you can see in CloudTrail, which beats a permission you did not know you granted.</p>



<h2 class="wp-block-heading">Arguments that do not survive contact</h2>



<ul class="wp-block-list"><li><strong>&#8220;We review every policy before merge.&#8221;</strong> Review quality is a function of volume and attention, and generation raises volume. Composition failures are also the hardest to spot by reading, because spotting them means knowing what other roles exist and what they can do.</li><li><strong>&#8220;Access Analyzer came back clean.&#8221;</strong> Validation checks the document. It does not know that the role ARN you may pass happens to have <code>AdministratorAccess</code> attached. Clean findings on a policy that grants a dangerous chain is the normal case, not an anomaly.</li><li><strong>&#8220;A permissions boundary is overkill for a small project.&#8221;</strong> Small projects are where this bites hardest. One account, one deploy role, no organization, nothing between a bad policy and everything you own. The boundary is one managed policy and one CLI call.</li><li><strong>&#8220;We will tighten it after launch.&#8221;</strong> Nobody does. If you genuinely intend to, drive it from Access Analyzer&#8217;s unused access findings so you have a work queue instead of an intention.</li><li><strong>&#8220;Just prompt the model to follow least privilege.&#8221;</strong> That changes the output surface, not the reasoning. Fewer wildcards, better statements, no evaluation of what the combination permits.</li></ul>



<h2 class="wp-block-heading">How I would decide</h2>



<p class="wp-block-paragraph">My rule is about which actions are in the document, not about who or what wrote it.</p>



<p class="wp-block-paragraph"><strong>Ship generated policies freely</strong> when the document touches only data-plane actions on named resources, the identity has a boundary, and the pipeline checks pass. Read a bucket prefix, publish to a topic, write to a table. That is most policies, and hand-writing them wastes your afternoon.</p>



<p class="wp-block-paragraph"><strong>Treat generated output as a first draft</strong> when the policy includes any IAM write action, any <code>PassRole</code>, any trust policy edit, any resource policy readable from outside the account, or any KMS key policy. Use it to save typing, then verify every statement against the service authorization reference yourself.</p>



<p class="wp-block-paragraph"><strong>Do not generate at all</strong> for the boundary policies and SCPs. Those are the mechanism. Something that constrains everything else should be short, hand-written, understood by whoever owns the account, and changed rarely.</p>



<p class="wp-block-paragraph">Two notes for small engagements. If you run application infrastructure on a VPS from a provider like Contabo or InterServer and pull AWS in for storage or mail, that server holds long-lived credentials somewhere your AWS controls do not reach, so scope its policy to a bucket prefix and attach a boundary before worrying about anything else. And if the credentials go to a SaaS connector, a marketing platform such as GoHighLevel or a payments integration, you are handing keys to a system whose behavior you cannot audit. Those are the policies to write by hand.</p>



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



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



<h3 class="wp-block-heading">Are AI-generated IAM policies safe to use in production?</h3>



<p class="wp-block-paragraph">For data-plane permissions on named resources, generally yes, provided the identity carries a permissions boundary and the policy passes automated validation. For anything involving IAM write actions, <code>PassRole</code>, trust policies or key policies, treat the output as a draft and verify each statement yourself. What matters is the blast radius of the actions, not the authorship.</p>



<h3 class="wp-block-heading">Why does a generated policy pass validation and still grant too much?</h3>



<p class="wp-block-paragraph">Validation examines one document in isolation. Privilege escalation usually comes from a combination of individually reasonable permissions, or from the interaction between the policy and something outside it, such as which roles are passable and what those roles can do. A document can be internally correct and still open a path.</p>



<h3 class="wp-block-heading">What is the difference between a permissions boundary and an SCP?</h3>



<p class="wp-block-paragraph">Both cap permissions and neither grants any. A boundary attaches to a single IAM user or role and limits what that identity&#8217;s policies can grant it. An SCP applies to every principal in an account or organizational unit and needs AWS Organizations. With one account and no organization, boundaries are what you have. With an organization, use both.</p>



<h3 class="wp-block-heading">Can IAM Access Analyzer replace writing policies by hand?</h3>



<p class="wp-block-paragraph">Partly. Generation from CloudTrail activity beats a guess because it reflects what the workload actually called. It is limited by the analysis window, by per-service coverage differences, by the absence of action-level detail for data events, and by <code>iam:PassRole</code> being excluded. Treat it as a strong draft that needs a human pass over anything the generator could not see.</p>



<h3 class="wp-block-heading">Which IAM actions should I block outright in a small account?</h3>



<p class="wp-block-paragraph">The ones that let a principal rewrite its own permissions or borrow another identity&#8217;s: creating and setting default policy versions, attaching and putting inline policies on users, roles and groups, creating access keys for other users, and passing roles to compute services. Deny them for everything except a break-glass identity, then grant them back deliberately where a workload genuinely needs one.</p>



<h3 class="wp-block-heading">How do I catch an invented action name before production?</h3>



<p class="wp-block-paragraph">Run a linter that carries the IAM action catalogue. Parliament flags unknown actions and exits non-zero when it finds anything, so it drops straight into a pipeline step. Catching it early matters less because the fake action is dangerous and more because the eventual fix for the denial tends to be a service-level wildcard.</p>



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



<p class="wp-block-paragraph">AI-generated IAM policies are not more dangerous because a model wrote them. They are more dangerous because they arrive faster than you can think about them, and because they arrive looking finished.</p>



<p class="wp-block-paragraph">So stop trying to make the review step better. Put the boundary somewhere that does not depend on anyone paying attention: a permissions boundary on every identity a pipeline can create or modify, an SCP denying the escalation actions to everyone who does not need them, and a build step that fails when a forbidden action shows up. Then let generation happen at whatever speed it wants.</p>



<p class="wp-block-paragraph">The policy approved in ninety seconds is not the failure. The absence of anything underneath it was.</p>



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



<h2 class="wp-block-heading">Need help drawing that boundary in your account?</h2>



<p class="wp-block-paragraph">Most of the IAM work I take on is exactly this: an account that grew organically, permissions nobody wants to touch in case something breaks, and no ceiling underneath any of it. Things I can help with:</p>



<ul class="wp-block-list"><li>Auditing existing roles and policies for privilege escalation chains, including the passable-role paths that never show up in a single-document review</li><li>Designing and attaching permissions boundaries for pipeline, workload and human identities, including the delegation pattern that lets a deploy role create roles safely</li><li>Writing the SCP set for a small organization: escalation-relevant IAM actions, region restrictions, and protection for logging and billing resources</li><li>Adding Access Analyzer validation and custom policy checks to a CI pipeline so forbidden actions fail the build instead of relying on review</li><li>Replacing over-broad roles with policies generated from CloudTrail activity, then closing the gaps the generator cannot see</li><li>Scoping down credentials handed to third-party SaaS integrations and to servers outside AWS, where your account controls do not apply</li></ul>



<p class="wp-block-paragraph">If you want a second opinion on something specific, send me the policy JSON, the output of a failing <code>check-access-not-granted</code> run, or the role list from your account, and I will tell you what I would change and why.</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/ai-generated-iam-policies/">AI-Generated IAM Policies: Where the Hard Boundary Belongs</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</title>
		<link>https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 10 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Residency]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[Presigned URLs]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=382</guid>

					<description><![CDATA[<p>Free egress is not free serving. R2 still bills every read, S3 does not bill origin transfer to CloudFront, and the variable that decides both bills is cache hit ratio. An evenhanded comparison of Cloudflare R2 vs Amazon S3 for media offloading, with the four levers that move the number and a decision procedure you can run against your own traffic.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</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 disk-full alert was the easy part. A media library outgrows the box it lives on, someone moves it to object storage over a weekend, the alert stops firing, everyone moves on. Two weeks later the complaint is different: images are slow for visitors on the other side of the planet, and there is a line on the bill nobody can explain item by item.</p>



<p class="wp-block-paragraph">Moving the files is the solved part. Which bucket they land in, and what sits in front of that bucket, decides whether you spend almost nothing or spend more than the server you were trying to shrink.</p>



<p class="wp-block-paragraph">Cloudflare R2 vs Amazon S3 usually gets reduced to one sentence: R2 does not charge for data transfer out, S3 does, therefore R2. That is wrong in both directions, and understanding why is most of the value here. This post covers the failure mode that shows up after the migration rather than during it, an honest profile of each service, the levers that actually move the number, and a procedure you can run against your own traffic in an afternoon.</p>



<h2 class="wp-block-heading">Free egress is not free serving</h2>



<p class="wp-block-paragraph">R2 charges nothing to move bytes out, on any storage class. It still charges for the read. Every <code>GetObject</code> and every <code>HeadObject</code> is a Class B operation, and a media library made of thousands of small files racks those up fast. Cloudflare&#8217;s own asset hosting example in the R2 pricing docs makes the point: storage falls inside the free tier, writes fall inside the free tier, and the whole bill is read operations.</p>



<p class="wp-block-paragraph">So the meter is not gigabytes. The meter is requests that reach the bucket, which means the variable deciding your bill on either platform is cache hit ratio.</p>



<p class="wp-block-paragraph">This is where offloading setups go wrong without anyone noticing. The plugin rewrites image URLs to point at the bucket, images render, pages look fine, and every request for every thumbnail travels all the way to the origin and gets billed. Nothing breaks. Nothing alerts. The number just grows with traffic.</p>



<p class="wp-block-paragraph">There is a sharper version on the R2 side. A new bucket gives you a public URL on an <code>r2.dev</code> subdomain, which is convenient and tempting. Cloudflare documents it as a testing endpoint with a variable rate limit. Push production traffic through it and requests get throttled with <code>429 Too Many Requests</code>, throughput can be throttled too, and you get no cache, no WAF, no bot management. Those only exist once the bucket sits behind a custom domain you control. That is the invisible failure: not a broken image, a working setup billed on every request and rate limited under load.</p>



<h2 class="wp-block-heading">Cloudflare R2: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where R2 wins</h3>



<ul class="wp-block-list">
<li><strong>Egress really is zero.</strong> Not zero within a partner network, not zero up to a ratio of stored data. Zero, on both storage classes, through the S3 API and through Workers.</li>

<li><strong>A custom domain puts it behind the CDN.</strong> One managed CNAME and reads start being absorbed at the edge instead of hitting the bucket. No second product to buy and wire up.</li>

<li><strong>Deletes are free operations</strong>, along with aborting a multipart upload. Cleaning up a messy library costs nothing.</li>

<li><strong>Unauthorized requests are not billed.</strong> A caller without permission gets a 401 and you are not charged, which matters once someone starts hammering your bucket path.</li>

<li><strong>Migration tooling is free to use.</strong> Super Slurper copies a bucket across in bulk, Sippy migrates lazily on first miss. You pay only for the operations they perform against R2.</li>
</ul>



<h3 class="wp-block-heading">Where R2 hurts</h3>



<ul class="wp-block-list">
<li><strong>S3 compatible is not S3 identical.</strong> Cloudflare publishes a table of which operations are implemented and which are not. Real tooling has broken on the gaps: when AWS shipped SDKs that enabled CRC32 checksums by default, R2 rejected the header until the mismatch was resolved.</li>

<li><strong>There is no cold archive tier.</strong> Infrequent Access is as cold as it gets, and it carries a minimum storage duration, a retrieval fee, and doubled operation rates. For an active library that is the wrong tier, not a saving.</li>

<li><strong>Data lives in one primary location.</strong> Location hints are best effort rather than a region guarantee, and are honored only the first time a bucket with that name is created. Read performance away from that location comes from the edge cache, not from replicas.</li>

<li><strong>Plugin support is thinner.</strong> WP Offload Media, the long-standing WordPress option, officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage. R2 users generally land on Media Cloud, Advanced Media Offloader, Next3 Offload, or the S3-Uploads route driven from WP-CLI.</li>
</ul>



<h2 class="wp-block-heading">Amazon S3: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where S3 wins</h3>



<ul class="wp-block-list">
<li><strong>Origin transfer to CloudFront is not billed.</strong> This quietly demolishes the usual comparison. AWS waives data transfer from an AWS origin to CloudFront, so a properly built S3 media stack never pays the direct-to-internet rate on cached traffic.</li>

<li><strong>The CloudFront free allowance is permanent</strong>, covering a monthly volume of data transfer out and requests rather than expiring after twelve months. Small sites can sit inside it indefinitely.</li>

<li><strong>Event driven derivatives are mature.</strong> Upload fires a Lambda that generates thumbnails, strips metadata, converts formats, writes the result back. Hard to beat if your media pipeline does real work.</li>

<li><strong>A full storage ladder with real lifecycle transitions</strong>, down to deep archive. If a large chunk of your library is genuinely cold, S3 has somewhere cheap to park it.</li>

<li><strong>Governance depth.</strong> Object Lock, versioning, replication, IAM condition keys, CloudTrail data events. When an auditor asks for a control, S3 usually has a named feature for it.</li>
</ul>



<h3 class="wp-block-heading">Where S3 hurts</h3>



<ul class="wp-block-list">
<li><strong>The default configuration is the expensive one.</strong> Point image URLs at the bucket endpoint and every byte leaves at the internet rate with nothing cached in front of it. The cheap path has to be built deliberately.</li>

<li><strong>Four meters instead of two:</strong> storage, bucket requests, CDN requests, CDN data transfer, plus per-feature charges. Forecasting takes real effort.</li>

<li><strong>Geography changes the rate.</strong> Delivery costs more per gigabyte to some regions than others, so an audience shift moves your bill without you changing anything.</li>

<li><strong>Leaving costs money.</strong> Copying a large library out is billed transfer, once, in a lump. Usually small against ongoing savings, but it is a real number somebody has to approve.</li>
</ul>



<h2 class="wp-block-heading">How to decide between Cloudflare R2 vs Amazon S3</h2>



<p class="wp-block-paragraph">Four levers move the outcome. Everything else is noise.</p>



<h3 class="wp-block-heading">Cache hit ratio</h3>



<p class="wp-block-paragraph">The dominant term on both platforms, and the one people skip. At a high hit ratio the origin barely gets touched and both bills collapse toward the storage line. At a low hit ratio, R2 bills read operations and S3 bills origin GETs, and both climb with traffic. Media should be trivially cacheable because the files never change. If yours is not caching, that is a configuration problem worth fixing before you pick a vendor, because fixing it changes the answer.</p>



<h3 class="wp-block-heading">Object count, not library size</h3>



<p class="wp-block-paragraph">WordPress does not store one file per upload. It stores the original plus every registered image size, and themes and page builders happily register more. Ten thousand uploads can easily be sixty thousand objects, each one a write on migration and a read on a cache miss. Count them before you model anything:</p>



<pre class="wp-block-code"><code>cd /path/to/wordpress

# Objects you are about to create in the bucket
find wp-content/uploads -type f | wc -l

# Bytes
du -sh wp-content/uploads

# How much of that count is derivative sizes rather than originals
find wp-content/uploads -type f -regextype posix-extended 
  -regex '.*-[0-9]+x[0-9]+.(jpe?g|png|webp|avif)$' | wc -l</code></pre>



<p class="wp-block-paragraph">That last number is usually the surprise. If most of your object count is thumbnails, trimming unused registered sizes before migrating is the cheapest optimization available, and it shrinks writes, reads, and storage at once.</p>



<h3 class="wp-block-heading">How cold the library actually is</h3>



<p class="wp-block-paragraph">Most libraries are a long tail. A small set of recent files takes nearly all the traffic while the rest sits untouched for years. If that tail is large, S3 has somewhere genuinely cheap to put it and R2 does not. Be careful with R2&#8217;s Infrequent Access class here: it lowers the storage rate but raises both operation rates, adds a retrieval fee, and enforces a minimum duration whether you keep the object or not. It fits write-once, read-almost-never data, not a library anything still links to.</p>



<h3 class="wp-block-heading">What the rest of your stack already is</h3>



<p class="wp-block-paragraph">If your application already runs in AWS and generates derivatives with Lambda on upload, moving to R2 means rebuilding that pipeline against Workers or R2 event notifications. That is real work you do not get paid for. If your site is a VPS from somewhere like InterServer or Contabo with Cloudflare already in front of it, R2 is nearly free work: the DNS is there, the cache is there, and the bucket slots in behind a subdomain you already control.</p>



<h2 class="wp-block-heading">Setup details that change the answer</h2>



<p class="wp-block-paragraph">A few configuration choices matter more than the vendor choice. Get these wrong and the cheaper platform produces the bigger bill.</p>



<h3 class="wp-block-heading">Serve from a custom domain, always</h3>



<p class="wp-block-paragraph">On R2 this is the difference between a cached, protected asset host and a throttled test endpoint. Attach one from the dashboard or from Wrangler:</p>



<pre class="wp-block-code"><code>npx wrangler r2 bucket domain add my-media-bucket 
  --domain=cdn.example.com 
  --zone-id=&lt;YOUR_ZONE_ID&gt;

npx wrangler r2 bucket domain list my-media-bucket</code></pre>



<p class="wp-block-paragraph">Then disable public access on the <code>r2.dev</code> subdomain, or you have left a second uncached, unprotected door into the same objects.</p>



<h3 class="wp-block-heading">Verify the cache instead of assuming it</h3>



<p class="wp-block-paragraph">Assumptions about caching are where the money leaks. Ask the edge directly: request the same object twice and read the headers.</p>



<pre class="wp-block-code"><code># Cloudflare in front of R2
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg 
  | grep -iE 'cf-cache-status|cache-control|age'

# CloudFront in front of S3
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg 
  | grep -iE 'x-cache|cache-control|age'</code></pre>



<p class="wp-block-paragraph">First request, expect a miss. Second, expect a hit. If the second still reports a miss, or reports a status meaning the response was never eligible for caching, every image view is costing you an origin read. Fix that before you compare anything.</p>



<h3 class="wp-block-heading">Long cache lifetimes and versioned filenames</h3>



<p class="wp-block-paragraph">Uploaded media is immutable in practice. Nobody edits the bytes of <code>photo-1024x768.jpg</code>, they upload a new file. So cache lifetimes should be long and the object name should change when the content does. If you find yourself purging image caches regularly, the real problem is that your filenames are not versioned.</p>



<p class="wp-block-paragraph">On the Cloudflare side, Smart Tiered Cache is worth enabling for R2 origins. It routes edge misses through an upper tier data center close to your bucket instead of letting every edge location fetch independently, which cuts the number of requests reaching R2 at all.</p>



<h3 class="wp-block-heading">Clean up failed uploads</h3>



<p class="wp-block-paragraph">Large uploads use multipart. Interrupted ones leave orphaned parts that occupy billed storage and do not show up in a normal object listing. Both platforms support lifecycle rules for this, and on R2 you can manage them from Wrangler:</p>



<pre class="wp-block-code"><code>npx wrangler r2 bucket lifecycle list my-media-bucket

npx wrangler r2 bucket lifecycle add my-media-bucket 
  --name=expire-temp 
  --prefix=tmp/ 
  --expire-days=30</code></pre>



<p class="wp-block-paragraph">Configure an abort rule for incomplete multipart uploads on day one, whichever platform you land on. It is the most common source of storage you pay for and cannot see.</p>



<h2 class="wp-block-heading">A decision procedure you can run this afternoon</h2>



<ol class="wp-block-list">
<li><strong>Measure your current cache hit ratio</strong> for image paths, from analytics or a sampled read of access logs. Everything downstream depends on this number.</li>

<li><strong>Count objects, not gigabytes.</strong> Run the <code>find</code> commands above and separate originals from derivative sizes.</li>

<li><strong>Estimate monthly origin reads</strong> as total image requests multiplied by the miss rate. That, not your bandwidth, is what you feed into either pricing calculator.</li>

<li><strong>Treat writes as a one-off migration spike</strong> plus a modest steady rate. Migration is usually the largest write event the bucket ever sees.</li>

<li><strong>Price both against current published rates</strong> on the same day, using Cloudflare&#8217;s R2 calculator and the AWS pricing calculator. Rates move. Do not trust a number copied from a blog post, including this one.</li>

<li><strong>Test with a prefix before committing.</strong> Offload one year of uploads, point the site at it, watch cache status headers and the operations dashboard for a week. A week of real traffic beats any spreadsheet.</li>
</ol>



<p class="wp-block-paragraph">If it comes out close, take R2. Not because it is cheaper in that scenario, but because it has fewer meters to reason about, and a simpler operational model is worth something on the day something breaks.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<h3 class="wp-block-heading">&#8220;S3 charges egress, R2 doesn&#8217;t, so R2 always wins&#8221;</h3>



<p class="wp-block-paragraph">Only true against the worst possible S3 setup. Transfer from an S3 bucket to CloudFront is not billed, so a properly built S3 media stack pays CDN delivery rates on cache misses, not the direct-from-bucket rate. Add CloudFront&#8217;s permanent free allowance and a small site pays nothing on either platform. The gap opens at scale and on origin-heavy traffic, not on the first terabyte.</p>



<h3 class="wp-block-heading">&#8220;R2 is a CDN&#8221;</h3>



<p class="wp-block-paragraph">R2 is object storage that can sit behind a CDN you already have. The caching, the WAF, the bot rules all come from the Cloudflare zone, and none of it applies to the development URL. If the bucket is not behind a custom domain on a zone you control, you have storage without delivery.</p>



<h3 class="wp-block-heading">&#8220;S3 compatible means drop-in&#8221;</h3>



<p class="wp-block-paragraph">It means most tools work with a changed endpoint and a region value of <code>auto</code>. It does not mean every operation, header, and checksum behaves identically. Budget an afternoon for fighting an SDK default, and test uploads, multipart uploads, deletes, and signed URLs against the real bucket before cutting over.</p>



<h3 class="wp-block-heading">&#8220;We&#8217;ll save money moving cold files to Infrequent Access&#8221;</h3>



<p class="wp-block-paragraph">Sometimes. The lower storage rate arrives with higher operation rates, a retrieval fee, and a minimum duration. If files are cold enough to justify that, they are probably cold enough to belong in a real archive tier, which R2 does not have. Model it against your read pattern rather than assuming a cheaper per-gigabyte number is a cheaper bill.</p>



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



<h3 class="wp-block-heading">Is Cloudflare R2 cheaper than S3 for a WordPress media library?</h3>



<p class="wp-block-paragraph">Usually, once traffic is meaningful and the library is served publicly. For a small site behind a well configured CDN, both can land near zero. R2&#8217;s advantage grows with egress volume and with the number of requests that miss cache, which is why measuring hit ratio comes first.</p>



<h3 class="wp-block-heading">Can I use the same WordPress plugin for both?</h3>



<p class="wp-block-paragraph">Several plugins speak the S3 API and support both, including Media Cloud, Advanced Media Offloader, and Next3 Offload. WP Offload Media officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage, so check current provider support before assuming a swap is free. Whichever you pick, confirm it rewrites existing URLs and not only new uploads.</p>



<h3 class="wp-block-heading">How do I migrate an existing S3 bucket to R2 without downtime?</h3>



<p class="wp-block-paragraph">Cloudflare offers two paths. Super Slurper copies everything in bulk. Sippy migrates incrementally, pulling an object from the source the first time it is requested and serving from R2 afterward. Both are free to use, you pay for the operations they perform against R2, and your source bucket may charge you for the reads.</p>



<h3 class="wp-block-heading">Do I still need a CDN in front of R2?</h3>



<p class="wp-block-paragraph">You need a custom domain on a Cloudflare zone, which is what puts the CDN in front of it. That is not optional for production. The development URL is rate limited by design and gets no cache, WAF, or bot management.</p>



<h3 class="wp-block-heading">Can I keep S3 and just put Cloudflare in front of it?</h3>



<p class="wp-block-paragraph">You can, and it does cut origin reads. Be aware that cache misses then pull from S3 across the public internet, billed as ordinary S3 egress, unlike an S3 origin sitting behind CloudFront. Reasonable as an interim step, rarely the cheapest end state.</p>



<h3 class="wp-block-heading">What about Backblaze B2, Wasabi, or DigitalOcean Spaces?</h3>



<p class="wp-block-paragraph">All viable and worth pricing if you are already modelling. B2 competes on raw storage, Wasabi sells predictability, Spaces is convenient when your droplets are already there. The analysis transfers directly: find the meters, find your cache hit ratio, multiply.</p>



<h3 class="wp-block-heading">How do I monitor the bill after migrating?</h3>



<p class="wp-block-paragraph">Watch operations and cache hit ratio, not storage. Storage grows slowly and predictably. Operations track traffic and configuration mistakes, which is where the unpleasant surprises live. A Grafana Cloud dashboard or a scheduled pull from the provider&#8217;s usage API is enough; the point is that somebody looks weekly rather than at invoice time.</p>



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



<p class="wp-block-paragraph">If you take one thing from this Cloudflare R2 vs Amazon S3 comparison, take this: the vendor choice is second order. What decides your media offloading bill is how many requests reach the bucket, and that is a function of your cache configuration, your object count, and whether you put a real domain in front of the storage.</p>



<p class="wp-block-paragraph">Fix the cache first. Count the objects. Then price both, on the same day, against your own numbers. Most of the time R2 comes out ahead for public media, S3 comes out ahead when the library is entangled with AWS services or needs a genuine archive tier, and the difference is smaller than the internet suggests.</p>



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



<h2 class="wp-block-heading">Need help moving a media library without breaking it?</h2>



<p class="wp-block-paragraph">Media offloading looks like a plugin install and turns into a URL rewriting, caching, and permissions problem. I work with teams on the parts that are easy to get subtly wrong:</p>



<ul class="wp-block-list">
<li>Modelling R2 against S3 using your real object counts, request volumes, and cache hit ratio instead of a generic calculator</li>

<li>Planning and running the migration: bulk or incremental copy, URL rewriting, and a rollback path if the cutover misbehaves</li>

<li>Custom domain, cache rule, and header configuration so origin reads collapse instead of tracking your traffic</li>

<li>Bucket permissions, CORS, signed URL flows for private downloads, and lifecycle rules for orphaned multipart uploads</li>

<li>Trimming registered image sizes and derivative sprawl before migration so you stop paying to store thumbnails nothing links to</li>

<li>Dashboards for operations, cache hit ratio, and storage growth so the bill stops being a monthly surprise</li>
</ul>



<p class="wp-block-paragraph">Send me a <code>curl -I</code> of one of your image URLs and a rough object count, and I can usually tell you quickly whether you have a pricing problem or a caching problem.</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/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Green Pipeline, Stale Data: Tracking Latency, Freshness and Failure Rates</title>
		<link>https://john-nessime.com/blog/devops/data-pipeline-freshness-monitoring/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 08 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Apache Airflow]]></category>
		<category><![CDATA[Data Freshness]]></category>
		<category><![CDATA[Data Observability]]></category>
		<category><![CDATA[Data Quality]]></category>
		<category><![CDATA[dbt]]></category>
		<category><![CDATA[Deadman Alerting]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Kafka]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[PromQL]]></category>
		<category><![CDATA[Pushgateway]]></category>
		<category><![CDATA[SLO]]></category>
		<category><![CDATA[SRE]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=407</guid>

					<description><![CDATA[<p>Every task green, zero errors, and the dashboard still shows yesterday's numbers. Run status describes your code, not your data. Here is how to instrument freshness, latency and failure rate so your pipeline tells you before an analyst does.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/data-pipeline-freshness-monitoring/">Green Pipeline, Stale Data: Tracking Latency, Freshness and Failure Rates</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 lands at 8:40 in the morning. &#8220;Are the revenue numbers right? They look like yesterday&#8217;s.&#8221; You open the orchestrator and every task is green. The run finished in eleven minutes, comfortably inside its window. Zero errors. Zero retries. The pipeline did exactly what it was told to do, and the number on the dashboard is a day old.</p>



<p class="wp-block-paragraph">That gap is the entire problem. Run status tells you the code executed. It tells you nothing about whether data arrived. Data pipeline freshness monitoring is the signal that closes the gap, and most teams add it only after an analyst finds the stale table first.</p>



<p class="wp-block-paragraph">This post covers the three signals worth instrumenting for a batch or streaming pipeline: how current the data is, how long it took to get there, and how often the thing breaks. For each one I&#8217;ll cover what to measure, where to measure it, the queries and config that produce it, and the specific ways the measurement lies to you.</p>



<h2 class="wp-block-heading">The signal that lies to you first: run status</h2>



<p class="wp-block-paragraph">Job success is a statement about your code path, not about your data. A pipeline that reads an empty S3 prefix, transforms zero rows, writes zero rows and exits cleanly has succeeded. So has one whose upstream API silently started returning an empty page after a token rotation. So has one whose incremental watermark got stuck and now re-reads the same already-loaded slice on every run.</p>



<p class="wp-block-paragraph">All three are green. All three are producing stale data. This is why the ordering matters: freshness is the signal that fires first in a real incident, and run status is usually the last one to notice anything at all.</p>



<p class="wp-block-paragraph">So keep run status. It&#8217;s cheap and it catches crashes. Just stop treating it as your top-line health indicator.</p>



<h2 class="wp-block-heading">Freshness: measure the table, not the job</h2>



<p class="wp-block-paragraph">Freshness is the age of the newest record in a dataset. It&#8217;s measured against the dataset itself, not against the process that filled it, and that distinction is what makes it useful. If your job disappears entirely, freshness keeps climbing and keeps alerting. If your job succeeds while doing nothing, freshness keeps climbing and keeps alerting. It&#8217;s the one signal that survives both failure modes.</p>



<h3 class="wp-block-heading">Two clocks, and you need both</h3>



<p class="wp-block-paragraph">Every row usually carries two timestamps: when the event happened in the source system, and when your pipeline wrote it. Track the maximum of each.</p>



<ul class="wp-block-list">
<li><strong>Max event time</strong> tells you whether the upstream system is still producing. If this stops moving, the problem is upstream of you.</li>



<li><strong>Max load time</strong> tells you whether your pipeline is still writing. If this stops moving while event time is fine, the problem is yours.</li>
</ul>



<p class="wp-block-paragraph">Watching only one of them means every stale-data page starts with twenty minutes of figuring out which side of the boundary the fault sits on. Watching both answers that in the alert body.</p>



<p class="wp-block-paragraph">A freshness probe is a small scheduled query. This one is PostgreSQL syntax, and it returns both clocks plus a volume check in a single round trip:</p>



<pre class="wp-block-code"><code>select
  'orders' as dataset,
  extract(epoch from max(event_ts))  as max_event_ts,
  extract(epoch from max(loaded_at)) as max_loaded_at,
  count(*) filter (where loaded_at &gt;= now() - interval '1 hour') as rows_last_hour
from analytics.orders;</code></pre>



<p class="wp-block-paragraph">The row count matters. A pipeline can advance its load timestamp while writing almost nothing, which is what a partially broken source looks like. Freshness alone will not catch that; freshness plus volume will.</p>



<p class="wp-block-paragraph">Run that probe on a schedule that&#8217;s independent of the pipeline, push the results as gauges, and alert on age in PromQL:</p>



<pre class="wp-block-code"><code># Newest row is older than 90 minutes
time() - max by (dataset) (dataset_max_loaded_timestamp_seconds) &gt; 5400

# The probe itself has stopped reporting: a deadman check
absent(dataset_max_loaded_timestamp_seconds{dataset="orders"})</code></pre>



<p class="wp-block-paragraph">That second rule is the one people forget. A freshness metric that disappears looks identical to a healthy silence on a graph. <code>absent()</code> is what turns &#8220;no data&#8221; into a page.</p>



<h3 class="wp-block-heading">Where dbt fits</h3>



<p class="wp-block-paragraph">If you&#8217;re already running dbt, source freshness is built in and worth using before you write anything custom. You declare thresholds per source, and dbt queries the maximum of your timestamp column and compares it to now.</p>



<pre class="wp-block-code"><code>sources:
  - name: raw_shop
    schema: raw
    config:
      loaded_at_field: _loaded_at
      freshness:
        warn_after: {count: 2, period: hour}
        error_after: {count: 6, period: hour}
    tables:
      - name: orders
      - name: refunds
        config:
          freshness:
            warn_after: {count: 12, period: hour}
            error_after: {count: 24, period: hour}</code></pre>



<p class="wp-block-paragraph">Three details that trip people up. First, <code>dbt source freshness</code> is a separate command; <code>dbt build</code> does not run it, so a green build says nothing about source staleness. Second, it exits non-zero when a source hits its <code>error_after</code> threshold, which makes it a natural gate at the top of a job: fail fast rather than building models on stale input. Third, results land in <code>target/sources.json</code>, which is the artifact you parse if you want to distinguish warn from error, or ship the numbers into Prometheus rather than just failing the run.</p>



<pre class="wp-block-code"><code>dbt source freshness --select source:raw_shop --output target/freshness.json</code></pre>



<p class="wp-block-paragraph">One caveat on the YAML above: dbt moved these keys under a <code>config:</code> block in recent releases, and <code>loaded_at_field</code> followed later. Older projects nest them directly under the source. Check what your project&#8217;s version expects before copying, because a misplaced key fails quietly by simply not calculating freshness at all.</p>



<h2 class="wp-block-heading">Latency: name the clock before you name the number</h2>



<p class="wp-block-paragraph">&#8220;Our pipeline latency is twelve minutes&#8221; is meaningless until you say which two points you measured between. There are at least three plausible definitions, and teams routinely argue past each other because they&#8217;re each using a different one.</p>



<ul class="wp-block-list">
<li><strong>Run duration.</strong> Start to end of the job. Easy, and mostly useless for anyone downstream.</li>



<li><strong>Ingestion latency.</strong> Source extract to target load. This is what you control.</li>



<li><strong>End-to-end latency.</strong> Event time to the moment the row is queryable. This is what the business actually feels, and it includes queue wait, scheduler delay, and every upstream hop you don&#8217;t own.</li>
</ul>



<p class="wp-block-paragraph">Publish end-to-end as the headline and keep the others as breakdown. If you only track run duration, a scheduler backlog that delays every run by forty minutes is completely invisible to you: each individual run still takes eleven minutes.</p>



<p class="wp-block-paragraph">Instrument per stage with a histogram so you can ask percentile questions later without re-instrumenting:</p>



<pre class="wp-block-code"><code>histogram_quantile(
  0.95,
  sum by (le, stage) (rate(pipeline_stage_duration_seconds_bucket[6h]))
)</code></pre>



<p class="wp-block-paragraph">Use the median for capacity planning and p95 or p99 for the SLO. Averages hide the exact tail that generates the complaints, and on a pipeline that runs a few dozen times a day, a single pathological run is a real fraction of your day.</p>



<p class="wp-block-paragraph">For streaming, the equivalent is consumer lag expressed in time rather than offsets. Offset lag of fifty thousand messages means nothing without a rate; two hundred seconds of lag means something to everyone. Kafka Lag Exporter popularised this by interpolating a time estimate from observed offset and timestamp samples, exposing <code>kafka_consumergroup_group_max_lag_seconds</code> alongside the offset-based <code>kafka_consumergroup_group_lag</code>. Worth knowing: that project&#8217;s repository has been archived and is read-only, so if you&#8217;re starting fresh, check whether your broker vendor or a maintained fork covers it before you deploy something unmaintained into the critical path.</p>



<h2 class="wp-block-heading">Failure rate: decide what counts as a failure</h2>



<p class="wp-block-paragraph">Failure rate is trivially easy to compute and surprisingly easy to compute wrongly. The denominator and the definition both need a decision.</p>



<p class="wp-block-paragraph">Count runs, not tasks. A DAG with sixty tasks where one flaps on a transient network error looks catastrophic at task level and fine at run level. The run is the unit the consumer cares about.</p>



<p class="wp-block-paragraph">Count a run that succeeded on its third retry as a success for availability and a failure for a separate reliability metric. Both are true and they answer different questions. If your only metric folds retries into success, you will never see the slow degradation of an upstream API until it stops responding entirely.</p>



<pre class="wp-block-code"><code>sum by (pipeline) (rate(pipeline_runs_total{result="failure"}[6h]))
/
sum by (pipeline) (rate(pipeline_runs_total[6h]))</code></pre>



<p class="wp-block-paragraph">And add a category label for the failure reason at push time: source unavailable, schema mismatch, permission denied, timeout, validation failed. The rate tells you something is wrong. The category tells you who to wake up. Without it, every failure alert costs you a log dive before you can even route the incident.</p>



<h2 class="wp-block-heading">Getting metrics out of a job that exits</h2>



<p class="wp-block-paragraph">Prometheus scrapes. Batch jobs finish and vanish. Pushgateway bridges that: the job pushes before exiting, and Pushgateway holds the values for Prometheus to scrape on its own schedule.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

JOB="orders_load"
PGW="http://pushgateway.internal:9091"

start=$(date +%s)
if python /opt/pipelines/load_orders.py; then
  result=0
else
  result=1
fi
end=$(date +%s)

{
  cat &lt;&lt;EOF
# TYPE pipeline_run_duration_seconds gauge
pipeline_run_duration_seconds $((end - start))
# TYPE pipeline_last_run_timestamp_seconds gauge
pipeline_last_run_timestamp_seconds $end
# TYPE pipeline_last_run_success gauge
pipeline_last_run_success $((1 - result))
EOF
  if [ "$result" -eq 0 ]; then
    cat &lt;&lt;EOF
# TYPE pipeline_last_success_timestamp_seconds gauge
pipeline_last_success_timestamp_seconds $end
EOF
  fi
} | curl --fail --data-binary @- "$PGW/metrics/job/$JOB"

exit $result</code></pre>



<p class="wp-block-paragraph">The conditional block is doing real work. curl&#8217;s <code>--data-binary</code> issues a POST, and a POST to Pushgateway replaces only the metrics whose names appear in the payload, leaving the rest of the group intact. So a failing run updates the run timestamp and the success flag while leaving the previous <code>pipeline_last_success_timestamp_seconds</code> exactly where it was. That&#8217;s what lets <code>time() - pipeline_last_success_timestamp_seconds</code> keep climbing across consecutive failures. Send a PUT instead and you replace the whole group, wiping the value you needed.</p>



<p class="wp-block-paragraph">On the Prometheus side, one setting is not optional:</p>



<pre class="wp-block-code"><code>scrape_configs:
  - job_name: pushgateway
    honor_labels: true
    static_configs:
      - targets: ['pushgateway.internal:9091']</code></pre>



<p class="wp-block-paragraph">Without <code>honor_labels: true</code>, Prometheus overwrites the <code>job</code> label your pipeline pushed with the scrape job&#8217;s own name, and every pipeline in your estate collapses into one indistinguishable series called <code>pushgateway</code>.</p>



<p class="wp-block-paragraph">The trap worth internalising: Pushgateway never forgets. Metrics persist until something explicitly deletes them or the process restarts. A pipeline you decommissioned last quarter is still cheerfully reporting a success timestamp and a duration, and it looks alive on every dashboard. Treat Pushgateway as a cache of past executions, not a picture of current state, and use the <code>push_time_seconds</code> gauge that Pushgateway attaches to each group to tell the difference between a fresh push and a fossil.</p>



<h3 class="wp-block-heading">If you&#8217;re on Airflow, the SLA feature is gone</h3>



<p class="wp-block-paragraph">This one will bite anyone upgrading. The <code>sla</code> and <code>sla_miss_callback</code> parameters were removed in Airflow 3.0, and the replacement, Deadline Alerts, arrived in 3.1. DAGs carrying the old configuration need manual migration; they don&#8217;t quietly keep working.</p>



<pre class="wp-block-code"><code>from datetime import timedelta

from airflow.sdk import AsyncCallback, DAG, DeadlineAlert, DeadlineReference
from airflow.providers.slack.notifications.slack_webhook import SlackWebhookNotifier

with DAG(
    dag_id="orders_load",
    deadline=DeadlineAlert(
        reference=DeadlineReference.DAGRUN_QUEUED_AT,
        interval=timedelta(minutes=45),
        callback=AsyncCallback(
            SlackWebhookNotifier,
            kwargs={"text": "orders_load has not finished 45 minutes after queuing."},
        ),
    ),
):
    ...</code></pre>



<p class="wp-block-paragraph">Note the reference point. Measuring from when the run was queued, rather than from its logical date, means scheduler backlog counts against the deadline. That&#8217;s usually what you want, because a run that sat in a queue for an hour is late to its consumers regardless of how fast it executed once it started.</p>



<p class="wp-block-paragraph">There&#8217;s a structural weakness here worth naming: any alert that lives inside the orchestrator dies with the orchestrator. If the scheduler is down, nothing evaluates your deadline and nothing notifies anyone. The freshness probe from earlier is the answer, and it needs to run somewhere else. A small VPS from a provider like Contabo or InterServer running Prometheus and Alertmanager, or a hosted option like Grafana Cloud, gives you a watcher outside the blast radius of the thing being watched. This is the single highest-value piece of monitoring most data teams are missing.</p>



<h2 class="wp-block-heading">Alerting without burning your on-call</h2>



<p class="wp-block-paragraph">The fastest way to make all of this worthless is to alert on every threshold crossing. Three rules keep it survivable.</p>



<ol class="wp-block-list">
<li><strong>Set thresholds from observed behaviour, not from wishes.</strong> Look at a month of actual freshness values and set <code>warn_after</code> a comfortable margin above the normal worst case. A source that habitually passes at eleven hours against a twelve-hour threshold is not healthy, it&#8217;s one upstream hiccup from paging you.</li>



<li><strong>Page on consumer impact, not internal events.</strong> A task retry is not an incident. A dataset breaching the freshness commitment its consumers rely on is. Route everything else to a channel someone reads in the morning.</li>



<li><strong>Use burn rate over multiple windows for SLOs.</strong> A short window catches fast breakage, a long window catches slow erosion, and requiring both to fire filters out the transient spikes that generate most false pages.</li>
</ol>



<p class="wp-block-paragraph">One more: alert on the absence of your own telemetry. A freshness gauge that stops updating is indistinguishable from one that&#8217;s fine, right up until someone asks about the numbers.</p>



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



<p class="wp-block-paragraph"><strong>Freshness alert fires but the data looks current.</strong> Almost always a timezone problem. Your <code>loaded_at</code> column is in local time, <code>now()</code> is in UTC, and the offset shows up as a constant bias in the age. Store load timestamps in UTC and cast explicitly at read time.</p>



<p class="wp-block-paragraph"><strong>Freshness flaps in and out of breach.</strong> Your check runs too close to the expected arrival. If loads land around six and your probe runs at five past, a fifteen-minute upstream delay produces an intermittent failure that trains everyone to ignore the alert. Move the probe later or widen the threshold.</p>



<p class="wp-block-paragraph"><strong>Every pipeline reports as one series.</strong> <code>honor_labels: true</code> is missing from the Pushgateway scrape config.</p>



<p class="wp-block-paragraph"><strong>A decommissioned pipeline still shows healthy.</strong> Stale group in Pushgateway. Delete the group and add a check on <code>push_time_seconds</code> so the next one surfaces on its own.</p>



<p class="wp-block-paragraph"><strong>Latency looks fine but consumers say data is late.</strong> You&#8217;re measuring run duration and they&#8217;re feeling end-to-end. Add queue wait and event-time-to-load and the gap will be obvious.</p>



<p class="wp-block-paragraph"><strong>Freshness passes, row count is near zero.</strong> The load timestamp advanced without meaningful data. This is a broken source or a stuck watermark, and it&#8217;s the reason the volume check belongs in the same probe.</p>



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



<ul class="wp-block-list">
<li>Treating job success as the health signal and discovering staleness through a human.</li>



<li>Running the freshness check inside the same pipeline it&#8217;s meant to police.</li>



<li>Tracking freshness without volume, so a zero-row load reads as healthy.</li>



<li>Reporting latency without saying which two timestamps it spans.</li>



<li>Putting run IDs, batch IDs or timestamps into Prometheus labels, which multiplies your series count without bound. Those belong in traces or logs.</li>



<li>Alerting on task-level failures instead of run-level outcomes, then muting the whole channel a week later.</li>



<li>Assuming Pushgateway reflects current state rather than the last thing anyone pushed.</li>
</ul>



<h2 class="wp-block-heading">Best practices for data pipeline freshness monitoring</h2>



<ul class="wp-block-list">
<li>Start with your three most-used tables. Full coverage is a project; three tables is an afternoon and catches most of the pain.</li>



<li>Write down the freshness commitment per dataset in plain language, then encode it. &#8220;Yesterday&#8217;s orders are complete by 07:00&#8221; converts directly into a threshold.</li>



<li>Name metrics consistently across pipelines. A shared prefix and a stable label set is what makes one dashboard work for all of them.</li>



<li>Keep labels low-cardinality: pipeline, dataset, stage, environment, result. Nothing unbounded.</li>



<li>Emit a failure category alongside every failure so alerts route themselves.</li>



<li>Run the deadman check on infrastructure that doesn&#8217;t share a failure domain with the pipeline.</li>



<li>Link every alert to a runbook that names the owner and the first three things to check.</li>
</ul>



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



<h3 class="wp-block-heading">What is the difference between pipeline latency and data freshness?</h3>



<p class="wp-block-paragraph">Latency measures how long a specific batch of data took to travel from source to destination. Freshness measures how old the newest available record is right now, regardless of whether anything is currently running. A pipeline can have excellent latency and terrible freshness if it stopped being triggered.</p>



<h3 class="wp-block-heading">How often should freshness checks run?</h3>



<p class="wp-block-paragraph">Frequently enough that you find out before your consumers do. A useful rule is roughly a quarter of your tolerance window: if data may be up to four hours old, check hourly. Freshness probes are cheap single-aggregate queries, so the limiting factor is usually warehouse billing rather than load.</p>



<h3 class="wp-block-heading">Do I need a data observability platform for this?</h3>



<p class="wp-block-paragraph">Not to start. Freshness, latency and failure rate for a handful of critical datasets is a scheduled query, a push, and a few alert rules. Commercial platforms earn their cost when you need automatic column-level lineage, anomaly detection across hundreds of tables, or coverage of assets nobody has explicitly instrumented. That&#8217;s a real problem at scale, and a genuinely expensive one to build yourself. It&#8217;s just not the problem you have on day one.</p>



<h3 class="wp-block-heading">How do I monitor freshness for a table that only updates weekly?</h3>



<p class="wp-block-paragraph">Set the threshold from the schedule plus a delivery margin, and add a deadman rule so a missing metric alerts on its own. For genuinely static reference tables, disable freshness explicitly rather than leaving a check that always warns; in dbt that means setting freshness to null for the table.</p>



<h3 class="wp-block-heading">Should freshness checks fail the pipeline or just warn?</h3>



<p class="wp-block-paragraph">Both, in different places. Checking source freshness at the start of a job and failing hard prevents you building models on stale input, which is the cheapest bug to prevent and the most expensive to unwind. Checking output freshness after the fact should alert rather than fail, because the run is already over.</p>



<h3 class="wp-block-heading">What percentile should I use for a latency SLO?</h3>



<p class="wp-block-paragraph">p95 for most internal analytics pipelines, p99 where downstream systems make automated decisions on the data. Track the median separately for capacity planning. Never use the average as the SLO number, because it hides exactly the tail that produces complaints.</p>



<h3 class="wp-block-heading">Does this work for streaming pipelines too?</h3>



<p class="wp-block-paragraph">Yes, with different plumbing. Freshness becomes consumer lag measured in time, latency becomes event-time to availability-time, and failure rate becomes connector and task state plus dead-letter volume. The reasoning is identical; only the source of the numbers changes.</p>



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



<p class="wp-block-paragraph">If you remember one thing, make it this: measure the data, not the job. Run status, task counts and duration all describe your code. Only freshness describes what your consumers actually receive, and it&#8217;s the one signal that stays honest when the pipeline succeeds at doing nothing.</p>



<p class="wp-block-paragraph">Good data pipeline freshness monitoring is not a platform purchase. It&#8217;s a scheduled query that reports the age and volume of your most important tables, pushed somewhere durable, with a deadman rule so that silence is treated as a failure rather than as health. Add latency broken down by stage and failure rate categorised by reason, and you can answer &#8220;is the data good right now&#8221; without opening a single log file.</p>



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



<h2 class="wp-block-heading">Need help instrumenting your pipelines?</h2>



<p class="wp-block-paragraph">Most of my consulting work in this area is retrofitting observability onto pipelines that already exist and can&#8217;t be paused. Specifically:</p>



<ul class="wp-block-list">
<li>Defining freshness and latency commitments per dataset, then translating them into thresholds and alert rules that hold up on-call.</li>



<li>Adding freshness and volume probes to existing warehouses without touching the pipelines themselves.</li>



<li>Wiring batch jobs into Prometheus through Pushgateway, including the grouping-key and stale-metric problems that bite six months later.</li>



<li>Migrating Airflow DAGs off the removed SLA feature onto Deadline Alerts, or onto external checks that survive a scheduler outage.</li>



<li>Building the Grafana dashboard that answers &#8220;is the data good right now&#8221; in one screen, with drill-down by stage and failure category.</li>



<li>Cutting alert noise on pipelines where the channel has already been muted, by moving from task-level events to consumer-impact SLOs.</li>
</ul>



<p class="wp-block-paragraph">If you want a concrete starting point, send me a DAG file, a scrape config, or a screenshot of the dashboard you don&#8217;t trust, and I&#8217;ll tell you what I&#8217;d instrument first and why.</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/data-pipeline-freshness-monitoring/">Green Pipeline, Stale Data: Tracking Latency, Freshness and Failure Rates</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Amazon Macie PII Detection: The Buckets It Never Opened</title>
		<link>https://john-nessime.com/blog/cloud-computing/amazon-macie-pii-detection/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Compliance]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Amazon Data Firehose]]></category>
		<category><![CDATA[Amazon Macie]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS KMS]]></category>
		<category><![CDATA[AWS Organizations]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Classification]]></category>
		<category><![CDATA[Data Governance]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Encryption]]></category>
		<category><![CDATA[EventBridge]]></category>
		<category><![CDATA[PII Redaction]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Security Hub]]></category>
		<category><![CDATA[Sensitive Data Discovery]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=405</guid>

					<description><![CDATA[<p>A Macie bucket labeled "Not sensitive" often just means Macie never read it. Extensionless objects, unsupported storage classes, unreachable KMS keys and quota truncation all produce silence that looks identical to a clean result. Here's how to measure coverage, fix the four gaps, tune identifiers, and keep the bill honest.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/amazon-macie-pii-detection/">Amazon Macie PII Detection: The Buckets It Never Opened</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 email came from legal, not from security. A customer had exercised a data access request, someone pulled the export by hand, and it contained email addresses and phone numbers sitting in a bucket Macie had labeled <em>Not sensitive</em>. Nobody had done anything wrong. The console was telling the truth as it understood it. It just didn&#8217;t understand very much about that bucket.</p>



<p class="wp-block-paragraph">That is the failure mode worth internalizing before anything else: Amazon Macie PII detection reports on what it managed to read, not on what is there. Objects it never opened produce no findings, and no findings looks identical to a clean result. The gap between &#8220;we scanned this&#8221; and &#8220;we found nothing&#8221; is where most Macie deployments quietly fail their first audit.</p>



<p class="wp-block-paragraph">This post covers where that gap comes from and how to close it: the eligibility chain every object goes through, the four distinct reasons an object gets skipped, how to tune identifiers so the findings are worth reading, what actually drives the bill, and how to route results somewhere a human will act on them.</p>



<h2 class="wp-block-heading">The eligibility chain behind Amazon Macie PII detection</h2>



<p class="wp-block-paragraph">Every object passes the same gates before a byte gets inspected. The order matters, because each gate fails differently and needs a different fix.</p>



<ol class="wp-block-list">
<li>Is it in a general purpose S3 bucket? Directory buckets are out of scope entirely.</li>



<li>Is the storage class supported? Standard, Standard-IA, One Zone-IA, Intelligent-Tiering, Glacier Instant Retrieval and Reduced Redundancy are in. Glacier Deep Archive and S3 Express One Zone are not.</li>



<li>Does the object key carry a recognized file extension? This is a string check on the name, not a content sniff.</li>



<li>Can Macie retrieve and decrypt it, given the bucket policy, object ACL and encryption key?</li>



<li>Does the content parse, and stay inside the per-file quotas?</li>



<li>Do the active data identifiers match anything in it?</li>
</ol>



<p class="wp-block-paragraph">Only the last gate produces a finding. Everything before it produces silence. Macie does record why each object was skipped, but that lives in coverage data and object samples, not on the findings page most people look at.</p>



<p class="wp-block-paragraph">One detail explains a lot of confusion: when you first enable automated sensitive data discovery, every bucket gets a sensitivity score of 50 and the label <em>Not yet analyzed</em>. A bucket whose permissions block Macie stays there permanently. It never turns red. It sits mid-list, looking unremarkable next to buckets that genuinely were analyzed.</p>



<h2 class="wp-block-heading">Gap one: objects Macie never opened</h2>



<p class="wp-block-paragraph">Macie calls these <em>unclassifiable</em>. They fail the storage class or extension check, so no retrieval is attempted. This is the largest source of false confidence I&#8217;ve seen, and the cheapest to fix.</p>



<h3 class="wp-block-heading">Missing file extensions</h3>



<p class="wp-block-paragraph">Classifiability comes from the file name extension. A file full of customer records named <code>part-00003-a4f9</code> is invisible. The same bytes named <code>part-00003-a4f9.json</code> get inspected and produce findings.</p>



<p class="wp-block-paragraph">This bites hardest on streaming ingestion. Amazon Data Firehose writes to S3 without appending an extension unless compression, format conversion, or the explicit file extension setting adds one. An uncompressed JSON stream lands as a tree of extensionless objects. Athena and Glue read them fine, because they infer format from the table definition. Macie skips every one.</p>



<p class="wp-block-paragraph">Fix it upstream: set the S3 file extension on the delivery configuration, or enable GZIP compression or Parquet conversion, both of which append a recognized extension. If you can&#8217;t change the producer, the fallback is a copy-and-rename step into a scan prefix, which costs you storage plus a pipeline to maintain. Fixing the producer is almost always cheaper.</p>



<h3 class="wp-block-heading">Unsupported storage classes</h3>



<p class="wp-block-paragraph">A lifecycle rule transitioning old exports to Glacier Deep Archive quietly removes them from scope. That may be the right storage decision, but it needs to be a conscious one, because archived customer data is still customer data when a regulator asks.</p>



<p class="wp-block-paragraph">Macie exposes these counts per bucket, split by cause. This is the first query I run against a new account:</p>



<pre class="wp-block-code"><code># Buckets where Macie can't classify objects, broken down by cause
aws macie2 describe-buckets 
  --query 'buckets[?unclassifiableObjectCount.total &gt; `0`].{
      bucket: bucketName,
      classifiable: classifiableObjectCount,
      skipped_total: unclassifiableObjectCount.total,
      skipped_extension: unclassifiableObjectCount.fileType,
      skipped_storage_class: unclassifiableObjectCount.storageClass
    }' 
  --output table</code></pre>



<p class="wp-block-paragraph">Read the ratio, not the raw number. Forty thousand classifiable objects and twelve skipped is fine. Three classifiable and four hundred thousand skipped is a reporting artifact pretending to be a scan result, and that bucket&#8217;s sensitivity score means nothing.</p>



<h2 class="wp-block-heading">Gap two: objects it opened and couldn&#8217;t read</h2>



<p class="wp-block-paragraph">These are classification errors rather than eligibility failures. Macie selected the object, tried to fetch it, and was refused. Three causes, and they need three different people to fix them.</p>



<ul class="wp-block-list">
<li><strong>Customer-provided keys (SSE-C).</strong> Macie cannot supply the key material, so it cannot retrieve the object. No permissions fix exists. Re-encrypt with S3 managed or KMS keys if you want coverage.</li>



<li><strong>KMS key policy.</strong> For customer managed keys, the key policy must allow the Macie service-linked role, <code>AWSServiceRoleForAmazonMacie</code>, to decrypt. Cross-account buckets need the key owner to grant it, not the bucket owner. AWS publishes a permission analyzer script in the <code>aws-samples/amazon-macie-scripts</code> repository that enumerates every key Macie needs and generates the CLI commands to fix them.</li>



<li><strong>Restrictive bucket policies.</strong> An explicit <code>Deny</code> conditioned on source IP or VPC endpoint blocks Macie along with everything else. The working pattern excludes the service-linked role ARN from the deny using the <code>aws:PrincipalArn</code> condition key.</li>
</ul>



<p class="wp-block-paragraph">To see which objects Macie actually touched in a bucket, pull the object samples. It&#8217;s the closest thing to a scan log you get:</p>



<pre class="wp-block-code"><code># Objects automated discovery selected, with per-object status
aws macie2 list-resource-profile-artifacts 
  --resource-arn arn:aws:s3:::your-bucket-name</code></pre>



<p class="wp-block-paragraph">Anything with a status of <code>SKIPPED</code> is a lead. Take the key, check its encryption settings in S3, and you usually have your answer inside a minute.</p>



<h2 class="wp-block-heading">Gap three: it read the file and nothing matched</h2>



<p class="wp-block-paragraph">Macie ships managed data identifiers covering common PII, financial data and credentials across many countries. Automated discovery uses a recommended subset by default rather than all of them. Sensible, but it is a default, and it will miss things that matter to you.</p>



<p class="wp-block-paragraph"><strong>Add the managed identifiers for your actual jurisdictions.</strong> If you hold records for customers in a country whose national ID identifier isn&#8217;t in the recommended set, you&#8217;re scanning for the wrong things with perfect efficiency. Pull the current list with <code>aws macie2 list-managed-data-identifiers</code> and compare it against where your customers live.</p>



<p class="wp-block-paragraph"><strong>Write custom identifiers for what only you know is sensitive.</strong> Internal account numbers, case references, employee IDs. A custom identifier is a regex plus optional keywords and a proximity rule, and the keywords are what save you. A bare <code>[0-9]{8}</code> matches timestamps, order totals and row counts, and you drown. The same regex with keywords and a match distance only fires when the number sits near a word that gives it meaning.</p>



<p class="wp-block-paragraph">Test the pattern before it goes near a job. This runs the criteria against sample text and returns match counts without creating anything:</p>



<pre class="wp-block-code"><code># Dry-run a custom identifier before creating it
aws macie2 test-custom-data-identifier 
  --regex 'ACC-[0-9]{4}-[0-9]{4}' 
  --keywords account customer acct 
  --maximum-match-distance 30 
  --sample-text 'customer account ACC-4821-9930 updated; total 1234-5678'</code></pre>



<p class="wp-block-paragraph">Run it against real samples, including the awkward ones, then against a file you know is clean and confirm zero. A pattern you only tested on positives is a pattern you haven&#8217;t tested.</p>



<p class="wp-block-paragraph">The mirror image is allow lists, which tell Macie to ignore specific text or patterns: your support inbox address, your published press contact, the seeded test records in everyone&#8217;s staging fixtures. Without them the same non-issues appear in every scan until the team stops reading findings. Allow lists accept predefined text or regex, and the per-account and per-job quotas are tight enough that you should curate rather than dump.</p>



<h2 class="wp-block-heading">Gap four: quota truncation inside large files</h2>



<p class="wp-block-paragraph">Subtle, because the object <em>is</em> analyzed. Just not completely, and a partial result reports like a full one.</p>



<ul class="wp-block-list">
<li>Per-file size quotas differ by format. Exceed the quota for a type and Macie analyzes none of that file, not part of it.</li>



<li>Archives have separate ceilings for nesting depth, extracted bytes and file count. If the metadata shows a breach up front, nothing is extracted. Cross a limit partway through and Macie stops, reporting only what it processed.</li>



<li>JSON and JSON Lines have a nested depth limit. Past it, the file is not analyzed at all.</li>



<li>Full names and mailing addresses cap out per file. After the cap Macie stops counting and stops reporting locations for that type, so the occurrence count is a floor, never a total.</li>
</ul>



<p class="wp-block-paragraph">Findings carry a status reason naming the limit that was hit, with values like <code>FILE_EXCEEDS_SIZE_LIMIT</code>, <code>ARCHIVE_NESTING_LEVEL_OVER_LIMIT</code> and <code>JSON_NESTING_LEVEL_OVER_LIMIT</code>. Filter on those before declaring a data lake of large compressed exports clean. Check current quota values in the Macie documentation rather than memorizing them, since they differ per format.</p>



<h2 class="wp-block-heading">Automated discovery or a discovery job?</h2>



<p class="wp-block-paragraph"><strong>Automated sensitive data discovery</strong> evaluates your inventory daily and samples representative objects across as many buckets as it can, grouping by metadata like prefix, extension and last-modified date. It is breadth-first and it does not re-analyze unchanged objects. Its job is to tell you which buckets deserve attention. It is not designed to prove a bucket is clean, and reading it that way is the root of most Macie misunderstandings.</p>



<p class="wp-block-paragraph"><strong>Sensitive data discovery jobs</strong> are the depth tool. You choose the buckets, scope by prefix, extension, size or object tag, choose the identifiers, and run once or on a schedule. This is what you point at the bucket automated discovery just flagged.</p>



<pre class="wp-block-code"><code># One-time job over a single prefix, recommended identifiers, 30% sampling
aws macie2 create-classification-job 
  --job-type ONE_TIME 
  --name "exports-prefix-review" 
  --managed-data-identifier-selector RECOMMENDED 
  --sampling-percentage 30 
  --s3-job-definition '{
    "bucketDefinitions": [
      {"accountId": "111122223333", "buckets": ["your-bucket-name"]}
    ],
    "scoping": {
      "includes": {
        "and": [
          {"simpleScopeTerm": {
             "comparator": "STARTS_WITH",
             "key": "OBJECT_KEY",
             "values": ["exports/"]
          }}
        ]
      }
    }
  }'</code></pre>



<p class="wp-block-paragraph">One detail about <code>--sampling-percentage</code> that trips people up: it selects a random percentage of eligible <em>objects</em> and then analyzes each selected object completely. It does not read a fraction of each file. For a bucket where every object comes from one pipeline, 20 or 30 percent tells you what you need. For heterogeneous uploads, sampling is a coin flip and you want the full pass.</p>



<h2 class="wp-block-heading">What actually drives the bill</h2>



<p class="wp-block-paragraph">Macie bills on three dimensions, and the one that surprises people is not the one they budget for.</p>



<ul class="wp-block-list">
<li><strong>Bucket evaluation.</strong> Per general purpose bucket monitored, prorated daily, up to the account ceiling. Predictable, driven by bucket count rather than data volume.</li>



<li><strong>Object monitoring.</strong> Per object tracked while automated discovery is enabled. This is inventory bookkeeping, not content reading, and it runs whether or not anything gets scanned that day. On an account with a log archive holding hundreds of millions of tiny objects, it can dwarf the other two.</li>



<li><strong>Data inspected.</strong> Per GB actually analyzed, covering both automated discovery and jobs.</li>
</ul>



<p class="wp-block-paragraph">So excluding buckets from automated discovery is a genuine cost lever, not just noise reduction. Log archives, CloudTrail destinations, build artifact stores and backup targets are usually safe exclusions. Exclude for the right reason though: a backup bucket holding database dumps of your customer table is exactly what you want scanned, however boring its name sounds.</p>



<p class="wp-block-paragraph">The three dimensions appear as distinct usage types in the Cost and Usage Report, so they split cleanly. A FinOps platform like Vantage or CloudZero, or just a Cost Explorer view grouped by usage type, tells you within a day whether object monitoring or inspection is driving the number. That changes the fix entirely: object monitoring is solved by scoping, inspection by sampling and scheduling.</p>



<h2 class="wp-block-heading">Getting findings somewhere a human sees them</h2>



<p class="wp-block-paragraph">Findings that only exist in the Macie console get read during onboarding week and never again. Two things are worth wiring up on day one.</p>



<p class="wp-block-paragraph"><strong>Publish findings outward.</strong> Macie emits findings to EventBridge and integrates with Security Hub. EventBridge earns its keep because you can filter on severity and finding type and route only what matters, whether that&#8217;s a Slack channel, a ticket, or a Lambda that tags the bucket. If you already centralize alerts in Grafana Cloud or similar, that path keeps sensitive data findings next to everything else on call sees.</p>



<p class="wp-block-paragraph"><strong>Configure the discovery results repository.</strong> Findings tell you what was found and where. The detailed discovery results, including analysis logs for objects where nothing was found, need an S3 bucket and a KMS key configured to persist. Nothing is retained long term until you set this up, and &#8220;we scanned it and found nothing&#8221; is exactly the record an auditor asks for.</p>



<p class="wp-block-paragraph">One operational note on the reveal feature, which retrieves sample occurrences so a human can confirm a finding is real. It needs a customer managed KMS key and it&#8217;s genuinely useful for triage. It also means someone is now looking at live customer PII, which belongs in your access model and audit trail. Same goes if an engineer downloads an object to investigate locally: that copy is unmanaged PII on a laptop and needs a secure deletion step, whether that&#8217;s a wipe tool from a vendor like O&amp;O Software or a documented and enforced process. Findings triage has a habit of creating the exposure it was meant to prevent.</p>



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



<h2 class="wp-block-heading">Troubleshooting a bucket that reports nothing</h2>



<p class="wp-block-paragraph">When Amazon Macie PII detection reports nothing for a bucket you have doubts about, work down this in order. Each step rules out one gate, and stopping at the first hit saves you the rest.</p>



<ol class="wp-block-list">
<li><strong>Check the sensitivity label.</strong> <em>Not yet analyzed</em> at score 50 means nothing was analyzed. Do not read that as clean.</li>



<li><strong>Check the coverage page.</strong> Macie names the issue directly: access denied, invalid encryption, invalid KMS key, permission denied, or unclassifiable.</li>



<li><strong>Compare classifiable and unclassifiable counts</strong> with the <code>describe-buckets</code> query above.</li>



<li><strong>List a few object keys.</strong> Run <code>aws s3 ls</code> against a prefix and look for extensions. Extensionless keys explain the whole thing.</li>



<li><strong>Check the storage class.</strong> A lifecycle rule may have moved everything somewhere Macie doesn&#8217;t read.</li>



<li><strong>Check encryption.</strong> SSE-C is unfixable without re-encryption. A customer managed KMS key is fixable with a key policy change.</li>



<li><strong>Check object samples</strong> with <code>list-resource-profile-artifacts</code> to see exactly what was selected and skipped.</li>



<li><strong>Only now question the identifiers.</strong> Run a targeted job with a custom identifier for the data you expect and see whether it fires.</li>
</ol>



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



<ul class="wp-block-list">
<li>Treating a low sensitivity score as evidence of no PII. It reflects what was found <em>and</em> how much was analyzed, so low coverage produces a low score.</li>



<li>Enabling Macie in one region and assuming account-wide coverage. Settings and results are per region, every time.</li>



<li>Running default identifiers in a business operating outside the jurisdictions the recommended set covers.</li>



<li>Building custom identifiers from regex alone, then abandoning Macie over the false positive volume.</li>



<li>Skipping the discovery results repository, then having no evidence trail when someone asks what was scanned and when.</li>



<li>Excluding buckets by name pattern without checking contents. Backup and export buckets are frequently the highest-risk ones you own.</li>
</ul>



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



<ul class="wp-block-list">
<li>Measure coverage before findings. A coverage report is the first deliverable of a Macie rollout, not an afterthought.</li>



<li>Fix extensions at the producer rather than building a rename pipeline downstream.</li>



<li>Run the KMS permission analyzer script once per account, then again whenever a new customer managed key appears.</li>



<li>Use automated discovery for breadth and targeted jobs for depth, letting the first choose targets for the second.</li>



<li>Test every custom identifier against both a positive and a negative sample before it reaches a job.</li>



<li>Curate allow lists early. The cost of a noisy first month is a team that stops reading findings.</li>



<li>Re-check coverage after any change to bucket policies, KMS keys, lifecycle rules or ingestion pipelines. All four silently remove data from scope.</li>
</ul>



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



<h3 class="wp-block-heading">Can Amazon Macie scan anything other than S3?</h3>



<p class="wp-block-paragraph">No. Macie analyzes objects in S3 general purpose buckets only. The documented workaround is bringing data to it: export RDS or Aurora snapshots to S3 in Parquet, or export a DynamoDB table to S3, then run a discovery job against the export. That works, but the export is now a second copy of your sensitive data with its own encryption and deletion requirements.</p>



<h3 class="wp-block-heading">Does Macie scan new objects as they land?</h3>



<p class="wp-block-paragraph">Not per object. Automated discovery evaluates inventory on a daily cycle and prioritizes objects that are new or recently changed, so fresh data moves up the queue rather than triggering an immediate scan. If you need scanning tied to arrival, drive a discovery job from S3 event notifications through EventBridge and accept that you own that orchestration.</p>



<h3 class="wp-block-heading">Why does a bucket I know has PII show a low sensitivity score?</h3>



<p class="wp-block-paragraph">Almost always coverage rather than detection. Check whether the objects are classifiable, whether Macie can decrypt them, and whether the label reads <em>Not yet analyzed</em>. If Macie genuinely read them and found nothing, the next suspect is the identifier set, and a targeted job with a custom identifier will tell you in one run.</p>



<h3 class="wp-block-heading">Do I need both automated discovery and discovery jobs?</h3>



<p class="wp-block-paragraph">For most teams, yes. Automated discovery gives you the map at predictable cost. Jobs give you proof for a specific bucket at a specific time, which is what compliance evidence actually requires. Running only jobs means you never discover the bucket nobody told you about.</p>



<h3 class="wp-block-heading">How do I cut false positives without missing real PII?</h3>



<p class="wp-block-paragraph">In order of preference: add keywords and a proximity rule so matches need context; use allow lists for specific known-benign values like your published contact details and test fixtures; and only then remove managed identifiers that are consistently wrong for your data. Removing identifiers is the bluntest option and the easiest to regret, so leave it last.</p>



<h3 class="wp-block-heading">Does Macie work across accounts in an organization?</h3>



<p class="wp-block-paragraph">Yes, through AWS Organizations with a delegated administrator. Design around this: the administrator&#8217;s automated discovery settings apply to member accounts, members can&#8217;t change them, and members see coverage and sensitivity data for their own buckets but not the sensitive data findings themselves. That shapes who can actually remediate what.</p>



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



<p class="wp-block-paragraph">Amazon Macie PII detection is only as good as its coverage, and coverage fails silently by design. An object with the wrong extension, the wrong storage class, or an unreachable encryption key produces no finding, and no finding renders identically to a clean result. Before you hand a Macie report to anyone who will make a decision from it, put the coverage numbers next to it: how many objects were classifiable, how many were analyzed, how many were skipped and why.</p>



<p class="wp-block-paragraph">Do that once and the rest of the service becomes straightforward. Skip it and you&#8217;re maintaining an expensive dashboard reporting on whichever subset of your data happened to be readable.</p>



<h2 class="wp-block-heading">Need help getting Macie to actually see your data?</h2>



<p class="wp-block-paragraph">Most of the work in a Macie rollout isn&#8217;t turning it on. It&#8217;s the unglamorous part: proving what got scanned, fixing the reasons things didn&#8217;t, and making the output land somewhere a human acts on it. That&#8217;s the part I help with.</p>



<ul class="wp-block-list">
<li>Coverage audits across your S3 estate, with per-bucket classifiable, analyzed and skipped counts and the specific cause of each gap</li>



<li>Fixing those causes: KMS key policies, bucket policies blocking the service-linked role, lifecycle rules archiving data out of scope, pipelines writing extensionless objects</li>



<li>Custom data identifiers and allow lists tuned against your real data and tested on positive and negative samples first</li>



<li>Cost shaping: working out whether object monitoring or data inspection drives your bill, and scoping discovery so the number is defensible</li>



<li>Findings pipelines through EventBridge or Security Hub into the alerting and ticketing you already run, with severity filtering that keeps signal above noise</li>



<li>Multi-account setup under AWS Organizations, including the delegated administrator model and who can see and remediate what</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me the output of the <code>describe-buckets</code> query above, or a screenshot of your coverage page, and I&#8217;ll tell you what&#8217;s actually being scanned.</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/amazon-macie-pii-detection/">Amazon Macie PII Detection: The Buckets It Never Opened</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>EKS Pod Identity vs IRSA: What Actually Decides the Choice</title>
		<link>https://john-nessime.com/blog/cloud-security/eks-pod-identity-vs-irsa/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 03 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Containers Security]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[ABAC]]></category>
		<category><![CDATA[Amazon EKS]]></category>
		<category><![CDATA[AWS Fargate]]></category>
		<category><![CDATA[AWS STS]]></category>
		<category><![CDATA[Blast Radius]]></category>
		<category><![CDATA[Confused Deputy]]></category>
		<category><![CDATA[Credential Chain]]></category>
		<category><![CDATA[Cross-Account Access]]></category>
		<category><![CDATA[DaemonSets]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Karpenter]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[OIDC]]></category>
		<category><![CDATA[Role Chaining]]></category>
		<category><![CDATA[Terraform]]></category>
		<category><![CDATA[Trust Policies]]></category>
		<category><![CDATA[Workload Identity]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=762</guid>

					<description><![CDATA[<p>Both give your pods short-lived AWS credentials, but they put the cluster-to-role relationship in completely different places. A practical comparison of EKS Pod Identity vs IRSA: where each one wins, where each refuses to run, the silent node-role fallback that hides broken wiring, and a decision procedure you can apply per workload.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-security/eks-pod-identity-vs-irsa/">EKS Pod Identity vs IRSA: What Actually Decides the Choice</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A service account gets an association, the deployment rolls, the pod comes up healthy, and the application keeps working. Weeks later someone reads CloudTrail during an access review and notices that every API call from that workload was made as the node role, not the role you carefully scoped. No <code>AccessDenied</code>. No crash loop. No alert. The permissions you thought you had removed were never actually removed, because the pod never used the identity you assigned it.</p>



<p class="wp-block-paragraph">That failure should shape how you think about EKS Pod Identity vs IRSA. Both deliver the same end result to your application: short-lived, rotated credentials with nothing hardcoded. The differences are in how trust is expressed, who has to touch IAM to change it, and what happens when the wiring is subtly wrong.</p>



<p class="wp-block-paragraph">This post compares the two on what actually decides the choice: how each scales across clusters, where each refuses to work at all, the failures each hides, and a decision procedure you can apply to a real cluster.</p>



<h2 class="wp-block-heading">Same outcome, two very different trust shapes</h2>



<p class="wp-block-paragraph">Most of the trade-offs fall out of the mechanism, so it is worth being precise about both.</p>



<h3 class="wp-block-heading">IRSA: federation through an OIDC provider</h3>



<p class="wp-block-paragraph">The cluster has an OIDC issuer, which you register as an identity provider in IAM. A projected service account token is mounted into the pod, and the SDK exchanges it for credentials through the web identity flow against STS. The role&#8217;s trust policy is what makes that work, and it names one specific cluster:</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:sub":
          "system:serviceaccount:production:my-app",
        "oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLED539D4633E53DE1B716D3041E:aud":
          "sts.amazonaws.com"
      }
    }
  }]
}</code></pre>



<p class="wp-block-paragraph">The important detail is that the issuer URL is unique per cluster. The relationship between a workload and a role lives inside IAM, and it is cluster-specific by construction.</p>



<h3 class="wp-block-heading">Pod Identity: an EKS API and an on-node agent</h3>



<p class="wp-block-paragraph">Pod Identity moves that mapping out of IAM. You install the EKS Pod Identity Agent add-on, which runs as a DaemonSet, then create an association through the EKS API binding a namespace plus service account name to a role. The agent calls the EKS Auth API for temporary credentials, and the pod picks them up through the container credential provider that AWS SDKs already support.</p>



<p class="wp-block-paragraph">The trust policy contains nothing cluster-specific:</p>



<pre class="wp-block-code"><code>{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowEksAuthToAssumeRoleForPodIdentity",
    "Effect": "Allow",
    "Principal": { "Service": "pods.eks.amazonaws.com" },
    "Action": [ "sts:AssumeRole", "sts:TagSession" ]
  }]
}</code></pre>



<p class="wp-block-paragraph"><code>sts:TagSession</code> is not decoration. Pod Identity attaches session tags on every assume, so a policy allowing only <code>sts:AssumeRole</code> fails. It is the most common first-attempt error.</p>



<p class="wp-block-paragraph">Creating the association is a single call:</p>



<pre class="wp-block-code"><code>aws eks create-pod-identity-association 
  --cluster-name prod-eu 
  --namespace production 
  --service-account my-app 
  --role-arn arn:aws:iam::111122223333:role/my-app</code></pre>



<h2 class="wp-block-heading">Where IRSA still wins</h2>



<p class="wp-block-paragraph">Treating IRSA as legacy is the most common mistake here. For some workloads it is not the older option, it is the only option.</p>



<ul class="wp-block-list">
<li><strong>Fargate pods.</strong> The Pod Identity Agent is a DaemonSet on EC2 nodes. Fargate has no node for it to land on, and AWS documents Fargate pods as unsupported. If part of your workload runs on Fargate, IRSA is the mechanism there, permanently, not as a stopgap.</li>



<li><strong>Windows nodes.</strong> Also documented as unsupported for Pod Identity.</li>



<li><strong>Older SDKs you cannot rebuild.</strong> The web identity flow has been in the SDKs far longer, so a vendor image you cannot patch is more likely to work with IRSA.</li>



<li><strong>Direct federation into workload accounts.</strong> If your model wants the pod&#8217;s identity federating straight into a role in the resource-owning account, IRSA does that natively. Pod Identity reaches other accounts by role chaining, a different trust shape even when the outcome looks the same.</li>



<li><strong>You already have it working at a scale you control.</strong> A few clusters with a Terraform module that provisions the provider and the roles is not a problem that needs solving.</li>
</ul>



<h3 class="wp-block-heading">Where IRSA hurts</h3>



<p class="wp-block-paragraph">The pain is administrative, and it grows with cluster count rather than workload count. Every new cluster brings a new OIDC issuer, which means editing the trust policy of every role those workloads use. Trust policies have a size limit, so a role trusting many issuers eventually runs out of room and you end up duplicating roles to work around it.</p>



<p class="wp-block-paragraph">The same asymmetry shows up in blue/green cluster upgrades. Standing a new cluster beside the old one means every role used by every migrating workload needs a trust policy edit before the new cluster can do anything, and another edit afterwards to clean up. That is IAM work on the critical path of an upgrade, usually owned by a different team than the one doing it.</p>



<h2 class="wp-block-heading">Where Pod Identity wins</h2>



<ul class="wp-block-list">
<li><strong>Roles become portable.</strong> One role, one generic trust policy, any number of clusters. Adding a cluster is an EKS API call per workload, not an IAM edit per role.</li>



<li><strong>The permission split matches how teams are organised.</strong> Creating an association needs EKS permissions, not <code>iam:UpdateAssumeRolePolicy</code>, so cluster admins stop filing IAM tickets for every new service account.</li>



<li><strong>Session tags arrive for free.</strong> Every assume carries <code>eks-cluster-arn</code>, <code>eks-cluster-name</code>, <code>kubernetes-namespace</code>, <code>kubernetes-service-account</code>, <code>kubernetes-pod-name</code> and <code>kubernetes-pod-uid</code>. That is usable attribute-based access control and the CloudTrail audit trail you always wanted.</li>



<li><strong>Cross-account access without touching application code.</strong> An association can carry a target role ARN, and EKS performs the role chaining for you.</li>
</ul>



<p class="wp-block-paragraph">The ABAC angle is underrated. One policy can serve dozens of workloads, because the session tag identifies the caller:</p>



<pre class="wp-block-code"><code>"Condition": {
  "StringEquals": {
    "s3:ExistingObjectTag/team": "${aws:PrincipalTag/kubernetes-namespace}"
  }
}</code></pre>



<h3 class="wp-block-heading">Where Pod Identity hurts</h3>



<p class="wp-block-paragraph">The generic trust policy is a real trade-off. Written exactly as AWS shows it, that role can be assumed on behalf of <em>any</em> service account in <em>any</em> namespace in <em>any</em> cluster in the account, as long as someone with EKS permissions creates an association pointing at it. Blast radius control has moved from IAM into the EKS API. If those permissions are loosely held, you widened it.</p>



<p class="wp-block-paragraph">The fix is to put the constraint back in the trust policy using the request tags Pod Identity sends:</p>



<pre class="wp-block-code"><code>"Condition": {
  "StringEquals": {
    "aws:RequestTag/kubernetes-namespace": "production",
    "aws:RequestTag/kubernetes-service-account": "my-app"
  }
}</code></pre>



<p class="wp-block-paragraph">You can also pin to a cluster with <code>aws:SourceArn</code>. That costs some of the portability you switched for, which is the point: decide per role. A role reading one team&#8217;s bucket should be pinned. A role used by an identical workload in six clusters should not.</p>



<p class="wp-block-paragraph">The other cost is a new moving part. The agent is a DaemonSet, so it needs to tolerate your taints, and it must be running before pods on that node can get credentials.</p>



<h2 class="wp-block-heading">The failures that actually bite</h2>



<h3 class="wp-block-heading">Silent fallback to the node role</h3>



<p class="wp-block-paragraph">This is the one from the opening, and it is invisible by design. Pod Identity works through the container credential provider, which the SDK finds by reading two environment variables EKS injects into the pod:</p>



<pre class="wp-block-code"><code>AWS_CONTAINER_CREDENTIALS_FULL_URI=http://169.254.170.23/v1/credentials
AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=/var/run/secrets/pods.eks.amazonaws.com/serviceaccount/eks-pod-identity-token</code></pre>



<p class="wp-block-paragraph">An SDK that predates that provider does not error on these. It does not know what they mean, so it keeps walking the credential chain until it reaches instance metadata and picks up the node role. The application works. The permissions are wrong. Nothing complains.</p>



<p class="wp-block-paragraph">Two defences, and you want both. Check the minimum SDK version list in the EKS documentation and pin base images above it, and block pod access to IMDS so falling back has nowhere to fall. The first prevents the problem; the second turns a silent success into a loud failure.</p>



<p class="wp-block-paragraph">Verify from inside the pod. The ARN in the response tells you which identity is really in use:</p>



<pre class="wp-block-code"><code>kubectl exec -n production deploy/my-app -- aws sts get-caller-identity</code></pre>



<h3 class="wp-block-heading">The association that matches nothing</h3>



<p class="wp-block-paragraph">An association is an exact match on cluster, namespace and service account name. A typo in any of the three produces an association that exists, looks fine in the console, and never applies to a pod. List them and read them against your manifests:</p>



<pre class="wp-block-code"><code>aws eks list-pod-identity-associations --cluster-name prod-eu</code></pre>



<p class="wp-block-paragraph">Associations also only take effect for pods started after they exist. If you create one and nothing changes, restart the workload before you start debugging IAM.</p>



<h3 class="wp-block-heading">Both mechanisms configured at once</h3>



<p class="wp-block-paragraph">During a migration you will often have a service account carrying an IRSA annotation and a Pod Identity association at the same time. Both paths can work, and which one the SDK uses depends on credential chain ordering. AWS documents that credentials found earlier in the chain keep being used even when an association exists.</p>



<p class="wp-block-paragraph">Do not reason about it. Check the caller identity and check CloudTrail, then remove the one you are not keeping so nobody has to work it out again.</p>



<h3 class="wp-block-heading">PackedPolicyTooLarge</h3>



<p class="wp-block-paragraph">Session tags, managed policy ARNs and inline session policies are compressed into a packed format with its own size limit, and six session tags on every assume is not free. The association can disable the automatic tags, at the cost of the ABAC and audit benefits that were half the reason to use Pod Identity.</p>



<h2 class="wp-block-heading">A decision procedure for EKS Pod Identity vs IRSA</h2>



<p class="wp-block-paragraph">Work through these in order and stop at the first one that answers.</p>



<ol class="wp-block-list">
<li><strong>Does this workload run on Fargate or Windows nodes?</strong> IRSA. There is no decision to make.</li>



<li><strong>Can you rebuild the image with a current SDK?</strong> If not, IRSA, or fix the image first.</li>



<li><strong>Does your model require direct OIDC federation into the resource account?</strong> IRSA. Role chaining is not the same shape, even if it reaches the same bucket.</li>



<li><strong>Will this role be used from more than one cluster, now or after the next upgrade?</strong> Pod Identity. The saving compounds every time you add or replace a cluster.</li>



<li><strong>Do you want cluster and namespace in the CloudTrail record without extra work?</strong> Pod Identity, because of the session tags.</li>



<li><strong>None of the above?</strong> Pod Identity for anything new, IRSA left alone where it already works.</li>
</ol>



<p class="wp-block-paragraph">AWS&#8217;s own guidance lands in the same place: Pod Identity for new applications on supported node types, IRSA where you already have it working or where Pod Identity cannot run.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<ul class="wp-block-list">
<li><strong>&#8220;IRSA is deprecated.&#8221;</strong> It is not. AWS has said it continues to invest in it, and Fargate support alone guarantees it stays.</li>



<li><strong>&#8220;Pod Identity is less secure because the trust policy is generic.&#8221;</strong> Only if you leave it generic. Request tag conditions scope it as tightly as an IRSA trust policy, and you keep the session tags IRSA does not give you.</li>



<li><strong>&#8220;Run one mechanism per cluster, cleanly.&#8221;</strong> A mixed cluster is documented, supported, and for clusters with Fargate pods it is the permanent steady state, not a transitional mess.</li>



<li><strong>&#8220;EKS add-ons can only use IRSA.&#8221;</strong> True early on, and still repeated in older write-ups. Add-on creation and update now accept Pod Identity associations directly. Check your specific add-on rather than trusting a blog post, including this one.</li>



<li><strong>&#8220;Migrate everything, then delete the OIDC provider.&#8221;</strong> Deleting it breaks every role still trusting it, including ones you forgot. Migrate workload by workload, and delete the provider only after CloudTrail shows zero <code>AssumeRoleWithWebIdentity</code> calls for that issuer across a full batch cycle.</li>
</ul>



<h2 class="wp-block-heading">How I&#8217;d decide, and what I&#8217;d do first</h2>



<ul class="wp-block-list">
<li>Default new workloads on EC2 or Karpenter-managed nodes to Pod Identity, and leave working IRSA alone until you have a reason to touch it.</li>



<li>Add request tag conditions for anything sensitive. Portability helps a shared platform role and hurts a role that reads customer data.</li>



<li>Restrict pod access to IMDS before you migrate anything, not after. It converts the silent failure into a visible one.</li>



<li>Migrate with the role trusting both principals at once, so rollback is a manifest change rather than an IAM change.</li>



<li>Put credential identity in your dashboards. A CloudTrail query counting calls made by node roles from workload namespaces catches the exact failure this post opened with. Amazon Managed Grafana or Grafana Cloud will render it if you already ship CloudTrail somewhere; a scheduled CLI check from a CI runner or a small VPS from Contabo or InterServer is a fine low-tech alternative.</li>



<li>Codify associations in Terraform or OpenTofu from the start. They are cheap to create by hand and easy to lose track of.</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">Is EKS Pod Identity replacing IRSA?</h3>



<p class="wp-block-paragraph">No. AWS recommends Pod Identity for new applications on supported node types and continues to support IRSA. Since Pod Identity cannot run on Fargate or Windows nodes, IRSA stays for the workloads that live there.</p>



<h3 class="wp-block-heading">Can I use both in the same cluster?</h3>



<p class="wp-block-paragraph">Yes, and it is a documented, supported pattern. Older workloads on IRSA and new ones on Pod Identity is normal. Avoid configuring both for the same service account beyond a migration window, because which one wins depends on SDK credential chain ordering.</p>



<h3 class="wp-block-heading">Why does my pod get AccessDenied after switching to Pod Identity?</h3>



<p class="wp-block-paragraph">Check the trust policy for <code>sts:TagSession</code> first. Pod Identity always sends session tags, so a trust policy allowing only <code>sts:AssumeRole</code> is rejected. After that, check that the namespace and service account in the association match the pod exactly.</p>



<h3 class="wp-block-heading">Does Pod Identity work for cross-account access?</h3>



<p class="wp-block-paragraph">Yes, through role chaining. The association takes a role in the cluster account plus a target role in the resource account, and EKS chains them. It also exposes an external ID you can require in the target role&#8217;s trust policy to prevent a confused deputy problem.</p>



<h3 class="wp-block-heading">Do I have to change my application code?</h3>



<p class="wp-block-paragraph">No, provided the SDK is recent enough. Both work through the default credential chain, so the application calls AWS as usual. The only change that bites is an SDK too old to understand the container credential provider.</p>



<h3 class="wp-block-heading">How do I confirm which identity a pod is actually using?</h3>



<p class="wp-block-paragraph">Run <code>aws sts get-caller-identity</code> inside the container and read the assumed role ARN. If it names the node role rather than the one you assigned, the chain fell through. Cross-check in CloudTrail, where session tags also give you the namespace and service account for Pod Identity sessions.</p>



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



<p class="wp-block-paragraph">The real question in EKS Pod Identity vs IRSA is not which is more modern. It is where you want the cluster-to-role relationship to live. IRSA puts it in IAM trust policies: precise, cluster-specific, and increasing administrative drag as clusters multiply. Pod Identity puts it in the EKS API: portable, fast to change, and it hands you the job of deciding per role how much of that portability you actually want.</p>



<p class="wp-block-paragraph">Pick per workload, not per cluster. And whichever you pick, verify the identity from inside the pod, because the worst outcome here is not a permission error. It is a workload that has been running with the wrong credentials since the day you deployed it.</p>



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



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



<p class="wp-block-paragraph">Most of this work is unglamorous: reading trust policies, matching associations against manifests, proving what a pod is really authenticating as. Things I help with:</p>



<ul class="wp-block-list">
<li>Auditing every service account in a cluster and reporting which identity its pods actually use, not the one the manifest claims</li>



<li>Planning and running an IRSA to Pod Identity migration workload by workload, with a rollback that needs no IAM change</li>



<li>Writing scoped trust policies with request tag conditions, so portability is a deliberate choice per role</li>



<li>Restricting pod access to IMDS and confirming nothing was silently depending on the node role</li>



<li>Setting up cross-account access with target roles and external IDs, plus the CloudTrail view that proves it is used as intended</li>



<li>Codifying associations and roles in Terraform or OpenTofu, so the next cluster is a variable change rather than a project</li>
</ul>



<p class="wp-block-paragraph">Send me a trust policy, a service account manifest, or the output of <code>aws sts get-caller-identity</code> from inside one of your pods, and I will tell you what it is really doing.</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-security/eks-pod-identity-vs-irsa/">EKS Pod Identity vs IRSA: What Actually Decides the Choice</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</title>
		<link>https://john-nessime.com/blog/devops/opentelemetry-on-aws-adot/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 01 Sep 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[ADOT]]></category>
		<category><![CDATA[Amazon ECS]]></category>
		<category><![CDATA[Amazon EKS]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cardinality]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OpenTelemetry]]></category>
		<category><![CDATA[OTLP]]></category>
		<category><![CDATA[Semantic Conventions]]></category>
		<category><![CDATA[SigV4]]></category>
		<category><![CDATA[Tail Sampling]]></category>
		<category><![CDATA[Tracing]]></category>
		<category><![CDATA[Transaction Search]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[X-Ray]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=523</guid>

					<description><![CDATA[<p>A pipeline that returns 200 is not a pipeline that works. The four decisions behind running OpenTelemetry on AWS with ADOT, the CloudWatch OTLP endpoint limits that drop data without erroring, and how to catch the failures before a colleague does.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/opentelemetry-on-aws-adot/">Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A colleague messages you: &#8220;checkout fell off the service map.&#8221; The service is fine. It&#8217;s serving traffic, the collector pod is <code>Running</code>, the exporter queue is empty, and nothing in the collector log reads like an error. But a chunk of the spans that left the application never showed up in CloudWatch.</p>



<p class="wp-block-paragraph">That&#8217;s the signature failure of telemetry pipelines on AWS. Transport worked, authentication worked, the endpoint answered <code>200</code>. It kept part of the payload and dropped the rest, because something in the batch broke a limit your collector has no idea exists.</p>



<p class="wp-block-paragraph">Getting started with OpenTelemetry on AWS is not the hard part. AWS Distro for OpenTelemetry (ADOT) will have you shipping traces in an afternoon. The hard part is four decisions underneath it: where the collector sits, which door telemetry uses to get into AWS, what happens to your metrics on the way through, and whether a second backend later costs you a config line or a migration. This post covers those four decisions and how each one fails without announcing itself.</p>



<h2 class="wp-block-heading">What ADOT actually buys you</h2>



<p class="wp-block-paragraph">ADOT is not a separate protocol or agent. It&#8217;s a downstream build of the upstream OpenTelemetry Collector plus AWS-flavoured SDKs, tested by AWS and covered by AWS Support. The config syntax is the upstream syntax, so a config written for vanilla OTel runs on ADOT and vice versa. The difference is which components are compiled in: ADOT ships the X-Ray exporter, the CloudWatch EMF exporter, the SigV4 extension, and ECS metric receivers already there.</p>



<p class="wp-block-paragraph">So the choice is narrower than it looks. Take ADOT for a build someone else validates and supports. Take upstream Contrib if you need a component ADOT hasn&#8217;t bundled, or you run one collector image across AWS, another cloud, and bare metal. Neither choice touches your instrumentation, which is the entire point.</p>



<h2 class="wp-block-heading">Decision one: where the collector runs</h2>



<p class="wp-block-paragraph">Three shapes, trading the same three things: blast radius, cost, and how much processing happens before data leaves your network.</p>



<h3 class="wp-block-heading">Sidecar</h3>



<p class="wp-block-paragraph">One collector container per ECS task or pod. The application talks to <code>localhost</code>, so no service discovery and no network hop to get wrong, and failure stays contained to one workload. The cost: you pay for that container everywhere, batches stay small because batching is per-instance, and any config change is a redeploy of every task.</p>



<h3 class="wp-block-heading">Agent plus gateway</h3>



<p class="wp-block-paragraph">A light collector per node (a DaemonSet on EKS) forwarding to a small pool of gateways. This is the shape I reach for first on anything past a handful of services. The agent does host-level enrichment, the gateway does the expensive work: large batches, tail sampling, redaction, fan-out. Config changes hit the gateway only.</p>



<p class="wp-block-paragraph">The catch: a gateway pool is a thing you now operate. It needs autoscaling and its own alerting, and undersized it starts refusing data at exactly the moment you have an incident and volume spikes.</p>



<h3 class="wp-block-heading">No collector at all</h3>



<p class="wp-block-paragraph">The ADOT SDKs can export straight to the CloudWatch OTLP endpoints, signing requests with credentials already available to the process. For a handful of Lambda functions this is the right answer: nothing to run, nothing to scale, no extra container in the cold start path. What you give up is control. Batching, retries, sampling policy, redaction, and backend routing all move into application processes and their environment variables, so changing any of them is a fleet redeploy rather than a config push. Good starting point, poor steady state.</p>



<h2 class="wp-block-heading">Decision two: which door into AWS</h2>



<p class="wp-block-paragraph">Two ways to hand telemetry to AWS, and they behave very differently.</p>



<p class="wp-block-paragraph">The older path uses AWS-specific exporters. <code>awsxray</code> converts OTLP spans into X-Ray segment documents and calls the X-Ray API. <code>awsemf</code> converts OTLP metrics into CloudWatch Embedded Metric Format and writes them as log events. Both work, both are well trodden, and both reshape your data into an AWS-native format on the way out.</p>



<p class="wp-block-paragraph">The newer path is native OTLP. CloudWatch exposes OTLP endpoints per signal, reached with the plain <code>otlphttp</code> exporter and a SigV4 signer. Same exporter you&#8217;d point at Grafana Cloud or Honeycomb, different URL and authenticator. That&#8217;s what &#8220;export anywhere&#8221; means in practice, and it&#8217;s why I default to it on new work.</p>



<p class="wp-block-paragraph">The endpoints follow a per-service pattern:</p>



<ul class="wp-block-list">
<li>Traces: <code>https://xray.&lt;region&gt;.amazonaws.com/v1/traces</code></li>

<li>Metrics: <code>https://monitoring.&lt;region&gt;.amazonaws.com/v1/metrics</code></li>

<li>Logs: <code>https://logs.&lt;region&gt;.amazonaws.com/v1/logs</code></li>
</ul>



<p class="wp-block-paragraph">Three constraints there will each cost you an afternoon if you don&#8217;t know them going in.</p>



<p class="wp-block-paragraph"><strong>HTTP only.</strong> No gRPC. If your collector exports over <code>otlp</code> on 4317 you can&#8217;t just change the URL, you have to switch exporters. Receiving gRPC from applications is fine, it&#8217;s the outbound leg that must be HTTP.</p>



<p class="wp-block-paragraph"><strong>SigV4 required.</strong> That means the <code>sigv4auth</code> extension, configured per signal because the signing service name differs: <code>xray</code>, <code>monitoring</code>, and <code>logs</code> respectively. Bearer tokens are an alternative for metrics and logs, but not for traces.</p>



<p class="wp-block-paragraph"><strong>Logs need headers, not just a URL.</strong> Target log group and stream travel in <code>x-aws-log-group</code> and <code>x-aws-log-stream</code> headers. Omit them and the request has nowhere to land.</p>



<p class="wp-block-paragraph">A working shape for traces and logs looks like this:</p>



<pre class="wp-block-code"><code>extensions:
  sigv4auth/traces:
    region: "us-east-1"
    service: "xray"
  sigv4auth/logs:
    region: "us-east-1"
    service: "logs"

exporters:
  otlphttp/traces:
    compression: gzip
    traces_endpoint: https://xray.us-east-1.amazonaws.com/v1/traces
    auth:
      authenticator: sigv4auth/traces

  otlphttp/logs:
    compression: gzip
    logs_endpoint: https://logs.us-east-1.amazonaws.com/v1/logs
    headers:
      x-aws-log-group: MyApplicationLogs
      x-aws-log-stream: default
    auth:
      authenticator: sigv4auth/logs

service:
  extensions: [sigv4auth/traces, sigv4auth/logs]
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlphttp/traces]
    logs:
      receivers: [otlp]
      exporters: [otlphttp/logs]</code></pre>



<p class="wp-block-paragraph">Note <code>traces_endpoint</code> and <code>logs_endpoint</code> rather than <code>endpoint</code>. This one bites people. The <code>endpoint</code> setting is a <em>base URL</em> and the exporter appends the signal path itself, so <code>endpoint: https://monitoring.us-east-1.amazonaws.com/v1/metrics</code> actually requests <code>/v1/metrics/v1/metrics</code>. The per-signal settings take a full path. If you&#8217;re getting 404s from a URL you&#8217;re certain is right, this is why.</p>



<p class="wp-block-paragraph">Also worth a comment in your config: upstream has renamed the component to <code>otlp_http</code> and <code>otlphttp</code> is now a deprecated alias scheduled for removal. Both work today.</p>



<h3 class="wp-block-heading">Traces need Transaction Search turned on first</h3>



<p class="wp-block-paragraph">This is the single most common reason a correctly configured trace pipeline produces nothing. The X-Ray OTLP endpoint requires Transaction Search to be enabled on the account, which redirects span ingestion into CloudWatch Logs:</p>



<pre class="wp-block-code"><code>aws xray update-trace-segment-destination --destination CloudWatchLogs</code></pre>



<p class="wp-block-paragraph">Spans then land in a log group named <code>aws/spans</code>, with a percentage indexed in X-Ray as trace summaries for search. The default index rate is one percent, enough to find traces while all spans stay queryable as structured logs. The caller needs <code>xray:UpdateTraceSegmentDestination</code> and <code>xray:UpdateIndexingRule</code> plus log group creation rights.</p>



<p class="wp-block-paragraph">Two consequences to plan for. Span ingestion is billed separately from log ingestion, so it&#8217;s a line item rather than a rounding error. And AWS recommends <code>always_on</code> sampling in the SDK on this path, because the indexing rule already handles reduction. Sample in both places and your service map goes patchy.</p>



<h3 class="wp-block-heading">The X-Ray SDK clock is running</h3>



<p class="wp-block-paragraph">The dates matter here, so plainly: the X-Ray SDKs and daemon entered maintenance mode on 25 February 2026 (security fixes only, no new instrumentation support), with end of support on 25 February 2027. The X-Ray <em>service</em> is fine and still gaining features. It&#8217;s the client libraries and the UDP daemon winding down. If you run <code>aws-xray-sdk</code> and a daemon sidecar, that is technical debt with a published expiry, and either the collector or the CloudWatch agent replaces the daemon.</p>



<h2 class="wp-block-heading">Decision three: metrics, temporality, and the cardinality bill</h2>



<p class="wp-block-paragraph">Metrics are where the silent drops live, because collector defaults and endpoint limits disagree.</p>



<p class="wp-block-paragraph">The metrics endpoint caps a single request at 1 MB uncompressed and 1,000 datapoints, counted across resource, scope, and metric levels combined. The upstream batch processor&#8217;s default is far larger. Leave it alone and you build oversized requests, and the response to an oversized or partially invalid request is not always a clean failure: it can come back <code>200</code> with some metrics accepted and others rejected or throttled. So set the batch size deliberately. AWS&#8217;s own examples use a conservative value:</p>



<pre class="wp-block-code"><code>processors:
  batch:
    send_batch_size: 200
    timeout: 10s</code></pre>



<p class="wp-block-paragraph">The other limits shape attribute design more than config:</p>



<ul class="wp-block-list">
<li>150 labels maximum across resource, scope, and datapoint attributes per datapoint</li>

<li>40 KB combined label and value size per series per datapoint</li>

<li>One million new series creatable per ten-minute window, per account</li>

<li>Timestamps no more than ten minutes in the future or fourteen days in the past</li>
</ul>



<p class="wp-block-paragraph">That new-series ceiling catches teams out. Attach a request ID, a session ID, or a raw URL path to a metric attribute and every request mints a fresh series. You&#8217;ll hit a million faster than you expect, and it presents as metrics randomly going missing rather than a quota error. Unbounded identifiers belong on spans and logs. Metric attributes should be values you could enumerate on a whiteboard.</p>



<p class="wp-block-paragraph">Temporality is the other conscious decision. The OpenTelemetry SDK spec defaults to cumulative, while CloudWatch&#8217;s metric model is delta-shaped: it wants what happened this period, not the total since your process booted. Counters that look like ever-climbing staircases instead of rates are that mismatch. The <code>cumulativetodelta</code> processor converts in the pipeline, keeping the decision in the collector rather than scattered across SDK environment variables in every service.</p>



<p class="wp-block-paragraph">Decide once and centrally either way. Mixed temporality across one account produces dashboards that are subtly wrong for months before anyone notices. And once volume climbs, pointing a cost tool such as Vantage or CloudZero at the CloudWatch line items earns its setup time, because telemetry spend grows in steps nobody approved.</p>



<h2 class="wp-block-heading">Decision four: a second backend without a second instrumentation</h2>



<p class="wp-block-paragraph">Here&#8217;s the payoff. A collector pipeline holds multiple exporters, and adding one is a config change:</p>



<pre class="wp-block-code"><code>service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/traces, otlphttp/vendor]</code></pre>



<p class="wp-block-paragraph">The same spans now reach CloudWatch and Grafana Cloud, Honeycomb, Datadog, or a self-hosted Tempo, with no application redeploy and no second agent. That&#8217;s the cashable value of instrumenting once against an open protocol: the switching cost of a backend drops from a migration project to a pull request.</p>



<p class="wp-block-paragraph">Be honest about the price. You pay ingest twice, and egress from the gateway is real money at volume. Collector memory scales with exporter queue count, so a fan-out gateway needs headroom. And if one backend slows, backpressure can reach the pipeline feeding the other, which argues for separate pipelines per destination when their reliability differs.</p>



<p class="wp-block-paragraph">It works in reverse too. Because the metrics and logs endpoints accept bearer tokens, a machine with no AWS credentials at all can ship into CloudWatch: a CI runner, another cloud, or a VPS at a provider like Contabo or InterServer running part of your stack. One collector config, one destination, regardless of who owns the hardware. Traces are the exception and still require SigV4.</p>



<h2 class="wp-block-heading">Why OpenTelemetry on AWS fails quietly</h2>



<p class="wp-block-paragraph">Most breakages here don&#8217;t throw. How to recognise the common ones:</p>



<h3 class="wp-block-heading">Data is missing but nothing errors</h3>



<p class="wp-block-paragraph">Almost always a limit breach inside an accepted request. Check batch sizes against the per-signal caps first, then attribute counts. Turn on the collector&#8217;s internal telemetry and compare the exporter&#8217;s sent counters against its failed counters. If sent looks healthy and data is still missing, the loss is happening server-side after acceptance, which narrows it to limits.</p>



<h3 class="wp-block-heading">403 with a signature mismatch</h3>



<p class="wp-block-paragraph">A SigV4 problem, not an IAM problem. The usual cause is the wrong signing service name in the extension, since <code>xray</code>, <code>monitoring</code>, and <code>logs</code> are not interchangeable. If you sign requests yourself rather than using the extension, note that the traces endpoint is stricter about which headers land in the signed set, so sign a minimal stable set rather than everything the HTTP client added.</p>



<h3 class="wp-block-heading">Backfilled data vanishes</h3>



<p class="wp-block-paragraph">Every endpoint enforces a timestamp window, and fourteen days in the past is the outer edge for all three signals. Replaying an old queue past that boundary gets rejected. Not a bug, but it looks like one when the replay job appears to succeed and produces nothing.</p>



<h3 class="wp-block-heading">Traces arrive but the service map is empty</h3>



<p class="wp-block-paragraph">Usually a missing or inconsistent <code>service.name</code> resource attribute, or double sampling between the SDK and the indexing rule. Confirm what&#8217;s actually leaving the collector before hunting in the console: add the <code>debug</code> exporter to a copy of the pipeline, set <code>verbosity: detailed</code>, and read the resource attributes on real spans.</p>



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



<ul class="wp-block-list">
<li>Pointing an OTLP gRPC exporter at a CloudWatch endpoint. They&#8217;re HTTP only, and the error won&#8217;t say so clearly.</li>

<li>Using <code>endpoint</code> with a full signal path instead of the per-signal <code>traces_endpoint</code>, <code>metrics_endpoint</code>, or <code>logs_endpoint</code>.</li>

<li>Leaving the batch processor at its default size on the metrics pipeline.</li>

<li>Sampling in the SDK <em>and</em> relying on Transaction Search indexing rules, halving visibility twice over.</li>

<li>Putting request IDs, user IDs, or raw paths into metric attributes.</li>

<li>Running a gateway pool with no alerting on the collector&#8217;s own health, so saturation is invisible until you need it.</li>
</ul>



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



<ul class="wp-block-list">
<li>Keep instrumentation vendor-neutral. Plain OpenTelemetry SDK APIs, with the collector owning every AWS-specific decision.</li>

<li>Set <code>service.name</code>, <code>service.version</code>, and <code>deployment.environment</code> as resource attributes everywhere. Almost every correlation feature depends on them.</li>

<li>Alert on exporter failure counters and queue depth, and route those alerts somewhere that doesn&#8217;t depend on the pipeline being healthy.</li>

<li>Version the collector config in Git and deploy it like application code, with a staging pipeline you can break safely.</li>

<li>Redact in a processor before export. Once telemetry reaches a backend, removing it is a support ticket.</li>

<li>Test a second exporter early, even to a throwaway account. A vendor migration is the wrong time to learn the fan-out path doesn&#8217;t work.</li>
</ul>



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



<h3 class="wp-block-heading">Do I still need a collector if CloudWatch accepts OTLP directly?</h3>



<p class="wp-block-paragraph">Not to get data in, no. You need one to control what happens before it leaves: batching to fit endpoint limits, tail sampling, redaction, and fan-out. Small serverless estates can reasonably skip it. Anything with a dozen services will want one.</p>



<h3 class="wp-block-heading">Can I send OTLP to CloudWatch over gRPC?</h3>



<p class="wp-block-paragraph">No. The CloudWatch OTLP endpoints are HTTP 1.1 only, accept binary or JSON payloads, and support gzip or no compression. Your collector can still receive gRPC from applications, it just can&#8217;t forward over it.</p>



<h3 class="wp-block-heading">What&#8217;s the difference between ADOT and the upstream collector?</h3>



<p class="wp-block-paragraph">Same codebase, different build. ADOT is AWS&#8217;s tested distribution with AWS components bundled and AWS Support behind it; upstream Contrib carries a wider component set and moves faster. Configuration is compatible either way, so it&#8217;s a support and packaging choice, not an architectural one.</p>



<h3 class="wp-block-heading">Do I have to enable Transaction Search to send traces?</h3>



<p class="wp-block-paragraph">For the X-Ray OTLP endpoint, yes. It&#8217;s a prerequisite, and enabling it routes span ingestion through CloudWatch Logs. Using the <code>awsxray</code> exporter against the classic X-Ray API instead doesn&#8217;t require it, but you give up the span-level analytics Transaction Search provides.</p>



<h3 class="wp-block-heading">Can I ship telemetry to CloudWatch from outside AWS?</h3>



<p class="wp-block-paragraph">For metrics and logs, yes, using bearer token authentication instead of SigV4, which removes the need for AWS credentials on the host. Traces still require SigV4. Never hardcode the token in the config; read it from a mounted secret file or an injected environment variable.</p>



<h3 class="wp-block-heading">Is it urgent to migrate off the X-Ray SDK?</h3>



<p class="wp-block-paragraph">Not an emergency, but it&#8217;s on a clock: maintenance mode now, end of support 25 February 2027. Existing applications keep working, they just won&#8217;t get new library instrumentation. Plan the migration on your schedule rather than someone else&#8217;s.</p>



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



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



<p class="wp-block-paragraph">A pipeline that returns <code>200</code> is not a pipeline that works. Judge every design decision in OpenTelemetry on AWS against one question: when this drops data, do I find out from a dashboard or from a colleague asking why a service vanished off the map?</p>



<p class="wp-block-paragraph">Size batches against the published limits, keep unbounded identifiers off metric attributes, decide temporality in one place, and instrument with plain OpenTelemetry so the export target stays a config line. Do that and &#8220;instrument once, export anywhere&#8221; stops being a slogan and becomes a property you can test.</p>



<h2 class="wp-block-heading">Need help with your OpenTelemetry pipeline on AWS?</h2>



<p class="wp-block-paragraph">Most of this work is unglamorous and specific, which is why it gets deferred. Things I can help with:</p>



<ul class="wp-block-list">
<li>Auditing an ADOT or upstream collector config for silent drops, oversized batches, and limit breaches</li>

<li>Designing collector topology for ECS, EKS, or Lambda, including gateway sizing and autoscaling</li>

<li>Migrating X-Ray SDK and daemon workloads to OpenTelemetry ahead of end of support</li>

<li>Cutting telemetry spend through sampling policy, cardinality control, and attribute pruning</li>

<li>Dual export to CloudWatch and a third-party backend, so a future switch is a config change</li>

<li>Meta-monitoring that tells you the pipeline is broken before your users do</li>
</ul>



<p class="wp-block-paragraph">Send me a collector config, an exporter log, or a screenshot of the gap in your dashboard and I&#8217;ll tell you what I&#8217;d look at first.</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/opentelemetry-on-aws-adot/">Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>One Home, Six Portals: Property Listing Deduplication Without False Merges</title>
		<link>https://john-nessime.com/blog/technical-guides/property-listing-deduplication/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 01 Sep 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Real Estate Technology]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Address Matching]]></category>
		<category><![CDATA[Data Quality]]></category>
		<category><![CDATA[Deduplication]]></category>
		<category><![CDATA[Entity Resolution]]></category>
		<category><![CDATA[Human In The Loop]]></category>
		<category><![CDATA[Identity Resolution]]></category>
		<category><![CDATA[libpostal]]></category>
		<category><![CDATA[Perceptual Hashing]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[PostGIS]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[Real Estate Data]]></category>
		<category><![CDATA[Record Linkage]]></category>
		<category><![CDATA[RESO Web API]]></category>
		<category><![CDATA[Splink]]></category>
		<category><![CDATA[Survivorship Rules]]></category>
		<category><![CDATA[UPRN]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=536</guid>

					<description><![CDATA[<p>Two portals, one house, two records. Merging them is easy. Merging the wrong two is invisible and expensive. A practical look at blocking, match scoring, survivorship rules and the time axis most listing pipelines get wrong.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/property-listing-deduplication/">One Home, Six Portals: Property Listing Deduplication Without False Merges</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 ticket said &#8220;wrong agent phone number on the detail page.&#8221; Not missing. Wrong. The number belonged to a real agency, just not the one marketing that flat.</p>



<p class="wp-block-paragraph">Two source records had been collapsed into one. Flat 12 and Flat 12A in the same block: same postcode, same rooftop geocode, both two-bed, both published the same week by different agencies. One record won the price field. The other won the contact block. What came out the far end was a listing for a property that does not exist.</p>



<p class="wp-block-paragraph">That is what makes property listing deduplication hard, and it is not the problem people plan for. This post covers candidate generation, match scoring, survivorship rules, the time axis almost nobody models, and how each fails in production.</p>



<h2 class="wp-block-heading">Under-merging is visible. Over-merging is not.</h2>



<p class="wp-block-paragraph">Get this asymmetry wrong and every threshold downstream is mistuned.</p>



<p class="wp-block-paragraph">Fail to merge two records for the same property and a user sees two cards for one house. It looks sloppy, someone reports it, you fix it that afternoon.</p>



<p class="wp-block-paragraph">Merge two <em>different</em> properties and nothing looks broken. The card renders. Real price, real photos, real agent, all belonging to different homes. Filters return it for the wrong bedroom count. Price analytics inherit a value never asked for that unit. A lead goes to an agency that never had the instruction. Nobody reports it, because nobody can see it.</p>



<p class="wp-block-paragraph">A missed merge is a duplicate. A false merge is corrupted data with a plausible face on it.</p>



<h2 class="wp-block-heading">Why property listing deduplication is not a string-matching problem</h2>



<p class="wp-block-paragraph">The obvious first attempt is to normalise the address, hash it, and group by the hash. It gets you further than you expect and then stops dead, because portals do not disagree on addresses randomly. They disagree structurally.</p>



<ul class="wp-block-list">
<li>One portal puts the sub-unit in its own field, one puts it in line one, one drops it.</li>

<li>New-build stock is marketed by plot number long before it is marketed by street address.</li>

<li>Rural properties are named rather than numbered, and the name is what agencies edit most.</li>

<li>Some agencies fuzz the address on tenanted stock and publish only a street or postcode sector.</li>
</ul>



<p class="wp-block-paragraph">Normalisation still earns its place, it just is not the decision. <a href="https://github.com/openvenues/libpostal" target="_blank" rel="noreferrer noopener">libpostal</a> is the standard tool: a C library trained on open address data, with bindings for Python, Go, Ruby, Java and Node, plus a PostgreSQL extension.</p>



<pre class="wp-block-code"><code>-- Via the pgsql-postal extension, which wraps libpostal.
-- postal_normalize returns every expansion that "makes sense",
-- so you index all of them rather than betting on one.
SELECT unnest(postal_normalize('Flat 12a, 12 Bramble Ct., Manchester'));</code></pre>



<p class="wp-block-paragraph">Expand rather than collapse. You do not know which spelling the other portal chose, and matching on set intersection is more forgiving than picking a canonical form and hoping both sides picked the same one.</p>



<h3 class="wp-block-heading">Look for a real identifier first</h3>



<p class="wp-block-paragraph">Probabilistic matching is what you do when there is no key. Sometimes there is one, and it is worth a lot of scoring code.</p>



<ul class="wp-block-list">
<li><strong>Great Britain:</strong> the UPRN. Assigned to every addressable location by local authorities and Ordnance Survey, it survives renaming, renumbering and demolition. OS Open UPRN publishes identifiers and coordinates as open data. Same UPRN on both sides ends the argument.</li>

<li><strong>United States:</strong> inside a single MLS, the RESO Data Dictionary gives a stable listing key and the RESO Web API is the current transport. RETS is deprecated and boards have been switching it off. The key is stable within an MLS, not across them, so overlapping markets put you back into probabilistic matching.</li>

<li><strong>Open cadastre:</strong> a parcel or title identifier is the best anchor available, with the caveat that one parcel can hold many dwellings.</li>
</ul>



<p class="wp-block-paragraph">Resolve on the identifier first and let the fuzzy matcher handle the remainder. That set is smaller and stranger than you expect, which is fine, because the strange ones are where false merges live.</p>



<h2 class="wp-block-heading">Blocking: never compare every record with every record</h2>



<p class="wp-block-paragraph">All-pairs comparison is quadratic. A million listings is roughly half a trillion pairs. You do not need a bigger machine, you need to stop generating pairs that were never going to match. Blocking produces candidates cheaply from an index, and only candidates get scored.</p>



<p class="wp-block-paragraph">A good key is selective enough to shrink the space and stable enough that both portals agree on it. Those pull against each other. Postcode plus bedroom count is selective, but bedroom count is one of the most disputed fields in any feed, because portals count studies, box rooms and loft conversions differently. Block on it and you discard true matches before scoring runs.</p>



<p class="wp-block-paragraph">Keys that hold up tend to be geometric or structural rather than descriptive:</p>



<ul class="wp-block-list">
<li>A geohash or H3 cell around the geocoded point, plus its neighbours, so pairs straddling a cell edge survive.</li>

<li>Postcode or postal sector combined with the numeric part of the street address.</li>

<li>A trigram index on the normalised street name, which tolerates abbreviations that break exact keys.</li>

<li>The agency contact number, which catches one agency&#8217;s stock syndicated under wildly different formatting.</li>

<li>A perceptual hash of the lead photo, which catches what the address fields lost.</li>
</ul>



<pre class="wp-block-code"><code>CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX ON listing USING gist (geog);
CREATE INDEX ON listing USING gin (street_norm gin_trgm_ops);

-- The % operator compares against pg_trgm.similarity_threshold,
-- which is a session setting, not a hardcoded constant.
SET pg_trgm.similarity_threshold = 0.6;

-- Candidate pairs only. b.id &gt; a.id yields each pair once.
SELECT a.id AS left_id, b.id AS right_id
FROM listing a
JOIN listing b
  ON b.id &gt; a.id
 AND ST_DWithin(a.geog, b.geog, 75)
 AND a.street_norm % b.street_norm
WHERE a.source_portal &lt;&gt; b.source_portal;</code></pre>



<p class="wp-block-paragraph"><code>ST_DWithin</code> on a <code>geography</code> column takes metres and uses the GiST index. Seventy-five is deliberately generous: rooftop and street-interpolated geocodes for one address routinely land tens of metres apart. Strictness belongs in the score, not the block.</p>



<p class="wp-block-paragraph">Every key has a blind spot, so run three or four narrow passes and union the results. That is far cheaper than one pass loose enough to cover everything. Track what each pass contributes alone, because passes rot: a feed changes, a pass stops producing candidates, and recall drops without a single error in the logs.</p>



<h2 class="wp-block-heading">Scoring: what each field is actually worth</h2>



<p class="wp-block-paragraph">The instinct is to weight fields by how much you trust them. That is the wrong axis. The right one is how <em>surprising</em> the agreement is.</p>



<p class="wp-block-paragraph">This is the Fellegi-Sunter model, the standard framing for record linkage. Two probabilities matter per comparison: how likely the fields agree given the records are the same property, and how likely they agree given they are not. The ratio is the evidence. Multiply across fields for a match score.</p>



<h3 class="wp-block-heading">Cardinality decides the weight</h3>



<p class="wp-block-paragraph">Two listings agreeing on &#8220;detached house&#8221; tells you almost nothing, because a large share of the market is detached houses. Agreeing on an unusual property name in a small postcode sector tells you a great deal, because there are very few ways for that to happen by accident.</p>



<p class="wp-block-paragraph">So a field&#8217;s weight is not constant. Agreeing on a postcode in a dense city centre is weak evidence; the same agreement in a sparse rural area is strong. Term-frequency adjustment handles that per value rather than per column, which matters in property data because property data is geographically lumpy.</p>



<p class="wp-block-paragraph"><a href="https://moj-analytical-services.github.io/splink/" target="_blank" rel="noreferrer noopener">Splink</a> is the one I reach for first. It implements Fellegi-Sunter with expectation-maximisation parameter estimation, runs on DuckDB locally and Spark for larger jobs, supports term-frequency adjustments, and shows which field contributed what to a score. That last part matters: &#8220;the model said so&#8221; is not an answer you can act on. The trade-off is that an unsupervised model needs no labelled data but is only as sane as your blocking.</p>



<h3 class="wp-block-heading">Photos are strong evidence until they aren&#8217;t</h3>



<p class="wp-block-paragraph">Two portals carrying one property usually carry the same photo set from the same agency, occasionally recompressed. A perceptual hash survives that, because it fingerprints visual structure rather than bytes.</p>



<pre class="wp-block-code"><code>from PIL import Image
import imagehash

# hash_size=16 gives a 256-bit hash instead of the 64-bit default.
# More bits means fewer accidental collisions between two beige
# living rooms shot from the same corner with the same wide lens.
a = imagehash.phash(Image.open("portal_a_01.jpg"), hash_size=16)
b = imagehash.phash(Image.open("portal_b_03.jpg"), hash_size=16)

distance = a - b   # subtraction returns the Hamming distance
print(distance)</code></pre>



<p class="wp-block-paragraph">Use the <em>count</em> of near-identical pairs across both sets, not the best single pair. One shared photo can be a stock exterior. Six is not.</p>



<p class="wp-block-paragraph">Now the trap that produces false merges at scale. New-build schemes reuse the same CGI renders, show-home interiors and drone shots across every plot. Photo evidence will insist that forty distinct apartments are one apartment, and it will insist confidently, because the agreement really is surprising.</p>



<p class="wp-block-paragraph">Treat photo agreement as conditional. Count how many distinct listings share each hash across the corpus, then downweight or discard hashes appearing on more than a handful. Same term-frequency logic, applied to images. Watermarks are the other tripwire: a portal stamping its logo into the corner shifts the hash for every photo it serves.</p>



<h2 class="wp-block-heading">Three outcomes, not two</h2>



<p class="wp-block-paragraph">A score comes out. What you do with it should not be a single threshold.</p>



<ol class="wp-block-list">
<li><strong>Below the lower threshold:</strong> treat as distinct. Two cards.</li>

<li><strong>Between the thresholds:</strong> hold for review. Do not merge, do not discard, surface the pair with its field-level evidence.</li>

<li><strong>Above the upper threshold:</strong> merge automatically.</li>
</ol>



<p class="wp-block-paragraph">The width of that middle band is a budget decision, not an accuracy decision. Widen it and you catch more ambiguous pairs before they do damage, at the cost of review time. Narrow it and you are choosing which error you will eat. Because over-merging is the expensive one, the band should sit asymmetrically.</p>



<p class="wp-block-paragraph">One rule with no downside: clustered records keep their source rows. Store the cluster assignment in a separate table keyed by source record ID, never by overwriting the source. When a merge turns out wrong, you want an <code>UPDATE</code> on a mapping table rather than a restore from backup.</p>



<h2 class="wp-block-heading">Survivorship: which record wins each field</h2>



<p class="wp-block-paragraph">Merging decides records belong together. Survivorship decides what the merged record says, and it is where the opening bug came from. Do not pick a winning <em>record</em>. Pick a winning <em>source per field</em>, and write it down.</p>



<ul class="wp-block-list">
<li><strong>Price:</strong> most recently observed, tie-broken by source rank. Staleness is the main risk.</li>

<li><strong>Agent and contact:</strong> highest-ranked source only, never blended. A number and an agency name are one unit; splitting them produces a contact that does not exist.</li>

<li><strong>Floor area and bedrooms:</strong> the source with the most complete structured fields, and flag the cluster when sources disagree. Disagreement here often means the merge itself is wrong.</li>

<li><strong>Photos:</strong> union, deduplicated by hash, ordered by highest-ranked source.</li>
</ul>



<pre class="wp-block-code"><code>-- Winning price per cluster: freshest observation,
-- with source_rank breaking ties on identical timestamps.
SELECT DISTINCT ON (cluster_id)
       cluster_id,
       price,
       source_portal,
       observed_at
FROM listing_price_observation
ORDER BY cluster_id,
         observed_at DESC,
         source_rank ASC;</code></pre>



<p class="wp-block-paragraph">Keep provenance on the output. Every surviving field should carry which source it came from and when it was observed. That is the difference between debugging a bad merge in ten minutes and debugging it in two days.</p>



<h2 class="wp-block-heading">The time axis nobody models</h2>



<p class="wp-block-paragraph">A property is listed, withdrawn, then relisted months later with a different agency, a different price and new photos. One entity or two?</p>



<p class="wp-block-paragraph">Both. There are two entities and most pipelines model only one:</p>



<ul class="wp-block-list">
<li>The <strong>property</strong>: physical and permanent. Same building, same UPRN or parcel, forever.</li>

<li>The <strong>listing event</strong>: commercial and episodic. One campaign, one agency, one price band.</li>
</ul>



<p class="wp-block-paragraph">Collapse them and you get a record whose price history is a jagged line with no explanation, whose days-on-market figure is meaningless, and where a price cut and a relisting are indistinguishable. Separate them and both questions get easy: duplicates resolve at the property level, campaign analytics at the event level.</p>



<p class="wp-block-paragraph">My rule for splitting events: a gap in observation beyond some window, combined with a change of agency or a price move outside a tolerance, starts a new event on the same property. Tune the window per market.</p>



<h2 class="wp-block-heading">Troubleshooting: where these pipelines break</h2>



<h3 class="wp-block-heading">Cluster count collapses overnight</h3>



<p class="wp-block-paragraph">Almost always a blocking key that became non-selective. A portal starts sending a placeholder coordinate for missing geocodes, ten thousand listings land on one point, and that becomes a single enormous block. Alert on largest-block size, not just job duration. A pass that generates one huge block runs slowly first and merges wrongly second.</p>



<h3 class="wp-block-heading">One cluster swallows a whole street</h3>



<p class="wp-block-paragraph">Transitive closure doing what it was told. A matches B, B matches C, so all three are one cluster even though A and C are plainly different. Cap cluster size and quarantine anything over it. Check internal consistency too: a cluster with three bedroom counts and a fifty percent price spread is wrong regardless of the pairwise scores.</p>



<h3 class="wp-block-heading">Fresh duplicates appear every single day</h3>



<p class="wp-block-paragraph">The source record IDs are not stable. A portal that regenerates its internal ID on every republish produces a new record daily, and a cluster mapping keyed on that ID re-resolves from scratch every run. Key the mapping on a fingerprint you control, built from fields that do not churn.</p>



<h3 class="wp-block-heading">The run got slow and nothing changed</h3>



<p class="wp-block-paragraph">Check the trigram and GiST indexes are still being used. A change in normalisation that alters the distribution of <code>street_norm</code> can push the planner onto a sequential scan, and the join goes quadratic with no error anywhere. Run <code>EXPLAIN ANALYZE</code> on the candidate query before adding hardware. When you do need hardware, entity resolution is memory-hungry rather than CPU-hungry and spikes during pair scoring, so a single large-RAM instance from a provider like <a href="https://contabo.com" target="_blank" rel="noreferrer noopener">Contabo</a> or <a href="https://www.interserver.net" target="_blank" rel="noreferrer noopener">InterServer</a> is often cheaper than a managed cluster you need for one stage of one job.</p>



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



<ul class="wp-block-list">
<li><strong>Tuning to a single accuracy number.</strong> Precision and recall are not interchangeable here.</li>

<li><strong>Destroying source records on merge.</strong> Every merge you cannot reverse is a merge you cannot fix.</li>

<li><strong>Blocking on a disputed field.</strong> Bedroom count and property type feel selective and are exactly what portals disagree on.</li>

<li><strong>Trusting geocode proximity alone.</strong> Two flats in one block are metres apart and are not the same property.</li>

<li><strong>Dropping the sub-unit.</strong> Discard &#8220;Flat 2&#8221; because it did not parse and you have built a machine that merges apartment buildings into single homes.</li>

<li><strong>No feedback loop.</strong> Review decisions are labelled training data arriving for free.</li>
</ul>



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



<ul class="wp-block-list">
<li>Hold out a hand-labelled sample of a few hundred pairs, weighted toward hard cases, and score every model change against it.</li>

<li>Make every merge explainable at field level. If you cannot show why, you cannot defend it to the person whose listing you broke.</li>

<li>Keep the review queue small enough that it is actually worked. An unread queue is auto-merging with extra steps.</li>

<li>Stamp each cluster with the rule version that produced it, so changes can be evaluated rather than guessed at.</li>

<li>Emit merge rate, split rate, queue depth, largest block size and per-pass contribution as first-class metrics. A managed backend such as <a href="https://grafana.com/products/cloud/" target="_blank" rel="noreferrer noopener">Grafana Cloud</a> saves running another stateful service, though self-hosted Prometheus is fine if you already have it.</li>

<li>Cache photo hashes in object storage keyed by source image URL. Hashing is the slow part of every rerun and never needs doing twice. Storage without egress fees, such as Cloudflare R2, fits when hashing and storage are not colocated.</li>
</ul>



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



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



<h3 class="wp-block-heading">Can I just match on address and postcode?</h3>



<p class="wp-block-paragraph">For one market with clean feeds and no apartment blocks, further than you would think. It breaks on sub-units, named properties, plot-marketed new-builds and portals that abbreviate differently. Treat exact address matching as one high-confidence signal, not the whole decision.</p>



<h3 class="wp-block-heading">Which library should I start with?</h3>



<p class="wp-block-paragraph">Splink for the model, libpostal for address normalisation, your existing database for blocking. Splink&#8217;s advantage is that it explains its scores. Zingg and the Python Record Linkage Toolkit solve adjacent problems and suit teams that prefer active learning or lightweight prototyping.</p>



<h3 class="wp-block-heading">Do I need machine learning for property listing deduplication?</h3>



<p class="wp-block-paragraph">Not to start. Deterministic rules on a good identifier plus a handful of weighted comparisons handle most pairs. The probabilistic model earns its place on the residue, which is where the expensive mistakes live. Build the deterministic layer first so you can measure what is left.</p>



<h3 class="wp-block-heading">How do I handle deliberately hidden addresses?</h3>



<p class="wp-block-paragraph">Lean on non-address signals: photo hashes, floor area, agency identity, price. Accept that some pairs are unresolvable and design the queue to hold them rather than forcing a decision. A permanently unresolved pair beats a confidently wrong merge.</p>



<h3 class="wp-block-heading">Should one property from two agencies be one record or two?</h3>



<p class="wp-block-paragraph">A product decision, not a technical one, and worth settling before you build. Multi-agency instructions are legitimate. Resolving to one property with several current listing events keeps both facts intact and leaves the display choice to the front end.</p>



<h3 class="wp-block-heading">How often should the pipeline re-resolve?</h3>



<p class="wp-block-paragraph">Incrementally on every ingest, so new listings join existing clusters immediately, plus a full rebuild on a slower cadence to pick up rule changes and correct drift. The rebuild is the step people skip, and it catches clusters that were correct when formed and are not any more.</p>



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



<p class="wp-block-paragraph">Property listing deduplication is not really a matching problem. It is a problem of asymmetric error costs wearing a matching problem&#8217;s clothes.</p>



<p class="wp-block-paragraph">A duplicate card is visible, cheap and gets reported. A false merge is invisible, plausible, and quietly poisons everything downstream. Build your thresholds, blocking passes and survivorship rules around that asymmetry, keep the source records so mistakes stay reversible, and put a human in the band where the evidence is not conclusive. The rest is tuning.</p>



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



<h2 class="wp-block-heading">Need help with a multi-portal listing pipeline?</h2>



<p class="wp-block-paragraph">This is the kind of work I do. If you are running property data from several portals and something is not adding up, I can help with:</p>



<ul class="wp-block-list">
<li>Designing blocking and match scoring for cross-portal feeds, including the multi-pass strategy and the metrics to watch it with.</li>

<li>Auditing an existing dedupe pipeline for false merges, oversized clusters and passes that quietly stopped contributing.</li>

<li>Building the two-level property and listing-event model so price history, days on market and duplicate suppression stop fighting each other.</li>

<li>Setting up survivorship rules with field-level provenance, so a bad merge is a ten-minute fix rather than a restore.</li>

<li>Migrating ingestion off deprecated transports onto current portal or MLS APIs without losing history.</li>

<li>Right-sizing the infrastructure so the resolution job runs on something appropriate rather than whatever was already there.</li>
</ul>



<p class="wp-block-paragraph">Send me two records that should have matched and didn&#8217;t, or two that shouldn&#8217;t have and did, and I&#8217;ll tell you what&#8217;s going on.</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/property-listing-deduplication/">One Home, Six Portals: Property Listing Deduplication Without False Merges</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Parquet vs JSON vs CSV: Where the Money Actually Goes</title>
		<link>https://john-nessime.com/blog/technical-guides/parquet-vs-json-vs-csv-cost-performance-2/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 31 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Athena]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[BigQuery]]></category>
		<category><![CDATA[Columnar Storage]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Lake]]></category>
		<category><![CDATA[Data Partitioning]]></category>
		<category><![CDATA[DuckDB]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Parquet]]></category>
		<category><![CDATA[Predicate Pushdown]]></category>
		<category><![CDATA[Schema Design]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=342</guid>

					<description><![CDATA[<p>A practical comparison of Parquet, JSON and CSV on cost and query performance, covering how each billing model changes the answer, the mechanics behind columnar savings, the failure modes that cancel them out, and a six-step way to model the migration before you commit to it.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/parquet-vs-json-vs-csv-cost-performance-2/">Parquet vs JSON vs CSV: Where the Money Actually Goes</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A team converts its event logs from JSON to Parquet over a weekend. The Glue job runs clean, the files land in S3, the dashboards keep working. Monday morning someone opens the Athena bill expecting it to have fallen off a cliff, and it has moved by about a tenth.</p>



<p class="wp-block-paragraph">That is the failure mode worth understanding, and it stays invisible until you go looking for it. The conversion was real. The files did get much smaller. But the dashboard queries were still selecting every column from an unpartitioned table, so the engine still had to open every column chunk in every file. Columnar storage only pays out when the query gives the reader something to skip.</p>



<p class="wp-block-paragraph">This is a working comparison of <strong>Parquet vs JSON vs CSV</strong> on the two things that end up on an invoice: bytes read, and time spent reading them. Each format gets a profile with the cases where it wins and the cases where it does not, followed by the places where the promised savings quietly evaporate and a procedure for modelling the change on your own data before you spend an engineering week on it.</p>



<h2 class="wp-block-heading">Your billing model decides how much the format matters</h2>



<p class="wp-block-paragraph">Before comparing formats, work out what you are actually charged for. The same conversion produces very different savings depending on which of these you are on.</p>



<ul class="wp-block-list"><li><strong>Bytes scanned from object storage.</strong> Amazon Athena is the clearest case. You are billed for the bytes a query reads out of S3, rounded up, with a minimum charge per query. Here a format change converts almost directly into money.</li><li><strong>Logical bytes processed.</strong> BigQuery&#8217;s on-demand model bills on the uncompressed logical size of the columns a query references in a native table, not on the compressed size sitting on disk. Compressing harder does not lower that bill. Touching fewer columns does.</li><li><strong>Compute time.</strong> Snowflake, Databricks, EMR, and anything you run on your own hardware charge you for how long a machine is awake. The format shows up as wall clock time, which is a real cost but a much softer one.</li></ul>



<p class="wp-block-paragraph">The BigQuery detail catches people out. Google documents that on-demand cost is calculated from logical, uncompressed bytes for native tables, but that when you query <em>external</em> data stored as Parquet or ORC, the bytes charged are limited to the columns BigQuery actually reads. The same file format therefore sits on opposite sides of the billing line depending on whether the data was loaded or is being referenced in place. Both Athena and BigQuery also apply a 10 MB minimum per query, and BigQuery applies it per table referenced, so a swarm of small queries against small tables costs far more than the data volume suggests.</p>



<h2 class="wp-block-heading">CSV: cheap to produce, expensive to interrogate</h2>



<p class="wp-block-paragraph">CSV has no schema, no statistics, and no internal structure. Every value is text. The number 1000000000 takes ten bytes instead of the four it would take as a 32-bit integer, and because the layout is row-wise, there is nothing for a column-aware compressor to exploit.</p>



<h3 class="wp-block-heading">Where CSV wins</h3>



<ul class="wp-block-list"><li>A human needs to open it. Nothing else comes close.</li><li>Export from a legacy system that offers no other option.</li><li>Small data. Below a few megabytes, the metadata and encoding overhead of a columnar file is not repaid.</li><li>Streaming appends. You can write a line at a time and stop whenever you like.</li></ul>



<h3 class="wp-block-heading">Where CSV does not</h3>



<p class="wp-block-paragraph">Two problems, and the second is worse than the first.</p>



<p class="wp-block-paragraph">The first is that the reader has to parse every byte, even the bytes it intends to throw away. The DuckDB team published a comparison on TPC-H data at scale factor 20, run on a laptop, reporting the median of five runs, and it is the most honest set of numbers I have seen on this. Loading the <code>lineitem</code> table took roughly 11.8 seconds from CSV against 5.2 seconds from Snappy-compressed Parquet, with the CSV file about five times larger on disk. That is a 2x gap on load, not the order of magnitude most people assume. Modern CSV readers are genuinely fast.</p>



<p class="wp-block-paragraph">The gap opens when you query the files directly rather than loading them. On the same setup, TPC-H Q1 ran in about 6.7 seconds against CSV and 0.9 seconds against Parquet, and the join-heavy Q21 took roughly 20 seconds against 2.1. That difference is not parsing throughput. It is that the Parquet reader can skip row groups using statistics and skip unreferenced columns entirely, while the CSV reader cannot skip anything, because nothing in the file tells it what it would be skipping.</p>



<p class="wp-block-paragraph">The second problem is dialect. There is an RFC for CSV and it is widely ignored. Quoting, escaping, embedded newlines, date formats, and whether <code>NA</code> means null or Namibia are conventions rather than rules, so every consumer re-guesses and sooner or later one guesses differently from the others. That failure does not raise an error. It produces wrong numbers in a report nobody questions.</p>



<h2 class="wp-block-heading">JSON: you pay for the schema on every single row</h2>



<p class="wp-block-paragraph">Newline-delimited JSON is what most event pipelines emit, because a producer can add a field without coordinating with anyone downstream. That flexibility is worth something real. It is also why JSON is usually the most expensive format on a bytes-scanned bill: every record repeats every key name, so a field called <code>customer_subscription_status</code> carries those thirty-odd bytes on every row, forever.</p>



<h3 class="wp-block-heading">Where JSON wins</h3>



<ul class="wp-block-list"><li>Landing zone for data whose shape you do not control. Accept it, keep the raw copy, argue about the schema later.</li><li>Genuinely nested or sparse records, where flattening into columns would give you a table that is mostly nulls.</li><li>Debugging. Being able to decompress one file and read a record with your eyes is worth a lot at three in the morning.</li><li>API and webhook payloads, where it is simply what arrives.</li></ul>



<h3 class="wp-block-heading">Where JSON does not</h3>



<p class="wp-block-paragraph">As a query target at any serious volume. You are paying scan charges on repeated key names, on quote characters, and on the fact that every number is stored as text. Compressing the files helps storage and helps the scan bill on Athena, but it cannot give the engine anything to skip, and gzip brings a separate problem covered below.</p>



<p class="wp-block-paragraph">The nested-data argument for keeping JSON has also weakened. Parquet now has a ratified Variant type for semi-structured data, with support arriving across Delta Lake, Iceberg, and Spark, plus a shredding scheme that pulls frequently accessed fields out into real columns while leaving the rest flexible. If you are keeping JSON purely because your records have unpredictable shape, that reason is worth revisiting rather than treating as settled.</p>



<h2 class="wp-block-heading">Parquet: what the file is actually doing</h2>



<p class="wp-block-paragraph">Understanding the layout is what lets you predict whether a migration will pay for itself, so it is worth thirty seconds.</p>



<p class="wp-block-paragraph">A Parquet file is divided into row groups. Inside each row group, every column is stored as its own contiguous column chunk, and each chunk is split into pages. The footer at the end of the file carries the schema and, per column chunk, statistics such as minimum, maximum, and null count. That structure is what makes the two headline optimisations possible:</p>



<ul class="wp-block-list"><li><strong>Column pruning.</strong> The reader consults the footer, works out the byte ranges of the three columns you asked for, and never issues a read for the other forty-seven.</li><li><strong>Predicate pushdown.</strong> If a row group&#8217;s maximum value for <code>event_date</code> falls below your filter&#8217;s lower bound, the entire row group is skipped without being read.</li></ul>



<p class="wp-block-paragraph">Two later additions refine this. The page index stores per-page minimum and maximum values in a structure near the footer, so a reader doing a selective scan can locate matching pages without walking every page header, which previously meant pulling most of the column off disk anyway. Bloom filters cover the case statistics cannot help with at all: a high-cardinality column such as a user ID, where every row group&#8217;s min and max span the whole value range and pruning by range is useless. A bloom filter can say that a value is definitely not present in a row group. DuckDB even exposes <code>parquet_bloom_probe</code> so you can check which row groups a given value would eliminate.</p>



<p class="wp-block-paragraph">Encoding does the rest of the work. Because a column chunk holds values of a single type, dictionary and run-length encoding apply before any general-purpose compressor starts. Low-cardinality columns such as country codes or status flags compress extremely well. UUIDs and free text do not, and writers often disable dictionary encoding for them. This is why compression ratios quoted in blog posts are close to meaningless for your data: the ratio is a property of your cardinality, not of the format.</p>



<h3 class="wp-block-heading">Where Parquet does not win</h3>



<ul class="wp-block-list"><li><strong>Point lookups.</strong> Fetching one complete row by ID means touching every column chunk. A row format reads a single contiguous block instead.</li><li><strong>Row-level updates.</strong> Parquet files are immutable. Changing one row means rewriting the file, which is precisely the problem Iceberg and Delta Lake exist to solve on top of it.</li><li><strong>Streaming writes.</strong> The schema lives in the footer, so a writer has to finish the file before it is readable. Small-batch writers produce small files, which carries its own tax.</li><li><strong>Tiny datasets.</strong> Below a few megabytes, footer and row group overhead is not worth paying.</li><li><strong>Human inspection.</strong> It is binary. You need tooling, and someone will eventually ask you for a CSV export anyway.</li></ul>



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



<h2 class="wp-block-heading">Where the savings quietly disappear</h2>



<p class="wp-block-paragraph">Every one of these has cost someone a migration that looked excellent in the design document.</p>



<ul class="wp-block-list"><li><strong>Small files.</strong> Each file costs a metadata read, an object storage request, and reader setup. Thousands of 2 MB Parquet files will lose to a handful of large CSVs. The commonly cited target is roughly 128 MB to 1 GB per file, and you need a compaction step to get there if your writer emits micro-batches.</li><li><strong>One enormous row group.</strong> Parallelism in most engines is per row group. A file containing a single row group is processed by a single thread no matter how many cores you have. Check this rather than assuming the writer got it right.</li><li><strong>Select-everything queries in the reporting layer.</strong> Column pruning cannot help a query that asks for all columns. This is the one from the opening paragraph, and it is usually a BI tool doing it rather than a person.</li><li><strong>No partitioning.</strong> Format reduces bytes per row. Partitioning reduces how many rows are considered at all. The two multiply, and partitioning is normally the larger lever. Converting without partitioning leaves most of the money on the table.</li><li><strong>Gzipped text.</strong> AWS documents that Parquet and ORC are always splittable, because they compress sections independently and carry metadata pointing at those sections, while most text compression formats are not. Bzip2 and LZO are splittable; gzip is not. One 5 GB gzipped CSV cannot be divided among workers, so it is read start to finish by one reader. The compression made storage cheaper and the query slower.</li><li><strong>Uppercase file extensions.</strong> A small operational trap worth knowing: Athena determines the compression type of CSV and JSON data from the file extension, does not recognise uppercase extensions such as <code>.GZ</code>, and treats a file with no extension as uncompressed plain text.</li></ul>



<h2 class="wp-block-heading">How to model the change before you migrate</h2>



<p class="wp-block-paragraph">Do not migrate on faith. This takes an afternoon and answers the question properly.</p>



<ol class="wp-block-list"><li><strong>Find out what you scan today.</strong> Every Athena query execution carries a <code>DataScannedInBytes</code> statistic. Pull it for a representative window and rank queries by bytes scanned. Almost always a handful of them are most of the bill, and those are the only ones worth optimising.</li><li><strong>Convert one day of data, not all of it.</strong> A single partition is enough to measure with.</li><li><strong>Look inside the resulting file</strong> before trusting it. Row group count, row group size, and compression codec decide whether the file behaves.</li><li><strong>Re-run the same queries</strong> against the converted partition and compare bytes scanned. Measured, not estimated.</li><li><strong>Price the write side.</strong> Conversion is not free. Glue jobs, EMR time, and Lambda invocations all cost money, and if a dataset is queried twice a month the conversion may never pay back.</li><li><strong>Check who else reads the files.</strong> One downstream consumer that only speaks CSV turns a clean migration into a dual-write pipeline.</li></ol>



<p class="wp-block-paragraph">For step three, DuckDB is the fastest way to inspect a file without standing up a cluster. It runs happily on a laptop or a small VPS from a provider like Contabo or InterServer, which is usually all the compute this kind of analysis needs:</p>



<pre class="wp-block-code"><code>-- one row per column chunk: row groups, sizes, codec
SELECT row_group_id, row_group_num_rows, row_group_bytes,
       path_in_schema, compression
FROM parquet_metadata('events.parquet')
LIMIT 20;

-- convert a sample and control the row group size
COPY (SELECT * FROM 'events.json')
TO 'events.parquet'
(FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 1000000);</code></pre>



<p class="wp-block-paragraph">The <code>parquet_metadata</code> function reads the footer and returns a row per column chunk, so you can see straight away whether your writer produced one row group or two hundred, and which codec it used. If you would rather stay in Python, <code>pyarrow.parquet.ParquetFile</code> exposes the same footer through its <code>metadata</code> attribute, including per-column statistics and whether a bloom filter offset is present.</p>



<p class="wp-block-paragraph">If the data already lives in S3 and you want the engine you are already paying for to do the conversion, Athena&#8217;s CTAS handles it in one statement. The AWS documentation lists GZIP and SNAPPY as the compression options for Parquet output here, with GZIP as the default:</p>



<pre class="wp-block-code"><code>CREATE TABLE events_parquet
WITH (
  format = 'PARQUET',
  write_compression = 'SNAPPY',
  external_location = 's3://your-bucket/events-parquet/',
  partitioned_by = ARRAY['event_date']
) AS
SELECT event_id, user_id, event_type, amount, event_date
FROM events_json;</code></pre>



<p class="wp-block-paragraph">Three things to know before running it. Partition columns have to come last in the SELECT list. CTAS refuses to write into a location that already contains data, so a re-run needs the prefix cleared first. And Athena has a write limit of 100 partitions per CTAS statement, which means a large backfill has to be chunked rather than fired off in one go.</p>



<h2 class="wp-block-heading">How I would decide</h2>



<ul class="wp-block-list"><li><strong>Keep the raw landing zone in whatever arrives.</strong> Usually JSON. Do not convert on ingest and discard the original; you will want it the first time a schema assumption turns out to be wrong.</li><li><strong>Convert once, at the point where data becomes queryable.</strong> Partitioned Parquet for anything a dashboard or an analyst touches repeatedly.</li><li><strong>Partition before you optimise the format.</strong> If there is budget for exactly one change, partition on the column your queries actually filter on.</li><li><strong>Choose the codec by read frequency.</strong> Zstd where scanned bytes are the bill and the data is read often, Snappy where decompression speed matters more, gzip only when a consumer demands it.</li><li><strong>Leave small CSVs alone.</strong> Reference tables, seed data, config. Converting a 4 MB lookup file to Parquet is busywork.</li><li><strong>Watch the scan bill, not the storage bill.</strong> Storage is cents. Scans are dollars. Athena workgroups can cap bytes scanned per query, and that limit is worth setting before someone discovers what a full table scan costs.</li></ul>



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



<h3 class="wp-block-heading">Is Parquet always cheaper than CSV?</h3>



<p class="wp-block-paragraph">On a bytes-scanned billing model, for analytical queries that touch a subset of columns, almost always. It is not cheaper for point lookups, for very small datasets, for row-level updates, or when the query selects every column and filters on nothing. It also costs compute to produce, which matters when the data is rarely read.</p>



<h3 class="wp-block-heading">How much smaller is Parquet than CSV in practice?</h3>



<p class="wp-block-paragraph">It depends entirely on cardinality, and anyone quoting a single ratio is guessing about your data. In the DuckDB TPC-H comparison the Parquet file came out around five times smaller than the CSV. Columns with few distinct values do far better than that; UUID and free-text columns do far worse. Measure on one partition of your own data instead of trusting a headline number.</p>



<h3 class="wp-block-heading">Does compressing my JSON or CSV give me the same savings?</h3>



<p class="wp-block-paragraph">Partially. On Athena you are billed for bytes scanned before decompression, so compression does reduce the bill. What it cannot do is let the engine skip columns or row groups, and if you reach for gzip you lose splittability, which can make queries slower even as they get cheaper. Compression and columnar layout solve different halves of the problem.</p>



<h3 class="wp-block-heading">Should I use Parquet or ORC?</h3>



<p class="wp-block-paragraph">For most teams that difference is much smaller than the difference between either of them and CSV. Parquet has wider support across engines and languages, which is usually the deciding factor. ORC has a long history in Hive-centric stacks and remains a reasonable choice if that is where you already live. Both are splittable and both support predicate pushdown.</p>



<h3 class="wp-block-heading">Why did my costs not drop after converting to Parquet?</h3>



<p class="wp-block-paragraph">The usual suspects, in the order I would check them: the table is not partitioned, the queries select all columns, the files are too small, or the writer produced one enormous row group. Pull <code>DataScannedInBytes</code> for the same query before and after. If the number barely moved, the engine was not able to skip anything, and the problem is the query or the layout rather than the format.</p>



<h3 class="wp-block-heading">Is JSON still worth keeping now that Parquet has a Variant type?</h3>



<p class="wp-block-paragraph">As a raw landing format and a transport format, yes. As the thing analysts query directly, the case is weaker than it used to be. Variant support is still spreading across engines, so confirm your query engine handles it before designing around it rather than assuming it is available everywhere.</p>



<h3 class="wp-block-heading">What file size should I aim for?</h3>



<p class="wp-block-paragraph">Roughly 128 MB to 1 GB per file is the widely used guidance, with row groups sized so a file holds several of them rather than one. Below that range you pay per-file overhead on every query; above it you can starve parallelism if the row group layout is wrong.</p>



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



<p class="wp-block-paragraph">The <strong>Parquet vs JSON vs CSV</strong> question is not really about the formats. It is about how much of your data the engine is permitted to ignore. Parquet hands an engine the most opportunities to skip work, JSON gives it almost none, and CSV sits between the two while making every reader re-guess the schema.</p>



<p class="wp-block-paragraph">An opportunity to skip work is not the same as skipping it, though. If the query asks for every column, if the table is not partitioned, if the files are too small or the row groups too large, you have paid the conversion cost and bought a smaller file with the same bill attached. Measure bytes scanned before and after on a single partition. That one number tells you the truth in an afternoon, and it beats any comparison table you will read, including this one.</p>



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



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



<p class="wp-block-paragraph">Most format work starts as a cost problem and ends as a layout problem. Things I can help with:</p>



<ul class="wp-block-list"><li>Auditing which queries actually drive your Athena or BigQuery bill, and whether a format change would move them at all</li><li>Designing the partition scheme and file sizing before the conversion, so the migration is worth running</li><li>Building the CSV or JSON to partitioned Parquet conversion as a Glue, Spark, or DuckDB job, including compaction for small-file pipelines</li><li>Diagnosing conversions that did not pay off: row group layout, dictionary encoding, splittability, gzip traps</li><li>Setting scan limits, workgroup controls, and alerting so one bad query cannot produce a surprise invoice</li><li>Retrofitting Iceberg or Delta Lake where the real requirement is row-level updates rather than a different file format</li></ul>



<p class="wp-block-paragraph">If you want a concrete opinion rather than a general one, send me a query&#8217;s execution statistics along with the table schema and a listing of one partition, and I will tell you where your bytes are going.</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/parquet-vs-json-vs-csv-cost-performance-2/">Parquet vs JSON vs CSV: Where the Money Actually Goes</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Zoho Mail Not Receiving Email? A DNS Troubleshooting Guide</title>
		<link>https://john-nessime.com/blog/technical-guides/zoho-mail-not-receiving-email/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 31 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Networking]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[dig]]></category>
		<category><![CDATA[DKIM]]></category>
		<category><![CDATA[DMARC]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[DNS Cache]]></category>
		<category><![CDATA[DNS Records]]></category>
		<category><![CDATA[DNS Troubleshooting]]></category>
		<category><![CDATA[DNSSEC]]></category>
		<category><![CDATA[Domain Configuration]]></category>
		<category><![CDATA[Email Authentication]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[MX Records]]></category>
		<category><![CDATA[Nameservers]]></category>
		<category><![CDATA[SERVFAIL]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[SPF]]></category>
		<category><![CDATA[TTL]]></category>
		<category><![CDATA[Zoho]]></category>
		<category><![CDATA[Zoho Mail]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=344</guid>

					<description><![CDATA[<p>Sending works, nothing comes in, and no bounce shows up anywhere. That asymmetry tells you almost everything: inbound mail is decided by MX records, not by SPF. This guide walks the inbound path in the order a sending server actually walks it, groups the real causes into five DNS failure families, and gives you the dig commands to prove which one you are looking at.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/zoho-mail-not-receiving-email/">Zoho Mail Not Receiving Email? A DNS Troubleshooting Guide</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 ticket almost always reads the same way. &#8220;We can send email fine, but nothing is coming in.&#8221; Someone has been waiting two days on a signed contract that never arrived. No bounce landed in anyone&#8217;s inbox. No error appeared anywhere in the Zoho interface. The mail just went somewhere else.</p>



<p class="wp-block-paragraph">That asymmetry, sending works and receiving doesn&#8217;t, is the single most useful clue in the whole problem. It narrows the search enormously. Yet most guides on Zoho Mail not receiving email open by telling you to check your SPF record, which has nothing whatsoever to do with inbound delivery. People lose a full day tuning an SPF string while their MX records quietly point at a data center that has never heard of their domain.</p>



<p class="wp-block-paragraph">This guide walks the inbound path in the order a sending mail server actually walks it, groups the real causes by failure family, and gives you the exact commands to prove which one you&#8217;re looking at. Everything here applies whether your DNS lives at Cloudflare, Namecheap, GoDaddy, Route 53, or a cPanel box at InterServer or Contabo.</p>



<h2 class="wp-block-heading">Sending and receiving are two separate systems</h2>



<p class="wp-block-paragraph">This is worth internalizing before you touch a single DNS record, because it eliminates most of the internet&#8217;s advice on this topic in one move.</p>



<p class="wp-block-paragraph">When you send from Zoho, your client authenticates against Zoho&#8217;s outbound SMTP server with a username and password. Your domain&#8217;s DNS plays no part in that handshake. SPF, DKIM and DMARC records exist so that the <em>recipient&#8217;s</em> server can decide whether to trust mail claiming to be from you. They are judgements other people make about your outbound mail.</p>



<p class="wp-block-paragraph">When someone sends <em>to</em> you, none of that matters. Their server looks up your MX records, connects to whatever hostname it finds, and hands the message over. Your SPF record is never consulted. Your DKIM key is never consulted.</p>



<p class="wp-block-paragraph">So if outbound works and inbound doesn&#8217;t, the fault is in the MX lookup, the connection that follows it, or Zoho&#8217;s decision about what to do with the message once it arrives. Not SPF. There is one narrow exception, covered later, where Zoho&#8217;s own inbound spam checks reject mail because the <em>sender&#8217;s</em> SPF fails. That is a different record on a different domain.</p>



<h2 class="wp-block-heading">The inbound path, in the order it actually happens</h2>



<p class="wp-block-paragraph">Six things happen between someone hitting send and a message appearing in your Zoho inbox:</p>



<ol class="wp-block-list">
<li>The sending server splits the recipient address and takes the domain part.</li>



<li>It asks DNS for the MX records of that domain, following delegation from the root down to whichever nameservers the registry says are authoritative for you.</li>



<li>It sorts the results by preference number and takes the lowest one first.</li>



<li>It resolves that hostname to an address record.</li>



<li>It opens an SMTP connection and offers the message with MAIL FROM and RCPT TO.</li>



<li>The receiving server decides whether that recipient exists in its organization and where to file the message.</li>
</ol>



<p class="wp-block-paragraph">Steps two through four are pure DNS. Step five is network. Step six is Zoho configuration. Diagnosing this well is mostly a matter of finding out which step you&#8217;re stuck on instead of guessing.</p>



<h2 class="wp-block-heading">Five DNS failure families behind Zoho Mail not receiving email</h2>



<p class="wp-block-paragraph">Nearly every inbound failure I&#8217;ve seen falls into one of these. They present almost identically from the user&#8217;s chair, which is why guessing goes badly.</p>



<h3 class="wp-block-heading">1. You&#8217;re editing records at a provider that isn&#8217;t authoritative</h3>



<p class="wp-block-paragraph">This is the most common one and the most frustrating, because the records look perfect in the control panel you&#8217;re staring at. They&#8217;re just not the records the internet reads.</p>



<p class="wp-block-paragraph">It happens whenever the domain is registered in one place and the nameservers point somewhere else. You buy at Namecheap, delegate to Cloudflare, then add MX records back in the Namecheap DNS panel out of habit. Or a previous developer moved DNS to a hosting account you no longer log into. The registrar panel accepts your edits and stores them faithfully. Nobody queries them.</p>



<p class="wp-block-paragraph">Find out where authority actually sits:</p>



<pre class="wp-block-code"><code># Which nameservers does the parent zone delegate to?
dig +short NS example.com

# What do those nameservers say about your MX, directly?
dig MX example.com @ns1.yourprovider.com +norecurse

# Watch the full delegation chain from the root
dig +trace MX example.com</code></pre>



<p class="wp-block-paragraph">The <code>+trace</code> output is the one to read carefully. It shows each referral step, so you can see exactly which nameserver set the world is being pointed at. If those names don&#8217;t match the provider where you&#8217;ve been editing, you&#8217;ve found your problem and the fix takes thirty seconds.</p>



<h3 class="wp-block-heading">2. Correct-looking MX records for the wrong Zoho data center</h3>



<p class="wp-block-paragraph">This is the failure mode that stays invisible longest, and it&#8217;s the reason copying MX values off a random blog post is dangerous.</p>



<p class="wp-block-paragraph">Zoho runs regional data centers, and your account lives in exactly one of them. The mail hostnames differ by region: the international service uses <code>.com</code> hostnames, and the European and Indian deployments use their own regional equivalents. Your domain&#8217;s mailboxes exist only in the data center where your organization was created.</p>



<p class="wp-block-paragraph">Point your MX at the wrong region and everything looks healthy. The records resolve. The hostnames are genuine Zoho servers. A lookup tool will show green. But mail arrives at infrastructure that has no record of your domain, and senders get a rejection saying the address doesn&#8217;t exist, or nothing at all.</p>



<p class="wp-block-paragraph">For the international service, Zoho&#8217;s documentation specifies three records, in this order of preference:</p>



<pre class="wp-block-code"><code>Priority 10   mx.zoho.com
Priority 20   mx2.zoho.com
Priority 50   mx3.zoho.com</code></pre>



<p class="wp-block-paragraph">Do not take that as gospel for your account. The authoritative source is the DNS Mapping section of your own Zoho Mail Admin Console, which shows the exact hostnames for the data center your organization sits in. Read them there, copy them from there. If you&#8217;re unsure which region you&#8217;re on, the domain in the URL when you log into the admin console tells you.</p>



<p class="wp-block-paragraph">Verify what the world sees, not what your panel shows:</p>



<pre class="wp-block-code"><code>dig +short MX example.com

# Confirm the target hostname itself resolves
dig +short mx.zoho.com</code></pre>



<h3 class="wp-block-heading">3. Leftover MX records from whatever came before</h3>



<p class="wp-block-paragraph">MX preference is not a fallback list in the sense most people assume. A sending server tries the lowest number first, and if that server accepts the message, the transaction is over. It never touches the others.</p>



<p class="wp-block-paragraph">So a stale record from your old host sitting at priority 0 or 5 doesn&#8217;t compete with Zoho. It wins outright, every time. The mail is delivered successfully, to a mailbox nobody checks, on a server that may still be quietly accepting for your domain. The sender sees no error. You see nothing at all.</p>



<p class="wp-block-paragraph">This is also why partial migrations bite. Two providers both accepting mail for one domain means your messages split between them based on preference and retry timing, which looks like random message loss and is nearly impossible to reason about from the inside.</p>



<p class="wp-block-paragraph">The rule is simple: after cutover, the only MX records in the zone should be Zoho&#8217;s. Delete everything else, including records for subdomains you forgot about. Check the full answer rather than a truncated view:</p>



<pre class="wp-block-code"><code>dig MX example.com +noall +answer</code></pre>



<h3 class="wp-block-heading">4. A CNAME sitting where the MX records live</h3>



<p class="wp-block-paragraph">Two related problems here, and both are structural rather than typos.</p>



<p class="wp-block-paragraph">The first: an MX record must point to a hostname with an address record. Pointing an MX at a name that is itself a CNAME is forbidden by the DNS specifications, and Zoho documents this explicitly. Some resolvers cope, others don&#8217;t, and the ones that don&#8217;t will fail intermittently in ways that look like a network problem.</p>



<p class="wp-block-paragraph">The second: a CNAME at the zone apex. Classic DNS forbids a CNAME coexisting with any other record at the same name, and MX records at the apex are exactly that. Providers like Cloudflare work around this with CNAME flattening, which synthesizes an address record and does keep your MX records functional. Providers with a naive implementation may shadow or drop them. If someone added an apex CNAME to point the bare domain at a hosting platform or a site builder, that&#8217;s worth checking early.</p>



<pre class="wp-block-code"><code>dig CNAME example.com +short
dig A example.com +short</code></pre>



<p class="wp-block-paragraph">One thing that is <em>not</em> a problem, despite frequent claims otherwise: Cloudflare&#8217;s orange-cloud proxy setting. Proxying applies to HTTP traffic on address records. It has no effect on MX records, and toggling it grey will not fix inbound mail.</p>



<h3 class="wp-block-heading">5. DNSSEC left broken after a nameserver change</h3>



<p class="wp-block-paragraph">Less common, but spectacular when it happens, because it takes the entire domain down rather than just mail.</p>



<p class="wp-block-paragraph">If DNSSEC was enabled and the DS record at the registry no longer matches the signing keys at your current DNS provider, validating resolvers refuse to return any answer at all. They return SERVFAIL. Non-validating resolvers still work, which is why the site loads fine for you and mail from major providers, who mostly validate, vanishes.</p>



<p class="wp-block-paragraph">The test is a one-liner. <code>+cd</code> disables validation checking:</p>



<pre class="wp-block-code"><code># Fails with SERVFAIL if validation is broken
dig MX example.com

# Same query, validation bypassed
dig +cd MX example.com</code></pre>



<p class="wp-block-paragraph">If the second command returns your records and the first doesn&#8217;t, you have a DNSSEC mismatch. The fix is at your registrar: either update the DS record to match your current provider&#8217;s keys, or remove DNSSEC entirely, confirm mail flows, and re-enable it properly afterwards.</p>



<h2 class="wp-block-heading">When DNS is clean and mail still doesn&#8217;t arrive</h2>



<p class="wp-block-paragraph">If your MX records resolve correctly to the right data center and nothing else is in the zone, DNS has done its job. The remaining causes sit on either side of it.</p>



<h3 class="wp-block-heading">Your own server is intercepting the mail</h3>



<p class="wp-block-paragraph">This catches people running a site on cPanel or DirectAdmin, and it&#8217;s genuinely counterintuitive. Control panels have an email routing setting per domain, typically offering local, remote, or automatic delivery. If it&#8217;s set to local mail exchanger, the mail server on that box delivers messages for your domain into its own accounts and never performs an MX lookup.</p>



<p class="wp-block-paragraph">The effect is narrow but confusing: external senders reach Zoho normally, while anything sent from a script or a form <em>on that server</em> goes to a local mailbox instead. Contact form submissions disappear while ordinary email works. Set routing to remote mail exchanger on any host where the site lives but the mailboxes don&#8217;t.</p>



<h3 class="wp-block-heading">The address doesn&#8217;t exist in your Zoho organization</h3>



<p class="wp-block-paragraph">Zoho will only accept mail for addresses it knows about. A user account, an alias on a user, or a group. If someone hands out an address that was never created, Zoho rejects it and the sender gets an address-not-found bounce that they may not think to forward to you.</p>



<p class="wp-block-paragraph">Zoho supports a catch-all address for exactly this, and it&#8217;s worth setting one during a migration so misaddressed mail lands somewhere recoverable rather than bouncing into the void.</p>



<h3 class="wp-block-heading">Zoho accepted it and quarantined it</h3>



<p class="wp-block-paragraph">Under Security and Compliance in the Admin Console, Zoho lets you decide what happens to mail that fails SPF, DKIM, DMARC or DNSBL checks. The available actions include delivering it, quarantining it, or rejecting it outright, with or without a bounce.</p>



<p class="wp-block-paragraph">Set to reject-without-bounce, a sender whose own SPF is slightly wrong gets silently dropped, and neither party learns anything. This is the narrow SPF connection I mentioned earlier: it&#8217;s the sender&#8217;s SPF being judged, not yours. Check the incoming quarantine before concluding mail never arrived, and check the organization blocked list while you&#8217;re there, since a blocked domain or TLD produces exactly the same silence.</p>



<h3 class="wp-block-heading">The domain lapsed or lost verification</h3>



<p class="wp-block-paragraph">If a domain expires, its DNS stops resolving and every associated record becomes unreachable, which also invalidates the domain verification Zoho performed. Zoho&#8217;s documentation notes that after renewal you have to reconfigure MX records as part of reverification. Renewing the domain alone doesn&#8217;t restore mail.</p>



<h2 class="wp-block-heading">A troubleshooting sequence that ends in an answer</h2>



<p class="wp-block-paragraph">Work these in order. Each step either clears a layer or hands you the cause, so you never end up changing three things at once and losing track of which one worked.</p>



<ol class="wp-block-list">
<li><strong>Confirm delegation.</strong> Run <code>dig +short NS example.com</code> and check it matches the provider you&#8217;re editing. If not, stop here and fix that first.</li>



<li><strong>Read the MX records from an authoritative nameserver.</strong> Use <code>dig MX example.com @ns1.yourprovider.com</code> so you&#8217;re seeing the source of truth rather than a cached copy.</li>



<li><strong>Compare against the Admin Console.</strong> Open the DNS Mapping page in Zoho Mail and match hostnames character for character, including the regional suffix.</li>



<li><strong>Confirm nothing else is in the answer.</strong> Any non-Zoho MX record is a suspect, especially one with a lower preference number.</li>



<li><strong>Test resolution from a public resolver.</strong> Try <code>dig MX example.com @1.1.1.1</code> and <code>@8.8.8.8</code>. Differences between them mean you&#8217;re mid-propagation and waiting is the fix.</li>



<li><strong>Rule out DNSSEC.</strong> Compare <code>dig MX example.com</code> against <code>dig +cd MX example.com</code>.</li>



<li><strong>Prove the SMTP conversation.</strong> Send a real test and watch what the server says.</li>



<li><strong>Check quarantine and blocked lists</strong> in the Zoho Admin Console before concluding anything was lost.</li>
</ol>



<p class="wp-block-paragraph">For step seven, <code>swaks</code> is the cleanest tool. It speaks SMTP for you and prints the full exchange, so you see the actual response code rather than guessing from a bounce message:</p>



<pre class="wp-block-code"><code># Stop after RCPT TO so no message is actually delivered
swaks --to you@example.com --server mx.zoho.com --quit-after RCPT</code></pre>



<p class="wp-block-paragraph">A 250 after RCPT TO means Zoho recognises the address and would accept the mail. A 550 means it doesn&#8217;t know that recipient, which points straight at a missing mailbox or the wrong data center. A connection timeout usually isn&#8217;t Zoho at all: many residential and cloud providers block outbound port 25 by default, so run this from a machine you know has egress on 25. If you only have a laptop on a consumer connection, testing through a VPN endpoint that permits SMTP, or from a small VPS, avoids chasing a phantom.</p>



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



<ul class="wp-block-list">
<li><strong>Fixing SPF to solve an inbound problem.</strong> Your SPF record has no role in receiving mail. If you can send but not receive, it is not SPF.</li>



<li><strong>Copying MX values from a tutorial.</strong> Regional deployments have different hostnames. Use your own Admin Console every time.</li>



<li><strong>Adding Zoho&#8217;s records without removing the old ones.</strong> A lower preference number beats Zoho outright and nothing warns you.</li>



<li><strong>Trusting the DNS panel over a query.</strong> A saved record and a published record are different things. Query it.</li>



<li><strong>Cutting MX over before creating the mailboxes.</strong> Every address that doesn&#8217;t exist yet bounces, and those messages are gone.</li>



<li><strong>Blaming propagation on day three.</strong> Propagation is bounded by the TTL that was in effect before the change. If it&#8217;s been longer than that, something is genuinely wrong.</li>



<li><strong>Trailing dot confusion.</strong> Some panels want <code>mx.zoho.com.</code> with the dot, others append the zone themselves and turn <code>mx.zoho.com</code> into an unresolvable name. Always confirm with a query afterwards.</li>
</ul>



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



<ul class="wp-block-list">
<li><strong>Drop the TTL before you migrate.</strong> Lower the MX TTL to a few minutes a day ahead of cutover, do the change, then raise it back. This turns a rollback from an overnight wait into a coffee break.</li>



<li><strong>Create every mailbox, alias and group before touching MX.</strong> Zoho&#8217;s own guidance is to configure users first and change MX last.</li>



<li><strong>Set a catch-all during the transition.</strong> It converts &#8220;we lost an email&#8221; into &#8220;it&#8217;s in the catch-all mailbox&#8221;.</li>



<li><strong>Keep DNS in one place.</strong> One authoritative provider, one panel, no ambiguity about where records live.</li>



<li><strong>Monitor the MX record itself.</strong> An uptime or synthetic monitoring service that checks DNS answers, or a scheduled <code>dig</code> comparison in your own pipeline, catches an accidental zone edit before a client does.</li>



<li><strong>Enable DKIM and set DMARC after inbound is confirmed working.</strong> They matter for your outbound reputation. Doing them first just adds variables while you&#8217;re debugging.</li>



<li><strong>Keep a copy of the zone.</strong> Export it before major changes. Reconstructing a zone from memory during an outage is miserable.</li>
</ul>



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



<h3 class="wp-block-heading">Why can I send from Zoho Mail but not receive?</h3>



<p class="wp-block-paragraph">Because sending authenticates directly against Zoho&#8217;s outbound servers and doesn&#8217;t consult your domain&#8217;s DNS, while receiving depends entirely on your MX records. Working outbound with broken inbound is a strong signal that the problem is an MX record, a nameserver delegation issue, or a leftover record from a previous provider.</p>



<h3 class="wp-block-heading">How long should MX changes take to work?</h3>



<p class="wp-block-paragraph">Propagation is governed by the TTL that was set on the old record, not by any fixed schedule. If the previous TTL was one hour, resolvers holding a cached copy will refresh within an hour. If it was set to a day, expect a day. Nothing you do afterwards speeds up a cache that has already been populated.</p>



<h3 class="wp-block-heading">Do I need all three Zoho MX records?</h3>



<p class="wp-block-paragraph">Mail will flow with only the primary, but the backups exist so that senders have somewhere to deliver if the primary is unreachable. Without them, sending servers queue and retry, which delays delivery and eventually bounces. Add the full set your Admin Console lists.</p>



<h3 class="wp-block-heading">Does SPF affect whether I receive email?</h3>



<p class="wp-block-paragraph">Your own SPF record does not. It&#8217;s evaluated by servers receiving mail from you. The one indirect connection is that Zoho evaluates the <em>sender&#8217;s</em> SPF on incoming mail, and if your Admin Console is set to reject or quarantine on SPF failure, legitimate mail from badly configured senders can disappear.</p>



<h3 class="wp-block-heading">Why do lookup tools say my MX records are fine when mail still fails?</h3>



<p class="wp-block-paragraph">Lookup tools verify that records exist and resolve. They can&#8217;t know which Zoho data center your organization lives in, whether the mailbox has been created, or whether a message is sitting in quarantine. A green result rules out one layer, not all of them.</p>



<h3 class="wp-block-heading">Can I run Zoho Mail alongside another mail provider on one domain?</h3>



<p class="wp-block-paragraph">Not by simply listing both sets of MX records. That splits mail unpredictably. Zoho does offer routing arrangements designed for gradual migration, configured on the Zoho side rather than by stacking MX records in DNS. If you need a genuine split, use the supported mechanism.</p>



<h3 class="wp-block-heading">My contact form emails vanish but normal email works. Why?</h3>



<p class="wp-block-paragraph">Almost always the control panel email routing setting on the server hosting the site. If it&#8217;s on local mail exchanger, mail generated on that machine is delivered into a local account rather than sent out to Zoho. Switch it to remote.</p>



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



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



<p class="wp-block-paragraph">If you take one thing from this: when Zoho Mail is not receiving email but sending works, stop looking at authentication records. Inbound mail is decided by your MX records, and those records have to be published by the nameservers that are actually authoritative for your domain, pointing at the data center your Zoho organization lives in, with nothing else in the answer competing with them.</p>



<p class="wp-block-paragraph">Query, don&#8217;t assume. Read the answer from the authoritative nameserver rather than the panel, compare it against your own Admin Console instead of a tutorial, then work down through SMTP and Zoho&#8217;s own quarantine. Almost every case resolves in that order, and the ones that don&#8217;t are usually a mailbox that was never created in the first place.</p>



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



<h2 class="wp-block-heading">Need someone to untangle your mail DNS?</h2>



<p class="wp-block-paragraph">I work on DNS and mail delivery problems like this regularly, usually for people who have already tried three fixes and made things harder to reason about. Things I can help with:</p>



<ul class="wp-block-list">
<li>Diagnosing inbound mail failures end to end, from delegation through SMTP response codes to Zoho&#8217;s quarantine</li>



<li>Planning and executing a mail migration to Zoho with a TTL schedule and a rollback path that actually works</li>



<li>Auditing and cleaning up a zone that several people have edited over the years</li>



<li>Setting up SPF, DKIM and DMARC properly once inbound is stable, including staged DMARC enforcement</li>



<li>Fixing DNSSEC mismatches after a nameserver move without taking the domain down again</li>



<li>Adding DNS and mail-flow monitoring so the next zone edit doesn&#8217;t go unnoticed for a week</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>dig +trace MX yourdomain.com</code> and a rejection message from a sender, and I can usually tell you where it&#8217;s breaking before we&#8217;ve agreed on anything.</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/zoho-mail-not-receiving-email/">Zoho Mail Not Receiving Email? A DNS Troubleshooting Guide</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Tenant Isolation on AWS: Building a Multi-Tenant Workshop Platform That Doesn&#8217;t Leak</title>
		<link>https://john-nessime.com/blog/cloud-computing/tenant-isolation-aws-multi-tenant-saas/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 31 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[SaaS Engineering]]></category>
		<category><![CDATA[ABAC]]></category>
		<category><![CDATA[Amazon Aurora]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS STS]]></category>
		<category><![CDATA[Cognito]]></category>
		<category><![CDATA[Cost Allocation Tags]]></category>
		<category><![CDATA[DynamoDB]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Least Privilege]]></category>
		<category><![CDATA[Multi-Tenant]]></category>
		<category><![CDATA[Noisy Neighbor]]></category>
		<category><![CDATA[Pool Model]]></category>
		<category><![CDATA[Row-Level Security]]></category>
		<category><![CDATA[SaaS Architecture]]></category>
		<category><![CDATA[Session Policies]]></category>
		<category><![CDATA[Silo Model]]></category>
		<category><![CDATA[Tenant Isolation]]></category>
		<category><![CDATA[Tenant Onboarding]]></category>
		<category><![CDATA[Token Vending Machine]]></category>
		<category><![CDATA[Workshop Management]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=538</guid>

					<description><![CDATA[<p>A missing tenant filter doesn't throw an error, it returns a 200 with too many rows. This is how to build a multi-tenant workshop management platform on AWS where the isolation boundary sits below your application code: STS session tags feeding IAM conditions, DynamoDB leading keys, scoped S3 prefixes, forced PostgreSQL row-level security, and a control plane that verifies each new tenant is fenced before anyone logs in.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/tenant-isolation-aws-multi-tenant-saas/">Tenant Isolation on AWS: Building a Multi-Tenant Workshop Platform That Doesn&#8217;t Leak</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A ticket comes in from one of the workshops on the platform. A service advisor pulled the weekly job report and there&#8217;s a vehicle on it that never came through their door. Wrong registration, wrong customer, wrong shop.</p>



<p class="wp-block-paragraph">By the time that ticket lands, the leak already happened. You can patch the query in an hour. What you cannot do is tell the affected garage how many other reports were wrong, how long it had been wrong, or whether anyone downloaded a CSV. There&#8217;s no log that answers those questions, because nothing ever denied anything. The database happily returned the rows. The API happily serialised them.</p>



<p class="wp-block-paragraph">That&#8217;s the shape of the problem. Tenant isolation on AWS is not really about writing careful code. It&#8217;s about arranging things so that careless code fails loudly instead of quietly returning somebody else&#8217;s data.</p>



<p class="wp-block-paragraph">This post walks through building a multi-tenant workshop management platform on AWS with isolation baked in from the first commit: where tenant context comes from, how the boundary changes shape per storage service, what the control plane has to own, and how you prove any of it works. The example is a shop management system, with job cards, vehicle histories, parts inventory and technician timesheets, but the patterns apply to any vertical SaaS product where one customer&#8217;s records must never touch another&#8217;s.</p>



<h2 class="wp-block-heading">The failure mode that stays invisible</h2>



<p class="wp-block-paragraph">Most multi-tenant systems start with a <code>tenant_id</code> column and a convention: every query filters on it. That works right up until it doesn&#8217;t.</p>



<p class="wp-block-paragraph">The convention breaks in ordinary ways. Someone adds a reporting endpoint and copies a query from a script that ran as an admin. A new join pulls in a table nobody remembered to filter. An ORM lazy-loads a relationship and the filter lives on the parent, not the child. A background job that recalculates parts margins runs without any tenant in scope at all, because it processes everything.</p>



<p class="wp-block-paragraph">None of these throw. That&#8217;s the whole issue. A missing authorisation check produces a 403 you&#8217;ll notice in staging. A missing tenant filter produces a 200 with too many rows, and 200s don&#8217;t page anyone.</p>



<p class="wp-block-paragraph">The fix is not more discipline. It&#8217;s moving the filter somewhere the application cannot forget it: into IAM, into the database engine, or both. AWS makes this point directly in its own SaaS guidance, and it&#8217;s the right one. If your only defence against cross-tenant reads is that developers remember, you don&#8217;t have a defence, you have a habit.</p>



<h2 class="wp-block-heading">Pick the isolation model before you write code</h2>



<p class="wp-block-paragraph">There are three shapes, and they&#8217;re usually described as pool, silo and bridge.</p>



<ul class="wp-block-list">
<li><strong>Pool.</strong> Every tenant shares the same tables, buckets and compute. Cheapest to run, cheapest to deploy, and the model where isolation has to be enforced explicitly because nothing physical separates anyone.</li>



<li><strong>Silo.</strong> Each tenant gets dedicated resources: its own database, its own bucket, sometimes its own account. Isolation is close to free, operations are not. Migrations, deploys and monitoring all multiply by tenant count.</li>



<li><strong>Bridge.</strong> Mixed. Shared compute, dedicated storage, or pooled for the standard tier and siloed for the enterprise tier that asked hard questions in procurement.</li>
</ul>



<p class="wp-block-paragraph">For a workshop platform, most independent garages will be small, and pooling is the only sane starting point. The trap is treating that as permanent. Sooner or later a dealer group with forty sites will ask for a dedicated database, and if you have not left room for a per-tenant routing decision, retrofitting it means rewriting your data access layer.</p>



<p class="wp-block-paragraph">What I&#8217;d actually do: build pooled, but put the storage target behind a resolver from day one. A function that takes a tenant ID and returns a connection, a table name or a bucket prefix. When the first silo tenant arrives, you change the resolver, not four hundred call sites.</p>



<h2 class="wp-block-heading">Where tenant context comes from</h2>



<p class="wp-block-paragraph">This is the part people get subtly wrong, and it undermines everything downstream. The tenant identifier must come from the authenticated identity, never from the request. Not a header, not a query parameter, not a field in the JSON body. If a client can influence the tenant ID, your isolation model is decoration.</p>



<p class="wp-block-paragraph">In practice that means a custom claim in the token your identity provider issues. Amazon Cognito can carry a custom attribute for this, and so can any external IdP you federate with. The API layer reads the claim, and from that point the tenant is a fact about the caller rather than an input to the call.</p>



<p class="wp-block-paragraph">Then you push that fact down into AWS itself using a session tag. When your service assumes a role, it attaches the tenant as a tag on the session. Every subsequent AWS API call made with those credentials carries the tag in the request context, where IAM policies can reference it as <code>aws:PrincipalTag</code>.</p>



<pre class="wp-block-code"><code>import boto3

def scoped_session(tenant_id, role_arn):
    sts = boto3.client("sts")
    resp = sts.assume_role(
        RoleArn=role_arn,
        RoleSessionName=f"workshop-{tenant_id}",
        Tags=[{"Key": "TenantID", "Value": tenant_id}],
    )
    c = resp["Credentials"]
    return boto3.Session(
        aws_access_key_id=c["AccessKeyId"],
        aws_secret_access_key=c["SecretAccessKey"],
        aws_session_token=c["SessionToken"],
    )</code></pre>



<p class="wp-block-paragraph">Two things make this work, and both are easy to miss. The role&#8217;s trust policy has to allow <code>sts:TagSession</code> alongside the assume-role action, or the call fails. And the calling principal needs permission to pass that tag. Get either wrong and you&#8217;ll spend an afternoon reading an error that sounds like it&#8217;s about the role rather than the tag.</p>



<p class="wp-block-paragraph">One session tag, one role, one policy. That&#8217;s attribute-based access control, and it&#8217;s the reason ABAC scales where a role per tenant does not. Roles are a finite resource in an AWS account. Tags are not.</p>



<h2 class="wp-block-heading">Tenant isolation on AWS changes shape per service</h2>



<p class="wp-block-paragraph">There is no single isolation control. Each service exposes a different lever, and you have to learn every one your architecture touches.</p>



<h3 class="wp-block-heading">DynamoDB: the partition key is the boundary</h3>



<p class="wp-block-paragraph">If job cards live in DynamoDB with the tenant ID as the partition key, IAM can pin every read and write to that key. The condition key is <code>dynamodb:LeadingKeys</code>.</p>



<pre class="wp-block-code"><code>{
  "Effect": "Allow",
  "Action": [
    "dynamodb:GetItem",
    "dynamodb:PutItem",
    "dynamodb:UpdateItem",
    "dynamodb:Query"
  ],
  "Resource": "arn:aws:dynamodb:REGION:ACCOUNT:table/JobCards",
  "Condition": {
    "ForAllValues:StringEquals": {
      "dynamodb:LeadingKeys": ["${aws:PrincipalTag/TenantID}"]
    }
  }
}</code></pre>



<p class="wp-block-paragraph">Now a Query that omits the tenant partition key doesn&#8217;t return other tenants&#8217; job cards. It gets denied. That&#8217;s the behaviour you want: loud, logged, and impossible to miss in CloudTrail.</p>



<p class="wp-block-paragraph">Two caveats worth knowing before you commit. Scan operations don&#8217;t have a leading key to constrain, so granting <code>dynamodb:Scan</code> alongside this condition undermines the whole arrangement. And global secondary indexes have their own key structure, so an index whose partition key isn&#8217;t the tenant needs separate thought. Design the access patterns so that no query ever needs to look across tenants, and this stops being a problem.</p>



<h3 class="wp-block-heading">S3: two surfaces, not one</h3>



<p class="wp-block-paragraph">Vehicle photos, inspection PDFs and signed job sheets go to S3 under a per-tenant prefix. The mistake is scoping only the object actions and leaving <code>ListBucket</code> open, which lets a tenant enumerate every other garage&#8217;s filenames even without reading them. Filenames leak plenty: customer names, registration plates, invoice numbers.</p>



<p class="wp-block-paragraph">Object actions are scoped through the resource ARN. Listing is scoped through the <code>s3:prefix</code> request condition. You need both statements, because they protect different operations.</p>



<pre class="wp-block-code"><code>[
  {
    "Effect": "Allow",
    "Action": "s3:ListBucket",
    "Resource": "arn:aws:s3:::workshop-tenant-files",
    "Condition": {
      "StringLike": {
        "s3:prefix": ["${aws:PrincipalTag/TenantID}/*"]
      }
    }
  },
  {
    "Effect": "Allow",
    "Action": ["s3:GetObject", "s3:PutObject"],
    "Resource": "arn:aws:s3:::workshop-tenant-files/${aws:PrincipalTag/TenantID}/*"
  }
]</code></pre>



<p class="wp-block-paragraph">The policy variable in the resource ARN is doing real work there. One policy, every tenant, no template rendering at request time.</p>



<h3 class="wp-block-heading">Aurora PostgreSQL: row-level security, and the trap in it</h3>



<p class="wp-block-paragraph">Relational data is where most workshop platforms actually live, because job cards, parts lines and labour rates are relational. IAM cannot see inside a table, so the boundary moves into PostgreSQL itself via row-level security.</p>



<pre class="wp-block-code"><code>ALTER TABLE job_cards ENABLE ROW LEVEL SECURITY;
ALTER TABLE job_cards FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON job_cards
  USING (tenant_id = current_setting('app.tenant_id', true))
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true));</code></pre>



<p class="wp-block-paragraph"><code>USING</code> controls which rows are visible to reads, updates and deletes. <code>WITH CHECK</code> controls what can be written. Without the second clause, a tenant can read only its own rows but insert a row stamped with someone else&#8217;s tenant ID. The first protects the read path, the second protects the write path, and you want both.</p>



<p class="wp-block-paragraph">Now the trap, and it&#8217;s the one that gives teams false confidence. PostgreSQL superusers and roles carrying <code>BYPASSRLS</code> ignore row security entirely, and by default so does the table owner. If your application connects as the same role that ran the migrations, your policies are not in effect and your tests pass anyway. That&#8217;s why <code>FORCE ROW LEVEL SECURITY</code> is in the snippet above, and why the application should connect as a dedicated non-owner, non-superuser role.</p>



<p class="wp-block-paragraph">The second trap is connection reuse. Set the tenant with <code>SET LOCAL</code> inside a transaction, or with <code>set_config</code> using the transaction-local flag. Plain <code>SET</code> persists for the life of the connection, and a pooled connection outlives the request. That&#8217;s how one garage&#8217;s context ends up serving the next garage&#8217;s query.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Test row-level security as the application role, against a pooled connection, with at least two tenants in the table. Testing as the owner tells you nothing.</p>
</blockquote>



<h2 class="wp-block-heading">Compute isolation is a different question</h2>



<p class="wp-block-paragraph">Data isolation stops one tenant reading another&#8217;s records. It does nothing about one tenant consuming everyone&#8217;s capacity.</p>



<p class="wp-block-paragraph">Workshop platforms have a specific version of this. End of month, every garage runs its invoicing and MOT reminder batch at roughly the same time. A dealer group importing three years of service history will happily saturate a shared worker pool while forty independents wait for their job cards to load.</p>



<p class="wp-block-paragraph">Levers worth knowing, roughly in order of how much they cost you:</p>



<ul class="wp-block-list">
<li>API Gateway usage plans, keyed per tenant, to cap request rates at the edge before anything expensive runs.</li>



<li>Separate queues, or at minimum separate consumer concurrency, for bulk imports versus interactive requests. Bulk work should never share a lane with a screen someone is waiting on.</li>



<li>Reserved or provisioned concurrency on the Lambda functions serving interactive paths, so a batch surge cannot starve them.</li>



<li>Dedicated compute for premium tenants. This is silo by another name, and it&#8217;s the honest answer when a customer&#8217;s load profile genuinely doesn&#8217;t fit the pool.</li>
</ul>



<p class="wp-block-paragraph">Be honest with yourself about which problem you&#8217;re solving. Throttling is not isolation. It limits blast radius, it doesn&#8217;t create a boundary.</p>



<h2 class="wp-block-heading">What the control plane has to own</h2>



<p class="wp-block-paragraph">Separate the control plane from the application plane early. The control plane manages tenants; the application plane serves them. Mixing the two is how a bug in the onboarding flow ends up with production credentials.</p>



<p class="wp-block-paragraph">Onboarding a new workshop is a sequence, and it should be a single idempotent workflow rather than a checklist someone follows:</p>



<ol class="wp-block-list">
<li>Generate a non-guessable tenant identifier. Lowercase alphanumeric, no customer name in it, because it will end up inside resource ARNs and key prefixes.</li>



<li>Write the tenant record: tier, isolation model, storage target, status.</li>



<li>Provision identity. User pool group or IdP mapping, with the tenant claim wired in.</li>



<li>Provision storage. For a pooled tenant that&#8217;s a prefix and a seeded row. For a siloed one it&#8217;s real infrastructure, which is why this step must be asynchronous.</li>



<li>Apply tags used for cost allocation and reporting.</li>



<li>Run a verification step that proves the new tenant can reach its own data and cannot reach a canary tenant&#8217;s data.</li>
</ol>



<p class="wp-block-paragraph">Step six is the one teams skip. It&#8217;s also the only step that tells you the previous five worked.</p>



<p class="wp-block-paragraph">On per-tenant cost: activated cost allocation tags attribute anything that is a distinct tagged resource, which covers siloed tenants nicely. Pooled resources will not split by themselves, because a shared table doesn&#8217;t know which garage caused which read. If you need per-tenant margin, emit consumption as a metric dimension from the application: request counts, storage bytes, document pages processed. Tools like Vantage or CloudZero can allocate shared spend afterwards, but only from the signal you produce. Nothing recovers attribution you never recorded.</p>



<p class="wp-block-paragraph">The AWS SaaS Builder Toolkit is worth a look here. It codifies control plane concepts as CDK constructs and will save you real time on onboarding plumbing. Read its own guidance first: the project describes itself as sample code, and expects you to review security fit before production. That&#8217;s a fair description rather than a warning label, but treat it as a starting point, not a finished platform.</p>



<h2 class="wp-block-heading">Proving isolation actually holds</h2>



<p class="wp-block-paragraph">An isolation model you haven&#8217;t tried to break is a design document, not a control.</p>



<p class="wp-block-paragraph">The tests that earn their keep are negative ones. Seed two tenants. Authenticate as the first. Then deliberately do the wrong thing: request the second tenant&#8217;s job card by ID, list the second tenant&#8217;s S3 prefix, run a query with the session variable set to the wrong value. Every one of those should fail, and the test should assert on the failure.</p>



<ul class="wp-block-list">
<li>Run the cross-tenant suite on every pull request, not nightly. It&#8217;s the regression that matters most and the one most likely to be introduced by an innocent refactor.</li>



<li>Use the IAM policy simulator to check a policy change before it ships, particularly when someone widens a resource ARN.</li>



<li>Turn on IAM Access Analyzer so external access grants surface without anyone having to notice them.</li>



<li>Audit the database on a schedule: tables with RLS enabled but no policy attached, tables missing FORCE, application roles that have quietly acquired ownership or BYPASSRLS during an incident.</li>



<li>Alert on AccessDenied volume per tenant. A spike is either a bug you introduced or someone probing, and both are worth knowing about.</li>
</ul>



<p class="wp-block-paragraph">Keep a permanent canary tenant in every environment, including production, holding nothing but synthetic data. Every negative test targets it. It costs almost nothing and it means your isolation tests never need real customer records.</p>



<h2 class="wp-block-heading">Troubleshooting the failures you&#8217;ll actually hit</h2>



<h3 class="wp-block-heading">AccessDenied on a policy that looks correct</h3>



<p class="wp-block-paragraph">Almost always the session tag isn&#8217;t present. If the tag is missing from the request context, the condition can&#8217;t match and the statement doesn&#8217;t apply. Call <code>sts:GetCallerIdentity</code> with the scoped credentials and confirm you&#8217;re on the assumed role you think you are, then check CloudTrail for the AssumeRole event and look at whether the tag was actually passed. Tag keys are case sensitive, and <code>TenantId</code> is not <code>TenantID</code>.</p>



<h3 class="wp-block-heading">Queries return zero rows instead of the right rows</h3>



<p class="wp-block-paragraph">Classic RLS symptom. The session variable is unset, so the policy predicate compares against null and nothing matches. Check <code>current_setting</code> inside the same transaction as the query, not in a separate connection from your SQL client. Empty results are the safe failure here, which is exactly why they&#8217;re easy to misread as a data problem.</p>



<h3 class="wp-block-heading">One tenant intermittently sees another&#8217;s data</h3>



<p class="wp-block-paragraph">Intermittent means state reuse. Look at connection pooling first, then at any per-request context stored in thread-local or async-local storage that isn&#8217;t reset when the request finishes. If you&#8217;re using a proxy in front of the database, understand how it handles session state, because some proxies pin a connection to a client once session-level settings are detected, which changes the behaviour you tested against.</p>



<h3 class="wp-block-heading">Isolation works in the API but not in background jobs</h3>



<p class="wp-block-paragraph">Because the job has no request, so it has no token, so it has no tenant. Whatever runs asynchronously needs the tenant carried on the message and a session assumed per tenant when the work is processed. A worker that loops over all tenants with admin credentials is the single most common place isolation quietly stops applying.</p>



<h3 class="wp-block-heading">Session tags don&#8217;t survive a second AssumeRole</h3>



<p class="wp-block-paragraph">Session tags are not automatically carried forward when you chain roles unless they were marked transitive. If your architecture hops through more than one role, this is where the tenant context evaporates. Flatten the chain if you can; if you can&#8217;t, mark the tag transitive deliberately and document why.</p>



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



<ul class="wp-block-list">
<li>Taking the tenant ID from a request header or body instead of the authenticated token.</li>



<li>Using the customer&#8217;s name or a sequential integer as the tenant identifier, then putting it in bucket prefixes where it becomes both guessable and enumerable.</li>



<li>Scoping S3 object actions but leaving bucket listing wide open.</li>



<li>Running the application as the PostgreSQL table owner, so RLS is enabled and silently inert.</li>



<li>Writing a USING clause with no WITH CHECK, leaving the write path open.</li>



<li>Creating one IAM role per tenant and discovering the account ceiling somewhere around the point the business gets interesting.</li>



<li>Assuming cost allocation tags will attribute pooled spend. They won&#8217;t, and by the time you need the numbers the history is gone.</li>



<li>Testing isolation only through the UI, where the frontend is already sending the right tenant every time.</li>
</ul>



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



<ul class="wp-block-list">
<li>Derive tenant context from the token, propagate it as a session tag, and never let application code choose it.</li>



<li>Enforce the boundary at the layer below your code: IAM conditions for AWS resources, RLS for relational rows.</li>



<li>Put storage targets behind a resolver so moving a tenant from pool to silo is a configuration change.</li>



<li>Make onboarding one idempotent workflow that ends in a verification step.</li>



<li>Emit tenant as a dimension on logs and metrics from the start, so cost and performance questions stay answerable.</li>



<li>Keep a canary tenant and run cross-tenant negative tests in CI on every change.</li>



<li>Put a WAF or edge layer such as Cloudflare in front of tenant subdomains, and keep tenant routing decisions out of the origin application where you can.</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">Is a shared database with a tenant_id column ever acceptable?</h3>



<p class="wp-block-paragraph">Yes, provided the column is enforced by the engine rather than by convention. A shared table with row-level security, forced, queried by a non-owner role, is a real boundary. A shared table where every query is expected to include the filter is not. The schema is the same; the guarantee is completely different.</p>



<h3 class="wp-block-heading">ABAC or dynamically generated IAM policies?</h3>



<p class="wp-block-paragraph">ABAC for most cases. One role, one policy, tenant supplied per session, and nothing grows as you add customers. Dynamic policy generation, sometimes called a token vending machine, earns its place when a single policy genuinely cannot express the rule, such as when per-tenant resource names have to be injected rather than a key prefix. The cost is that you now own the correctness of a policy generator, plus per-request latency, and session policies have a size ceiling you can hit.</p>



<h3 class="wp-block-heading">Should each tenant get its own AWS account?</h3>



<p class="wp-block-paragraph">It&#8217;s the strongest boundary available and the most expensive to operate. For a workshop platform serving independent garages it&#8217;s overkill. It becomes reasonable when a customer&#8217;s contract, regulator or data residency requirement makes shared infrastructure a non-starter, and at that point you&#8217;re pricing it as a premium tier rather than absorbing it.</p>



<h3 class="wp-block-heading">How do I handle a user who works at two workshops?</h3>



<p class="wp-block-paragraph">Model it as one identity with multiple tenant memberships and an explicit active tenant per session, rather than a token carrying a list. The active tenant becomes the session tag. Switching workshops means a new session, which is exactly the behaviour you want because it makes the switch visible in your audit trail.</p>



<h3 class="wp-block-heading">Does row-level security hurt query performance?</h3>



<p class="wp-block-paragraph">It adds a predicate the planner has to satisfy, so the answer depends on your indexes. Index the tenant column, and index it as the leading column of composite indexes that support your common filters. Compare plans as the application role before and after enabling policies, because a plan captured as the owner may not reflect what the application actually runs.</p>



<h3 class="wp-block-heading">Where does application-level authorisation fit?</h3>



<p class="wp-block-paragraph">Alongside, not instead. Tenant isolation answers &#8220;which organisation&#8217;s data is this&#8221;. Authorisation answers &#8220;may this technician void an invoice&#8221;. Different questions, different layers. A policy engine such as Amazon Verified Permissions handles the second cleanly, and keeping them separate stops role logic creeping into your isolation boundary.</p>



<h3 class="wp-block-heading">Can I retrofit isolation onto a platform that already has tenants?</h3>



<p class="wp-block-paragraph">You can, and it&#8217;s tedious rather than impossible. Enforce at the database first, because that&#8217;s where the leak actually happens: table by table, FORCE enabled, application moved to a non-owner role. Then move the tenant ID out of request payloads and into the token. Add IAM conditions last, since they&#8217;re the least likely source of a live leak. Expect to find at least one background job with no tenant scope at all.</p>



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



<p class="wp-block-paragraph">Tenant isolation on AWS works when the boundary sits below your application code, in a layer that denies rather than trusts. IAM conditions on session tags for AWS resources, forced row-level security for relational data, and a control plane that verifies a new tenant is properly fenced before anyone logs in.</p>



<p class="wp-block-paragraph">Retrofitting that onto a running multi-tenant platform is possible but grim, because you&#8217;re doing it while real workshops have real data in the system. Doing it on day one costs a week. That&#8217;s the entire trade, and it&#8217;s not a close call.</p>



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



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



<p class="wp-block-paragraph">Most of the isolation problems I see are not exotic. They&#8217;re a missing WITH CHECK clause, an application connecting as the table owner, or a background job nobody scoped. Things I can help with:</p>



<ul class="wp-block-list">
<li>Reviewing an existing multi-tenant design and finding where the boundary is enforced by convention rather than by the platform.</li>



<li>Implementing ABAC with STS session tags across DynamoDB, S3 and Aurora, including the trust policy wiring that trips people up.</li>



<li>Setting up PostgreSQL row-level security correctly, with forced policies, a dedicated application role and pooling that doesn&#8217;t leak session state.</li>



<li>Building a tenant onboarding workflow that provisions identity, storage and tagging idempotently and verifies itself.</li>



<li>Writing the cross-tenant negative test suite and wiring it into CI so isolation regressions fail the build.</li>



<li>Adding per-tenant usage metering so cost, performance and tier decisions rest on data instead of guesses.</li>
</ul>



<p class="wp-block-paragraph">Send me a policy document, an RLS definition or a CloudTrail AccessDenied event and I&#8217;ll tell you what it&#8217;s actually enforcing.</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/tenant-isolation-aws-multi-tenant-saas/">Tenant Isolation on AWS: Building a Multi-Tenant Workshop Platform That Doesn&#8217;t Leak</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
