<?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: Kamil Mrzygłód</title>
    <description>The latest articles on DEV Community by Kamil Mrzygłód (@kamil-mrzyglod).</description>
    <link>https://dev.to/kamil-mrzyglod</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3878338%2F24ad974c-562d-49aa-bf2a-2151942319bf.jpeg</url>
      <title>DEV Community: Kamil Mrzygłód</title>
      <link>https://dev.to/kamil-mrzyglod</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kamil-mrzyglod"/>
    <language>en</language>
    <item>
      <title>What AMQP compatibility means for a local Azure emulator</title>
      <dc:creator>Kamil Mrzygłód</dc:creator>
      <pubDate>Tue, 26 May 2026 12:51:46 +0000</pubDate>
      <link>https://dev.to/kamil-mrzyglod/what-amqp-compatibility-means-for-a-local-azure-emulator-3de8</link>
      <guid>https://dev.to/kamil-mrzyglod/what-amqp-compatibility-means-for-a-local-azure-emulator-3de8</guid>
      <description>&lt;p&gt;"Supports AMQP" covers a lot of ground. A broker that accepts a TCP connection on port 5671 and exchanges OPEN/BEGIN frames with the client technically speaks AMQP. So does a broker that handles hundreds of thousands of messages per second with full PeekLock semantics, dead-letter routing, and session state. When an emulator claims AMQP compatibility, the interesting question is how far that compatibility actually goes — and specifically, whether it is deep enough for a real message-processing framework to drive it.&lt;/p&gt;

&lt;p&gt;This post is about what that means in practice, and why we know Topaz passes the test: because we got it wrong first and had to fix it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/TheCloudTheory/Topaz" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star Topaz on GitHub&lt;/a&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The two layers of Service Bus compatibility
&lt;/h2&gt;

&lt;p&gt;Most discussions of Azure Service Bus compatibility focus on the control plane: can you create namespaces, queues, and topics through ARM or the Azure CLI? That layer is important — it is what makes &lt;code&gt;az servicebus queue create&lt;/code&gt; and &lt;code&gt;azurerm_servicebus_queue&lt;/code&gt; work locally — but it is not the interesting layer for message-processing code.&lt;/p&gt;

&lt;p&gt;The interesting layer is the AMQP data plane, and it breaks down into two sub-layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SDK compatibility&lt;/strong&gt; — does the Azure Service Bus SDK connect, authenticate, send, and receive? This is the easier bar. The SDK connects through CBS (Claims-Based Security), opens a sender link for sending and a receiver link for receiving, and uses basic settled transfers for most operations. A passable emulator can get this right with a few hundred lines of AMQP link handling code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Framework compatibility&lt;/strong&gt; — does a message-processing framework like MassTransit, NServiceBus, or Rebus actually work on top of it? Frameworks drive a more complete subset of the AMQP specification. They open management links alongside receive links, use &lt;code&gt;$management&lt;/code&gt; request-response to perform operations the SDK does not surface directly, expect unsettled transfers with explicit client-side settlement, and rely on correct credit replenishment to maintain throughput. These behaviors are not optional features — they are the normal operating path.&lt;/p&gt;

&lt;p&gt;The gap between these two bars is larger than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MassTransit actually does over AMQP
&lt;/h2&gt;

&lt;p&gt;MassTransit's Azure Service Bus transport (&lt;code&gt;MassTransit.Azure.ServiceBus.Core&lt;/code&gt;) uses the Azure SDK as its underlying client but adds a layer of messaging conventions on top. When a &lt;code&gt;ReceiveEndpoint&lt;/code&gt; starts, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Opens an AMQP session and a receiver link to the queue.&lt;/li&gt;
&lt;li&gt;Immediately opens a second link to &lt;code&gt;&amp;lt;queue&amp;gt;/$management&lt;/code&gt; — a request-response link used for management operations like &lt;code&gt;com.microsoft:update-disposition&lt;/code&gt; and &lt;code&gt;com.microsoft:renew-lock&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sends an AMQP &lt;code&gt;FLOW&lt;/code&gt; frame on the receiver link with initial link credit, indicating how many messages it is prepared to accept.&lt;/li&gt;
&lt;li&gt;For every message it processes, sends a &lt;code&gt;DISPOSITION&lt;/code&gt; frame to complete or abandon it, then expects the broker to update the session's delivery state and replenish credit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 is where most partial AMQP implementations break down. The Azure Service Bus SDK does not surface queue-level &lt;code&gt;$management&lt;/code&gt; directly to callers; it is an internal transport detail. The root &lt;code&gt;$management&lt;/code&gt; link is handled by &lt;code&gt;IRequestProcessor&lt;/code&gt; in most AMQP server implementations, but queue-scoped &lt;code&gt;$management&lt;/code&gt; links are distinct — they attach to the queue's link processor, not the root processor. An emulator that routes all &lt;code&gt;$management&lt;/code&gt; traffic to one handler will complete the CBS authentication but silently drop every queue management request, causing MassTransit's &lt;code&gt;CompleteAsync&lt;/code&gt; to wait 60 seconds for a response that never arrives.&lt;/p&gt;

&lt;p&gt;Step 4 is where the second class of failures appears. If the broker sends transfers as sender-settled (the &lt;code&gt;settled&lt;/code&gt; bit set in the TRANSFER frame), the receiver never adds the delivery to its unsettled map. When MassTransit calls &lt;code&gt;CompleteAsync&lt;/code&gt;, the SDK sees no pending delivery with that lock token, settlement happens locally without waiting for broker confirmation, and no &lt;code&gt;DISPOSITION&lt;/code&gt; frame is sent. The broker never gets the acknowledgement it expects. Credit is consumed but never replenished. After the first message, the consumer stops receiving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs we found
&lt;/h2&gt;

&lt;p&gt;Running MassTransit against an early version of Topaz exposed exactly these two failure modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: Missing queue &lt;code&gt;$management&lt;/code&gt; handler.&lt;/strong&gt; Topaz already handled the root &lt;code&gt;$management&lt;/code&gt; link for CBS token validation. Queue-scoped management links were being attached to the link processor without a handler. MassTransit's &lt;code&gt;CompleteAsync&lt;/code&gt; calls timed out after 60 seconds with an &lt;code&gt;amqp:internal-error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix required intercepting &lt;code&gt;ATTACH&lt;/code&gt; frames addressed to &lt;code&gt;&amp;lt;anything&amp;gt;/$management&lt;/code&gt; inside &lt;code&gt;LinkProcessor&lt;/code&gt; and routing them to a dedicated request-response endpoint — separate from the root management handler. On the sender side, the endpoint registers a request processor that reads the &lt;code&gt;operation&lt;/code&gt; property from incoming application properties, builds the appropriate response (status code, correlation ID, operation-specific payload for &lt;code&gt;com.microsoft:renew-lock&lt;/code&gt;), and sends it back on the paired response link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: Wrong management response property names.&lt;/strong&gt; Once queue management requests were being answered, MassTransit's completion path started working — but threw &lt;code&gt;amqp:internal-error (GeneralError)&lt;/code&gt; instead of returning. Decompiling the Azure SDK revealed the issue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;AmqpResponseStatusCode&lt;/span&gt; &lt;span class="nf"&gt;GetResponseStatusCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;AmqpMessage&lt;/span&gt; &lt;span class="n"&gt;responseMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// reads responseMessage.ApplicationProperties.Map["statusCode"]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The initial management responses used &lt;code&gt;status-code&lt;/code&gt; and &lt;code&gt;status-description&lt;/code&gt; — the property names from the CBS specification. Service Bus management uses camelCase: &lt;code&gt;statusCode&lt;/code&gt; and &lt;code&gt;statusDescription&lt;/code&gt;. The SDK parsed the response, found no &lt;code&gt;statusCode&lt;/code&gt; key, and treated the reply as a failure. Changing two string literals fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 3: Sender-settled transfers.&lt;/strong&gt; After the response key fix, the &lt;code&gt;amqp:internal-error&lt;/code&gt; disappeared. But only the first message was consumed; the consumer then sat idle. Looking at the AMQP frame trace showed: one &lt;code&gt;FLOW&lt;/code&gt; frame from the consumer link, one outgoing &lt;code&gt;TRANSFER&lt;/code&gt;, then silence. No new &lt;code&gt;FLOW&lt;/code&gt; frame, no credit replenishment.&lt;/p&gt;

&lt;p&gt;The cause was in &lt;code&gt;OutgoingLinkEndpoint&lt;/code&gt;. Topaz was setting the &lt;code&gt;Settled&lt;/code&gt; property on outgoing deliveries to &lt;code&gt;true&lt;/code&gt; — sender-settled transfers. The SDK never put the delivery in the unsettled map. &lt;code&gt;CompleteAsync&lt;/code&gt; short-circuited. No &lt;code&gt;DISPOSITION&lt;/code&gt; came back. No credit was restored. The fix was a one-line change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: sender-settled — credit consumed without replenishment&lt;/span&gt;
&lt;span class="n"&gt;DeliverySettledProperty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: unsettled — let the receiver settle explicitly via DISPOSITION&lt;/span&gt;
&lt;span class="n"&gt;DeliverySettledProperty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer now receives a message, completes it, the broker sees the &lt;code&gt;DISPOSITION&lt;/code&gt; and returns credit — standard PeekLock behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the working example looks like
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Topaz.Examples.MassTransit&lt;/code&gt; project in the repository is a minimal ASP.NET Core app that starts a Topaz container via Testcontainers, provisions a Service Bus namespace and queue via the ARM API, and wires up MassTransit with a consumer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMassTransit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddConsumer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MessageConsumer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UsingAzureServiceBus&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TopazResourceHelpers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetServiceBusConnectionStringWithTls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sbnamespace"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReceiveEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sbqueue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConfigureConsumer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MessageConsumer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PrefetchCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&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;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MassTransit uses the TLS endpoint (port 5671) because it expects a standard Azure Service Bus connection string without &lt;code&gt;UseDevelopmentEmulator=true&lt;/code&gt;. The non-TLS endpoint (port 8889) uses pre-settled receive-and-delete semantics — compatible with the Azure SDK's development emulator mode, but not with how MassTransit drives the receive path. For any framework that manages its own PeekLock cycle, the TLS endpoint is the right choice.&lt;/p&gt;

&lt;p&gt;The worker sends one message per second. With the three bugs above fixed, the output is exactly one &lt;code&gt;Message dispatched&lt;/code&gt; and one &lt;code&gt;Message consumed&lt;/code&gt; per second — sustained indefinitely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Message dispatched: {"Timestamp":"2026-05-26T09:14:35.86...","Message":"The time is ..."}
Message consumed:   {"Timestamp":"2026-05-26T09:14:35.86...","Message":"The time is ..."}
Message dispatched: {"Timestamp":"2026-05-26T09:14:36.97...","Message":"The time is ..."}
Message consumed:   {"Timestamp":"2026-05-26T09:14:36.97...","Message":"The time is ..."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why MassTransit support is a meaningful signal
&lt;/h2&gt;

&lt;p&gt;MassTransit is not the only framework that exercises this part of the AMQP specification, but it is a widely-used one in the .NET ecosystem and it drives all three of the failure modes described above simultaneously. If Topaz runs MassTransit end-to-end, it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Queue-scoped &lt;code&gt;$management&lt;/code&gt; request-response is implemented and returns correct responses.&lt;/li&gt;
&lt;li&gt;Transfers are unsettled, so receivers that manage their own settlement cycle work correctly.&lt;/li&gt;
&lt;li&gt;Credit replenishment is functional, so sustained throughput is possible without the consumer stalling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;NServiceBus's Azure Service Bus transport and any other library that implements the full PeekLock cycle over the Azure SDK should follow the same path.&lt;/p&gt;

&lt;p&gt;What this does &lt;strong&gt;not&lt;/strong&gt; guarantee: dead-letter queues, message sessions, topic subscription rules, and partitioned entities are not yet implemented. If your application depends on those features, the Azure Service Bus Emulator still has the advantage on that specific surface. Topaz's &lt;a href="https://topaz.thecloudtheory.com/roadmap/" rel="noopener noreferrer"&gt;roadmap&lt;/a&gt; tracks when those features are coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison with the Azure Service Bus Emulator
&lt;/h2&gt;

&lt;p&gt;The Microsoft emulator ships as two Docker containers (emulator plus SQL Server), configures entities via a static &lt;code&gt;config.json&lt;/code&gt; at startup, and does not implement the ARM control plane. What it does have is a more complete messaging feature set at the moment: dead-letter queues, message sessions, and topic filters work today.&lt;/p&gt;

&lt;p&gt;The trade-off is concrete. If you need &lt;code&gt;az servicebus queue create&lt;/code&gt; to work locally, Terraform &lt;code&gt;azurerm_servicebus_queue&lt;/code&gt; to apply locally, or multiple namespaces in the same environment, the emulator cannot help — it has no ARM API. If you need dead-letter queues or message sessions, Topaz cannot yet help.&lt;/p&gt;

&lt;p&gt;For teams in between — who need a real PeekLock consumer with sustained receive throughput and ARM-level infrastructure tooling in the same local process — Topaz is the current answer.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>testing</category>
    </item>
    <item>
      <title>Let Copilot handle your local Azure setup via MCP</title>
      <dc:creator>Kamil Mrzygłód</dc:creator>
      <pubDate>Thu, 21 May 2026 08:15:21 +0000</pubDate>
      <link>https://dev.to/kamil-mrzyglod/let-copilot-handle-your-local-azure-setup-via-mcp-4hm8</link>
      <guid>https://dev.to/kamil-mrzyglod/let-copilot-handle-your-local-azure-setup-via-mcp-4hm8</guid>
      <description>&lt;p&gt;The best GitHub Copilot experience for local Azure development is not "write me a bash script that calls &lt;code&gt;az group create&lt;/code&gt;". It is telling the assistant what you need to build and having it wire up the local infrastructure while you write the application code. Instead of tab-switching to a terminal, remembering the right parameter names, and sequencing five CLI commands in the right order, you describe the stack in natural language and the assistant handles it — Key Vault, Storage Account, Service Bus namespace, all provisioned, connection strings returned, ready to paste into your configuration file.&lt;/p&gt;

&lt;p&gt;Topaz ships a &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP) server that makes this possible against the local emulator. This post covers how the MCP server works, why the Docker networking setup is non-trivial, and what the full setup looks like end to end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/TheCloudTheory/Topaz" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star Topaz on GitHub&lt;/a&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually does here
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol is a standard that lets AI assistants call external tools and receive structured results — creating real side effects rather than generating commands for you to copy-paste and run manually. When Copilot calls a tool, the call goes to the MCP server process, the server executes it against a live Topaz instance, and the result comes back to the assistant. The assistant sees real data: the vault URI of the Key Vault it just created, the connection string for the Service Bus namespace, the login server for the Container Registry.&lt;/p&gt;

&lt;p&gt;This matters because the assistant can chain those results. After creating a Storage Account, it can pass the connection string directly to the next tool call that creates a Key Vault secret — without you manually copying values between terminal windows.&lt;/p&gt;

&lt;p&gt;The Topaz MCP server exposes two kinds of capabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt; — individual operations: create a resource group, provision a Key Vault, fetch all connection strings in a subscription, check emulator health.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts&lt;/strong&gt; — pre-defined multi-step recipes that wire tools together into complete scenarios: bootstrap a full dev environment, set up a Functions-ready stack, provision a document processing pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Docker connectivity problem
&lt;/h2&gt;

&lt;p&gt;When you run the MCP server as a Docker container — which is the recommended way to distribute it, so clients do not need the .NET runtime — it needs to reach the Topaz host over the network. The Topaz host also runs as a Docker container. Two containers running on the same machine are not on the same network by default; they cannot reach each other by hostname.&lt;/p&gt;

&lt;p&gt;The first instinct is &lt;code&gt;--network host&lt;/code&gt;. The Docker documentation describes this as "the container shares the host's network namespace". On Linux this is exactly what it means — the container sees &lt;code&gt;localhost&lt;/code&gt;, all published ports, and everything else on the host's network stack. In practice, this works when Topaz runs directly on the host machine. But it breaks as soon as both Topaz and the MCP server are running as containers, because &lt;code&gt;localhost&lt;/code&gt; inside the MCP container is the Linux VM's loopback, not a path to the Topaz container.&lt;/p&gt;

&lt;p&gt;Even on Linux, where &lt;code&gt;--network host&lt;/code&gt; does give the MCP container access to the host network stack, there is a second problem: wildcard subdomains. The Topaz MCP tools do not only call &lt;code&gt;topaz.local.dev:8899&lt;/code&gt; for ARM operations. When the Key Vault tool calls the Key Vault data-plane, it calls &lt;code&gt;&amp;lt;vault-name&amp;gt;.vault.topaz.local.dev:8898&lt;/code&gt;. When the Container Registry tool calls the registry endpoint, it calls &lt;code&gt;&amp;lt;registry-name&amp;gt;.cr.topaz.local.dev:8892&lt;/code&gt;. These subdomains are dynamic — they depend on resource names the user provides at runtime. &lt;code&gt;--network host&lt;/code&gt; gives network access but does nothing for DNS; &lt;code&gt;topaz.local.dev&lt;/code&gt; might resolve if the host has it in &lt;code&gt;/etc/hosts&lt;/code&gt;, but no host-machine &lt;code&gt;/etc/hosts&lt;/code&gt; entry covers &lt;code&gt;my-vault.vault.topaz.local.dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The correct solution has two parts: a shared Docker network, and a wildcard DNS resolver.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network and DNS setup
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;RunTopazAsContainer&lt;/code&gt; tool returns a shell command that sets up the full environment in one step. The command does three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Create a user-defined bridge network with a fixed subnet:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create &lt;span class="nt"&gt;--subnet&lt;/span&gt; 172.28.0.0/16 topaz-net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;User-defined networks have Docker's built-in DNS — containers can resolve each other by name. The fixed subnet allows assigning stable IP addresses, which the dnsmasq configuration depends on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Start a lightweight DNS resolver at a fixed IP:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; topaz-dns &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt; topaz-net &lt;span class="nt"&gt;--ip&lt;/span&gt; 172.28.0.53 &lt;span class="se"&gt;\&lt;/span&gt;
  alpine sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"apk add -q --no-cache dnsmasq &amp;amp;&amp;amp; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
    dnsmasq --no-daemon --no-resolv --server=8.8.8.8 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
            --address=/.topaz.local.dev/172.28.0.10"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--address=/.topaz.local.dev/172.28.0.10&lt;/code&gt; directive tells dnsmasq to resolve every hostname that ends in &lt;code&gt;.topaz.local.dev&lt;/code&gt; — at any depth — to &lt;code&gt;172.28.0.10&lt;/code&gt;. That covers &lt;code&gt;topaz.local.dev&lt;/code&gt; itself, &lt;code&gt;my-vault.vault.topaz.local.dev&lt;/code&gt;, &lt;code&gt;stdev.blob.storage.topaz.local.dev&lt;/code&gt;, any registry name, any Service Bus namespace. The resolver is at &lt;code&gt;172.28.0.53&lt;/code&gt; following DNS convention; all queries it does not know about forward to &lt;code&gt;8.8.8.8&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Start the Topaz host container at the fixed IP:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; topaz.local.dev &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt; topaz-net &lt;span class="nt"&gt;--ip&lt;/span&gt; 172.28.0.10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8899:8899 &lt;span class="nt"&gt;-p&lt;/span&gt; 8898:8898 ... &lt;span class="se"&gt;\&lt;/span&gt;
  thecloudtheory/topaz-host:&amp;lt;version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Topaz container is named &lt;code&gt;topaz.local.dev&lt;/code&gt;. On a user-defined network, Docker DNS resolves container names, so even without the dnsmasq sidecar, the base &lt;code&gt;topaz.local.dev&lt;/code&gt; hostname would resolve by container name alone. The dnsmasq sidecar handles everything else.&lt;/p&gt;

&lt;p&gt;The MCP container connects to the same network and points at the DNS sidecar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; topaz-net &lt;span class="nt"&gt;--dns&lt;/span&gt; 172.28.0.53 thecloudtheory/topaz-mcp:&amp;lt;version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--dns&lt;/code&gt; flag is set at container creation time — it controls what Docker writes into the container's &lt;code&gt;/etc/resolv.conf&lt;/code&gt; before any process starts. Once the MCP container is running, &lt;code&gt;topaz.local.dev&lt;/code&gt; and all its subdomains resolve to &lt;code&gt;172.28.0.10&lt;/code&gt;, which is the Topaz host.&lt;/p&gt;

&lt;h2&gt;
  
  
  The certificate
&lt;/h2&gt;

&lt;p&gt;Every Topaz endpoint is HTTPS. The Topaz host uses a self-signed certificate that covers &lt;code&gt;*.topaz.local.dev&lt;/code&gt; and all its subdomain patterns. The MCP container image has this certificate pre-installed in the Ubuntu system CA store — it is baked in during the Docker image build the same way the &lt;a href="https://github.com/TheCloudTheory/Topaz/tree/main/Examples/Compose" rel="noopener noreferrer"&gt;Compose example app&lt;/a&gt; handles it:&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;ubuntu:noble&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;cert-builder&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="nt"&gt;-qq&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; ca-certificates
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; certificate/topaz.crt /usr/local/share/ca-certificates/topaz.crt&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;update-ca-certificates

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled&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;final&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=cert-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cert-builder stage runs &lt;code&gt;update-ca-certificates&lt;/code&gt; (not available in the minimal chiseled final image) and the updated CA bundle is copied across. The MCP server process inherits the system CA store and trusts Topaz's certificate without any code-level bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting it up in VS Code
&lt;/h2&gt;

&lt;p&gt;The one-time setup is:&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;# Create the shared Docker network once — survives reboots until you remove it manually&lt;/span&gt;
docker network create &lt;span class="nt"&gt;--subnet&lt;/span&gt; 172.28.0.0/16 topaz-net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the server to &lt;code&gt;.vscode/mcp.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Topaz"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--rm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-i"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--network"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"topaz-net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--dns"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"172.28.0.53"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"thecloudtheory/topaz-mcp:&amp;lt;version&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After saving, VS Code prompts you to start the server. It appears in the MCP Servers panel, and GitHub Copilot picks up the tools automatically.&lt;/p&gt;

&lt;p&gt;The first time you use the MCP server, ask Copilot to start Topaz:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Start Topaz using the latest beta image for Apple Silicon."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Copilot calls &lt;code&gt;RunTopazAsContainer&lt;/code&gt; with &lt;code&gt;platform=linux/arm64&lt;/code&gt;, gets back a shell command, and tells you to run it. After that, every provisioning tool has a live Topaz instance to talk to. Depending on the LLM you're using you can just skip the platform architecture part:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Start Topaz using the latest beta image."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most of the models will be able to infer the architecture automatically and pass the correct &lt;code&gt;platform&lt;/code&gt; parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real session looks like
&lt;/h2&gt;

&lt;p&gt;Once the emulator is running, provisioning a complete local dev environment is a single chat message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Create a subscription called dev-local with ID &lt;code&gt;10000000-0000-0000-0000-000000000001&lt;/code&gt;, a resource group &lt;code&gt;rg-dev&lt;/code&gt; in &lt;code&gt;westeurope&lt;/code&gt;, a storage account &lt;code&gt;stdevlocal001&lt;/code&gt;, a Service Bus namespace &lt;code&gt;sbdevlocal&lt;/code&gt; with a queue called &lt;code&gt;orders&lt;/code&gt;, and a Key Vault &lt;code&gt;kv-dev&lt;/code&gt; with a secret &lt;code&gt;connection-string&lt;/code&gt; set to the Service Bus connection string. Use superadmin access."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Copilot issues seven tool calls in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CreateSubscription(subscriptionId=10000000-..., subscriptionName=dev-local, objectId=00000000-...)
CreateResourceGroup(subscriptionId=10000000-..., resourceGroupName=rg-dev, location=westeurope, ...)
CreateStorageAccount(...)
CreateServiceBusNamespace(...)
CreateServiceBusQueue(namespaceName=sbdevlocal, queueName=orders, ...)
CreateKeyVault(...)
  → vaultUri = https://kv-dev.vault.topaz.local.dev:8898
  → seededSecret = connection-string
GetConnectionStrings(subscriptionId=10000000-..., ...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final &lt;code&gt;GetConnectionStrings&lt;/code&gt; call returns a structured list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Storage account stdevlocal001:
  connectionString: DefaultEndpointsProtocol=https;AccountName=stdevlocal001;...
  blobServiceUri:   https://stdevlocal001.blob.storage.topaz.local.dev:8891/
  queueServiceUri:  https://stdevlocal001.queue.storage.topaz.local.dev:8893/

Service Bus namespace sbdevlocal:
  connectionString: Endpoint=sb://sbdevlocal.servicebus.topaz.local.dev:5671;...

Key Vault kv-dev:
  vaultUri: https://kv-dev.vault.topaz.local.dev:8898
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything in that output is a real, reachable endpoint backed by a live Topaz instance. You can paste the connection strings directly into &lt;code&gt;appsettings.Development.json&lt;/code&gt; or a &lt;code&gt;.env&lt;/code&gt; file and start the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompts: pre-defined stacks
&lt;/h2&gt;

&lt;p&gt;For common scenarios, the MCP server exposes prompts — multi-step recipes that wire the individual tools together so you do not have to specify the sequence yourself. Invoke them by name in the chat:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;bootstrap-topaz&lt;/code&gt;&lt;/strong&gt; — first-time setup. Starts the container, registers a subscription, creates a resource group, confirms health. This is the entry point before any provisioning prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;setup-functions-local-dev&lt;/code&gt;&lt;/strong&gt; — provisions a Storage Account (for &lt;code&gt;AzureWebJobsStorage&lt;/code&gt;), a Service Bus namespace and queue (for trigger), and a Key Vault with the storage connection string already stored as a secret. Returns a ready-to-paste &lt;code&gt;local.settings.json&lt;/code&gt; snippet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;setup-event-driven-microservice&lt;/code&gt;&lt;/strong&gt; — creates a Service Bus namespace with a command queue and an event topic (with a subscription), plus a Key Vault with the connection string. Models the write-side/read-side split directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;setup-multi-tenant-fixtures&lt;/code&gt;&lt;/strong&gt; — takes a list of tenant names and a naming prefix, then creates an isolated subscription, resource group, storage account, and Key Vault for each tenant. Useful for testing tenant isolation or seeding multi-tenant integration tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;inspect-environment&lt;/code&gt;&lt;/strong&gt; — runs a health check, lists subscriptions, and returns connection strings for every provisioned resource in one pass. Useful when you return to a session and want to know the current state.&lt;/p&gt;

&lt;p&gt;Each prompt is a structured instruction message that tells Copilot exactly which tools to call and in which order. You supply the parameter values; the prompt handles the sequencing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is most useful
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Onboarding.&lt;/strong&gt; A new developer cloning a repository does not need to know which Azure services the application uses or how to set them up locally. They ask Copilot to bootstrap the environment, Copilot reads the project context, picks the right prompt, and the infrastructure is ready before they have finished reading the README.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration testing setup.&lt;/strong&gt; Instead of maintaining a shared dev Azure subscription with manually-created test resources, each developer runs their own full local stack. Resources are created fresh per session, so tests never share state and there is nothing to clean up in a shared environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure experimentation.&lt;/strong&gt; Testing a new Service Bus topology or a multi-tenant naming convention against a real API before committing to it is fast when the infrastructure is local. Create it, test the application behaviour, tear it down, adjust the design, repeat — all in one chat session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI.&lt;/strong&gt; The same MCP workflow that provisions resources locally can provision them inside a CI job. The &lt;code&gt;GetConnectionStrings&lt;/code&gt; output feeds directly into the test runner environment. Topaz starts in a Docker container in the CI step, the MCP server provisions what the tests need, the tests run against real emulated endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tearing down
&lt;/h2&gt;

&lt;p&gt;When you are done with a session:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Stop Topaz and clean up the containers."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Copilot calls &lt;code&gt;StopTopazContainer&lt;/code&gt;, which stops the Topaz host container, the &lt;code&gt;topaz-dns&lt;/code&gt; resolver container, and removes the &lt;code&gt;topaz-net&lt;/code&gt; network. The next session starts fresh.&lt;/p&gt;

&lt;p&gt;If you just want to check what is running before deciding whether to stop it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Is Topaz running and which services are up?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;GetTopazStatus&lt;/code&gt; hits the health endpoint and probes all service ports, returning a per-service reachability report. Useful when something is not behaving as expected and you want to rule out an infrastructure problem before looking at the application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design constraint that shaped everything
&lt;/h2&gt;

&lt;p&gt;The most important thing the MCP server does differently from a simple "generate &lt;code&gt;az&lt;/code&gt; commands" approach is that it calls real APIs and returns real data. When &lt;code&gt;CreateKeyVault&lt;/code&gt; returns a vault URI, that URI resolves to a live endpoint. When &lt;code&gt;GetConnectionStrings&lt;/code&gt; returns a connection string, it connects to a live Service Bus namespace. The assistant is not simulating infrastructure — it is creating it.&lt;/p&gt;

&lt;p&gt;That distinction is what makes the DNS and certificate setup matter. A generated &lt;code&gt;az keyvault create&lt;/code&gt; command does not care whether the domain resolves inside a container. A real &lt;code&gt;SecretClient&lt;/code&gt; call to &lt;code&gt;https://kv-dev.vault.topaz.local.dev:8898&lt;/code&gt; does. The dnsmasq sidecar and the pre-installed certificate are not operational overhead — they are the thing that makes the data the assistant returns actually usable by an application.&lt;/p&gt;

</description>
      <category>githubcopilot</category>
      <category>azure</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Building a devcontainer for Topaz: workspace mounts, DNS wildcards, and why /etc/resolv.conf always wins</title>
      <dc:creator>Kamil Mrzygłód</dc:creator>
      <pubDate>Tue, 12 May 2026 14:23:29 +0000</pubDate>
      <link>https://dev.to/kamil-mrzyglod/building-a-devcontainer-for-topaz-workspace-mounts-dns-wildcards-and-why-etcresolvconf-always-ne</link>
      <guid>https://dev.to/kamil-mrzyglod/building-a-devcontainer-for-topaz-workspace-mounts-dns-wildcards-and-why-etcresolvconf-always-ne</guid>
      <description>&lt;p&gt;The "Open in Dev Container" badge is one of the highest-friction-to-value improvements you can make to a developer tool. Someone lands on your README, clicks the badge, waits for a container to build, and starts using the tool — no install steps, no certificate trust ceremony, no "is port 8899 available on this machine" questions. The conversion from "interesting project" to "I am actually running this" happens in the time it takes Docker to pull an image.&lt;/p&gt;

&lt;p&gt;Getting there, however, requires navigating a specific class of problems that are not particularly hard once you understand them but are completely opaque until you do. This post is a technical account of building the Topaz devcontainer: the three services that ended up in the Docker Compose file, why &lt;code&gt;/etc/resolv.conf&lt;/code&gt; defeated two consecutive DNS approaches, and what the working architecture looks like.&lt;/p&gt;

&lt;p&gt;By the way - if you're interested in further development of Topaz or just want to appreciate the idea, feel free to &lt;a href="https://github.com/TheCloudTheory/Topaz" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star Topaz on GitHub&lt;/a&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What we were building
&lt;/h2&gt;

&lt;p&gt;Topaz is a single binary that emulates Azure Storage, Key Vault, Service Bus, Event Hubs, Container Registry, Managed Identity, RBAC, ARM, and Entra ID. The devcontainer goal was straightforward: when a developer opens the repository in VS Code, Topaz Host should already be running, all the service ports should be reachable, TLS certificates should be trusted, and DNS wildcards for &lt;code&gt;*.topaz.local.dev&lt;/code&gt; should resolve — without any manual setup steps.&lt;/p&gt;

&lt;p&gt;The target end state was a developer terminal like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;topaz health
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"workingDirectory"&lt;/span&gt;:&lt;span class="s2"&gt;"/app"&lt;/span&gt;,&lt;span class="s2"&gt;"version"&lt;/span&gt;:&lt;span class="s2"&gt;"..."&lt;/span&gt;,&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"Healthy"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;curl https://topaz.local.dev:8899/health
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"workingDirectory"&lt;/span&gt;:&lt;span class="s2"&gt;"/app"&lt;/span&gt;,&lt;span class="s2"&gt;"version"&lt;/span&gt;:&lt;span class="s2"&gt;"..."&lt;/span&gt;,&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"Healthy"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;curl https://my-vault.vault.topaz.local.dev:8898/secrets
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;:[],&lt;span class="s2"&gt;"nextLink"&lt;/span&gt;:null&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second and third commands — using hostnames rather than IP addresses — are the ones that turned out to be the interesting engineering problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The initial architecture: three services in Docker Compose
&lt;/h2&gt;

&lt;p&gt;The devcontainer uses Docker Compose mode, which VS Code's Dev Containers extension supports directly. The natural structure maps to three services:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;devcontainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# the VS Code workspace container&lt;/span&gt;
  &lt;span class="na"&gt;topaz&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# the Topaz Host sidecar&lt;/span&gt;
  &lt;span class="na"&gt;dns-sidecar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# wildcard DNS resolver (more on this later)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three share a bridge network with a fixed subnet (&lt;code&gt;172.28.0.0/16&lt;/code&gt;) and static IP assignments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;devcontainer&lt;/code&gt; at &lt;code&gt;172.28.0.2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;topaz&lt;/code&gt; at &lt;code&gt;172.28.0.10&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dns-sidecar&lt;/code&gt; at &lt;code&gt;172.28.0.53&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixed IPs are important. DNS configuration needs to point at an address that is stable before any container starts, and the &lt;code&gt;address=/.topaz.local.dev/172.28.0.10&lt;/code&gt; dnsmasq rule needs to know where Topaz lives without dynamic lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem one: workspace mount in Compose mode
&lt;/h2&gt;

&lt;p&gt;The first thing that breaks when you move a devcontainer to Docker Compose mode is workspace mounting. In a single-container devcontainer, VS Code automatically bind-mounts your local workspace folder using the &lt;code&gt;workspaceMount&lt;/code&gt; property. In Compose mode, VS Code tries to inject a workspace mount into a generated override compose file, but the injection is unreliable — particularly when the workspace is on an external drive or a path the container runtime does not have in its file-sharing configuration.&lt;/p&gt;

&lt;p&gt;The first sign of the problem is an error like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OCI runtime exec failed: exec failed: unable to start container process:
chdir to cwd ("/workspaces/Topaz") set in config.json failed: no such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/workspaces/Topaz&lt;/code&gt; directory does not exist because the bind mount never happened. Adding &lt;code&gt;workspaceMount&lt;/code&gt; to &lt;code&gt;devcontainer.json&lt;/code&gt; looks like it should help, but the Dev Containers schema explicitly does not allow it in Compose mode — VS Code will flag it as an unknown property and ignore it.&lt;/p&gt;

&lt;p&gt;The reliable fix is to declare the workspace mount explicitly in the Docker Compose file:&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;devcontainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcr.microsoft.com/devcontainers/base:ubuntu&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;../:/workspaces/Topaz:cached&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sleep infinity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;..&lt;/code&gt; is relative to &lt;code&gt;.devcontainer/&lt;/code&gt;, so it resolves to the repository root. &lt;code&gt;workspaceFolder&lt;/code&gt; in &lt;code&gt;devcontainer.json&lt;/code&gt; must match the target path exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"workspaceFolder"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/workspaces/Topaz"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works when the container runtime can access the source path. When it cannot — because you are using Colima and an external drive is not in its mount list — Docker creates the bind-mount target as an empty directory rather than failing loudly, which is how we ended up with &lt;code&gt;topaz.crt&lt;/code&gt; and &lt;code&gt;topaz.key&lt;/code&gt; appearing as empty directories inside the container instead of files. Solving that for certificates is what led to the second problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem two: distributing TLS certificates without bind mounts
&lt;/h2&gt;

&lt;p&gt;Topaz requires a TLS certificate to start. The certificate files live in &lt;code&gt;certificate/topaz.crt&lt;/code&gt; and &lt;code&gt;certificate/topaz.key&lt;/code&gt; in the repository. The straightforward approach — bind-mounting them into the Topaz sidecar — fails on Colima (and on Docker Desktop when the path is outside the configured file-sharing list) for the same reason workspace mounts fail: the bind mount silently becomes an empty directory.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/TheCloudTheory/Topaz/tree/main/Examples/Compose" rel="noopener noreferrer"&gt;Docker Compose example in the repository&lt;/a&gt; solved this a different way: using &lt;code&gt;docker cp&lt;/code&gt; to populate a named volume, the same mechanism Testcontainers' &lt;code&gt;WithResourceMapping&lt;/code&gt; uses internally. A named volume is always accessible to containers regardless of which paths the runtime has permission to bind-mount from the host.&lt;/p&gt;

&lt;p&gt;The devcontainer uses the same pattern. A shell script at &lt;code&gt;.devcontainer/init-certs.sh&lt;/code&gt; runs via &lt;code&gt;initializeCommand&lt;/code&gt; in &lt;code&gt;devcontainer.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"initializeCommand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash .devcontainer/init-certs.sh"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;initializeCommand&lt;/code&gt; runs on the &lt;strong&gt;host machine&lt;/strong&gt; before any container starts, which means it has access to the certificate files at their actual paths:&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;VOLUME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"topaz-devcontainer-certs"&lt;/span&gt;
&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker create &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VOLUME&lt;/span&gt;&lt;span class="s2"&gt;:/certs"&lt;/span&gt; alpine&lt;span class="si"&gt;)&lt;/span&gt;
docker &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/topaz.crt"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;:/certs/topaz.crt"&lt;/span&gt;
docker &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/topaz.key"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;:/certs/topaz.key"&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Topaz sidecar then mounts the named volume rather than bind-mounting the files 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;topaz&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;topaz-devcontainer-certs:/certs:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;topaz-data:/app/.topaz&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--certificate-file&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/certs/topaz.crt&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--certificate-key&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/certs/topaz.key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The named volume is declared as &lt;code&gt;external: true&lt;/code&gt; in the compose file so Docker Compose does not try to create it (the &lt;code&gt;initializeCommand&lt;/code&gt; already did that).&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem three: DNS for *.topaz.local.dev
&lt;/h2&gt;

&lt;p&gt;This was the longest part of the investigation. Topaz services are reached at subdomains of &lt;code&gt;topaz.local.dev&lt;/code&gt; — &lt;code&gt;my-vault.vault.topaz.local.dev&lt;/code&gt;, &lt;code&gt;myaccount.blob.storage.topaz.local.dev&lt;/code&gt;, &lt;code&gt;myregistry.cr.topaz.local.dev&lt;/code&gt; — and these need to resolve to the Topaz sidecar IP inside the devcontainer. There is no wildcard support in &lt;code&gt;/etc/hosts&lt;/code&gt;, so a single &lt;code&gt;extra_hosts&lt;/code&gt; entry for &lt;code&gt;topaz.local.dev&lt;/code&gt; is not enough; every named resource would need a manual entry.&lt;/p&gt;

&lt;p&gt;The approach we tried first, failed, tried second, and failed in a different way before arriving at the working solution is worth describing in order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 1: extra_hosts in docker-compose.yml
&lt;/h3&gt;

&lt;p&gt;Docker Compose supports &lt;code&gt;extra_hosts&lt;/code&gt;, which injects entries into &lt;code&gt;/etc/hosts&lt;/code&gt;. The initial configuration added &lt;code&gt;topaz.local.dev&lt;/code&gt; to the devcontainer service:&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;devcontainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extra_hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;topaz.local.dev:172.28.0.10"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This did not work — not because the mechanism is wrong, but because VS Code generates a compose override file when it starts the devcontainer, and the override appears to discard or override &lt;code&gt;extra_hosts&lt;/code&gt; from the base file. The host entry never appeared in &lt;code&gt;/etc/hosts&lt;/code&gt; inside the container.&lt;/p&gt;

&lt;p&gt;Even if it had worked, it would only have solved &lt;code&gt;topaz.local.dev&lt;/code&gt;. Every vault, every storage account, every registry would still need a manual entry. The &lt;code&gt;extra_hosts&lt;/code&gt; approach was the wrong layer entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2: dnsmasq installed inside the devcontainer
&lt;/h3&gt;

&lt;p&gt;The next approach was to install &lt;code&gt;dnsmasq&lt;/code&gt; inside the devcontainer via &lt;code&gt;postCreateCommand&lt;/code&gt; and configure it to resolve &lt;code&gt;*.topaz.local.dev&lt;/code&gt; to &lt;code&gt;172.28.0.10&lt;/code&gt;. This is how the existing &lt;code&gt;install-linux.sh&lt;/code&gt; script works for non-container Linux installs.&lt;/p&gt;

&lt;p&gt;The first failure was a port 53 conflict. The devcontainer base image runs &lt;code&gt;systemd-resolved&lt;/code&gt; or has a stub resolver already listening on UDP port 53. Killing it with &lt;code&gt;systemctl stop systemd-resolved&lt;/code&gt; produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"systemd" is not running in this container due to its overhead.
Use the "service" command to start services instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;systemd does not run in devcontainers. The next attempt used &lt;code&gt;fuser -k 53/udp&lt;/code&gt; to kill whatever was on the port, which worked as a one-time fix.&lt;/p&gt;

&lt;p&gt;The second failure was &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. To make the container query our dnsmasq instance first, we needed to prepend &lt;code&gt;nameserver 127.0.0.1&lt;/code&gt; to &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. The file appeared writable, and our &lt;code&gt;tee&lt;/code&gt; overwrote it — but when a new shell opened, the change was gone. Docker bind-mounts &lt;code&gt;/etc/resolv.conf&lt;/code&gt; from a file managed by the container runtime. You can overwrite it with &lt;code&gt;tee&lt;/code&gt; within a single process's lifetime, but the next container process re-reads it from the bind-mounted source. You cannot delete it (&lt;code&gt;rm&lt;/code&gt; fails with "Device or resource busy"). Any content written to it via &lt;code&gt;tee&lt;/code&gt; in a &lt;code&gt;postCreateCommand&lt;/code&gt; is effectively transient.&lt;/p&gt;

&lt;p&gt;The third failure was timing. &lt;code&gt;postCreateCommand&lt;/code&gt; runs once after container creation. &lt;code&gt;postStartCommand&lt;/code&gt; runs on every start. dnsmasq installed during &lt;code&gt;postCreateCommand&lt;/code&gt; would not be running when the container was restarted — and &lt;code&gt;postStartCommand&lt;/code&gt; cannot install dnsmasq (apt is not available until &lt;code&gt;postCreateCommand&lt;/code&gt; has run). The lifecycle ordering makes in-container dnsmasq unreliable after a restart regardless of the resolv.conf problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The working solution: dnsmasq as a sidecar service
&lt;/h3&gt;

&lt;p&gt;The correct layer for this problem is not inside the devcontainer — it is in Docker Compose. Docker sets &lt;code&gt;/etc/resolv.conf&lt;/code&gt; based on the compose &lt;code&gt;dns:&lt;/code&gt; directive &lt;strong&gt;before&lt;/strong&gt; any container process starts, and the value persists for the lifetime of the container without anything inside the container being able to overwrite it.&lt;/p&gt;

&lt;p&gt;The solution is a dedicated DNS sidecar — a minimal alpine container running dnsmasq — and a &lt;code&gt;dns:&lt;/code&gt; entry on the devcontainer service pointing at it:&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;dns-sidecar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alpine:latest&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;sh -c "apk add --no-cache dnsmasq -q &amp;amp;&amp;amp;&lt;/span&gt;
           &lt;span class="s"&gt;echo 'address=/.topaz.local.dev/172.28.0.10' &amp;gt; /etc/dnsmasq.d/topaz.conf &amp;amp;&amp;amp;&lt;/span&gt;
           &lt;span class="s"&gt;dnsmasq --no-daemon --server=1.1.1.1 --server=8.8.8.8"&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;topaz-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ipv4_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.28.0.53"&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;devcontainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;172.28.0.53&lt;/span&gt;    &lt;span class="c1"&gt;# topaz DNS sidecar (resolves *.topaz.local.dev)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1.1.1.1&lt;/span&gt;        &lt;span class="c1"&gt;# fallback internet DNS&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dns-sidecar&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Docker Compose starts the devcontainer service, it writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;172&lt;/span&gt;.&lt;span class="m"&gt;28&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;53&lt;/span&gt;
&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;into the container's &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. This is a file Docker controls, not one we are trying to modify from inside the container. Any DNS lookup that reaches &lt;code&gt;172.28.0.53&lt;/code&gt; gets the &lt;code&gt;address=/.topaz.local.dev/172.28.0.10&lt;/code&gt; answer for Topaz subdomains and forwards everything else to &lt;code&gt;1.1.1.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;depends_on&lt;/code&gt; ensures the DNS sidecar is up before the devcontainer tries to use it. The &lt;code&gt;restart: unless-stopped&lt;/code&gt; keeps it running across container restarts. Because it is a separate service, the dnsmasq lifecycle is entirely independent of anything happening in &lt;code&gt;postCreateCommand&lt;/code&gt; or &lt;code&gt;postStartCommand&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After this change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;curl https://topaz.local.dev:8899/health
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"workingDirectory"&lt;/span&gt;:&lt;span class="s2"&gt;"/app"&lt;/span&gt;,&lt;span class="s2"&gt;"version"&lt;/span&gt;:&lt;span class="s2"&gt;"..."&lt;/span&gt;,&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"Healthy"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;curl https://my-vault.vault.topaz.local.dev:8898/secrets
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;:[],&lt;span class="s2"&gt;"nextLink"&lt;/span&gt;:null&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hosts-file entries. No manual entries per resource. Any resource created in Topaz with any name resolves automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What postCreate.sh does now
&lt;/h2&gt;

&lt;p&gt;With DNS and certificate distribution handled at the compose layer, &lt;code&gt;postCreateCommand&lt;/code&gt; is left with a smaller, cleaner set of responsibilities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust the Topaz TLS certificate&lt;/strong&gt; in the Ubuntu system CA store. This is a one-time operation — &lt;code&gt;update-ca-certificates&lt;/code&gt; ingests the cert so that &lt;code&gt;curl&lt;/code&gt;, the Azure SDK, and any other TLS client that uses the system store trusts Topaz's self-signed certificate without &lt;code&gt;--insecure&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Inject the certificate into the Azure CLI certifi bundle.&lt;/strong&gt; The Azure CLI ships its own bundled &lt;code&gt;cacert.pem&lt;/code&gt; separate from the system store. Without this step, &lt;code&gt;az rest --url https://topaz.local.dev:8899/...&lt;/code&gt; fails with an SSL error even though &lt;code&gt;curl&lt;/code&gt; works fine. The script finds the bundle at the path the &lt;code&gt;azure-cli&lt;/code&gt; devcontainer feature installs it (&lt;code&gt;/opt/az/lib/python*/site-packages/certifi/cacert.pem&lt;/code&gt;) and appends the Topaz cert.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Install the Topaz CLI.&lt;/strong&gt; The CLI (&lt;code&gt;topaz&lt;/code&gt;) and host binary (&lt;code&gt;topaz-host&lt;/code&gt;) are downloaded from the GitHub release. The version is resolved from &lt;code&gt;/releases&lt;/code&gt; rather than &lt;code&gt;/releases/latest&lt;/code&gt; because Topaz is currently in beta and beta releases do not appear at the &lt;code&gt;latest&lt;/code&gt; endpoint. The install is best-effort — if it fails (wrong architecture, network issue), the script continues rather than aborting the entire &lt;code&gt;postCreateCommand&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write shell environment variables.&lt;/strong&gt; &lt;code&gt;AZURE_TENANT_ID&lt;/code&gt; (the Topaz default tenant) and &lt;code&gt;REQUESTS_CA_BUNDLE&lt;/code&gt; (pointing at the system certificate store for Python-based tools) are appended to &lt;code&gt;~/.bashrc&lt;/code&gt; idempotently.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The final compose structure
&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;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;topaz-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
    &lt;span class="na"&gt;ipam&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;subnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.28.0.0/16"&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;topaz-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;topaz-devcontainer-certs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;devcontainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcr.microsoft.com/devcontainers/base:ubuntu&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;../:/workspaces/Topaz:cached&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sleep infinity&lt;/span&gt;
    &lt;span class="na"&gt;dns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;172.28.0.53&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1.1.1.1&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dns-sidecar&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;topaz-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ipv4_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.28.0.2"&lt;/span&gt;

  &lt;span class="na"&gt;topaz&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;thecloudtheory/topaz-host:latest&lt;/span&gt;
    &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linux/amd64&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;topaz-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ipv4_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.28.0.10"&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8899:8899"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8898:8898"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8892:8892"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8891:8891"&lt;/span&gt;
      &lt;span class="c1"&gt;# ... all service ports&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;topaz-devcontainer-certs:/certs:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;topaz-data:/app/.topaz&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--certificate-file&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/certs/topaz.crt&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--certificate-key&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/certs/topaz.key&lt;/span&gt;

  &lt;span class="na"&gt;dns-sidecar&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alpine:latest&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;sh -c "apk add --no-cache dnsmasq -q &amp;amp;&amp;amp;&lt;/span&gt;
             &lt;span class="s"&gt;echo 'address=/.topaz.local.dev/172.28.0.10' &amp;gt; /etc/dnsmasq.d/topaz.conf &amp;amp;&amp;amp;&lt;/span&gt;
             &lt;span class="s"&gt;dnsmasq --no-daemon --server=1.1.1.1 --server=8.8.8.8"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;topaz-net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;ipv4_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;172.28.0.53"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three services. Two volumes. One network. The certificate distribution happens before any container starts (&lt;code&gt;initializeCommand&lt;/code&gt;). The DNS configuration happens when Docker Compose starts the devcontainer service (&lt;code&gt;dns:&lt;/code&gt;). The certificate trust and CLI install happen once after container creation (&lt;code&gt;postCreateCommand&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints that shaped the design
&lt;/h2&gt;

&lt;p&gt;Most of the intermediate failures came from the same underlying constraint: &lt;strong&gt;inside a running container, the parts of &lt;code&gt;/etc/resolv.conf&lt;/code&gt; and &lt;code&gt;/etc/hosts&lt;/code&gt; that Docker manages are not ours to own.&lt;/strong&gt; Docker bind-mounts both files. You can read them, you can overwrite them with a new process, but the bind mount source is what Docker wrote when it started the container — and that is what survives restarts, new shells, and exec sessions.&lt;/p&gt;

&lt;p&gt;The only way to control what nameservers a Docker container queries is to set &lt;code&gt;dns:&lt;/code&gt; in the compose service definition before the container starts. Once it is running, you are working around a constraint rather than owning the solution. Every in-container approach — dnsmasq in &lt;code&gt;postCreateCommand&lt;/code&gt;, &lt;code&gt;tee&lt;/code&gt; to &lt;code&gt;/etc/resolv.conf&lt;/code&gt;, &lt;code&gt;fuser&lt;/code&gt; to free port 53 — is a workaround with a lifecycle problem attached to it. The sidecar approach is not.&lt;/p&gt;

&lt;p&gt;The same logic applies to the workspace mount. In Compose mode, the workspace bind mount is injected by VS Code into a generated override file. The injection is sometimes unreliable — particularly on external drives or when the container runtime has not been configured to share those paths. Declaring the mount explicitly in the base compose file makes it deterministic. The explicit declaration wins over whatever VS Code's override would have injected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this enables
&lt;/h2&gt;

&lt;p&gt;The devcontainer is the easiest path to a Topaz environment for anyone who does not want to go through the certificate trust and DNS configuration steps manually. Click the badge, wait for the build, open a terminal:&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;# DNS wildcard resolves automatically — no hosts file editing&lt;/span&gt;
vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;topaz health
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:&lt;span class="s2"&gt;"Healthy"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# HTTPS with the Topaz cert trusted — no --insecure needed&lt;/span&gt;
vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;az rest &lt;span class="nt"&gt;--method&lt;/span&gt; get &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://topaz.local.dev:8899/subscriptions?api-version=2020-01-01"&lt;/span&gt;

&lt;span class="c"&gt;# Named resources work immediately after creation&lt;/span&gt;
vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;az keyvault create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; my-vault &lt;span class="nt"&gt;--resource-group&lt;/span&gt; rg-dev &lt;span class="nt"&gt;--location&lt;/span&gt; westeurope
vscode ➜ /workspaces/Topaz &lt;span class="nv"&gt;$ &lt;/span&gt;curl https://my-vault.vault.topaz.local.dev:8898/secrets?api-version&lt;span class="o"&gt;=&lt;/span&gt;7.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The devcontainer is available at the root of the repository and as a standalone copy-into-your-project template in &lt;a href="https://github.com/TheCloudTheory/Topaz/tree/main/Examples/Devcontainer" rel="noopener noreferrer"&gt;&lt;code&gt;Examples/Devcontainer/&lt;/code&gt;&lt;/a&gt;. The standalone template bundles its own certificate copy so it has no dependency on the &lt;code&gt;certificate/&lt;/code&gt; directory — teams can drop &lt;code&gt;.devcontainer/&lt;/code&gt; into any project, add their resource names as needed, and get the same environment.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Topaz vs Azurite: what actually works locally and what doesn't</title>
      <dc:creator>Kamil Mrzygłód</dc:creator>
      <pubDate>Tue, 05 May 2026 11:59:04 +0000</pubDate>
      <link>https://dev.to/kamil-mrzyglod/topaz-vs-azurite-what-actually-works-locally-and-what-doesnt-231p</link>
      <guid>https://dev.to/kamil-mrzyglod/topaz-vs-azurite-what-actually-works-locally-and-what-doesnt-231p</guid>
      <description>&lt;p&gt;If you have ever written a line of Azure code on a laptop, you have used Azurite. It is the official local emulator for Azure Storage, ships in every Visual Studio install, and runs unchanged in tens of thousands of CI pipelines. For Storage-only workloads it is an excellent tool — Microsoft maintains it, Azure SDKs target it, and the parity with the real Azure Storage REST API is strong.&lt;/p&gt;

&lt;p&gt;The problem is that real applications stop at Azure Storage roughly never. The moment you reach for a secret in Key Vault, publish a message to Service Bus, push an image to a Container Registry, or want a &lt;code&gt;DefaultAzureCredential&lt;/code&gt; chain that does not silently fall back to interactive browser auth, Azurite has nothing to offer. You are left bolting together a Service Bus emulator from a community Docker image, mocking the Key Vault SDK in tests, and hoping that the way your CI fakes Entra tokens does not drift away from how production behaves.&lt;/p&gt;

&lt;p&gt;Topaz is a single .NET 8 (and when 1.3 version is released - .NET 10) binary that emulates Azure Storage, Key Vault, Service Bus, Event Hubs, Container Registry, Managed Identity, RBAC, ARM, and a working Entra ID layer in one process. This post is an honest comparison between the two, focused on what developers who already know Azurite actually run into.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Run the Azure SDK, Azure CLI, Terraform, and &lt;code&gt;docker push&lt;/code&gt; against a local emulator — no Azure subscription, no service principal, no cloud charges.&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap thecloudtheory/topaz &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew &lt;span class="nb"&gt;install &lt;/span&gt;topaz &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; topaz-host   &lt;span class="c"&gt;# macOS&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/TheCloudTheory/Topaz/main/install/get-topaz.sh | bash   &lt;span class="c"&gt;# Linux&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;&lt;a href="https://topaz.thecloudtheory.com/docs/intro" rel="noopener noreferrer"&gt;Getting started →&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where Topaz and Azurite agree
&lt;/h2&gt;

&lt;p&gt;It is worth being clear about this up front: for Azure Storage, Azurite is good. Anyone telling you otherwise is selling something. Topaz does not exist because Azurite is bad at Storage — it exists because Azurite is &lt;em&gt;only&lt;/em&gt; Storage, and most real Azure applications are not.&lt;/p&gt;

&lt;p&gt;For Blob, Queue, and Table data plane operations, the two emulators are at full parity on the cases that matter for day-to-day development:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Storage feature&lt;/th&gt;
&lt;th&gt;Topaz&lt;/th&gt;
&lt;th&gt;Azurite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blob basic operations (put, get, delete, head, list)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blob metadata, container metadata&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block blobs (&lt;code&gt;Put Block&lt;/code&gt;, &lt;code&gt;Put Block List&lt;/code&gt;, &lt;code&gt;Get Block List&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page blobs (&lt;code&gt;Put Page&lt;/code&gt;, &lt;code&gt;Get Page Ranges&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container ACLs and stored access policies&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container and blob leases (acquire / renew / change / release / break)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blob copy and snapshots&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table create / delete / query / entity CRUD&lt;/td&gt;
&lt;td&gt;Yes (stable)&lt;/td&gt;
&lt;td&gt;Yes (preview)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table ACL (stored access policies)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue messages (enqueue, dequeue, peek, update, delete, clear)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue ACL&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RA-GRS secondary endpoints&lt;/td&gt;
&lt;td&gt;Partial (roadmap)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two practical Storage gaps in Topaz today are Account / Service SAS token validation on the data plane (the ARM endpoints that &lt;em&gt;generate&lt;/em&gt; SAS strings exist; the data plane providers do not yet recognise the &lt;code&gt;?sv=...&amp;amp;sig=...&lt;/code&gt; parameters in incoming requests) and full RA-GRS secondary endpoint emulation. Both are scheduled — SAS validation is on the v1.4-beta milestone, RA-GRS is on v1.4-beta as well. More on that further down.&lt;/p&gt;

&lt;p&gt;The Storage feature that Azurite still does not have is &lt;em&gt;multiple named storage accounts as a first-class concept&lt;/em&gt;. Azurite's default account is &lt;code&gt;devstoreaccount1&lt;/code&gt;. Adding more requires editing your hosts file, exporting &lt;code&gt;AZURITE_ACCOUNTS&lt;/code&gt; with &lt;code&gt;name:key1:key2;name:key1:key2&lt;/code&gt;, and restarting the emulator. Topaz has a real ARM control plane: &lt;code&gt;az storage account create --name sa-orders --resource-group rg-local&lt;/code&gt; works the same way it works against Azure, registers a DNS entry automatically, and gives you a real connection string you can paste into Azure Storage Explorer or hand to Terraform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Azurite stops: Key Vault
&lt;/h2&gt;

&lt;p&gt;This is the largest single delta between the two emulators, and it is the reason most developers eventually try to replace Azurite.&lt;/p&gt;

&lt;p&gt;Azurite has no Key Vault. Not a partial one, not a stubbed one — none. The standard workarounds are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mock the &lt;code&gt;SecretClient&lt;/code&gt; in tests (loses any coverage of the actual auth layer).&lt;/li&gt;
&lt;li&gt;Read secrets from &lt;code&gt;appsettings.Development.json&lt;/code&gt; instead (creates a code-path divergence between local and production).&lt;/li&gt;
&lt;li&gt;Spin up a real Key Vault per developer (works, but now CI needs Azure credentials and the developer onboarding doc has a "before you can run tests, ask the platform team for..." section).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Topaz has the most complete Key Vault emulation of any service it ships. The control plane covers the full vault lifecycle — create, update, soft-delete, list deleted, recover, purge, name availability, access policy management — and the data plane runs on its own port (&lt;code&gt;8898&lt;/code&gt;) reachable at &lt;code&gt;https://&amp;lt;vault-name&amp;gt;.vault.topaz.local.dev:8898/&lt;/code&gt;, which is the URL pattern the Azure SDK already constructs.&lt;/p&gt;

&lt;p&gt;What works today on the data plane:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Operations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secrets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Set, Get (by name and version), List, Update, Delete, Get Versions, Backup, Restore, full soft-delete surface (Get Deleted, List Deleted, Recover, Purge)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Keys&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Create, Import (RSA + EC), Get, List, Update, Delete, Backup, Restore, Rotate, Get/Update Rotation Policy, full soft-delete surface, &lt;code&gt;encrypt&lt;/code&gt;, &lt;code&gt;decrypt&lt;/code&gt;, &lt;code&gt;sign&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt;, &lt;code&gt;wrapKey&lt;/code&gt;, &lt;code&gt;unwrapKey&lt;/code&gt;, &lt;code&gt;release&lt;/code&gt;, &lt;code&gt;Get Random Bytes&lt;/code&gt;, &lt;code&gt;Get Key Attestation&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Certificates&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not yet — full surface scheduled for v1.3-beta&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The soft-delete and recovery surface deserves a specific call-out because it is what most teams accidentally depend on. If your application catches &lt;code&gt;KeyVaultErrorException&lt;/code&gt; on a soft-deleted secret, recovers it, and continues — that code path is unreachable in any other local emulator. Topaz exercises it end-to-end. Tokens are real signed JWTs. The vault URL works with &lt;code&gt;DefaultAzureCredential&lt;/code&gt; exactly as in production.&lt;/p&gt;

&lt;p&gt;What is not there yet is the certificates data plane (CRUD, soft-delete surface, issuers, contacts, merge — all scheduled for v1.3-beta). If your application reads certificates from Key Vault — for example, an ASP.NET Core app loading its TLS cert via the Key Vault provider — that path is not yet covered. For secrets and keys, including the cryptographic operations, it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Azurite stops: Service Bus
&lt;/h2&gt;

&lt;p&gt;Service Bus is the second most common reason teams outgrow Azurite. The community workarounds — RabbitMQ, ActiveMQ, in-memory test doubles — all break the same invariant: the AMQP wire format is not the same, so any code that reaches into broker-specific behaviour (dead-letter queues, peek-lock vs receive-and-delete, deferred messages, session state) drifts away from Service Bus reality.&lt;/p&gt;

&lt;p&gt;Topaz runs a real AMQP 1.0 broker on ports &lt;code&gt;8889&lt;/code&gt; (plain) and &lt;code&gt;5671&lt;/code&gt; (AMQP/TLS), implemented natively in the host process. The Azure Service Bus SDK connects to it without modification. MassTransit, NServiceBus, and any other library that speaks AMQP work because they are speaking AMQP, not because Topaz pretends to be Service Bus through a thin shim.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;What works&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Namespaces (control plane)&lt;/td&gt;
&lt;td&gt;Create / Update / Delete / Get / List By Resource Group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queues (control plane)&lt;/td&gt;
&lt;td&gt;Create / Update / Delete / Get / List By Namespace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topics (control plane)&lt;/td&gt;
&lt;td&gt;Create / Update / Delete / Get / List By Namespace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscriptions&lt;/td&gt;
&lt;td&gt;Create / Update / Delete / Get (via AMQP management node)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messaging (data plane, AMQP)&lt;/td&gt;
&lt;td&gt;Send / Receive on queues and topics, Complete / Abandon / Dead-letter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AMQP entity management&lt;/td&gt;
&lt;td&gt;Create / Get / Delete queues, topics, subscriptions — used by MassTransit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Specifically &lt;em&gt;not&lt;/em&gt; implemented yet: subscription rule management (filters / actions), namespace authorization rules, list-keys / regenerate-keys, disaster recovery configs, migration configs, private endpoints. For most application development workflows this is a non-issue; if you are building tooling that programmatically rotates Service Bus SAS keys or manages DR pairing, real Azure is still the right target for those tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Azurite stops: Container Registry
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker push myregistry.azurecr.io/myapp:latest&lt;/code&gt; against a local emulator is the kind of thing that historically required either a real ACR (and therefore Azure credentials in CI) or running a generic Docker registry and pretending it is ACR (works for &lt;code&gt;pull&lt;/code&gt;, breaks the moment anything touches the ARM control plane or the ACR-specific OAuth2 exchange).&lt;/p&gt;

&lt;p&gt;Topaz emulates both layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Control plane&lt;/strong&gt; (ARM): create, update, delete, list registries; manage admin credentials; toggle &lt;code&gt;adminUserEnabled&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data plane&lt;/strong&gt; (OCI Distribution Spec, port &lt;code&gt;8892&lt;/code&gt;): manifest CRUD, blob upload (&lt;code&gt;POST&lt;/code&gt; / &lt;code&gt;PATCH&lt;/code&gt; / &lt;code&gt;PUT&lt;/code&gt; chunked uploads), blob download and existence checks, repository catalog, tag listing, and the ACR-specific &lt;code&gt;/oauth2/exchange&lt;/code&gt; endpoint that makes &lt;code&gt;az acr login&lt;/code&gt; work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full authentication flow — &lt;code&gt;GET /v2/&lt;/code&gt; returning a &lt;code&gt;Www-Authenticate&lt;/code&gt; challenge, &lt;code&gt;POST /oauth2/exchange&lt;/code&gt; swapping the Entra token for an ACR refresh token, and the bearer token round-trip — is implemented end-to-end. There is a &lt;a href="https://topaz.thecloudtheory.com/blog/acr-data-plane" rel="noopener noreferrer"&gt;separate post on how this works&lt;/a&gt; that goes into the design tradeoffs.&lt;/p&gt;

&lt;p&gt;What this unlocks: you can run &lt;code&gt;docker push&lt;/code&gt;, &lt;code&gt;helm push&lt;/code&gt;, and any OCI-compliant client against Topaz without modifying anything in your build pipeline. The registry hostname follows real Azure conventions (&lt;code&gt;myregistry.cr.topaz.local.dev:8892&lt;/code&gt;). Image promotion workflows, CI builds that publish images, and Terraform configurations that create ACRs all work locally. &lt;code&gt;docker pull&lt;/code&gt; and end-to-end image pull-through is on the roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Azurite stops: Entra ID and Managed Identity
&lt;/h2&gt;

&lt;p&gt;This is the silent one. Azurite does not need an identity layer because it accepts SharedKey for authentication and falls back to "no auth" when nothing is provided. The moment your application uses &lt;code&gt;DefaultAzureCredential&lt;/code&gt; — which is the Microsoft-recommended pattern for everything except Storage — Azurite cannot help. Your local code either talks to a real Entra tenant (forcing every developer onto a corporate identity) or you replace &lt;code&gt;DefaultAzureCredential&lt;/code&gt; with a custom test double (creating a code-path divergence with production).&lt;/p&gt;

&lt;p&gt;Topaz ships a working Entra ID emulation layer. A local tenant (&lt;code&gt;topaz.local.dev&lt;/code&gt;, tenant ID &lt;code&gt;50717675-3E5E-4A1E-8CB5-C62D8BE8CA48&lt;/code&gt;) is provisioned at startup with a built-in superadmin account. Every token is a real, signed JWT — same format Azure issues, same claims layout, signed with HMAC-SHA256, one-hour lifetime. The OIDC discovery endpoint (&lt;code&gt;/.well-known/openid-configuration&lt;/code&gt;) is fully functional, both &lt;code&gt;/organizations/&lt;/code&gt; and &lt;code&gt;/{tenantId}/&lt;/code&gt; variants are served, and four grant types are supported: &lt;code&gt;client_credentials&lt;/code&gt;, &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;authorization_code&lt;/code&gt;, and &lt;code&gt;refresh_token&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Tied to that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Graph API&lt;/strong&gt; for users, applications, service principals, and groups — enough to script a full identity setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed Identity&lt;/strong&gt; — user-assigned and system-assigned, including federated identity credentials. The same &lt;code&gt;DefaultAzureCredential&lt;/code&gt; chain that works in production works locally, no special credential type required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC&lt;/strong&gt; — role assignments and role definitions at any ARM scope, with the standard built-in roles (Owner, Contributor, Reader, plus the service-specific data-plane roles like &lt;code&gt;Storage Blob Data Contributor&lt;/code&gt;) pre-loaded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the difference between "I can read a blob locally" and "I can run my entire authn/authz path locally". The latter catches a class of bug — token audience mismatches, role assignment drift, scope errors — that mocks never catch.&lt;/p&gt;

&lt;p&gt;There is a &lt;a href="https://topaz.thecloudtheory.com/blog/entra-id-emulation" rel="noopener noreferrer"&gt;dedicated post on the Entra emulation layer&lt;/a&gt; if you want the design details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Azurite stops: ARM, Terraform, and the Azure CLI
&lt;/h2&gt;

&lt;p&gt;Azurite has no ARM control plane, which means there is no &lt;code&gt;az group create&lt;/code&gt;, no &lt;code&gt;azurerm_resource_group&lt;/code&gt;, no Bicep deployment, no way to express the resources your application needs in the same infrastructure-as-code language you use in production. Local development and production end up using two different definitions of "what infrastructure exists".&lt;/p&gt;

&lt;p&gt;Topaz has a working ARM emulation. The Resource Manager port (&lt;code&gt;8899&lt;/code&gt;) accepts &lt;code&gt;az&lt;/code&gt; and &lt;code&gt;azurerm&lt;/code&gt; provider traffic, exposes a metadata document at the discovery endpoint that points every Azure API URL at the local emulator, and accepts ARM template / Bicep deployments end-to-end. The same &lt;code&gt;terraform apply&lt;/code&gt; that creates resources in Azure can create them in Topaz with one provider setting:&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;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;metadata_host&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"topaz.local.dev:8899"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_provider_registrations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire integration. &lt;code&gt;metadata_host&lt;/code&gt; redirects endpoint discovery; the AzureRM provider then constructs every subsequent URL — Storage, Key Vault, Service Bus, Container Registry — from Topaz's metadata document. Detail in the &lt;a href="https://topaz.thecloudtheory.com/blog/terraform-local-azure-no-subscription" rel="noopener noreferrer"&gt;Terraform integration post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The Azure CLI works the same way: register Topaz as a cloud environment with &lt;code&gt;az cloud register&lt;/code&gt;, switch to it, and &lt;code&gt;az login&lt;/code&gt; issues a token from the local Entra layer. From there, &lt;code&gt;az keyvault secret set&lt;/code&gt;, &lt;code&gt;az servicebus queue create&lt;/code&gt;, &lt;code&gt;az acr login&lt;/code&gt;, &lt;code&gt;az group deployment create&lt;/code&gt; all work as they do against Azure. Azurite supports &lt;code&gt;az storage&lt;/code&gt; and only &lt;code&gt;az storage&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Azurite still does better
&lt;/h2&gt;

&lt;p&gt;A genuinely honest post needs this section. Azurite is not strictly worse — it is narrower, more mature in its narrow scope, and the right call for some workloads.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft maintenance.&lt;/strong&gt; Azurite is shipped and supported by Microsoft. Topaz is open source and maintained by an independent team. If your organisation requires vendor-supported software for local development tooling, that distinction matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RA-GRS secondary endpoints.&lt;/strong&gt; Azurite emulates the read-access geo-redundant secondary URL pattern today. Topaz has partial support; full secondary endpoint semantics — secondary DNS hostnames, &lt;code&gt;GeoReplicationStats&lt;/code&gt; payloads, and read-only enforcement — are scheduled for v1.4-beta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt; shortcut.&lt;/strong&gt; Azurite is hardcoded to the Azure SDK's &lt;code&gt;UseDevelopmentStorage=true&lt;/code&gt; connection string. Topaz uses real connection strings — the same format Azure issues — which is more flexible but loses the one-line shortcut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual Studio integration.&lt;/strong&gt; Azurite ships with Visual Studio and Storage Explorer has a built-in "Local Emulator" entry. Topaz works with Storage Explorer, but you connect via a connection string rather than the dedicated emulator option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maturity.&lt;/strong&gt; Azurite has been in production CI pipelines for years. Topaz is in beta as of v1.0 and is moving fast — that is also the reason this post needs to list which surfaces are not yet covered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your application uses only Storage, a single account is enough, and you do not need ARM or Terraform integration locally, Azurite is genuinely the simpler choice and there is no reason to switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is coming for Storage in Topaz
&lt;/h2&gt;

&lt;p&gt;The Storage roadmap is publicly tracked in &lt;a href="https://github.com/TheCloudTheory/Topaz/blob/main/BACKLOG.md" rel="noopener noreferrer"&gt;&lt;code&gt;BACKLOG.md&lt;/code&gt;&lt;/a&gt; and mirrored on the &lt;a href="https://topaz.thecloudtheory.com/docs/roadmap" rel="noopener noreferrer"&gt;website roadmap&lt;/a&gt;. The near-term Storage work in flight:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.4-beta — SAS token validation on the data plane.&lt;/strong&gt;&lt;br&gt;
The control plane already generates SAS tokens via &lt;code&gt;ListAccountSas&lt;/code&gt; and &lt;code&gt;ListServiceSas&lt;/code&gt;. The data-plane security providers (Blob, Queue, Table) currently only recognise the &lt;code&gt;Authorization:&lt;/code&gt; header — incoming requests with &lt;code&gt;?sv=...&amp;amp;sig=...&lt;/code&gt; query strings hit the missing-header path and return &lt;code&gt;401&lt;/code&gt;. The work in progress adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Account SAS validation: detects &lt;code&gt;sv&lt;/code&gt;, &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;srt&lt;/code&gt;, &lt;code&gt;sp&lt;/code&gt;, &lt;code&gt;se&lt;/code&gt;, &lt;code&gt;st&lt;/code&gt;, &lt;code&gt;spr&lt;/code&gt;, &lt;code&gt;sip&lt;/code&gt;, &lt;code&gt;sig&lt;/code&gt;, builds the canonical Account SAS string-to-sign, HMAC-SHA256 against the account key, validates expiry / service / resource type / HTTP method.&lt;/li&gt;
&lt;li&gt;Service SAS validation: per-service string-to-sign, including the canonicalized resource and the response-header overrides (&lt;code&gt;rscc&lt;/code&gt;, &lt;code&gt;rscd&lt;/code&gt;, etc.), with stored access policy lookup when &lt;code&gt;si=&amp;lt;policyId&amp;gt;&lt;/code&gt; is present.&lt;/li&gt;
&lt;li&gt;Stored access policy enforcement: the Container ACL / Queue ACL / Table ACL endpoints already round-trip &lt;code&gt;&amp;lt;SignedIdentifiers&amp;gt;&lt;/code&gt; XML to disk; v1.4 wires them into the SAS validation path so revoking a named policy actually revokes the tokens that reference it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;v1.4-beta — anonymous / public-access reads for Blob containers.&lt;/strong&gt;&lt;br&gt;
Real Azure allows containers created with &lt;code&gt;x-ms-blob-public-access: container&lt;/code&gt; (list + read) or &lt;code&gt;blob&lt;/code&gt; (read only) to permit unauthenticated reads. Topaz currently rejects every request without an &lt;code&gt;Authorization&lt;/code&gt; header. The fix is to store the public-access level on the container, look it up in the security provider when no auth is present, and permit the request when the level allows the operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.4-beta — RA-GRS secondary endpoint semantics.&lt;/strong&gt;&lt;br&gt;
Secondary DNS hostnames (&lt;code&gt;{accountName}-secondary.*&lt;/code&gt;), &lt;code&gt;secondaryEndpoints.blob/.table/.queue/.file&lt;/code&gt; in the storage account ARM response, the &lt;code&gt;?restype=service&amp;amp;comp=stats&lt;/code&gt; endpoint returning a realistic &lt;code&gt;GeoReplicationStats&lt;/code&gt; payload, and read-only enforcement that returns &lt;code&gt;403 WriteOperationNotSupportedOnSecondary&lt;/code&gt; on mutating requests against a secondary endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.5-beta — User Delegation SAS for Blob.&lt;/strong&gt;&lt;br&gt;
The Entra-derived SAS variant. Two coordinated pieces: an ARM endpoint that mints a user delegation key bounded by &lt;code&gt;(start, expiry, oid, tid)&lt;/code&gt;, and Blob data-plane validation that recomputes the key from the stored fields and validates the signed token. This is the only SAS variant that needs the local Entra layer to be coherent with the storage layer — which is part of why Topaz is built as one process rather than five.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.6-beta — unified data-plane port.&lt;/strong&gt;&lt;br&gt;
Real Azure exposes Blob / Table / Queue / File on port 443 with subdomain-based routing (&lt;code&gt;{account}.blob.core.windows.net&lt;/code&gt;, etc.). Topaz currently uses separate ports per sub-service (8891 / 8890 / 8893 / 8894) for routing simplicity. Some Azure CLI / SDK code paths construct storage URLs via &lt;code&gt;get_account_url()&lt;/code&gt;, which builds a single &lt;code&gt;https://&lt;/code&gt; URL from the cloud-suffix &lt;code&gt;storage_endpoint&lt;/code&gt; — encoding only one port. Consolidating onto a single port behind subdomain routing fixes that and makes the local URL pattern identical to production.&lt;/p&gt;

&lt;p&gt;These are all in the &lt;a href="https://github.com/TheCloudTheory/Topaz/blob/main/BACKLOG.md" rel="noopener noreferrer"&gt;public backlog&lt;/a&gt; with milestone labels — there is no roadmap kept in someone's head.&lt;/p&gt;
&lt;h2&gt;
  
  
  Developer experience: where the differences compound
&lt;/h2&gt;

&lt;p&gt;The feature comparison above is the headline. The day-to-day experience matters more.&lt;/p&gt;
&lt;h3&gt;
  
  
  Single binary, single process, single working directory
&lt;/h3&gt;

&lt;p&gt;Azurite is one process for Storage. Replicating Topaz's surface with separate emulators means orchestrating Azurite + a Service Bus emulator + a registry + a mock identity server, all writing to different directories, all logging in different formats, all started from different scripts. Every team that goes down this path eventually writes a Docker Compose file that nobody is happy with.&lt;/p&gt;

&lt;p&gt;Topaz is one binary or one Docker image. State lives in &lt;code&gt;.topaz/&lt;/code&gt; next to your project. Stop the host, the state is preserved. Start it again, the state comes back. Delete the directory, the slate is clean. There is one log stream and one health check (&lt;code&gt;GET /health&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;
  
  
  MCP server for AI tooling
&lt;/h3&gt;

&lt;p&gt;Topaz ships an MCP server (&lt;code&gt;Topaz.MCP&lt;/code&gt;) that exposes the host as a set of tools any MCP-compatible client can call. The intended use case is GitHub Copilot in VS Code — drop a &lt;code&gt;.vscode/mcp.json&lt;/code&gt; in your workspace, point it at the MCP binary, and Copilot can run &lt;code&gt;RunTopazAsContainer&lt;/code&gt;, &lt;code&gt;CreateSubscription&lt;/code&gt;, &lt;code&gt;CreateResourceGroup&lt;/code&gt;, &lt;code&gt;CreateKeyVault&lt;/code&gt;, &lt;code&gt;CreateStorageAccount&lt;/code&gt;, &lt;code&gt;CreateServiceBusNamespace&lt;/code&gt;, &lt;code&gt;CreateContainerRegistry&lt;/code&gt;, and a &lt;code&gt;GetConnectionStrings&lt;/code&gt; tool that returns ready-to-paste connection strings for everything it just provisioned.&lt;/p&gt;

&lt;p&gt;In practice this means you can describe a local environment in natural language:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Start Topaz. Create a subscription, a resource group &lt;code&gt;rg-dev&lt;/code&gt; in &lt;code&gt;westeurope&lt;/code&gt;, then provision a Storage account, a Service Bus namespace with a queue named &lt;code&gt;orders&lt;/code&gt;, and a Key Vault with a secret &lt;code&gt;db-password&lt;/code&gt;. Output the connection strings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;…and the assistant runs the whole sequence. There is also a &lt;code&gt;GetTopazStatus&lt;/code&gt; diagnostics tool for the case where a setup fails partway through and you want to know which ports are bound. Full details in the &lt;a href="https://topaz.thecloudtheory.com/docs/mcp-server" rel="noopener noreferrer"&gt;MCP server documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Azurite has no equivalent. There is no MCP integration, no programmatic way for an AI assistant to provision a Storage account, no notion of provisioning at all — the tool exists at the data plane and stops there.&lt;/p&gt;
&lt;h3&gt;
  
  
  Editor and IDE integration
&lt;/h3&gt;

&lt;p&gt;GitHub Copilot in VS Code works through the MCP server. The Topaz CLI (&lt;code&gt;topaz&lt;/code&gt;) is a thin client over the host process — every command is &lt;code&gt;topaz &amp;lt;verb&amp;gt;&lt;/code&gt; with consistent JSON output, which means it scripts cleanly from a terminal, a shell hook, a Makefile, or a CI step. A native VS Code extension is on the roadmap; for the moment, the MCP path covers the AI-assisted workflows and the CLI covers everything else.&lt;/p&gt;
&lt;h3&gt;
  
  
  Scaling out: CI and Docker
&lt;/h3&gt;

&lt;p&gt;Topaz publishes a Docker image (&lt;code&gt;topaz/host&lt;/code&gt;) that runs as a sidecar in any CI environment. The pattern in GitHub Actions is:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;topaz&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;topaz/host:latest&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="s"&gt;8899:8899&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;8898:8898&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;8891:8891&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;8889:8889&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;8892:8892&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--health-cmd="curl -f http://localhost:8899/health || exit 1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;terraform apply&lt;/code&gt; against &lt;code&gt;metadata_host = "topaz:8899"&lt;/code&gt; from any step. No Azure credentials in the pipeline secrets, no rate-limited subscription, no per-run cost. The same image runs as a Testcontainer inside .NET integration tests via the Topaz Testcontainers helper. The &lt;a href="https://topaz.thecloudtheory.com/docs/ecosystem/ci-cd" rel="noopener noreferrer"&gt;CI/CD integration guide&lt;/a&gt; covers GitHub Actions and Azure DevOps.&lt;/p&gt;

&lt;p&gt;Azurite has a Docker image too. The difference is what the image emulates — Topaz's image covers the same surface this post described (Storage + Key Vault + Service Bus + Event Hubs + ACR + Entra + ARM); Azurite's covers Storage. If your CI only needs Storage, that is fine. If it needs more, Azurite forces you back to the multi-emulator orchestration problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  ASP.NET Core integration
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;AddTopaz()&lt;/code&gt; is an extension method on &lt;code&gt;IServiceCollection&lt;/code&gt; that provisions local Azure infrastructure at application startup — declaratively, in the same &lt;code&gt;Program.cs&lt;/code&gt; where you wire up DI. Spin up a resource group, a Service Bus namespace, a Key Vault with seed secrets, a Storage account, all in code, all conditional on environment so the same &lt;code&gt;Program.cs&lt;/code&gt; runs unchanged in production. Detail in the &lt;a href="https://topaz.thecloudtheory.com/docs/ecosystem/aspnet-core" rel="noopener noreferrer"&gt;ASP.NET Core integration guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to keep Azurite
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your application uses only Azure Storage and a single account is sufficient.&lt;/li&gt;
&lt;li&gt;You need RA-GRS secondary endpoint emulation today.&lt;/li&gt;
&lt;li&gt;You need a Microsoft-supported emulator with vendor backing.&lt;/li&gt;
&lt;li&gt;Your toolchain is built around Azurite and migration is not worth the effort.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to switch to Topaz
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Your application uses any service beyond Storage — Key Vault, Service Bus, Event Hubs, Container Registry, Managed Identity, RBAC.&lt;/li&gt;
&lt;li&gt;You need multiple named storage accounts in local or CI environments without manual hosts file edits.&lt;/li&gt;
&lt;li&gt;You want a single process to replace multiple emulators and the Docker Compose file that holds them together.&lt;/li&gt;
&lt;li&gt;You use Terraform with the &lt;code&gt;azurerm&lt;/code&gt; provider and want a local target for &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;You want the full Azure CLI (&lt;code&gt;az keyvault&lt;/code&gt;, &lt;code&gt;az servicebus&lt;/code&gt;, &lt;code&gt;az acr&lt;/code&gt;, &lt;code&gt;az deployment&lt;/code&gt;) to work locally, not just &lt;code&gt;az storage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;You want ARM-level resource management (resource groups, subscriptions, ARM templates, Bicep) in CI without a real subscription.&lt;/li&gt;
&lt;li&gt;You want &lt;code&gt;DefaultAzureCredential&lt;/code&gt; to work end-to-end locally without code-path divergence from production.&lt;/li&gt;
&lt;li&gt;You want AI-assisted environment provisioning through MCP.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Migrating
&lt;/h2&gt;

&lt;p&gt;For Storage specifically, Topaz implements the same data-plane APIs Azurite does. Point your existing Azure SDK clients at Topaz's endpoints and they connect without code changes — only the endpoint hostname, port, and credentials change. The one item to check during migration is authentication: Topaz always enforces SharedKey signatures on Table and Queue requests, so any request that Azurite silently accepted with a missing or invalid signature will be rejected. This is intentional — it is the same behaviour real Azure has, and catching the divergence locally is the whole point.&lt;/p&gt;

&lt;p&gt;Beyond Storage, the &lt;a href="https://topaz.thecloudtheory.com/docs/api-coverage/" rel="noopener noreferrer"&gt;API coverage docs&lt;/a&gt; list which operations are implemented per service. If you hit something that is not yet supported, &lt;a href="https://github.com/TheCloudTheory/Topaz/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt; — the backlog is publicly tracked and feedback shapes priorities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Azurite is a good Storage emulator. Topaz is a Storage emulator that also covers Key Vault, Service Bus, Event Hubs, Container Registry, Managed Identity, RBAC, ARM, and Entra ID — in one binary, with one log stream, one working directory, and one Docker image. The Storage parity is essentially complete; the gaps that remain (SAS validation, RA-GRS, public-access reads) are scheduled and tracked in the open backlog.&lt;/p&gt;

&lt;p&gt;If your application is Storage-only, stay on Azurite. If it is anything else — and most real Azure applications are — Topaz exists to remove the orchestration tax of running five different local emulators that were never designed to work together.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>emulator</category>
    </item>
    <item>
      <title>Two days chasing a SharedKey signature mismatch: fixing azurerm_storage_table_entity in Topaz</title>
      <dc:creator>Kamil Mrzygłód</dc:creator>
      <pubDate>Thu, 30 Apr 2026 10:42:05 +0000</pubDate>
      <link>https://dev.to/kamil-mrzyglod/two-days-chasing-a-sharedkey-signature-mismatch-fixing-azurermstoragetableentity-in-topaz-15ag</link>
      <guid>https://dev.to/kamil-mrzyglod/two-days-chasing-a-sharedkey-signature-mismatch-fixing-azurermstoragetableentity-in-topaz-15ag</guid>
      <description>&lt;p&gt;Some bugs announce themselves loudly. A null pointer in a hot path, a missing route that returns 404 to every request — the kind of thing that fails immediately and points straight at the cause. Others are quieter. They let most of the stack work correctly and only reveal themselves at the intersection of two independently correct but mutually incompatible assumptions. The &lt;code&gt;azurerm_storage_table_entity&lt;/code&gt; failure was the second kind.&lt;/p&gt;

&lt;p&gt;This post is an account of a two-day investigation into a persistent &lt;code&gt;401 Unauthorized&lt;/code&gt; response on Terraform table entity operations, the four separate bugs we found along the way, and how pairing with GitHub Copilot shaped the investigation. The fix touched authentication, HTTP routing, upsert semantics, and stream lifecycle — each uncovered only after the previous one was resolved.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You can read more about Topaz here: &lt;a href="https://topaz.thecloudtheory.com/" rel="noopener noreferrer"&gt;https://topaz.thecloudtheory.com/&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;Topaz already had table storage support: accounts, tables, entity insert and query. The &lt;code&gt;azurerm_storage_table&lt;/code&gt; Terraform resource worked. What did not work was &lt;code&gt;azurerm_storage_table_entity&lt;/code&gt;. Any Terraform run that tried to create a table entity failed immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: creating Entity (Partition Key "pk1" / Row Key "rk1" / ...):
  executing request: unexpected status 401 (401 Unauthorized) with EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 401 was puzzling because the same storage account, created by the same Terraform run, authenticated correctly for every ARM-level operation. Listing keys worked. Creating the table worked. Only the data-plane entity operation failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The investigation begins: is the key the problem?
&lt;/h2&gt;

&lt;p&gt;The first hypothesis was key mismatch. Terraform calls &lt;code&gt;listKeys&lt;/code&gt; to get a storage account key and then uses it to sign data-plane requests with HMAC-SHA256. If Topaz returned a different key than it used to verify the signature, every request would fail.&lt;/p&gt;

&lt;p&gt;To confirm or rule this out, we added diagnostic logging across the authentication path. The &lt;code&gt;TableStorageSecurityProvider&lt;/code&gt; was modified to emit the full stored keys (as base64 and as raw bytes in hex), the received signature, the computed signature for both keys, the full &lt;code&gt;Authorization&lt;/code&gt; header, every request header with its raw bytes, and the exact string-to-sign in hex. &lt;code&gt;ListStorageAccountKeysEndpoint&lt;/code&gt; was modified to log a prefix of both keys on every call, so we could correlate what Terraform received with what Topaz verified against.&lt;/p&gt;

&lt;p&gt;The first finding was that the keys were stable. Terraform's &lt;code&gt;listKeys&lt;/code&gt; calls (there were four of them per apply — the provider is aggressive about refreshing credentials) consistently received the same key1 prefix. The key logged at verification time matched. The key mismatch hypothesis was eliminated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling out Topaz restarts
&lt;/h2&gt;

&lt;p&gt;One suspicious pattern appeared in the logs: at a certain point during a test run, ARM requests started returning &lt;code&gt;no route to host&lt;/code&gt; on port 8899. This looked like the Topaz container crashing and restarting mid-test — which would regenerate the storage keys and make Terraform's cached key stale.&lt;/p&gt;

&lt;p&gt;We checked the Docker container lifecycle carefully. The logs showed two AMQP listener errors at startup (a known benign issue with port reuse in the Docker test environment) but no crash or restart. The &lt;code&gt;no route to host&lt;/code&gt; was a red herring — it appeared after Terraform had already failed and was in its cleanup phase, not before. Cross-referencing the &lt;code&gt;listKeys&lt;/code&gt; timestamps with the entity request timestamps confirmed: the key prefix was identical across all four calls in the same test run. No restart, no key regeneration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrowing the scope: the isolated test
&lt;/h2&gt;

&lt;p&gt;At this point we were spending time waiting for the full storage batch test to complete — roughly thirty minutes per run because Terraform retries failing requests with exponential backoff before giving up. To eliminate noise from other resources in the same test, we created a minimal isolated scenario: a single resource group, a single storage account, a single table, and a single entity. Nothing else.&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;"azurerm_storage_table_entity"&lt;/span&gt; &lt;span class="s2"&gt;"entity"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;storage_table_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tbl&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;partition_key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pk1"&lt;/span&gt;
  &lt;span class="nx"&gt;row_key&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rk1"&lt;/span&gt;
  &lt;span class="nx"&gt;entity&lt;/span&gt; &lt;span class="p"&gt;=&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;"isolated-entity"&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;This reduced the iteration cycle and made the logs dramatically easier to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual bug: URL encoding
&lt;/h2&gt;

&lt;p&gt;With a clean log, the problem became visible. Here is what Topaz logged as the string-to-sign it computed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET\n\n\nThu, 30 Apr 2026 06:43:27 GMT\n/tfisoentityacct/isoentities(PartitionKey='pk1',%20RowKey='rk1')
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the TF debug log showed the URL Terraform actually sent the request to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://tfisoentityacct.table.storage.topaz.local.dev:8890/isoentities%28PartitionKey=%27pk1%27,%20RowKey=%27rk1%27%29
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entity key lookup path contains parentheses and single quotes. Terraform's go-azure-sdk encodes those: &lt;code&gt;(&lt;/code&gt; → &lt;code&gt;%28&lt;/code&gt;, &lt;code&gt;'&lt;/code&gt; → &lt;code&gt;%27&lt;/code&gt;. It signs the request using the raw encoded URL. ASP.NET Core receives the request and makes the decoded path available through &lt;code&gt;HttpRequest.Path&lt;/code&gt;. When Topaz called &lt;code&gt;ToString()&lt;/code&gt; on that path, it got the decoded form — &lt;code&gt;(PartitionKey='pk1',&lt;/code&gt; — which is not what was signed. The HMAC computation was based on a different string than what the client used.&lt;/p&gt;

&lt;p&gt;The fix was to read the raw request target before ASP.NET Core's decoding step using &lt;code&gt;IHttpRequestFeature.RawTarget&lt;/code&gt;, which preserves the wire-format path exactly as the client sent it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rawTarget&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IHttpRequestFeature&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()?.&lt;/span&gt;&lt;span class="n"&gt;RawTarget&lt;/span&gt;
                &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;
                &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;queryIndex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rawTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rawPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queryIndex&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;rawTarget&lt;/span&gt;&lt;span class="p"&gt;[..&lt;/span&gt;&lt;span class="n"&gt;queryIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rawTarget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a one-line conceptual fix that required updating the &lt;code&gt;IsRequestAuthorized&lt;/code&gt; call in all fourteen table endpoint classes. The base class &lt;code&gt;TableDataPlaneEndpointBase&lt;/code&gt; was refactored to accept an &lt;code&gt;HttpContext&lt;/code&gt; and extract the raw path internally, so all call sites became a single-argument call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the MERGE verb
&lt;/h2&gt;

&lt;p&gt;With auth fixed, the next run reached Terraform's actual entity creation step — and hit a new error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request MERGE /isoentities(PartitionKey='pk1',%20RowKey='rk1') has no corresponding endpoint assigned.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Azure Table Storage REST API uses HTTP &lt;code&gt;MERGE&lt;/code&gt; for Insert-or-Merge operations. Topaz's &lt;code&gt;InsertOrMergeTableEntityEndpoint&lt;/code&gt; only declared &lt;code&gt;POST&lt;/code&gt; in its &lt;code&gt;Endpoints&lt;/code&gt; array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Endpoints&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;@"POST /^.*?\(PartitionKey='.*?',(%20|\s)?RowKey='.*?'\)$"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform's go-azure-sdk uses &lt;code&gt;MERGE&lt;/code&gt; as the verb. The fix was straightforward — add &lt;code&gt;MERGE&lt;/code&gt; to the array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Endpoints&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;@"POST /^.*?\(PartitionKey='.*?',(%20|\s)?RowKey='.*?'\)$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;@"MERGE /^.*?\(PartitionKey='.*?',(%20|\s)?RowKey='.*?'\)$"&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;h2&gt;
  
  
  Bug three: Insert-or-Merge semantics
&lt;/h2&gt;

&lt;p&gt;With routing fixed, the next run returned &lt;code&gt;404 Not Found&lt;/code&gt;. The &lt;code&gt;HandleUpdateEntityRequest&lt;/code&gt; path calls &lt;code&gt;DataPlane.UpdateEntity&lt;/code&gt;, which throws &lt;code&gt;EntityNotFoundException&lt;/code&gt; when the entity file does not exist on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entityPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;false&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="nf"&gt;EntityNotFoundException&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;For a &lt;code&gt;PUT&lt;/code&gt; (Replace) or &lt;code&gt;PATCH&lt;/code&gt; (Merge), throwing here is correct — updating a non-existent entity should fail. But &lt;code&gt;MERGE&lt;/code&gt; in Table Storage semantics means &lt;em&gt;Insert-or-Merge&lt;/em&gt;: create the entity if it does not exist, merge the properties if it does. The existing code had no path for that case.&lt;/p&gt;

&lt;p&gt;We added an &lt;code&gt;UpsertEntity&lt;/code&gt; method to &lt;code&gt;TableServiceDataPlane&lt;/code&gt; that writes a new entity unconditionally, and a &lt;code&gt;upsert&lt;/code&gt; parameter to &lt;code&gt;HandleUpdateEntityRequest&lt;/code&gt; that catches &lt;code&gt;EntityNotFoundException&lt;/code&gt; and falls through to the upsert path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityNotFoundException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="n"&gt;Position&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;DataPlane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UpsertEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subscriptionIdentifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resourceGroupIdentifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;storageAccountName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partitionKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rowKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HttpStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoContent&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;h2&gt;
  
  
  Bug four: the disposed stream
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;buffered!.Position = 0&lt;/code&gt; line is significant. The first implementation of the upsert fallback passed the original &lt;code&gt;input&lt;/code&gt; stream directly to &lt;code&gt;UpsertEntity&lt;/code&gt;. The next test run returned &lt;code&gt;500 Internal Server Error&lt;/code&gt; with &lt;code&gt;Cannot access a disposed object&lt;/code&gt; in the Topaz logs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;UpdateEntity&lt;/code&gt; reads the body with a &lt;code&gt;StreamReader&lt;/code&gt; before throwing &lt;code&gt;EntityNotFoundException&lt;/code&gt;. &lt;code&gt;StreamReader&lt;/code&gt; disposes its underlying stream by default when it is disposed — which happens when the &lt;code&gt;using var sr&lt;/code&gt; block exits after the throw. By the time the &lt;code&gt;catch (EntityNotFoundException) when (upsert)&lt;/code&gt; block ran, the stream was already closed.&lt;/p&gt;

&lt;p&gt;The fix was to buffer the request body into a &lt;code&gt;MemoryStream&lt;/code&gt; before entering the &lt;code&gt;UpdateEntity&lt;/code&gt; call when &lt;code&gt;upsert&lt;/code&gt; is true, and use &lt;code&gt;leaveOpen: true&lt;/code&gt; in &lt;code&gt;UpdateEntity&lt;/code&gt;'s &lt;code&gt;StreamReader&lt;/code&gt; to avoid closing the memory stream on an unexpected throw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;MemoryStream&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;buffered&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&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="n"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;buffered&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;MemoryStream&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CopyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Position&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buffered&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;h2&gt;
  
  
  The test assertion
&lt;/h2&gt;

&lt;p&gt;After all four bugs were fixed, Terraform reported &lt;code&gt;Apply complete! Resources: 4 added, 0 changed, 0 destroyed.&lt;/code&gt; The test itself still failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;System.InvalidOperationException : The node must be of type 'JsonValue'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;terraform output -json&lt;/code&gt; response wraps each output in an envelope: &lt;code&gt;{ "partition_key": { "value": "pk1", "type": "string" } }&lt;/code&gt;. The initial test assertion called &lt;code&gt;.GetValue&amp;lt;string&amp;gt;()&lt;/code&gt; directly on &lt;code&gt;outputs["partition_key"]&lt;/code&gt;, which is a &lt;code&gt;JsonObject&lt;/code&gt;, not a &lt;code&gt;JsonValue&lt;/code&gt;. The fix was a one-character change — navigate to &lt;code&gt;["value"]&lt;/code&gt; first, matching every other test in the suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The role of Copilot in the investigation
&lt;/h2&gt;

&lt;p&gt;Copilot assisted throughout both days: adding the diagnostic logging, generating the isolated Terraform scenario and test class, and suggesting fixes as each root cause was identified. The investigation benefited from being able to express hypotheses in natural language — "could the key bytes differ even if the base64 strings match?" led immediately to logging the raw bytes in hex — and from having a second reader of the logs who could cross-reference the wire trace in the TF debug log against the canonicalization logic in the Topaz source.&lt;/p&gt;

&lt;p&gt;The most valuable contribution was narrowing the investigation down to the URL encoding issue. The logs included a &lt;code&gt;DecodedPathSTS&lt;/code&gt; variant that computed the signature using &lt;code&gt;Uri.UnescapeDataString(absolutePath)&lt;/code&gt;. When that also failed to match, it confirmed the issue was not simply percent-encoded spaces — it was the full encoding of the path characters. Tracing &lt;code&gt;IHttpRequestFeature.RawTarget&lt;/code&gt; was the correct escape hatch once the root cause was understood.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TableStorageSecurityProvider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signs against &lt;code&gt;IHttpRequestFeature.RawTarget&lt;/code&gt; (raw wire path) instead of &lt;code&gt;HttpRequest.Path&lt;/code&gt; (decoded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InsertOrMergeTableEntityEndpoint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Added &lt;code&gt;MERGE&lt;/code&gt; verb alongside &lt;code&gt;POST&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TableServiceDataPlane&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Added &lt;code&gt;UpsertEntity&lt;/code&gt;; &lt;code&gt;UpdateEntity&lt;/code&gt; uses &lt;code&gt;leaveOpen: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TableDataPlaneEndpointBase&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HandleUpdateEntityRequest&lt;/code&gt; accepts &lt;code&gt;upsert&lt;/code&gt; flag; buffers body into &lt;code&gt;MemoryStream&lt;/code&gt; for fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All 14 table endpoints&lt;/td&gt;
&lt;td&gt;Updated to pass &lt;code&gt;HttpContext&lt;/code&gt; to &lt;code&gt;IsRequestAuthorized&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The storage API coverage page now marks &lt;code&gt;azurerm_storage_table_entity&lt;/code&gt; create, read, and delete as implemented. The full Terraform scenario — resource group, storage account, table, entity — runs end-to-end in roughly two minutes with no manual steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Each of the four bugs was independently plausible and independently fixable. But they were invisible until the previous one was resolved. You cannot see that &lt;code&gt;MERGE&lt;/code&gt; is unrouted if you never get past the 401. You cannot see the upsert semantics gap if &lt;code&gt;MERGE&lt;/code&gt; returns 404 before reaching the data plane. You cannot see the disposed stream if the upsert path is never exercised. The debugging process was necessarily sequential — each fix revealed the next layer.&lt;/p&gt;

&lt;p&gt;The URL encoding issue is worth calling out specifically. The Azure Table Storage SharedKey algorithm requires signing the &lt;em&gt;canonicalized resource&lt;/em&gt;, which is derived from the request URL. Whether that URL is in its raw percent-encoded form or its decoded form is not a detail the specification makes obvious. ASP.NET Core's routing infrastructure silently decodes the path before most code ever sees it. &lt;code&gt;IHttpRequestFeature.RawTarget&lt;/code&gt; is the correct place to read the unmodified wire path, and it is not the first thing you reach for. Getting there required enough diagnostic signal to rule out every other explanation first.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>terraform</category>
      <category>emulator</category>
      <category>devops</category>
    </item>
    <item>
      <title>Running Terraform against Azure locally, without a subscription</title>
      <dc:creator>Kamil Mrzygłód</dc:creator>
      <pubDate>Tue, 14 Apr 2026 10:16:23 +0000</pubDate>
      <link>https://dev.to/kamil-mrzyglod/running-terraform-against-azure-locally-without-a-subscription-1lpf</link>
      <guid>https://dev.to/kamil-mrzyglod/running-terraform-against-azure-locally-without-a-subscription-1lpf</guid>
      <description>&lt;p&gt;&lt;em&gt;This posts explains how Terraform is integrated with Topaz - Azure emulator for local development and testing. You can check it &lt;a href="https://topaz.thecloudtheory.com/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every Terraform workflow that targets Azure needs the same things before it can do anything useful: an Azure subscription, a service principal or user account with the right permissions, and a network path to the Azure APIs. In a team setting you also need to make sure those credentials are available wherever terraform apply runs — local machines, CI agents, staging pipelines. The feedback loop is slow, and the blast radius for a misconfigured apply is real.&lt;/p&gt;

&lt;p&gt;Topaz removes all of that. The same terraform apply that would create resources in Azure can instead create them in a local emulator, with no subscription, no credentials to rotate, and no cloud charges. This post explains how the integration works and how to set it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the standard AzureRM provider works at all
&lt;/h2&gt;

&lt;p&gt;The key insight is that the AzureRM provider does not have Azure's API endpoints hardcoded. When it initialises, it fetches a metadata document from a discovery endpoint that describes where each Azure API lives. In a normal setup, that discovery endpoint is the Azure Resource Manager metadata endpoint at management.azure.com. Once the provider has that document, it constructs every subsequent request URL from it.&lt;/p&gt;

&lt;p&gt;The metadata_host setting exists precisely to point that discovery step somewhere else. Set it to Topaz's ARM port, and the provider fetches Topaz's metadata document instead. That document points every API URL — authentication, resource management, Key Vault, Storage — at the local emulator. The provider never knows it is not talking to Azure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;metadata_host&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"topaz.local.dev:8899"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_provider_registrations&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings are doing the work here. metadata_host redirects endpoint discovery to Topaz. resource_provider_registrations = "none" tells the provider not to attempt registering resource providers on startup — Topaz does not emulate the full registration flow, and it is not needed for local development anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS setup
&lt;/h2&gt;

&lt;p&gt;The hostname topaz.local.dev needs to resolve to 127.0.0.1 on your machine. Topaz ships install scripts that configure this using dnsmasq, which handles the wildcard subdomains that services like Container Registry depend on. The getting started guide covers installation and the one-time DNS and certificate setup.&lt;/p&gt;

&lt;p&gt;For containerised environments, the same configuration can be handled at the container network level — the Terraform integration guide covers that path as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication
&lt;/h2&gt;

&lt;p&gt;AzureRM needs a credential to authenticate against whatever it thinks Azure is. With Topaz, that means authenticating against Topaz's local Entra ID emulation layer, which responds to the same OAuth2 endpoints the real Azure uses.&lt;/p&gt;

&lt;p&gt;The simplest approach for local development is to use the Azure CLI configured for Topaz's local cloud. Once configured, az login issues a token from the local Entra layer, and the AzureRM provider picks it up through DefaultAzureCredential exactly as it would in production. No service principal, no environment variables, no secrets.&lt;/p&gt;

&lt;p&gt;A fixed subscription ID avoids drift between runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;topaz start &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--default-subscription&lt;/span&gt; 00000000-0000-0000-0000-000000000001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-level&lt;/span&gt; Information
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair that with:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;export ARM_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000001&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And every terraform plan and apply targets the same subscription, the same resource IDs, and the same state — which matters when you want your CI runs to behave identically to your laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete example
&lt;/h2&gt;

&lt;p&gt;With Topaz running, this is all it takes to create a resource group and a Key Vault locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"= 4.67.0"&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;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;metadata_host&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"topaz.local.dev:8899"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_provider_registrations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_resource_group"&lt;/span&gt; &lt;span class="s2"&gt;"example"&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;"rg-local"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"westeurope"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_key_vault"&lt;/span&gt; &lt;span class="s2"&gt;"example"&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;"kv-local"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;tenant_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"50717675-3e5e-4a1e-8cb5-c62d8be8ca48"&lt;/span&gt;
  &lt;span class="nx"&gt;sku_name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"standard"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
terraform apply &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;
terraform destroy &lt;span class="nt"&gt;-auto-approve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tenant ID above is Topaz's built-in local tenant — the same one az login uses when configured for the local cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  AzAPI provider
&lt;/h2&gt;

&lt;p&gt;If your Terraform configuration uses azapi resources alongside azurerm, the setup is straightforward. Keep the azurerm provider configured for Topaz metadata and add the azapi provider declaration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"= 4.67.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;azapi&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Azure/azapi"&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;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;metadata_host&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"topaz.local.dev:8899"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_provider_registrations&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azapi"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because azapi inherits its endpoint configuration from the same environment, and Topaz's metadata document covers the resource management endpoints that azapi targets, no additional configuration is needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it in CI
&lt;/h2&gt;

&lt;p&gt;The setup above works identically in a CI pipeline. Run Topaz as a service container or a background step, set ARM_SUBSCRIPTION_ID, and run terraform apply as normal. No Azure credentials in the pipeline, no cost per run, no rate limiting from the Azure APIs. The CI/CD integration guide has ready-to-use examples for GitHub Actions and Azure DevOps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works today
&lt;/h2&gt;

&lt;p&gt;Topaz currently supports Terraform workflows for Azure Storage, Key Vault, Service Bus, Event Hubs, Container Registry, and Resource Manager operations including resource groups and ARM template deployments. The API coverage docs list which operations are implemented per service.&lt;/p&gt;

&lt;p&gt;Not every AzureRM resource type is emulated yet. If you hit a resource that Topaz does not support, the provider will return a 404 or an unsupported operation error. Check the API coverage page for current status, and open an issue if something you need is missing.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>terraform</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
