<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alok Ranjan Daftuar</title>
    <description>The latest articles on DEV Community by Alok Ranjan Daftuar (@aloknecessary).</description>
    <link>https://dev.to/aloknecessary</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3791551%2F62fbfeb5-1fba-4e79-bc4b-780b7ce52748.jpg</url>
      <title>DEV Community: Alok Ranjan Daftuar</title>
      <link>https://dev.to/aloknecessary</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aloknecessary"/>
    <language>en</language>
    <item>
      <title>Queue vs Topic vs Log: Picking the Broker</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Mon, 02 Nov 2026 10:57:50 +0000</pubDate>
      <link>https://dev.to/aloknecessary/queue-vs-topic-vs-log-picking-the-broker-2o58</link>
      <guid>https://dev.to/aloknecessary/queue-vs-topic-vs-log-picking-the-broker-2o58</guid>
      <description>&lt;p&gt;Most broker decisions are made in a ten-minute conversation that ends with "we'll just use Kafka," or from the other side, "SQS is fine, it's cheap." Both sentences treat the broker as a throughput-and-price decision. It isn't. What you are actually choosing is a &lt;strong&gt;contract&lt;/strong&gt;: who owns a message after it is delivered, who tracks how far each consumer has read, and whether yesterday's events still exist tomorrow.&lt;/p&gt;

&lt;p&gt;Get the contract wrong and no tuning fixes it. A team that picks a queue and later needs a new service to bootstrap from the last 30 days of order events has nothing to replay. A team that picks Kafka for background jobs ends up owning partition planning, rebalances, and head-of-line blocking to get what a queue provides out of the box.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three primitives, three contracts
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;QUEUE (competing consumers)         TOPIC (fan-out)                    LOG (retained stream)

 producer ──▶ [ m3 m2 m1 ] ──┬▶ A    producer ──▶ topic ──┬▶ [sub-A] ▶ A   producer ──▶ [m1 m2 m3 m4 m5 …]
                             └▶ B                         ├▶ [sub-B] ▶ B                  ▲        ▲
   each message goes to ONE            each subscription gets                       consumer X      consumer Y
   consumer, then is deleted           its OWN copy                                 (offset 2)      (offset 5)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Queue&lt;/th&gt;
&lt;th&gt;Topic (pub/sub)&lt;/th&gt;
&lt;th&gt;Log&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delivery model&lt;/td&gt;
&lt;td&gt;Competing consumers: one handler per message&lt;/td&gt;
&lt;td&gt;Fan-out: every subscription gets a copy&lt;/td&gt;
&lt;td&gt;Every consumer group reads the whole stream independently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After consumption&lt;/td&gt;
&lt;td&gt;Deleted on ack&lt;/td&gt;
&lt;td&gt;Deleted per subscription on ack&lt;/td&gt;
&lt;td&gt;Retained until retention expires, regardless of reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read position owned by&lt;/td&gt;
&lt;td&gt;Broker (visibility timeout / lock)&lt;/td&gt;
&lt;td&gt;Broker, per subscription&lt;/td&gt;
&lt;td&gt;Consumer (offset)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replay&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, by rewinding offsets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure handling&lt;/td&gt;
&lt;td&gt;Per-message retry and DLQ&lt;/td&gt;
&lt;td&gt;Per-subscription retry and DLQ&lt;/td&gt;
&lt;td&gt;None built in: a failing message blocks its partition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is where teams get surprised. In a queue, message 7 failing does not stop message 8. In a log, the consumer's offset means "everything before this is done" — a message that keeps failing stalls its partition unless you build retry topics yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mapping to AWS and Azure
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;AWS&lt;/th&gt;
&lt;th&gt;Azure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Queue&lt;/td&gt;
&lt;td&gt;SQS Standard / FIFO&lt;/td&gt;
&lt;td&gt;Service Bus queues&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topic&lt;/td&gt;
&lt;td&gt;SNS, SNS FIFO&lt;/td&gt;
&lt;td&gt;Service Bus topics + subscriptions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event router&lt;/td&gt;
&lt;td&gt;EventBridge&lt;/td&gt;
&lt;td&gt;Event Grid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Log&lt;/td&gt;
&lt;td&gt;MSK, Kinesis Data Streams&lt;/td&gt;
&lt;td&gt;Event Hubs (Kafka endpoint)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three mapping details decide real designs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SNS and Service Bus topics are not equivalent.&lt;/strong&gt; On Azure, a topic subscription is a queue with its own lock, delivery count, and dead-letter sub-queue. On AWS, SNS pushes to a subscriber — the standard answer is SNS → SQS per consumer, which recreates what Service Bus gives natively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EventBridge and Event Grid are routers, not backbones.&lt;/strong&gt; Right for cloud/SaaS event reactions. Wrong for a high-volume internal event backbone where you need ordering and replay at a known cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition count is a design decision, not a tuning knob.&lt;/strong&gt; Kafka partitions can be increased but not reduced. Event Hubs Basic/Standard partition count is fixed at creation. Size it for how far back a recovering consumer might need to go.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern that covers most systems: topic fan-out into per-consumer queues
&lt;/h2&gt;

&lt;p&gt;When several services need the same event, publish once to a topic and give every consumer its own queue. Each consumer gets isolation, its own DLQ, and independent scaling.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS: SNS → SQS with filtering
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sqs_queue"&lt;/span&gt; &lt;span class="s2"&gt;"billing"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"billing-order-events"&lt;/span&gt;
  &lt;span class="nx"&gt;visibility_timeout_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="c1"&gt;# must exceed worst-case handler time&lt;/span&gt;
  &lt;span class="nx"&gt;redrive_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;deadLetterTargetArn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billing_dlq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;maxReceiveCount&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sns_topic_subscription"&lt;/span&gt; &lt;span class="s2"&gt;"billing"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;topic_arn&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqs"&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;raw_message_delivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# otherwise the body is an SNS JSON envelope&lt;/span&gt;
  &lt;span class="nx"&gt;filter_policy_scope&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MessageBody"&lt;/span&gt;
  &lt;span class="nx"&gt;filter_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"OrderPlaced"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"OrderCancelled"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-obvious decisions: &lt;code&gt;raw_message_delivery&lt;/code&gt; so consumers read your payload and not an SNS envelope, &lt;code&gt;filter_policy_scope = "MessageBody"&lt;/code&gt; so routing works on the payload, and a visibility timeout longer than your slowest handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure: Service Bus topic with a filtered subscription
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight bicep"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="n"&gt;billing&lt;/span&gt; &lt;span class="s1"&gt;'Microsoft.ServiceBus/namespaces/topics/subscriptions@2021-11-01'&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;
  &lt;span class="py"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'billing'&lt;/span&gt;
  &lt;span class="py"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;lockDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'PT2M'&lt;/span&gt;
    &lt;span class="py"&gt;maxDeliveryCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="py"&gt;deadLetteringOnMessageExpiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
  &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="pi"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="n"&gt;billingFilter&lt;/span&gt; &lt;span class="s1"&gt;'Microsoft.ServiceBus/namespaces/topics/subscriptions/rules@2021-11-01'&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;billing&lt;/span&gt;
  &lt;span class="py"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'order-lifecycle'&lt;/span&gt;
  &lt;span class="py"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;filterType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'SqlFilter'&lt;/span&gt;
    &lt;span class="py"&gt;sqlFilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="py"&gt;sqlExpression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'eventType IN (\'&lt;/span&gt;&lt;span class="n"&gt;OrderPlaced&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;', \'&lt;/span&gt;&lt;span class="n"&gt;OrderCancelled&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;')'&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Service Bus SQL filters evaluate message &lt;strong&gt;properties&lt;/strong&gt;, not the body — the publisher must set &lt;code&gt;eventType&lt;/code&gt; as an application property. A subscription without an explicit rule gets a catch-all &lt;code&gt;$Default&lt;/code&gt; rule; confirm it's not still attached after deployment or the subscription receives everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  When you need a log
&lt;/h2&gt;

&lt;p&gt;Reach for a log when retention is the requirement: new consumers must read history, projections must be rebuildable, or stream processing needs ordered per-key state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kafka-topics.sh &lt;span class="nt"&gt;--bootstrap-server&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BOOTSTRAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--create&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--topic&lt;/span&gt; order-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--partitions&lt;/span&gt; 12 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--replication-factor&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--config&lt;/span&gt; min.insync.replicas&lt;span class="o"&gt;=&lt;/span&gt;2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--config&lt;/span&gt; retention.ms&lt;span class="o"&gt;=&lt;/span&gt;604800000   &lt;span class="c"&gt;# 7 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--partitions 12&lt;/code&gt; is your parallelism ceiling per consumer group: a thirteenth consumer sits idle. &lt;code&gt;replication-factor 3&lt;/code&gt; with &lt;code&gt;min.insync.replicas=2&lt;/code&gt; and &lt;code&gt;acks=all&lt;/code&gt; tolerates one broker loss without losing acknowledged writes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Four questions, in order
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;If yes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Must a new or recovering consumer read events from before it existed?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Log&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Do several independent services need the same event, each at their own pace?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Topic fanning into per-consumer queues&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Is this work distribution: one handler per message, per-message retry, a DLQ?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Queue&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Is it a discrete state change from a cloud or SaaS source triggering reactions?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;EventBridge / Event Grid&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Answers can combine. A common production shape is a log as the system-of-record backbone with queues downstream for consumers that need per-message retry and a DLQ — so one poison message cannot stall a partition.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;This summary covers the three primitives, the AWS/Azure service mapping, and the decision framework. The full article includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete Terraform for the SNS → SQS fan-out pattern including the IAM queue policy scoped to the topic ARN&lt;/li&gt;
&lt;li&gt;Full Bicep for the Service Bus topic with duplicate detection and filtered subscription&lt;/li&gt;
&lt;li&gt;The "exactly-once" section — what SQS FIFO deduplication, Service Bus duplicate detection, and Kafka transactions actually cover (and don't cover)&lt;/li&gt;
&lt;li&gt;The archival consumer pattern: how to attach cheap replay insurance from day one when you choose a queue or topic&lt;/li&gt;
&lt;li&gt;What the wrong choice looks like in production — queue chosen when a log was needed, log chosen when a queue was needed, topic without a durable buffer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/queue-vs-topic-vs-log-picking-the-broker/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=queue-vs-topic-vs-log-picking-the-broker" rel="noopener noreferrer"&gt;Queue vs Topic vs Log: Picking the Broker — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>aws</category>
      <category>kafka</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Multi-Tenant MCP: Isolation Patterns That Hold Up</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:26:36 +0000</pubDate>
      <link>https://dev.to/aloknecessary/multi-tenant-mcp-isolation-patterns-that-hold-up-40lh</link>
      <guid>https://dev.to/aloknecessary/multi-tenant-mcp-isolation-patterns-that-hold-up-40lh</guid>
      <description>&lt;p&gt;Every multi-tenant MCP deployment starts with the same instinct: put a &lt;code&gt;tenant_id&lt;/code&gt; in the JWT, scope every tool call to it, and call the isolation problem solved. It isn't. The gap between "scoped by claim" and "actually isolated" is exactly where these deployments fail in production — not in testing, where a single code path is exercised, but in production, where a new tool handler forgets the filter, a cache key drops the tenant dimension, or an agent legitimately holds a valid token but aggregates a result that happened to include data from a neighboring tenant.&lt;/p&gt;

&lt;p&gt;This article is about what real isolation looks like across three architectural patterns, which layer each one enforces, and how to test the boundary rather than just build it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three patterns, not one
&lt;/h2&gt;

&lt;p&gt;Multi-tenant MCP deployments settle into three recognizable shapes. The pattern a platform needs isn't primarily a technical decision — it's downstream of what its most demanding tenant's compliance posture actually requires.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Isolation layer&lt;/th&gt;
&lt;th&gt;Cost profile&lt;/th&gt;
&lt;th&gt;Fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shared instance, claim-scoped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application/data layer&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;td&gt;Self-serve SaaS, low-sensitivity data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dedicated compute per tenant&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Infrastructure layer (namespace, network, compute)&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;td&gt;SOC 2, HIPAA, FedRAMP tenants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hybrid — shared compute, per-tenant credential vault&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Credential layer + claim-scoping&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;Most production SaaS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Forcing both a self-serve SaaS product and its first enterprise customer onto pattern 1 purely for engineering simplicity is the decision that surfaces as a failed security questionnaire months later, at exactly the point in a sales cycle where it's most expensive to revisit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 1: shared instance, claim-scoped
&lt;/h2&gt;

&lt;p&gt;The critical detail the "put tenant_id in the JWT" version of this pattern usually gets wrong is treating claim extraction as authorization. It's only identification — the actual boundary has to be enforced at the data layer, on every path a request can take:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleToolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;McpRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;McpResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// tenantId passed explicitly — no ambient global a new handler could forget&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;toolRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool_not_found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// not 403 — don't confirm existence&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;credentials&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;credentialStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;executeTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than they look. Returning &lt;code&gt;tool_not_found&lt;/code&gt; rather than &lt;code&gt;403&lt;/code&gt; avoids confirming to a caller that a tool exists at all — the difference between an isolation boundary and one that leaks information about what's on the other side. And passing &lt;code&gt;tenantId&lt;/code&gt; explicitly through every function call, rather than reading it from a request-scoped global, is deliberate: an ambient "current tenant" is exactly the kind of implicit state that a new tool handler, added six months later by someone who didn't write this code, can forget to consult.&lt;/p&gt;

&lt;p&gt;This pattern also depends on the MCP spec's OAuth 2.1 + PKCE baseline and Resource Indicators (RFC 8707) — which closes the specific failure mode where a token issued for one MCP server gets replayed against a different one. None of that is optional hardening; it's the baseline the pattern depends on to be a security boundary at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 2: dedicated compute per tenant
&lt;/h2&gt;

&lt;p&gt;For tenants under SOC 2, HIPAA, or FedRAMP obligations, claim-scoping inside a shared process is rarely sufficient — the compliance requirement is usually for a demonstrable infrastructure-level boundary. This is where namespace-and-labeling conventions stop being hygiene and start being the actual isolation mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny-cross-tenant&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-tenant-acme&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;tenant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;tenant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acme&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;k8s-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-dns&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NetworkPolicy&lt;/code&gt; is the actual isolation enforcement: pods in &lt;code&gt;mcp-tenant-acme&lt;/code&gt; can only reach other pods labeled with the same tenant, plus an explicit DNS exception. A &lt;code&gt;ResourceQuota&lt;/code&gt; alongside it addresses the noisy-neighbor problem — without it, one tenant's traffic spike can starve every other tenant even inside dedicated namespaces. The pattern only holds if quota, network policy, and RBAC are applied consistently across every tenant namespace — which is exactly the kind of thing worth generating from a template rather than hand-writing per tenant as the customer count grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sandboxing the execution layer
&lt;/h2&gt;

&lt;p&gt;Namespace-level isolation stops one tenant's pod from reaching another over the network. It does nothing about a single pod's own execution boundary. Standard containers share the host kernel — a &lt;code&gt;RuntimeClass&lt;/code&gt; backed by gVisor interposes a user-space kernel so agent-executed code never touches host syscalls directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RuntimeClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gvisor&lt;/span&gt;
&lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;runsc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth being specific about what gVisor buys versus what it doesn't. A &lt;code&gt;runsc&lt;/code&gt;-backed pod still shares the Kubernetes control plane and — absent the &lt;code&gt;NetworkPolicy&lt;/code&gt; above — the node's network stack. The syscall interception layer closes one specific class of escape (a kernel exploit reached through a container's syscall surface); it says nothing about a request that legitimately reaches the pod over the network and abuses an application-level bug once inside. Pattern 2's &lt;code&gt;NetworkPolicy&lt;/code&gt; and this section's &lt;code&gt;RuntimeClass&lt;/code&gt; are answering two different questions — who can reach this pod, and what can code running inside this pod reach past the kernel — and a deployment that only implements one of them has a boundary with a specific, known hole in it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hybrid most production platforms actually run
&lt;/h2&gt;

&lt;p&gt;In practice, the pattern that shows up most in production is shared compute for the MCP server fleet combined with a per-tenant credential vault — so a compromise of the shared fleet doesn't automatically compromise every tenant's downstream credentials at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCredential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Credential&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vaultPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`tenants/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/tools/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;vaultClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vaultPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// short-lived, tenant-scoped token&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The credential vault (HashiCorp Vault, AWS Secrets Manager with per-tenant IAM policies, or Azure Key Vault with per-tenant access policies) becomes the actual blast-radius boundary. A bug in the shared server's tenant-scoping logic is a serious incident, but it isn't automatically a credential-exfiltration incident for every tenant simultaneously — because the credentials were never loaded into a shared, unscoped in-memory store to begin with. This is the pattern worth defaulting to unless a specific tenant's compliance requirement forces pattern 2's dedicated compute.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing the boundary, not just building it
&lt;/h2&gt;

&lt;p&gt;An isolation pattern that hasn't been tested for cross-tenant leakage is a claim, not a guarantee:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN_A&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_token_for_tenant acme&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN_B&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_token_for_tenant globex&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;TOOL_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://mcp.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN_A&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Method: tools/call"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Name: create-record"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create-record"}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.result.id'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Tenant B attempting to read Tenant A's record must fail&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://mcp.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN_B&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Method: tools/call"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Name: get-record"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;jsonrpc&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;2.0&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:2,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;method&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;tools/call&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;params&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;get-record&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;arguments&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$TOOL_ID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}}}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'.error.code == "tool_not_found" or .error.code == "not_found"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wiring this into CI means an accidental regression in tenant-scoping logic — a new tool handler that forgets the filter, a cache key that drops the tenant dimension — fails a build instead of surfacing as a support ticket from a customer who noticed data that wasn't theirs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;This summary covers the three patterns and their core mechanics. The full article includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why &lt;code&gt;tool_not_found&lt;/code&gt; vs &lt;code&gt;403&lt;/code&gt; matters for information leakage at the isolation boundary&lt;/li&gt;
&lt;li&gt;The full &lt;code&gt;ResourceQuota&lt;/code&gt; + &lt;code&gt;NetworkPolicy&lt;/code&gt; + RBAC combination for dedicated-compute tenants&lt;/li&gt;
&lt;li&gt;When to use gVisor vs Kata Containers vs Firecracker, and how to match sandboxing depth to actual tool risk&lt;/li&gt;
&lt;li&gt;The OAuth 2.1 + Resource Indicators baseline that pattern 1 depends on to be a real security boundary&lt;/li&gt;
&lt;li&gt;Why the hybrid pattern captures most of pattern 2's blast-radius protection at close to pattern 1's cost&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/multi-tenant-mcp-isolation-patterns/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=multi-tenant-mcp-isolation-patterns" rel="noopener noreferrer"&gt;Multi-Tenant MCP: Isolation Patterns That Hold Up — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>kubernetes</category>
      <category>security</category>
      <category>cloud</category>
    </item>
    <item>
      <title>DNS in AWS: Route 53 Resolver, Private Hosted Zones, and Hybrid Resolution</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Tue, 27 Oct 2026 07:44:09 +0000</pubDate>
      <link>https://dev.to/aloknecessary/dns-in-aws-route-53-resolver-private-hosted-zones-and-hybrid-resolution-5goe</link>
      <guid>https://dev.to/aloknecessary/dns-in-aws-route-53-resolver-private-hosted-zones-and-hybrid-resolution-5goe</guid>
      <description>&lt;p&gt;Three earlier articles in the AWS Network Architecture series each hit the same wall and moved past it: VPC Endpoints needed &lt;code&gt;private_dns_enabled&lt;/code&gt; to work transparently. VPC Peering noted that DNS resolution across a peering connection needs its own explicit setting. Multi-Account Networking flagged that Transit Gateway spoke accounts don't automatically share DNS resolution scope. This article is the piece all three were pointing toward.&lt;/p&gt;

&lt;p&gt;Connectivity at the routing layer and name resolution are two separate problems. It's entirely possible — common, even — to have flawless routing and completely broken DNS, or vice versa. Both layers have to be built correctly, and this article covers the DNS side end to end.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Default: What a VPC Resolves Out of the Box
&lt;/h2&gt;

&lt;p&gt;Every VPC gets a built-in DNS resolver at its base CIDR plus two — for &lt;code&gt;10.0.0.0/16&lt;/code&gt;, that's &lt;code&gt;10.0.0.2&lt;/code&gt;. It's provided automatically, no provisioning required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From inside an EC2 instance&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/resolv.conf
&lt;span class="c"&gt;# nameserver 10.0.0.2&lt;/span&gt;

nslookup s3.ap-south-1.amazonaws.com 10.0.0.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This default resolver handles three things without any additional configuration: public DNS, AWS service endpoints, and any private hosted zone associated with that specific VPC. That third one is where most of this article lives — it works cleanly for a single VPC, and requires deliberate extra infrastructure the moment more than one VPC needs to share the same private namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Private Hosted Zones: DNS Scoped to a VPC
&lt;/h2&gt;

&lt;p&gt;A Route 53 private hosted zone only resolves for VPCs it's explicitly associated with — it never appears in public DNS. This is the mechanism behind &lt;code&gt;private_dns_enabled&lt;/code&gt; on Interface endpoints: AWS creates a private hosted zone, associates it with the endpoint's VPC, and that association makes the service's standard public name resolve to the endpoint's private IP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_zone"&lt;/span&gt; &lt;span class="s2"&gt;"internal"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"internal.example.com"&lt;/span&gt;

  &lt;span class="nx"&gt;vpc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"app_db"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db.internal.example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"A"&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_db_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An instance outside the associated VPC gets NXDOMAIN — the zone simply doesn't exist from that vantage point, by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Account Zone Association
&lt;/h2&gt;

&lt;p&gt;A private hosted zone can be associated with VPCs in other AWS accounts. Cross-account association requires an explicit authorization step from the zone-owning account first — a deliberate security gate so no account can silently attach to another account's internal DNS namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Zone-owning account: authorize the spoke VPC&lt;/span&gt;
aws route53 create-vpc-association-authorization &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hosted-zone-id&lt;/span&gt; Z0ABC123DEF &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vpc&lt;/span&gt; &lt;span class="nv"&gt;VPCRegion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ap-south-1,VPCId&lt;span class="o"&gt;=&lt;/span&gt;vpc-0spokeA1234

&lt;span class="c"&gt;# Spoke account: complete the association&lt;/span&gt;
aws route53 associate-vpc-with-hosted-zone &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--hosted-zone-id&lt;/span&gt; Z0ABC123DEF &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vpc&lt;/span&gt; &lt;span class="nv"&gt;VPCRegion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ap-south-1,VPCId&lt;span class="o"&gt;=&lt;/span&gt;vpc-0spokeA1234
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This two-step authorize-then-associate pattern fills the DNS gap that Transit Gateway routing alone doesn't provide in a centralized multi-account model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 53 Resolver: For Anything Crossing Outside AWS
&lt;/h2&gt;

&lt;p&gt;Zone association solves internal-name resolution within AWS. It doesn't solve resolving AWS-side private DNS names from outside AWS, or resolving on-prem/Azure DNS names from inside a VPC. Both directions need Route 53 Resolver endpoints.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;inbound endpoint&lt;/strong&gt; gives external resolvers a private IP inside the VPC to send queries to. An &lt;strong&gt;outbound endpoint&lt;/strong&gt; with forwarding rules lets VPC-side resolvers forward specific domain queries out to an external DNS server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_resolver_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"outbound"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"resolver-outbound-to-azure"&lt;/span&gt;
  &lt;span class="nx"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"OUTBOUND"&lt;/span&gt;

  &lt;span class="nx"&gt;security_group_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;ip_address&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;subnet_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shared_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;ip_address&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;subnet_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shared_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_resolver_rule"&lt;/span&gt; &lt;span class="s2"&gt;"to_azure"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;domain_name&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"corp.internal.azure.example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;rule_type&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FORWARD"&lt;/span&gt;
  &lt;span class="nx"&gt;resolver_endpoint_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_resolver_endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;target_ip&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.20.0.10"&lt;/span&gt;  &lt;span class="c1"&gt;# Azure private DNS resolver IP&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_resolver_rule_association"&lt;/span&gt; &lt;span class="s2"&gt;"spoke_a"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;resolver_rule_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_resolver_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to_azure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc-0spokeA1234"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule association step matters as much as the rule itself — a Resolver rule that exists but isn't associated with a given VPC has no effect there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sharing Resolver Rules Across Accounts via RAM
&lt;/h2&gt;

&lt;p&gt;Resolver rules follow the same centralized-ownership pattern as Transit Gateway sharing: a network account creates and owns the outbound endpoint and forwarding rules, then shares them to spoke accounts via AWS Resource Access Manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ram_resource_share"&lt;/span&gt; &lt;span class="s2"&gt;"resolver_rules"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ram-resolver-rules-to-azure"&lt;/span&gt;
  &lt;span class="nx"&gt;allow_external_principals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ram_principal_association"&lt;/span&gt; &lt;span class="s2"&gt;"org_share"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_organizations_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;resource_share_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ram_resource_share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolver_rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sharing to the whole Organization ARN means a newly created spoke account can associate with the existing forwarding rule immediately, without a manual RAM step per new account.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failure Checklist
&lt;/h2&gt;

&lt;p&gt;DNS-across-boundaries failures share a common symptom — a name that resolves fine from one vantage point and fails from another. Check in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the private hosted zone actually associated with the VPC making the query?&lt;/li&gt;
&lt;li&gt;For cross-account association, was the authorization step completed on the zone-owning side?&lt;/li&gt;
&lt;li&gt;For Resolver rules, is the rule associated with the specific VPC — not just created?&lt;/li&gt;
&lt;li&gt;Is a custom DHCP option set overriding the VPC's default resolver?&lt;/li&gt;
&lt;li&gt;For hybrid resolution, is the underlying VPN/network path actually up?&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;The full post covers additional depth on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The complete side-by-side mechanism summary table (default resolver vs. zone association vs. Resolver endpoints)&lt;/li&gt;
&lt;li&gt;Resolver query logging with Terraform — turning DNS debugging from guesswork into a direct log lookup&lt;/li&gt;
&lt;li&gt;Why DNSSEC is orthogonal to everything in this article and what it's actually for&lt;/li&gt;
&lt;li&gt;How this connects to the hybrid Azure/AWS migration scenario from the Migration Playbook series — both inbound and outbound resolution directions needed simultaneously during a phased migration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/aws-route53-dns-resolution/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=aws-route53-dns-resolution" rel="noopener noreferrer"&gt;DNS in AWS: Route 53 Resolver, Private Hosted Zones, and Hybrid Resolution — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>networking</category>
      <category>devops</category>
      <category>dns</category>
    </item>
    <item>
      <title>CIDR Planning for Azure-to-AWS Migrations: Reconciling Two Address Spaces That Were Never Designed Together</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Fri, 23 Oct 2026 05:55:50 +0000</pubDate>
      <link>https://dev.to/aloknecessary/cidr-planning-for-azure-to-aws-migrations-reconciling-two-address-spaces-that-were-never-designed-20mm</link>
      <guid>https://dev.to/aloknecessary/cidr-planning-for-azure-to-aws-migrations-reconciling-two-address-spaces-that-were-never-designed-20mm</guid>
      <description>&lt;p&gt;CIDR planning for a migration is not the same exercise as CIDR planning for a greenfield VPC. In a greenfield environment you pick a clean &lt;code&gt;/16&lt;/code&gt;, carve it into tiers, and you're done. In a migration, the target VPC has to coexist with — and eventually replace — a source Azure VNet that was sized and addressed by someone solving a completely different problem, possibly years earlier. The Azure side's CIDR is a fixed constraint, not a design choice. Everything downstream depends on knowing it accurately before a single AWS resource is provisioned.&lt;/p&gt;

&lt;p&gt;This post is part of the AWS Migration Playbook series. It picks up a thread left open in the MGN architecture article — the staging subnet's positioning relative to the rest of the target VPC — and connects it to the broader CIDR reconciliation problem that trips up nearly every real migration at some point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Audit the Azure Side First
&lt;/h2&gt;

&lt;p&gt;The most common cause of migration CIDR problems is starting the AWS-side design before fully knowing the Azure-side address space. Run this across every VNet in scope — not just the one hosting the current wave's servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network vnet list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'[].{Name:name, CIDR:addressSpace.addressPrefixes}'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
az network vnet subnet list &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &amp;lt;vnet-name&amp;gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'[].{Name:name, CIDR:addressPrefix}'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the organization has grown through acquisition or multiple teams provisioning Azure independently, don't assume VNet CIDRs were coordinated centrally. A VNet audited late — after the target VPC's early tiers are already locked in — might turn out to need a range that only fits in whatever's left over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Overlap Matters Even Without Hybrid Connectivity
&lt;/h2&gt;

&lt;p&gt;The "we're leaving Azure behind anyway" assumption holds right up until one of these scenarios appears — and they appear in nearly every real migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rollback plans&lt;/strong&gt; that require the Azure source and AWS target to be reachable from each other during a validation window, via VPN or equivalent — unusable if CIDRs overlap, full stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phased migrations&lt;/strong&gt; where some services remain on Azure while others move to AWS, and the two environments need to communicate during the transition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Future merges&lt;/strong&gt; with another AWS Organization or cloud footprint that happens to use the same range — unlikely to be on anyone's mind during the current migration, but a recurring cause of expensive re-IP projects years later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat overlap avoidance as a hard requirement for every migration, not just ones with a known hybrid connectivity need at planning time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Reconciliation Table
&lt;/h2&gt;

&lt;p&gt;Once every Azure VNet's CIDR is documented, build an explicit table of every range in use across both clouds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;CIDR&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Azure VNet: prod-eastus&lt;/td&gt;
&lt;td&gt;10.20.0.0/16&lt;/td&gt;
&lt;td&gt;Source, in use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure VNet: dev-eastus&lt;/td&gt;
&lt;td&gt;10.21.0.0/16&lt;/td&gt;
&lt;td&gt;Source, in use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure VNet: shared-services&lt;/td&gt;
&lt;td&gt;10.30.0.0/16&lt;/td&gt;
&lt;td&gt;Source, staying on Azure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS VPC: migration-target (proposed)&lt;/td&gt;
&lt;td&gt;10.0.0.0/16&lt;/td&gt;
&lt;td&gt;Proposed — needs validation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The proposed AWS range only gets marked valid once it's confirmed non-overlapping against every row — including VNets that aren't being migrated but will remain on Azure indefinitely. A range colliding with a permanently-staying VNet is just as blocking as one colliding with a VNet you're actively migrating away from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MGN Staging Subnet Deserves Its Own Allocation
&lt;/h2&gt;

&lt;p&gt;The staging subnet MGN uses to receive replicated data should be a deliberate, separate row in the tier table — not squeezed into whatever subnet has spare capacity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.0.0/16   vpc-migration-target
├── 10.0.0.0/24      public
├── 10.0.16.0/20     private/app
├── 10.0.64.0/24     data
├── 10.0.100.0/24    mgn-staging          ← new, migration-specific
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two reasons this matters: staging infrastructure is &lt;strong&gt;temporary&lt;/strong&gt; — present only for active migration waves and safe to fully decommission after cutover. Mixing it into a permanent tier makes cleanup harder to reason about. And a dedicated subnet makes it trivial to apply a tightly scoped security group and NACL specific to replication traffic, rather than inheriting the public tier's broader ruleset.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;/24&lt;/code&gt; (251 usable addresses) comfortably supports a wave of dozens of concurrent source servers. For unusually large waves, size explicitly against planned concurrent server count rather than assuming &lt;code&gt;/24&lt;/code&gt; is always sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve Headroom for Later Waves
&lt;/h2&gt;

&lt;p&gt;For multi-wave migrations, the target VPC needs headroom for VNets that haven't been audited yet at the time the first wave's CIDR plan is locked in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.0.0/16   vpc-migration-target
├── 10.0.0.0/24      public                    (wave 1)
├── 10.0.16.0/20     private/app — wave 1
├── 10.0.64.0/24     data — wave 1
├── 10.0.100.0/24    mgn-staging
├── 10.0.128.0/18    RESERVED — wave 2 (finance VNet, not yet audited)
├── 10.0.192.0/18    RESERVED — future waves
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reserve entire unused CIDR ranges, not just gaps between adjacent subnets. "Whatever's left over" is a much worse position to negotiate from than reserving room deliberately from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS Coexistence During the Transition Window
&lt;/h2&gt;

&lt;p&gt;CIDR reconciliation solves whether IP ranges can coexist — it doesn't solve whether names resolve correctly across both environments. For phased migrations where wave-one AWS services need to be reachable by private DNS name from wave-two Azure services, you need deliberate DNS forwarding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Azure Private DNS zones need conditional forwarding rules pointing at a resolver reachable in the AWS VPC (Route 53 Resolver inbound endpoint or the VPC's &lt;code&gt;.2&lt;/code&gt; resolver address)&lt;/li&gt;
&lt;li&gt;The reverse direction needs equivalent configuration on the AWS side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skipping this doesn't fail loudly — it fails as intermittent-looking connectivity issues that look like application bugs rather than a missing DNS forwarding rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate Before the First Server Replicates
&lt;/h2&gt;

&lt;p&gt;Before starting MGN replication for a wave's first server, validate empirically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From an Azure VM — confirm no undocumented overlap or peering&lt;/span&gt;
ip route show | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"10&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;(0|20|21|30)&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# From AWS — confirm staging subnet CIDR matches the plan&lt;/span&gt;
aws ec2 describe-subnets &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=tag:Name,Values=mgn-staging"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Subnets[*].{Subnet:SubnetId, CIDR:CidrBlock}'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches two recurring classes of mistake cheaply: a route on the Azure side hinting at a connection the audit missed, and a staging subnet provisioned with a different CIDR than the plan called for — easy to happen when a subnet is created manually or from a stale template.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;The full post covers additional depth on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The complete pre-wave CIDR checklist — five explicit gates to confirm before locking in a target VPC CIDR for any new wave&lt;/li&gt;
&lt;li&gt;Tier-to-tier mapping between Azure VNet subnets and AWS VPC tiers, including the &lt;code&gt;AzureBastionSubnet&lt;/code&gt; decision&lt;/li&gt;
&lt;li&gt;How this connects to the kernel fix and serial console recovery incidents from earlier in the series — the same "should have caught this before the maintenance window" cost pattern&lt;/li&gt;
&lt;li&gt;Why the overlap problem compounds specifically in phased migrations vs. lift-and-shift&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/aws-migration-cidr-planning/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=aws-migration-cidr-planning" rel="noopener noreferrer"&gt;CIDR Planning for Azure-to-AWS Migrations — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>azure</category>
      <category>migration</category>
      <category>networking</category>
    </item>
    <item>
      <title>Drift, Self-Heal, and Failure Modes: When ArgoCD's Reconciliation Loop Fights Something Else</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:02:02 +0000</pubDate>
      <link>https://dev.to/aloknecessary/drift-self-heal-and-failure-modes-when-argocds-reconciliation-loop-fights-something-else-2plm</link>
      <guid>https://dev.to/aloknecessary/drift-self-heal-and-failure-modes-when-argocds-reconciliation-loop-fights-something-else-2plm</guid>
      <description>&lt;p&gt;Auto-sync and self-heal are usually presented as unambiguous wins. They are — right up until something else in the cluster has a legitimate reason to change the same resource ArgoCD thinks it owns exclusively. When that happens, the result isn't a clear error. It's a quiet oscillation that looks like ArgoCD misbehaving, when it's actually doing exactly what it was told.&lt;/p&gt;

&lt;p&gt;This article maps the three most common shapes of that collision, why each one happens, and the precise fix for each.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Self-Heal Actually Does
&lt;/h2&gt;

&lt;p&gt;ArgoCD's &lt;code&gt;OutOfSync&lt;/code&gt; status means live cluster state doesn't match what's declared in Git. With &lt;code&gt;selfHeal: true&lt;/code&gt;, ArgoCD doesn't just refuse to accept the drift — it actively overwrites it back to the Git-declared state on every reconciliation pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the right default for almost everything a Deployment touches directly. The failure modes below all share one shape: a resource ArgoCD considers itself the sole owner of is also, legitimately, being modified by something else that has no idea ArgoCD exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  HPA vs. Self-Heal: The Replica Count Fight
&lt;/h2&gt;

&lt;p&gt;This is the most common collision. A &lt;code&gt;HorizontalPodAutoscaler&lt;/code&gt; manages &lt;code&gt;spec.replicas&lt;/code&gt; dynamically. Git, in a naive GitOps setup, also declares &lt;code&gt;spec.replicas&lt;/code&gt;. The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Load spikes ──► HPA scales Deployment to 8 replicas
                        │
                        ▼
        ArgoCD reconciliation runs
                        │
                        ▼
        Live replicas (8) != Git-declared replicas (3)
                        │
                        ▼
        selfHeal reverts Deployment back to 3 replicas
                        │
                        ▼
        HPA scales back to 8
                        │
                        └──── repeats every reconciliation interval
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a 3-minute reconciliation interval during a traffic spike, this produces roughly ten full oscillation cycles over 30 minutes — the service spends a meaningful fraction of the spike window under-provisioned, and the sawtoothing replica count reads as ArgoCD being broken rather than working exactly as configured.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;ignoreDifferences&lt;/code&gt; scoped to that field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ignoreDifferences&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
      &lt;span class="na"&gt;jsonPointers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/spec/replicas&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ArgoCD stops treating &lt;code&gt;spec.replicas&lt;/code&gt; as part of what it reconciles for this resource. The HPA becomes the sole owner of that field. Worth auditing across every HPA-managed Deployment in a fleet before &lt;code&gt;selfHeal: true&lt;/code&gt; is enabled fleet-wide — not reactively after the first incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sync Waves and Partial Failure
&lt;/h2&gt;

&lt;p&gt;Sync-wave ordering is a &lt;em&gt;sequencing&lt;/em&gt; guarantee, not a &lt;em&gt;transactional&lt;/em&gt; one. A wave-0 database migration Job that fails partway through doesn't roll back what wave-1 already applied — the Deployment's Pods may already be running against a schema mid-migration before ArgoCD retries wave 0.&lt;/p&gt;

&lt;p&gt;For anything where partial application genuinely can't be tolerated, a &lt;code&gt;PreSync&lt;/code&gt; hook with &lt;code&gt;hook-failure-policy: Abort&lt;/code&gt; is the safer pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/hook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PreSync&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/hook-failure-policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Abort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Abort&lt;/code&gt; stops the entire sync — including later waves — the moment the hook fails, rather than leaving later waves to apply against a database that's still mid-migration. Self-heal's retry behavior on a failed wave is useful for transient failures; it's the wrong tool when partial application has real consequences.&lt;/p&gt;




&lt;h2&gt;
  
  
  Operators Mutating ArgoCD-Owned Resources
&lt;/h2&gt;

&lt;p&gt;The third shape is structurally identical to the HPA case: a Kubernetes operator — a cert-manager controller, a service mesh sidecar injector, a mutating admission webhook — modifying a field on a resource ArgoCD also tracks. ArgoCD sees a live-vs-Git diff, self-heal reverts it, the operator re-mutates it, and the oscillation repeats.&lt;/p&gt;

&lt;p&gt;The fix is identical — &lt;code&gt;ignoreDifferences&lt;/code&gt; scoped to the contested field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ignoreDifferences&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
      &lt;span class="na"&gt;jsonPointers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/spec/containers/0/resources&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The harder part isn't the fix — it's noticing the collision exists. Unlike the HPA case, which announces itself through a visibly oscillating replica count, a webhook fight over a less visible field presents as intermittent &lt;code&gt;OutOfSync&lt;/code&gt; with no obvious pattern. When an Application shows unexplained intermittent drift, check &lt;code&gt;kubectl get events&lt;/code&gt; on the affected resource and the list of registered mutating webhooks before assuming the drift is a Git or ArgoCD problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision Framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Replica count oscillates on a fixed interval&lt;/td&gt;
&lt;td&gt;HPA fighting a Git-declared &lt;code&gt;spec.replicas&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ignoreDifferences&lt;/code&gt; on &lt;code&gt;/spec/replicas&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downstream Application fails with no clear cause after a transient upstream wave failure&lt;/td&gt;
&lt;td&gt;Sync-wave retry without transactional guarantees&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PreSync&lt;/code&gt; hook with &lt;code&gt;hook-failure-policy: Abort&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;OutOfSync&lt;/code&gt; appears and clears with no clear pattern on a specific field&lt;/td&gt;
&lt;td&gt;Mutating webhook or operator modifying a field ArgoCD tracks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ignoreDifferences&lt;/code&gt; scoped to the contested field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unclear which field is contested&lt;/td&gt;
&lt;td&gt;Diff noise&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;argocd app diff &amp;lt;app-name&amp;gt;&lt;/code&gt; against live state to identify the exact field before scoping &lt;code&gt;ignoreDifferences&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;The full article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The precise mechanism of &lt;code&gt;selfHeal: true&lt;/code&gt; and why it's correct by default&lt;/li&gt;
&lt;li&gt;The HPA oscillation failure at real numbers — 10 oscillation cycles over a 30-minute traffic spike, what it looks like on a dashboard, and why it reads as ArgoCD being broken&lt;/li&gt;
&lt;li&gt;The sync-wave partial failure scenario in detail — a backfill Job, a lock timeout, and application code reading nulls it wasn't written to handle&lt;/li&gt;
&lt;li&gt;The service mesh sidecar injector collision — why it presents as intermittent &lt;code&gt;OutOfSync&lt;/code&gt; with no obvious trigger, and how to trace it to the actual controller&lt;/li&gt;
&lt;li&gt;Series takeaways across all six articles — the thread connecting hub-and-spoke architecture, ApplicationSets, CI handoff, secrets management, progressive delivery, and failure modes into one coherent GitOps discipline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/gitops-drift-self-heal-failure-modes/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=gitops-drift-self-heal-failure-modes" rel="noopener noreferrer"&gt;Drift, Self-Heal, and Failure Modes — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>argocd</category>
      <category>gitops</category>
    </item>
    <item>
      <title>Deploying MCP Servers on Kubernetes</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Fri, 16 Oct 2026 06:30:46 +0000</pubDate>
      <link>https://dev.to/aloknecessary/deploying-mcp-servers-on-kubernetes-3435</link>
      <guid>https://dev.to/aloknecessary/deploying-mcp-servers-on-kubernetes-3435</guid>
      <description>&lt;p&gt;Most MCP servers start life as a local, stdio-based process — the client spawns the server as a child process, messages flow over stdin and stdout. The instinct once that server needs to serve more than one client is to containerize it and expose a port. That instinct produces a container that starts, passes a naive health check, and then does nothing useful.&lt;/p&gt;

&lt;p&gt;A process reading from stdin and writing to stdout inside a container has no listener, no port, nothing for a Kubernetes Service to route traffic toward. The fix isn't a networking change — it's a transport change in the server itself, from stdio to Streamable HTTP, before Kubernetes enters the picture at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The image
&lt;/h2&gt;

&lt;p&gt;The Dockerfile is unremarkable by design — the interesting decisions happen in the manifests, not the image. What matters is that the process binds to HTTP transport, not stdio, as its entrypoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/dist ./dist&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; MCP_TRANSPORT=streamable-http&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; MCP_HTTP_ADDR=0.0.0.0:8080&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; MCP_HTTP_ENDPOINT=/mcp&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three &lt;code&gt;MCP_*&lt;/code&gt; variables mirror the flags production-grade MCP server implementations expose for choosing transport mode at startup. Transport, bind address, and endpoint path all need to be explicit and set at deploy time, not hardcoded into the binary.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Deployment — probes that understand the workload
&lt;/h2&gt;

&lt;p&gt;An MCP server mid-tool-call is not the same thing as a hung process. The probes need to say so explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/health&lt;/span&gt;          &lt;span class="c1"&gt;# answers: is the process itself alive?&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
  &lt;span class="na"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;      &lt;span class="c1"&gt;# ~45s of true unresponsiveness before restart&lt;/span&gt;

&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/ready&lt;/span&gt;            &lt;span class="c1"&gt;# answers: can this pod accept new work right now?&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/health&lt;/code&gt; should do nothing more than confirm the process's event loop is running — never block on the status of an in-progress tool call. &lt;code&gt;/ready&lt;/code&gt; reflects capacity: a pod mid-reasoning can report itself not-ready for new work without being treated as dead.&lt;/p&gt;

&lt;p&gt;Two other details in the Deployment matter: &lt;code&gt;terminationGracePeriodSeconds: 45&lt;/code&gt; (the default 30s is a poor fit for a process that might be mid-tool-call during a rolling update), and resource requests set deliberately modest relative to limits — MCP servers spend most of their wall-clock time waiting on outbound I/O, not computing locally.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Service — the payoff of stateless transport
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-server&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-server&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No session affinity. No cookie-based stickiness annotation. Nothing beyond ordinary load balancing. This is the direct payoff of the July 2026 MCP spec removing the protocol-level session — any request can land on any pod, so the Service stays genuinely simple.&lt;/p&gt;




&lt;h2&gt;
  
  
  EKS: IRSA for short-lived credentials
&lt;/h2&gt;

&lt;p&gt;An MCP server calling AWS APIs needs credentials. The deployment-time decision that matters most is avoiding a long-lived static credential baked into the image or mounted as a Secret. IAM Roles for Service Accounts (IRSA) binds a Kubernetes ServiceAccount to an IAM role via OIDC federation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServiceAccount&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-server&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;eks.amazonaws.com/role-arn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/mcp-server-role&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IAM role should be scoped narrowly to whatever the server's tools actually need — an MCP server's tool surface is exactly the kind of thing that grows without anyone revisiting the IAM policy that backs it.&lt;/p&gt;




&lt;h2&gt;
  
  
  AKS: Workload Identity
&lt;/h2&gt;

&lt;p&gt;Workload Identity is AKS's equivalent — it federates a Kubernetes ServiceAccount token with an Entra ID application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServiceAccount&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-server&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;azure.workload.identity/client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11111111-2222-3333-4444-555555555555"&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;azure.workload.identity/use&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;azure.workload.identity/use: "true"&lt;/code&gt; label must be present on both the ServiceAccount and the pod template — a pod missing that label silently falls back to no identity at all rather than failing loudly, which makes it a frustrating thing to debug after the fact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Autoscaling — a safe floor, not the ideal signal
&lt;/h2&gt;

&lt;p&gt;CPU utilization doesn't track I/O-bound tool-call concurrency well, but a deployment needs something running from day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Resource&lt;/span&gt;
      &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cpu&lt;/span&gt;
        &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utilization&lt;/span&gt;
          &lt;span class="na"&gt;averageUtilization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;70&lt;/span&gt;
  &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;scaleUp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;stabilizationWindowSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
    &lt;span class="na"&gt;scaleDown&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;stabilizationWindowSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asymmetric stabilization windows are deliberate: scale up quickly (30s) to protect against bursts of concurrent tool-calling tasks, scale down slowly (300s) to avoid flapping. This will under-react to the specific load pattern MCP servers produce, but it's a safe default while a better signal is put in place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rolling updates without dropping in-flight work
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RollingUpdate&lt;/span&gt;
  &lt;span class="na"&gt;rollingUpdate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;maxUnavailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="na"&gt;maxSurge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;maxUnavailable: 0&lt;/code&gt; guarantees the rollout never reduces capacity below the current replica count — a new pod must pass its readiness probe before the corresponding old pod receives SIGTERM and begins the 45-second grace period. The difference between a rollout that's invisible to callers and one that produces a visible error-rate blip every time code ships.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verifying the deployment
&lt;/h2&gt;

&lt;p&gt;A request that exercises the actual transport confirms the deployment is correctly speaking the current specification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://mcp.example.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Method: tools/list"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Name: mcp-server"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A correctly deployed 2026-07-28-compliant server returns the tool list with no &lt;code&gt;Mcp-Session-Id&lt;/code&gt; in the response headers and no &lt;code&gt;initialize&lt;/code&gt; call required beforehand. If a session header shows up, the server (or a proxy in front of it) is still running the older stateful transport path. Worth catching in verification rather than discovering under load — and worth running as a smoke test on every deploy, not just at initial deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;The summary covers the core patterns. The full article goes deeper on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The complete Deployment YAML with all fields explained — resource requests, grace period, and why each value is what it is&lt;/li&gt;
&lt;li&gt;Why credential federation (IRSA and Workload Identity) matters more than it looks like it should — and the measured ecosystem data on static credential exposure in open-source MCP servers&lt;/li&gt;
&lt;li&gt;Namespace and labeling conventions worth setting at initial deployment, before isolation requirements force a full manifest rewrite&lt;/li&gt;
&lt;li&gt;The rolling update strategy in full context, and why &lt;code&gt;maxUnavailable: 0&lt;/code&gt; is the right default for this workload class&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/deploying-mcp-servers-on-kubernetes/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=deploying-mcp-servers-on-kubernetes" rel="noopener noreferrer"&gt;Deploying MCP Servers on Kubernetes — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Multi-Account AWS Networking: Shared VPC, RAM, and the Hub-and-Spoke Model</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:25:09 +0000</pubDate>
      <link>https://dev.to/aloknecessary/multi-account-aws-networking-shared-vpc-ram-and-the-hub-and-spoke-model-35ck</link>
      <guid>https://dev.to/aloknecessary/multi-account-aws-networking-shared-vpc-ram-and-the-hub-and-spoke-model-35ck</guid>
      <description>&lt;p&gt;Once an AWS Organization crosses a handful of accounts, the question stops being how to connect VPCs and becomes who owns the network at all. Two genuinely different models answer that question differently — Shared VPC and centralized Transit Gateway — and picking the wrong one early is expensive to unwind later.&lt;/p&gt;

&lt;p&gt;This post maps both models: what each centralizes, what each delegates, where each breaks down, and how to choose between them based on what your Organization actually needs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Model 1: Shared VPC
&lt;/h2&gt;

&lt;p&gt;Shared VPC uses AWS Resource Access Manager to let one account (typically a central network account) own a VPC and share specific subnets with other accounts. Those accounts launch resources directly into the shared subnets — EC2, RDS, Lambda in VPC mode — without creating a VPC of their own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Network account: share a subnet with a spoke account&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ram_resource_share"&lt;/span&gt; &lt;span class="s2"&gt;"vpc_share"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ram-shared-vpc-subnets"&lt;/span&gt;
  &lt;span class="nx"&gt;allow_external_principals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ram_resource_association"&lt;/span&gt; &lt;span class="s2"&gt;"app_subnet"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;resource_arn&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shared_app_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;resource_share_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ram_resource_share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ram_principal_association"&lt;/span&gt; &lt;span class="s2"&gt;"app_account"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"222233334444"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_share_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ram_resource_share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The network account retains ownership of route tables, NACLs, Internet Gateway, and NAT gateways. Spoke accounts cannot modify any of these — they can only launch resources into the subnets they've been given. Security groups are the one exception: each account creates and manages its own security groups for the resources it launches, even in a shared subnet.&lt;/p&gt;

&lt;p&gt;This split — centralized routing and NACLs, decentralized security groups — defines how Shared VPC feels operationally: the network team owns "can traffic reach this subnet at all," and application teams own "can this specific instance be reached."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it earns its complexity:&lt;/strong&gt; accounts that genuinely need to behave as if they're in the same network — shared load balancers, a common data tier, low-latency service-to-service calls. No Transit Gateway hop, no per-attachment cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it breaks down:&lt;/strong&gt; everything sharing the VPC shares the same blast radius. A misconfigured NACL in the network account affects every spoke simultaneously. Two spoke accounts can reach each other's resources over the VPC's local route by default — isolation requires deliberate security group discipline that Transit Gateway's route table segmentation gives you structurally.&lt;/p&gt;




&lt;h2&gt;
  
  
  Model 2: Centralized Transit Gateway (Hub-and-Spoke)
&lt;/h2&gt;

&lt;p&gt;Each account owns its own full VPC. A central network account owns a Transit Gateway shared via RAM — every spoke account attaches its VPC to the shared TGW.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Network account: share TGW with the entire Organization&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ec2_transit_gateway"&lt;/span&gt; &lt;span class="s2"&gt;"central"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tgw-org-central"&lt;/span&gt;
  &lt;span class="nx"&gt;default_route_table_association&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"disable"&lt;/span&gt;
  &lt;span class="nx"&gt;default_route_table_propagation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"disable"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ram_principal_association"&lt;/span&gt; &lt;span class="s2"&gt;"org_share"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;principal&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_organizations_organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;resource_share_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ram_resource_share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tgw_share&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sharing to the Organization ARN rather than individual account IDs means any new account added to the Organization can attach to the TGW without a manual RAM association step — important for Organizations still adding accounts regularly.&lt;/p&gt;

&lt;p&gt;Each spoke account attaches independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ec2_transit_gateway_vpc_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"spoke"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_private_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_private_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;transit_gateway_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tgw-0abc123central"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spoke&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The network team's authority is limited to the TGW's own route tables. Everything inside each spoke VPC — CIDRs, route tables, security groups, NACLs — is fully owned by that account's team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual selling point:&lt;/strong&gt; TGW route table associations give you structural segmentation. A production route table, a non-production route table, and a shared-services route table each control a completely different set of reachable attachments. A spoke account has no ability to affect that segmentation from its side. This is the property Shared VPC can't cleanly replicate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; every spoke VPC is a full VPC — its own CIDR allocation, its own NAT gateway, its own VPC endpoints. For an Organization with many small accounts, this multiplies infrastructure cost significantly compared to a single shared VPC.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Shared VPC&lt;/th&gt;
&lt;th&gt;Centralized Transit Gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPC ownership&lt;/td&gt;
&lt;td&gt;One VPC, centrally owned&lt;/td&gt;
&lt;td&gt;Each account owns its own VPC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Segmentation between accounts&lt;/td&gt;
&lt;td&gt;Requires security group discipline&lt;/td&gt;
&lt;td&gt;Native, via TGW route table association&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CIDR planning&lt;/td&gt;
&lt;td&gt;One VPC's CIDR to manage&lt;/td&gt;
&lt;td&gt;Every spoke needs non-overlapping CIDR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Lower — one NAT/endpoint fleet&lt;/td&gt;
&lt;td&gt;Higher — NAT/endpoints multiplied per spoke&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Many small teams needing shared infrastructure access&lt;/td&gt;
&lt;td&gt;Organizations needing strong trust-boundary segmentation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  A Hybrid Pattern Worth Knowing
&lt;/h2&gt;

&lt;p&gt;Keep each application account on its own full VPC connected via Transit Gateway, but put genuinely shared infrastructure — a central logging aggregator, an internal artifact registry, shared Active Directory — in its own dedicated VPC also attached to the same TGW. Every spoke gets low-friction access through normal TGW routing, gated by the same route table segmentation used for everything else. No ownership ambiguity about which application account "hosts" the shared infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  DNS Across Account Boundaries
&lt;/h2&gt;

&lt;p&gt;Routing connectivity doesn't automatically mean private DNS resolution works across accounts.&lt;/p&gt;

&lt;p&gt;For Shared VPC this is largely a non-issue — spoke accounts are in the same VPC, so standard VPC DNS resolution covers everything uniformly.&lt;/p&gt;

&lt;p&gt;For centralized TGW, each spoke has its own DNS scope. Cross-account private hosted zone resolution needs to be built deliberately — either through Route 53 Private Hosted Zone association across accounts (requires an authorization step from the zone-owning account) or through a centralized Route 53 Resolver with inbound/outbound endpoints in the shared services VPC. This is one of the more fiddly parts of the hub-and-spoke model and worth treating as its own planning task during initial Organization network design.&lt;/p&gt;




&lt;h2&gt;
  
  
  Multi-Account-Specific Failure Modes
&lt;/h2&gt;

&lt;p&gt;Beyond the standard route table/security group/NACL checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM share exists but principal association is missing or not accepted&lt;/strong&gt; — the resource simply doesn't appear in the spoke account with no error, just an absence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCPs blocking cross-account resource use&lt;/strong&gt; independent of RAM being correctly configured — produces an IAM access denied error, easy to spend time debugging the network layer when the block is a policy layer above it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CIDR overlap discovered only at TGW attachment time&lt;/strong&gt; — two spoke accounts provisioned independently can end up with overlapping ranges that only become visible when both try to attach and their routes conflict&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;This summary covers both models, the comparison table, the hybrid pattern, DNS considerations, and the key failure modes. The full article includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete Terraform for Shared VPC — VPC, subnets, RAM share, principal association&lt;/li&gt;
&lt;li&gt;Complete Terraform for centralized TGW — TGW with disabled default route tables, Organization-wide RAM share, spoke attachment&lt;/li&gt;
&lt;li&gt;Why disabling default TGW route table association matters for segmentation&lt;/li&gt;
&lt;li&gt;The migration-in-progress angle — how the account structure decided before the first MGN wave determines which model is even on the table later&lt;/li&gt;
&lt;li&gt;Full troubleshooting section for multi-account-specific failure modes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/aws-multi-account-networking/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=aws-multi-account-networking" rel="noopener noreferrer"&gt;Multi-Account AWS Networking: Shared VPC, RAM, and the Hub-and-Spoke Model — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
      <category>networking</category>
    </item>
    <item>
      <title>Recovering an Azure VM Boot Loop with Serial Console, Mid-Migration</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:10:26 +0000</pubDate>
      <link>https://dev.to/aloknecessary/recovering-an-azure-vm-boot-loop-with-serial-console-mid-migration-1po6</link>
      <guid>https://dev.to/aloknecessary/recovering-an-azure-vm-boot-loop-with-serial-console-mid-migration-1po6</guid>
      <description>&lt;p&gt;Installing a new kernel, running &lt;code&gt;update-grub&lt;/code&gt;, and purging the old kernel in the same maintenance window — before rebooting to confirm the new one actually boots — is a sequence that feels safe right up until the VM never comes back up. With no fallback GRUB entry left and no SSH, no RDP, nothing reachable, the only path back is something that doesn't depend on the OS network stack at all. This is what that recovery looks like in practice, and what the root cause turned out to be.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Azure Serial Console actually is
&lt;/h2&gt;

&lt;p&gt;Azure Serial Console gives direct access to the VM's serial port (COM1) through the Azure portal, completely independent of the guest OS's network stack. This is the critical property: it works when SSH, RDP, and every other normal access path are unreachable — including when the VM is stuck in a boot loop and never finishes booting at all.&lt;/p&gt;

&lt;p&gt;Two prerequisites worth confirming &lt;em&gt;before&lt;/em&gt; doing any kernel-level work, not during an incident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Boot diagnostics must be enabled&lt;/strong&gt; on the VM — verify this explicitly on any VM you're about to do kernel work on, not assumed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The portal account needs &lt;code&gt;Virtual Machine Contributor&lt;/code&gt; or equivalent&lt;/strong&gt; — read-only VM visibility is not enough for serial console access&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How the boot loop happened
&lt;/h2&gt;

&lt;p&gt;The kernel swap sequence was: install &lt;code&gt;linux-generic&lt;/code&gt;, run &lt;code&gt;update-grub&lt;/code&gt;, purge &lt;code&gt;linux-azure&lt;/code&gt; — all in the same session, without an intervening reboot. The assumption was that a successful package install and a clean &lt;code&gt;update-grub&lt;/code&gt; run meant the new kernel would boot. That assumption was wrong: the &lt;code&gt;linux-generic&lt;/code&gt; kernel had a dependency on an initramfs configuration that hadn't regenerated correctly for the Azure Hyper-V environment, and the VM never came back after the reboot.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;linux-azure&lt;/code&gt; already purged, there was no fallback GRUB entry. The VM sat in a continuous restart loop — visible in the Azure portal, but with no SSH, no RDP, nothing reachable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The recovery sequence
&lt;/h2&gt;

&lt;p&gt;Connecting via serial console and pressing &lt;strong&gt;Esc&lt;/strong&gt; during boot interrupts GRUB's countdown and lands on the menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GNU GRUB  version 2.06

*Ubuntu
 Advanced options for Ubuntu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a fallback kernel still present, selecting it directly is the fastest path back. In this case, &lt;code&gt;linux-azure&lt;/code&gt; had already been purged, so only &lt;code&gt;linux-generic&lt;/code&gt; entries remained — including a recovery mode option that also failed, since the problem was in the initramfs itself.&lt;/p&gt;

&lt;p&gt;The next step: drop to the GRUB command line and confirm what's actually on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grub&amp;gt; ls
(hd0) (hd0,gpt2) (hd0,gpt1) (hd0,gpt15)

grub&amp;gt; ls (hd0,gpt2)/boot
grub.cfg  vmlinuz-6.8.0-1015-generic  initrd.img-6.8.0-1015-generic  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel and initramfs files were both present — ruling out a missing-file problem and pointing toward a boot configuration or initramfs content issue. From the GRUB command line, booting manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grub&amp;gt; linux (hd0,gpt2)/boot/vmlinuz-6.8.0-1015-generic root=/dev/sda2 ro
grub&amp;gt; initrd (hd0,gpt2)/boot/initrd.img-6.8.0-1015-generic
grub&amp;gt; boot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This got the system far enough to reach a root shell — not a fully working boot, but enough to diagnose and fix the actual cause from inside the VM.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual root cause: missing modules.dep
&lt;/h2&gt;

&lt;p&gt;Once at a shell, regenerating the initramfs surfaced the real problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;update-initramfs &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; 6.8.0-1015-generic
&lt;span class="c"&gt;# W: missing /lib/modules/6.8.0-1015-generic/modules.dep!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;linux-generic&lt;/code&gt; package had installed, but &lt;code&gt;depmod&lt;/code&gt; — which builds the module dependency map the initramfs generation relies on — hadn't run correctly. Doing the install-and-purge in the same session, without an intervening reboot, left &lt;code&gt;dpkg&lt;/code&gt;'s package configuration hooks in an inconsistent state where this dependency step silently didn't complete.&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;depmod &lt;span class="nt"&gt;-a&lt;/span&gt; 6.8.0-1015-generic
update-initramfs &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; 6.8.0-1015-generic
update-grub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-c&lt;/code&gt; (create) rather than &lt;code&gt;-u&lt;/code&gt; (update) was the deliberate choice — the existing initramfs was the suspected malformed artifact, so regenerating from scratch was safer than patching it in place.&lt;/p&gt;

&lt;p&gt;After this, a normal reboot from the Azure portal came up cleanly on &lt;code&gt;linux-generic&lt;/code&gt; with SSH reachable again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the reboot between install and purge matters
&lt;/h2&gt;

&lt;p&gt;The general advice — reboot and confirm before purging the old kernel — is usually framed as "keep a fallback GRUB entry available." The &lt;code&gt;depmod&lt;/code&gt; root cause adds a second, more specific reason: a reboot between installing the new kernel and removing the old one forces a clean package configuration state. The same-session install-then-purge sequence can skip past the &lt;code&gt;depmod&lt;/code&gt; step that &lt;code&gt;update-initramfs&lt;/code&gt; depends on, producing a malformed initramfs that only surfaces as a boot failure after the reboot you skipped.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting checklist for serial-console-reachable boot failures
&lt;/h2&gt;

&lt;p&gt;In order of how often each turns out to be the actual cause:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirm kernel and initramfs files exist on disk&lt;/strong&gt; — &lt;code&gt;ls&lt;/code&gt; from the GRUB command line before assuming anything about &lt;em&gt;why&lt;/em&gt; boot is failing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;modules.dep&lt;/code&gt; and run &lt;code&gt;depmod -a&lt;/code&gt; explicitly&lt;/strong&gt; if initramfs generation logs mention missing module dependencies — a plain &lt;code&gt;update-initramfs&lt;/code&gt; re-run won't fix it without &lt;code&gt;depmod&lt;/code&gt; first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect &lt;code&gt;/etc/default/grub&lt;/code&gt; and &lt;code&gt;/boot/grub/grub.cfg&lt;/code&gt;&lt;/strong&gt; for a default entry pointing at a kernel version that no longer exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check disk space on &lt;code&gt;/boot&lt;/code&gt;&lt;/strong&gt; — a full &lt;code&gt;/boot&lt;/code&gt; partition can silently truncate &lt;code&gt;update-initramfs&lt;/code&gt; or &lt;code&gt;update-grub&lt;/code&gt; without a clear error, only surfacing as a boot failure afterward&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider a snapshot restore&lt;/strong&gt; if manual recovery isn't converging — sometimes the right first move rather than the last resort, depending on time pressure&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Pre-flight check for the next server in the wave
&lt;/h2&gt;

&lt;p&gt;Two changes folded back into the migration runbook after this incident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never purge the old kernel in the same session as installing the new one — always reboot and confirm first&lt;/li&gt;
&lt;li&gt;Confirm boot diagnostics is enabled and serial console access is usable — role permissions included — on every source VM &lt;em&gt;before&lt;/em&gt; starting kernel-level work
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm boot-diagnostics get-boot-log &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;vm-name&amp;gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK: boot diagnostics reachable"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING: boot diagnostics not enabled or not reachable — enable before kernel changes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;The full article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why this boot loop failure is structurally different from the AWS-side &lt;code&gt;ena&lt;/code&gt; driver issue in the previous article — same symptom ("server unreachable after kernel change"), completely different cause and recovery tool&lt;/li&gt;
&lt;li&gt;The full GRUB command-line recovery walkthrough with exact commands&lt;/li&gt;
&lt;li&gt;Why &lt;code&gt;update-initramfs -c&lt;/code&gt; rather than &lt;code&gt;-u&lt;/code&gt; was the right choice once at a shell&lt;/li&gt;
&lt;li&gt;The decision to do manual recovery rather than snapshot restore — and when snapshot restore is actually the better first move&lt;/li&gt;
&lt;li&gt;The pre-migration pre-flight script extended to confirm serial console access before kernel work begins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/azure-serial-console-boot-loop-recovery/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=azure-serial-console-boot-loop-recovery" rel="noopener noreferrer"&gt;Recovering an Azure VM Boot Loop with Serial Console, Mid-Migration — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>linux</category>
      <category>devops</category>
      <category>aws</category>
    </item>
    <item>
      <title>Progressive Delivery with Argo Rollouts: Canary, Blue-Green, and AnalysisTemplates That Actually Gate</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Mon, 05 Oct 2026 11:40:13 +0000</pubDate>
      <link>https://dev.to/aloknecessary/progressive-delivery-with-argo-rollouts-canary-blue-green-and-analysistemplates-that-actually-560c</link>
      <guid>https://dev.to/aloknecessary/progressive-delivery-with-argo-rollouts-canary-blue-green-and-analysistemplates-that-actually-560c</guid>
      <description>&lt;p&gt;A standard Kubernetes &lt;code&gt;Deployment&lt;/code&gt; rolling update has no concept of "check whether this is actually working before replacing more Pods." It replaces old Pods with new ones at the pace you configured, and if the new version is broken, it finds out the same way your users do — by serving it to more and more of them until someone notices.&lt;/p&gt;

&lt;p&gt;Argo Rollouts replaces the &lt;code&gt;Deployment&lt;/code&gt; resource with a &lt;code&gt;Rollout&lt;/code&gt; — API-compatible in its Pod template, but with an explicit strategy for how traffic shifts and, critically, automated checks that can halt or reverse that shift before it reaches everyone. This post covers the two strategies that matter in practice and the &lt;code&gt;AnalysisTemplate&lt;/code&gt; mechanism that makes the gating real rather than cosmetic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a Deployment Isn't Enough
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Deployment&lt;/code&gt;'s rolling update is governed by &lt;code&gt;maxSurge&lt;/code&gt; and &lt;code&gt;maxUnavailable&lt;/code&gt; — how many extra Pods can exist during the rollout, how many can be unavailable. Neither is a correctness check. A &lt;code&gt;Deployment&lt;/code&gt; will happily roll a broken version to 100% of Pods because nothing in its model asks "is this new version actually healthy" — only "are enough Pods reporting ready," which a broken-but-still-passing-liveness-probe version satisfies just fine.&lt;/p&gt;

&lt;p&gt;The two strategies have fundamentally different blast-radius profiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Canary                                    Blue-Green
  100% ─┐                                   100% ─┐ old (active)
        │                                         │
   10%  │ old          10% new                new │ 100% (preview,
        │              ▲                           │      no live traffic)
   50%  │ old      50% │ new                        │
        │              │ analysis gate               │ prePromotionAnalysis
    0%  │              │ new: 100%                    │ gate
        └──────────────┴──────────────►          cutover: 100% new,
        traffic shifts in steps,                  0% old, in one move
        gated between each step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Canary spreads risk across several small, gated exposures. Blue-green concentrates verification into one pre-cutover check, then moves all traffic at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  Canary Strategy
&lt;/h2&gt;

&lt;p&gt;Canary shifts traffic to the new version incrementally, in named steps, with a pause (manual or timed) between each. The key YAML shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;canary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5m&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;analysis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;templates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;templateName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-success-rate&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service-name&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service-canary&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;pause&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;5m&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;analysis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;templates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;templateName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-success-rate&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;setWeight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;setWeight&lt;/code&gt; step shifts that percentage of traffic to the new ReplicaSet via whichever traffic-management integration is configured — an Ingress controller, a service mesh, or a load balancer controller. The &lt;code&gt;analysis&lt;/code&gt; steps between weight increases are where this stops being "a slower rolling update" and becomes an actual gate: the rollout does not proceed to the next &lt;code&gt;setWeight&lt;/code&gt; unless the referenced &lt;code&gt;AnalysisTemplate&lt;/code&gt; reports success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The traffic-router gap worth knowing:&lt;/strong&gt; if the Ingress controller isn't one of Rollouts' supported traffic-router plugins, it silently falls back to a ReplicaSet-ratio approximation. A single canary Pod out of 10 total is not the same as a precise 10% of requests. Confirming which traffic-router plugin is actually active — not just assumed — is worth doing before trusting &lt;code&gt;setWeight&lt;/code&gt; values as precise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Blue-Green Strategy
&lt;/h2&gt;

&lt;p&gt;Blue-green skips the incremental traffic shift and instead runs the full new version alongside the full old version, with a single cutover once the new version is verified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;blueGreen&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;activeService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service-active&lt;/span&gt;
    &lt;span class="na"&gt;previewService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service-preview&lt;/span&gt;
    &lt;span class="na"&gt;autoPromotionEnabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;prePromotionAnalysis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;templates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;templateName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-smoke-test&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service-name&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service-preview&lt;/span&gt;
    &lt;span class="na"&gt;scaleDownDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new version scales up fully behind &lt;code&gt;previewService&lt;/code&gt; — reachable for testing, not yet receiving production traffic. &lt;code&gt;prePromotionAnalysis&lt;/code&gt; runs against the preview version before any cutover is possible. With &lt;code&gt;autoPromotionEnabled: false&lt;/code&gt;, cutover requires an explicit &lt;code&gt;kubectl argo rollouts promote&lt;/code&gt; even after analysis passes — a deliberate human checkpoint on top of the automated one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scaleDownDelaySeconds&lt;/code&gt; keeps the old ReplicaSet running for 5 minutes after cutover so a rollback is a service-selector flip back, not a fresh Pod startup. Setting it too low defeats the rollback advantage — if Pods take 90 seconds to become traffic-ready and &lt;code&gt;scaleDownDelaySeconds&lt;/code&gt; is 30, the old ReplicaSet is already gone by the time a problem surfaces.&lt;/p&gt;




&lt;h2&gt;
  
  
  AnalysisTemplates — What Actually Gates the Rollout
&lt;/h2&gt;

&lt;p&gt;Both strategies are only as good as the analysis behind them. An &lt;code&gt;AnalysisTemplate&lt;/code&gt; defines a metric query and a success condition; Rollouts polls it on an interval and compares the result against thresholds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AnalysisTemplate&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-success-rate&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service-name&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success-rate&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
      &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;successCondition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;result[0] &amp;gt;= &lt;/span&gt;&lt;span class="m"&gt;0.95&lt;/span&gt;
      &lt;span class="na"&gt;failureLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus.monitoring.svc:9090&lt;/span&gt;
          &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;sum(rate(http_requests_total{service="{{args.service-name}}", status!~"5.."}[1m]))&lt;/span&gt;
            &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="s"&gt;sum(rate(http_requests_total{service="{{args.service-name}}"}[1m]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This queries Prometheus for the canary's success rate every minute, five times, and requires each sample to hold at or above 95%. Rollouts supports the same pattern against Datadog, New Relic, CloudWatch, and others — the shape of "query a metric, define a success condition, sample N times" is the transferable part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two failure modes worth planning for explicitly:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;successCondition&lt;/code&gt; that's too loose (&lt;code&gt;result[0] &amp;gt;= 0.50&lt;/code&gt;, left over from testing) will pass a canary that's failing half its requests. The gate is present but not gating.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;count&lt;/code&gt; too low to be statistically meaningful on a low-traffic service — one or two failures in a handful of requests swing the ratio well outside a 95% threshold on pure noise, triggering rollbacks on releases that were actually fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both point to the same discipline: AnalysisTemplate thresholds need to be tuned against the actual traffic volume and baseline error rate of the specific service, not copied from another service's template.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where This Sits Against CI/CD Pipeline Stages
&lt;/h2&gt;

&lt;p&gt;Kargo's Stage-level verification (or a CI-to-GitOps handoff) answers "should this artifact be allowed into this environment at all." Argo Rollouts answers a different question, one layer down: "given that this artifact is now allowed into this environment, how carefully should it be exposed to that environment's traffic."&lt;/p&gt;

&lt;p&gt;The two compose rather than compete. A Kargo &lt;code&gt;Stage&lt;/code&gt; promoting into prod can just as easily be updating a &lt;code&gt;Rollout&lt;/code&gt; resource as a plain &lt;code&gt;Deployment&lt;/code&gt;. Nothing about progressive delivery requires picking a different CI-to-GitOps mechanism.&lt;/p&gt;

&lt;p&gt;Running both isn't redundant defense in depth for its own sake — each catches a class of failure the other structurally cannot. A service can pass every pre-promotion check and still fail an in-flight Rollouts analysis: a load-dependent memory leak, a connection pool exhausting under real concurrency, a downstream dependency behaving differently under production traffic patterns than under a synthetic staging test.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision Framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommended strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stateless service, traffic-splitting infrastructure already in place&lt;/td&gt;
&lt;td&gt;Canary — gradual blast-radius limiting, most granular control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No traffic-splitting infrastructure available&lt;/td&gt;
&lt;td&gt;Blue-green, or canary via ReplicaSet ratio approximation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release needs real-traffic-pattern validation before any production exposure&lt;/td&gt;
&lt;td&gt;Blue-green — preview service gives you that validation window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workload has strict version-coupling to a shared schema&lt;/td&gt;
&lt;td&gt;Neither — fix the deployment pattern first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;The full post covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete annotated YAML for both canary and blue-green &lt;code&gt;Rollout&lt;/code&gt; resources&lt;/li&gt;
&lt;li&gt;The exact traffic-router gap that makes &lt;code&gt;setWeight&lt;/code&gt; values imprecise without a supported plugin&lt;/li&gt;
&lt;li&gt;Why &lt;code&gt;scaleDownDelaySeconds&lt;/code&gt; sizing directly determines whether blue-green's rollback advantage is real or theoretical&lt;/li&gt;
&lt;li&gt;How AnalysisTemplate &lt;code&gt;count&lt;/code&gt; and &lt;code&gt;successCondition&lt;/code&gt; interact with low-traffic services to produce false rollbacks&lt;/li&gt;
&lt;li&gt;The precise relationship between Kargo/CI promotion gates and in-flight Rollouts analysis — why both are required and what each catches that the other cannot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/argo-rollouts-progressive-delivery/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=argo-rollouts-progressive-delivery" rel="noopener noreferrer"&gt;Progressive Delivery with Argo Rollouts — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>progressivedelivery</category>
      <category>gitops</category>
    </item>
    <item>
      <title>The MCP Stateless Revolution</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:58:38 +0000</pubDate>
      <link>https://dev.to/aloknecessary/the-mcp-stateless-revolution-57p9</link>
      <guid>https://dev.to/aloknecessary/the-mcp-stateless-revolution-57p9</guid>
      <description>&lt;p&gt;For two years, running an MCP server in production meant building infrastructure that had nothing to do with what the server was actually supposed to do. The protocol's session model forced sticky routing, shared session stores, and affinity cookies onto teams who otherwise had no reason to build stateful infrastructure at all. On July 28, 2026, the MCP specification removed the session entirely — and with it, the justification for that entire category of workaround.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the old transport forced on you
&lt;/h2&gt;

&lt;p&gt;An MCP &lt;code&gt;initialize&lt;/code&gt; handshake returned an &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header, and every subsequent request in that conversation had to carry the same ID back to the same server instance. Keeping that session alive in production meant one of three things: sticky sessions at the load balancer, a shared session store like Redis so any instance could serve any request, or a client-side retry strategy that tolerated session loss and re-initialized when a pod restarted.&lt;/p&gt;

&lt;p&gt;Each solution adds a moving part that fails in a specific, familiar way during a rolling deployment. A pod gets terminated, its in-memory session state goes with it, and every client pinned to that pod either gets a connection reset mid-conversation or silently starts talking to a session that no longer exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Pre-2026-07-28: sticky routing required&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-server&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;alb.ingress.kubernetes.io/target-group-attributes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;stickiness.enabled=true,&lt;/span&gt;
      &lt;span class="s"&gt;stickiness.type=lb_cookie,&lt;/span&gt;
      &lt;span class="s"&gt;stickiness.lb_cookie.duration_seconds=3600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What SEP-2567 actually removes
&lt;/h2&gt;

&lt;p&gt;The core change removes protocol-level sessions and the &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header from the Streamable HTTP transport entirely — not deprecates, removes. Every request is now self-describing: protocol version, client identity, and capabilities travel in &lt;code&gt;_meta&lt;/code&gt; on that request rather than being negotiated once and remembered by a pinned server instance.&lt;/p&gt;

&lt;p&gt;This does not mean MCP servers can no longer carry state across calls. If a server needs to remember something between tool calls, it mints an explicit handle and returns it from the first call. The client passes that handle back as an ordinary argument — the same way any REST API has always handled state. What disappears is the requirement that the &lt;em&gt;same pod&lt;/em&gt; be the one to look it up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Header-based routing: the other half of the change
&lt;/h2&gt;

&lt;p&gt;Statelessness solves scaling, but creates a new problem: how does a gateway make intelligent routing decisions without parsing every JSON-RPC body? SEP-2243 answers this: Streamable HTTP requests must now carry &lt;code&gt;Mcp-Method&lt;/code&gt; and &lt;code&gt;Mcp-Name&lt;/code&gt; headers. A load balancer can now route, throttle, and meter MCP traffic on headers — without ever being MCP-aware at the body level.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# AWS ALB post-2026-07-28 — no stickiness, header-based routing&lt;/span&gt;
&lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;alb.ingress.kubernetes.io/conditions.mcp-tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;[{"field":"http-header","httpHeaderConfig":{"httpHeaderName":"Mcp-Method","values":["tasks/*"]}}]&lt;/span&gt;
  &lt;span class="na"&gt;alb.ingress.kubernetes.io/actions.mcp-tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;{"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"mcp-tasks-pool","servicePort":8443}]}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long-running task calls route to a differently-sized backend pool. Ordinary tool calls go elsewhere. No custom MCP-parsing logic in the gateway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Per-request auth: the security implication
&lt;/h2&gt;

&lt;p&gt;Removing the session also removes the one place authentication used to be established once and implicitly trusted for the rest of the conversation. Every request now carries its own client identity in &lt;code&gt;_meta&lt;/code&gt;, which means authorization is evaluated per request. A revoked credential takes effect on the very next call instead of only once the session naturally expires. The infrastructure implication: your gateway now performs an identity check on every request rather than once per conversation — real added latency on every call, worth budgeting for explicitly when sizing the gateway layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The migration window
&lt;/h2&gt;

&lt;p&gt;The specification's deprecation policy gives a twelve-month compatibility runway for features being phased out. What is removed outright — with no grace period — is the session header and &lt;code&gt;initialize&lt;/code&gt; handshake. The realistic migration path is running both transport paths side by side behind the same ingress until every client you support has moved. The sticky-routing configuration doesn't disappear the day you upgrade — it stays live serving the old path while new traffic routes to the stateless pool through header-based rules.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;This summary covers the core protocol changes and their infrastructure implications. The full article includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete before/after Kubernetes Service and Ingress YAML for both AWS ALB and Azure Application Gateway&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ttlMs&lt;/code&gt;/&lt;code&gt;cacheScope&lt;/code&gt; caching signals (SEP-2549) and what they mean for CDN/edge caching&lt;/li&gt;
&lt;li&gt;W3C Trace Context propagation in &lt;code&gt;_meta&lt;/code&gt; (SEP-414) and distributed tracing implications&lt;/li&gt;
&lt;li&gt;How the removal of &lt;code&gt;Last-Event-ID&lt;/code&gt; stream resumability simplifies liveness probe design&lt;/li&gt;
&lt;li&gt;Detailed dual-path migration strategy to avoid mid-migration outages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/the-mcp-stateless-revolution/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=the-mcp-stateless-revolution" rel="noopener noreferrer"&gt;The MCP Stateless Revolution — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>cloud</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>VPC Endpoints: Gateway vs Interface, and the AWS Traffic That Shouldn't Touch the Internet</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Mon, 28 Sep 2026 05:31:14 +0000</pubDate>
      <link>https://dev.to/aloknecessary/vpc-endpoints-gateway-vs-interface-and-the-aws-traffic-that-shouldnt-touch-the-internet-2cnj</link>
      <guid>https://dev.to/aloknecessary/vpc-endpoints-gateway-vs-interface-and-the-aws-traffic-that-shouldnt-touch-the-internet-2cnj</guid>
      <description>&lt;p&gt;Traffic from a private subnet to S3 routes through your NAT gateway by default — and gets billed per GB for the privilege — when a free alternative has existed for years. VPC endpoints give AWS services a private, in-VPC address path that never touches the internet. There are two fundamentally different mechanisms, and understanding how they differ changes how you decide which services actually need one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why private subnet traffic hits NAT at all
&lt;/h2&gt;

&lt;p&gt;A private subnet instance calling &lt;code&gt;s3.ap-south-1.amazonaws.com&lt;/code&gt; does exactly what it would do calling any internet host: routes to the NAT gateway, gets source-translated, exits through the IGW, and comes back the same way. Both the instance and the S3 bucket are inside AWS — the traffic never needed to leave AWS's network. VPC endpoints fix this by giving the traffic a direct in-VPC path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gateway endpoints: a route table entry, nothing more
&lt;/h2&gt;

&lt;p&gt;Gateway endpoints exist for exactly two services: &lt;strong&gt;S3 and DynamoDB&lt;/strong&gt;. They are not network devices — they are route table targets. AWS injects a managed prefix list route automatically; traffic matching S3's IP ranges routes to the endpoint instead of NAT.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;service_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"com.amazonaws.ap-south-1.s3"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Gateway"&lt;/span&gt;
  &lt;span class="nx"&gt;route_table_ids&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_route_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cost: free.&lt;/strong&gt; No hourly charge, no per-GB processing charge. This makes the S3 Gateway endpoint close to a strict upgrade — there is essentially no reason not to add it to every VPC with private subnets that touch S3.&lt;/p&gt;

&lt;p&gt;The one real limitation: Gateway endpoints are associated with specific route tables, not the whole VPC. A new private subnet with its own route table falls back to NAT silently unless you explicitly associate the endpoint with that table too. Worth adding to any subnet-creation checklist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Interface endpoints: a real ENI, backed by PrivateLink
&lt;/h2&gt;

&lt;p&gt;Every other AWS service that supports VPC endpoints uses an &lt;strong&gt;Interface endpoint&lt;/strong&gt; — a real Elastic Network Interface provisioned in a subnet you specify, with a private IP from that subnet's range, backed by AWS PrivateLink.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"secretsmanager"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;service_name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"com.amazonaws.ap-south-1.secretsmanager"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Interface"&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aws_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpce_sm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it's a real ENI, it has its own security group — Interface endpoint access is controlled by security group rules, not route table association. &lt;code&gt;private_dns_enabled = true&lt;/code&gt; makes the endpoint transparent to application code: AWS overrides the public DNS name to resolve to the endpoint's private IP inside the VPC, so no SDK or application configuration change is needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost: the opposite of Gateway endpoints.&lt;/strong&gt; Interface endpoints bill hourly per AZ plus per-GB data processed. "Add Interface endpoints for everything" is not automatically correct — it requires actual usage data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which services actually justify an Interface endpoint
&lt;/h2&gt;

&lt;p&gt;Start with services every private instance calls constantly regardless of application logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;STS&lt;/strong&gt; — IAM role assumption is called far more often than people expect under the hood&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudWatch Logs&lt;/strong&gt; — continuous log shipping from busy applications&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ECR&lt;/strong&gt; — image pulls from private subnets, especially during autoscaling cold starts or frequent CI/CD deployments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These three tend to justify their hourly cost quickly in any account with meaningful private-subnet compute. Lower-traffic services — Secrets Manager called once at startup, SNS for occasional notifications — are worth evaluating with real Cost Explorer data rather than provisioning preemptively.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ce get-cost-and-usage &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--time-period&lt;/span&gt; &lt;span class="nv"&gt;Start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2026-07-01,End&lt;span class="o"&gt;=&lt;/span&gt;2026-08-01 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--granularity&lt;/span&gt; MONTHLY &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filter&lt;/span&gt; file://nat-gateway-filter.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metrics&lt;/span&gt; &lt;span class="s2"&gt;"UsageQuantity"&lt;/span&gt; &lt;span class="s2"&gt;"BlendedCost"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Side-by-side comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Gateway Endpoint&lt;/th&gt;
&lt;th&gt;Interface Endpoint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mechanism&lt;/td&gt;
&lt;td&gt;Route table entry&lt;/td&gt;
&lt;td&gt;ENI backed by PrivateLink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Services&lt;/td&gt;
&lt;td&gt;S3, DynamoDB only&lt;/td&gt;
&lt;td&gt;Most other AWS services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Hourly per AZ + per-GB processed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security control&lt;/td&gt;
&lt;td&gt;Existing SG/NACL on the instance&lt;/td&gt;
&lt;td&gt;Own security group on the endpoint ENI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;No change needed&lt;/td&gt;
&lt;td&gt;Private DNS override, if enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-subnet&lt;/td&gt;
&lt;td&gt;Associate route table per subnet&lt;/td&gt;
&lt;td&gt;Deploy ENI per AZ needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Failure modes worth knowing before you're debugging under pressure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private DNS conflicts in Transit Gateway / peered VPC designs&lt;/strong&gt; — if two connected VPCs both have Interface endpoints for the same service with private DNS enabled, DNS resolution can become ambiguous. An instance in VPC-A may resolve the service name to VPC-B's endpoint ENI. Test this explicitly in hub-and-spoke designs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security group too narrow&lt;/strong&gt; — a security group that misses the calling subnet's CIDR produces a connection timeout that looks identical to a routing failure, but is actually a security group issue at the endpoint itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint deployed in wrong AZ&lt;/strong&gt; — the endpoint still functions cross-AZ, but incurs cross-AZ data transfer charges on top of the endpoint's per-GB cost. Deploy into every AZ that has calling instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom DHCP option set or Route 53 Resolver rule taking precedence&lt;/strong&gt; — if the VPC has custom DNS resolution configured, the endpoint's private hosted zone override can be superseded and traffic reverts to NAT despite correct endpoint configuration.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;This summary covers the core mechanisms and decision framework. The full article includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete Terraform for both Gateway and Interface endpoints with security group scoping&lt;/li&gt;
&lt;li&gt;The full AWS CLI route table inspection workflow&lt;/li&gt;
&lt;li&gt;Detailed cost modelling for Interface endpoint break-even analysis&lt;/li&gt;
&lt;li&gt;Security group and NACL implications in depth&lt;/li&gt;
&lt;li&gt;The specific action to take this week if you haven't added the S3 Gateway endpoint yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/aws-vpc-endpoints-gateway-vs-interface/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=aws-vpc-endpoints-gateway-vs-interface" rel="noopener noreferrer"&gt;VPC Endpoints: Gateway vs Interface — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>networking</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Secrets Management in a GitOps World: Sealed Secrets vs. External Secrets Operator vs. Vault</title>
      <dc:creator>Alok Ranjan Daftuar</dc:creator>
      <pubDate>Thu, 24 Sep 2026 09:03:22 +0000</pubDate>
      <link>https://dev.to/aloknecessary/secrets-management-in-a-gitops-world-sealed-secrets-vs-external-secrets-operator-vs-vault-262c</link>
      <guid>https://dev.to/aloknecessary/secrets-management-in-a-gitops-world-sealed-secrets-vs-external-secrets-operator-vs-vault-262c</guid>
      <description>&lt;p&gt;GitOps means everything a cluster needs lives in Git. Secrets very much don't belong in Git — not even in a private repo, because Git history is forever and "who had read access eighteen months ago" is a much harder question than "who has read access today."&lt;/p&gt;

&lt;p&gt;Three approaches resolve that contradiction. Each breaks the "it's all just committed to Git" assumption in a different place, and each solves a different subset of the actual problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "Solving" Secrets in GitOps Actually Means
&lt;/h2&gt;

&lt;p&gt;Before comparing tools, it's worth being precise about what's being solved — because the three approaches solve different parts of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never having plaintext in Git&lt;/strong&gt; — not even encrypted-and-committed, if the encryption key itself can leak&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotating secrets without a Git commit&lt;/strong&gt; — if rotation requires a PR, it happens less often than it should&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoping which cluster or namespace can see which secret&lt;/strong&gt; — the same multi-tenancy question AppProjects answer for Applications, but for secret values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No single approach scores well on all three by default. Each is a different set of tradeoffs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sealed Secrets
&lt;/h2&gt;

&lt;p&gt;Bitnami's Sealed Secrets takes the most literal path: encrypt the secret client-side with a public key, commit the resulting &lt;code&gt;SealedSecret&lt;/code&gt; resource, and let an in-cluster controller — the only holder of the matching private key — decrypt it at apply time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic checkout-db-creds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'REPLACE_ME'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | kubeseal &lt;span class="nt"&gt;--format&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; checkout-db-creds-sealed.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# safe to commit — only the in-cluster controller can decrypt this&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnami.com/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SealedSecret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-db-creds&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;encryptedData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AgBy3i4OJSWK+PiTySYZZA9rO43cGDEQ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it gets you:&lt;/strong&gt; nothing sensitive ever touches Git. No external dependency — self-contained controller, no Vault cluster or cloud secrets service required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't get you:&lt;/strong&gt; rotation still requires a commit. Changing a database password means re-sealing and re-committing — same PR friction as any other manifest change. At 40 services rotating every 90 days, that's 40 separate re-seal-and-commit operations per quarter, each needing someone with &lt;code&gt;kubeseal&lt;/code&gt; access to the right cluster's public key at the time rotation is due.&lt;/p&gt;

&lt;p&gt;It's also cluster-scoped by design: a &lt;code&gt;SealedSecret&lt;/code&gt; sealed for cluster A's public key is meaningless on cluster B. At multi-cluster scale, that means either re-sealing per cluster or restricting Sealed Secrets to genuinely cluster-local secrets.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Secrets Operator
&lt;/h2&gt;

&lt;p&gt;ESO inverts the model entirely: the secret's actual value never enters Git at all. What's committed is a reference — "fetch this key from AWS Secrets Manager" — and a controller resolves that reference into a real &lt;code&gt;Secret&lt;/code&gt; by calling the secrets backend at sync time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-secrets.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ExternalSecret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-db-creds&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-secrets-manager&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-db-creds&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service/prod/db-password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This manifest is entirely safe to commit — there's no ciphertext, no encoded value. The sensitive part is the &lt;em&gt;value&lt;/em&gt; at &lt;code&gt;checkout-service/prod/db-password&lt;/code&gt;, which lives only in AWS Secrets Manager.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it gets you:&lt;/strong&gt; rotation with zero Git commits — update the value in Secrets Manager and every &lt;code&gt;ExternalSecret&lt;/code&gt; referencing it picks up the change on its next &lt;code&gt;refreshInterval&lt;/code&gt; cycle. Centralized secret storage that extends your existing cloud secrets service into Kubernetes rather than maintaining a separate story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't get you:&lt;/strong&gt; the secrets backend must be reachable from every cluster running ESO — reintroducing the network-reachability planning from multi-cluster architecture, this time for every spoke's path to Secrets Manager or Key Vault.&lt;/p&gt;

&lt;p&gt;One scoping risk worth calling out: a &lt;code&gt;SecretStore&lt;/code&gt; backed by an overly broad IAM role — one that can read every key under &lt;code&gt;checkout-service/*&lt;/code&gt; rather than the specific key a given &lt;code&gt;ExternalSecret&lt;/code&gt; needs — means a misconfigured &lt;code&gt;ExternalSecret&lt;/code&gt; in one namespace can pull secrets it was never meant to see. Scope the IAM policy behind each &lt;code&gt;SecretStore&lt;/code&gt; to the narrowest key path prefix that namespace actually owns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vault Agent Injector
&lt;/h2&gt;

&lt;p&gt;Where ESO pulls a secret into a Kubernetes &lt;code&gt;Secret&lt;/code&gt; object, Vault's Kubernetes integrations more often avoid creating a &lt;code&gt;Secret&lt;/code&gt; object at all. The Vault Agent Injector mounts secrets directly into a Pod's filesystem as files, at Pod startup, authenticated via Kubernetes' own service account token exchanged against Vault's Kubernetes auth method.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;vault.hashicorp.com/agent-inject&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
        &lt;span class="na"&gt;vault.hashicorp.com/role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service&lt;/span&gt;
        &lt;span class="na"&gt;vault.hashicorp.com/agent-inject-secret-db-creds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret/data/checkout-service/prod/db&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;checkout-service&lt;/span&gt;
          &lt;span class="c1"&gt;# application reads from /vault/secrets/db-creds — never from an env var&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it gets you:&lt;/strong&gt; the closest thing to "the secret was never a Kubernetes API object" — no &lt;code&gt;Secret&lt;/code&gt; resource exists to be over-broadly RBAC'd, listed via &lt;code&gt;kubectl get secrets&lt;/code&gt;, or accidentally dumped in a debug script.&lt;/p&gt;

&lt;p&gt;More importantly: Vault's database secrets engine can generate a unique, short-lived database username and password &lt;em&gt;per Pod&lt;/em&gt;, on demand, with a lease duration Vault itself enforces. A 1-hour lease means Vault revokes that specific credential at the database level after an hour, independent of whether anyone remembered to rotate anything. This changes rotation from "a scheduled human task" into "an enforced property of every credential issued" — a meaningfully different compliance posture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't get you:&lt;/strong&gt; simplicity. Running Vault well — unsealing, storage backend, HA, policy management — is a larger operational commitment than either of the other two approaches. If Vault isn't already part of your platform, adopting it purely to solve the GitOps secrets problem is usually the wrong-sized tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision Framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommended approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small team, few clusters, low rotation cadence&lt;/td&gt;
&lt;td&gt;Sealed Secrets — simplest, no external dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already using AWS Secrets Manager or Azure Key Vault&lt;/td&gt;
&lt;td&gt;External Secrets Operator — extends existing storage into Kubernetes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-cluster fleet, secrets needed consistently across clusters&lt;/td&gt;
&lt;td&gt;External Secrets Operator — reference model isn't cluster-scoped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already running Vault for dynamic credentials or PKI&lt;/td&gt;
&lt;td&gt;Vault Agent Injector — extends a tool you're already operating&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets where even a &lt;code&gt;Secret&lt;/code&gt; object existing in the cluster is too much exposure&lt;/td&gt;
&lt;td&gt;Vault injector — no &lt;code&gt;Secret&lt;/code&gt; object is ever created&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These aren't exclusive. Running ESO for the bulk of a fleet's secrets and reserving Vault's injector for the small set of credentials needing dynamic short-lived leases is a reasonable production posture — matching each secret's sensitivity to the approach that handles it well.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the Full Article
&lt;/h2&gt;

&lt;p&gt;The full article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The precise three-part definition of what "solving" secrets in GitOps actually means — and why no single tool covers all three&lt;/li&gt;
&lt;li&gt;The 90-day rotation cost at 40 services with Sealed Secrets, and why it surfaces at compliance audit time&lt;/li&gt;
&lt;li&gt;The cluster-scoping limitation of Sealed Secrets at multi-cluster scale&lt;/li&gt;
&lt;li&gt;Full &lt;code&gt;SecretStore&lt;/code&gt; + &lt;code&gt;ExternalSecret&lt;/code&gt; YAML with IRSA/workload-identity auth chain&lt;/li&gt;
&lt;li&gt;The IAM scoping risk with ESO and how it mirrors AppProject destinations restriction&lt;/li&gt;
&lt;li&gt;Vault's dynamic credential model explained — why it changes rotation from a task to an enforced property&lt;/li&gt;
&lt;li&gt;How all three approaches compose in a real fleet rather than requiring a single tool choice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://aloknecessary.in/blogs/gitops-secrets-management/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_syndication&amp;amp;utm_content=gitops-secrets-management" rel="noopener noreferrer"&gt;Secrets Management in a GitOps World — Full Article&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>kubernetes</category>
      <category>gitops</category>
    </item>
  </channel>
</rss>
