<?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: DevOps Start</title>
    <description>The latest articles on DEV Community by DevOps Start (@devopsstart).</description>
    <link>https://dev.to/devopsstart</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3862044%2F9672d1b5-f8fd-4473-998f-30a47c07608f.png</url>
      <title>DEV Community: DevOps Start</title>
      <link>https://dev.to/devopsstart</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devopsstart"/>
    <language>en</language>
    <item>
      <title>Azure SDK for Rust Migration Guide: REST to GA Crates</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:23:29 +0000</pubDate>
      <link>https://dev.to/devopsstart/azure-sdk-for-rust-migration-guide-rest-to-ga-crates-1hnn</link>
      <guid>https://dev.to/devopsstart/azure-sdk-for-rust-migration-guide-rest-to-ga-crates-1hnn</guid>
      <description>&lt;p&gt;If you have been hitting Azure REST endpoints from Rust with hand-rolled &lt;code&gt;reqwest&lt;/code&gt; calls and a pile of header-signing code, you can delete most of it now. The Azure SDK for Rust reached general availability in mid-2026, shipping stable 1.0 crates for Core, Identity, Key Vault, and Storage. This guide walks you through replacing raw REST access with the official clients: adding the crates, wiring up &lt;code&gt;DefaultAzureCredential&lt;/code&gt;, reading a Key Vault secret, downloading a blob, and turning on retries and tracing. Every step maps a piece of REST plumbing you can retire to the typed client that replaces it.&lt;/p&gt;

&lt;p&gt;The migration is not a rewrite. The clients follow the same design patterns as the .NET, Python, Go, and Java SDKs, so the shapes are predictable. What changes is that authentication, retries, and pagination stop being your problem and become the SDK's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually went GA
&lt;/h2&gt;

&lt;p&gt;The GA wave promoted a specific set of crates to stable 1.0. Knowing which ones are production-ready keeps you from pinning a preview crate by accident:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Crate&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_core&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared pipeline: HTTP, retries, auth traits, error types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_identity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Credential types, including &lt;code&gt;DefaultAzureCredential&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_security_keyvault_secrets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key Vault secrets client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_security_keyvault_keys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key Vault keys client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_security_keyvault_certificates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key Vault certificates client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_storage_blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blob upload, download, and container operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_storage_queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Queue send and receive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_core_opentelemetry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Distributed tracing bridge for the pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things did not make the GA cut, and you should plan around them. Event Hubs is slated for the next stable wave, and Cosmos DB support is in active development with a stable release expected later in 2026. If your service depends on either, keep your existing REST or preview code for those paths and migrate the rest now. Microsoft's &lt;a href="https://learn.microsoft.com/en-us/azure/developer/rust/sdk/overview" rel="noopener noreferrer"&gt;Rust on Azure overview&lt;/a&gt; tracks the current crate status if you want to confirm before you pin a version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: add the crates
&lt;/h2&gt;

&lt;p&gt;Start with a clean dependency set. From your crate root, let Cargo resolve the latest stable versions rather than guessing patch numbers:&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;$ &lt;/span&gt;cargo add azure_identity azure_security_keyvault_secrets azure_storage_blob tokio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pulls &lt;code&gt;azure_core&lt;/code&gt; transitively, so you rarely add it by hand. If you prefer to pin versions explicitly, your &lt;code&gt;Cargo.toml&lt;/code&gt; ends up looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;azure_identity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;azure_security_keyvault_secrets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;azure_storage_blob&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;azure_core&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;tokio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK is async-first and expects a Tokio runtime, which is why &lt;code&gt;tokio&lt;/code&gt; is in the list with the &lt;code&gt;full&lt;/code&gt; feature. If your service already runs on &lt;code&gt;async-std&lt;/code&gt; or a custom runtime, you will need a compatibility shim, because the clients are built and tested against Tokio.&lt;/p&gt;

&lt;p&gt;Verify the tree resolved cleanly before you write any client code:&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;$ &lt;/span&gt;cargo tree &lt;span class="nt"&gt;-p&lt;/span&gt; azure_core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a single &lt;code&gt;azure_core 1.x&lt;/code&gt; in the output. If two versions show up, one of your other Azure crates is still on a preview release, and mixing a 1.0 core with a 0.x client is the most common source of trait-mismatch errors during migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: replace your auth code with azure_identity
&lt;/h2&gt;

&lt;p&gt;This is where you delete the most code. If your REST client was fetching tokens from the IMDS endpoint or juggling a client secret from an environment variable, &lt;code&gt;DefaultAzureCredential&lt;/code&gt; replaces all of it with one type that tries a chain of sources in order: environment variables, workload identity, managed identity, and your local developer sign-in.&lt;/p&gt;

&lt;p&gt;Here is the full pattern for constructing a credential and handing it to a client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_security_keyvault_secrets&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"https://your-vault-name.vault.azure.net/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// client is now ready to make authenticated calls&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;credential.clone()&lt;/code&gt; is cheap. Credentials are reference-counted internally, so cloning shares the same token cache rather than re-authenticating. Build one credential at startup and clone it into every client you construct.&lt;/p&gt;

&lt;p&gt;For local development, &lt;code&gt;DefaultAzureCredential&lt;/code&gt; will pick up the identity you signed in with through &lt;code&gt;az login&lt;/code&gt;. Confirm that works before you run anything:&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;$ &lt;/span&gt;az login
&lt;span class="nv"&gt;$ &lt;/span&gt;az account show &lt;span class="nt"&gt;--query&lt;/span&gt; user.name &lt;span class="nt"&gt;-o&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to be explicit about using developer tooling and skip the managed-identity probes (which add latency and noisy log lines when you run locally), swap in &lt;code&gt;DeveloperToolsCredential&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DeveloperToolsCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DeveloperToolsCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on scope. In production, prefer managed identity so there is no secret to leak, and grant the identity only the specific Key Vault and Storage roles it needs. The same discipline that keeps &lt;a href="https://dev.to/blog/github-actions-security-how-to-stop-secret-leaks-in-cicd"&gt;secrets out of your CI pipelines&lt;/a&gt; applies here: the fewer long-lived credentials your Rust service holds, the smaller your blast radius. If you run across several Azure subscriptions, the &lt;a href="https://dev.to/tips/how-to-manage-multiple-azure-subscriptions-in-terraform"&gt;multi-subscription patterns from the Terraform side&lt;/a&gt; carry over, because &lt;code&gt;DefaultAzureCredential&lt;/code&gt; honors the same &lt;code&gt;AZURE_SUBSCRIPTION_ID&lt;/code&gt; and tenant environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: migrate a Key Vault secret read
&lt;/h2&gt;

&lt;p&gt;A typical pre-SDK secret fetch was a signed GET against &lt;code&gt;https://your-vault.vault.azure.net/secrets/{name}?api-version=7.4&lt;/code&gt;, plus JSON parsing to dig the value out of the response envelope. Replace the whole thing with a typed call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_security_keyvault_secrets&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"https://your-vault-name.vault.azure.net/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
        &lt;span class="nf"&gt;.get_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"database-password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.into_body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="py"&gt;.value&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secret length: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&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;Three details matter here. First, the empty string for the version argument means "current version," which is what you almost always want. Pass a specific version string only when you need to pin to a historical value. Second, the response is a two-stage unwrap: &lt;code&gt;.await?&lt;/code&gt; gives you the HTTP response, and &lt;code&gt;.into_body().await?&lt;/code&gt; deserializes it into the typed &lt;code&gt;Secret&lt;/code&gt; model. Third, never log the secret value itself. Print its length or a hash if you need a sanity check, as the example does above.&lt;/p&gt;

&lt;p&gt;Run it with your vault name substituted in:&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;$ &lt;/span&gt;cargo run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get a 403, the problem is almost always RBAC rather than code. Grant your identity the Key Vault Secrets User role:&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;$ &lt;/span&gt;az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Key Vault Secrets User"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad signed-in-user show &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"/subscriptions/&amp;lt;sub-id&amp;gt;/resourceGroups/&amp;lt;rg&amp;gt;/providers/Microsoft.KeyVault/vaults/&amp;lt;vault-name&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/azure/developer/rust/sdk/authentication/overview" rel="noopener noreferrer"&gt;authentication overview on Microsoft Learn&lt;/a&gt; documents the full credential chain and which environment variables each link reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: migrate blob storage access
&lt;/h2&gt;

&lt;p&gt;Blob access follows the same construction pattern. You build a client against the account URL, then operate on containers and blobs. Here is a download that streams the blob body into memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_storage_blob&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;BlobClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;blob_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;BlobClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"https://yourstorageaccount.blob.core.windows.net/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"reports"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s"&gt;"2026-q3.json"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blob_client&lt;/span&gt;&lt;span class="nf"&gt;.download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.into_body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"downloaded {} bytes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor arguments are the account URL, the container name, the blob name, the credential, and an options struct. Passing &lt;code&gt;None&lt;/code&gt; for options accepts the defaults, which is the right starting point. The &lt;code&gt;download&lt;/code&gt; call returns a response whose body you collect into bytes. For large blobs you would stream chunks rather than collecting the whole body, but collecting is fine for config files and small artifacts.&lt;/p&gt;

&lt;p&gt;The same authorization rule applies: the identity needs the Storage Blob Data Reader role (or Contributor if you also write). Assign it the same way you did for Key Vault, swapping the role name and the scope to your storage account.&lt;/p&gt;

&lt;p&gt;Uploading is the mirror image. You build the same client and hand it the bytes plus a length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RequestContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;generated_at&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;2026-07-31&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;}"&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;blob_client&lt;/span&gt;
    &lt;span class="nf"&gt;.upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;RequestContent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boolean argument is the overwrite flag. Passing &lt;code&gt;true&lt;/code&gt; replaces an existing blob of the same name, and &lt;code&gt;false&lt;/code&gt; fails the call if the blob already exists, which is the safer default when you are writing an object that should be created exactly once. As with the download, the options struct is &lt;code&gt;None&lt;/code&gt; until you need to set content type, metadata, or an access tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: retries and resilience are already on
&lt;/h2&gt;

&lt;p&gt;One of the quieter wins in the GA release is that resilience is built into &lt;code&gt;azure_core&lt;/code&gt;'s pipeline and on by default. Transient failures (HTTP 429, 503, and connection resets) are retried automatically with exponential backoff. You do not write retry loops anymore, and you should delete any you carried over from your REST client, because doubling up on retries turns a brief throttle into a much longer stall.&lt;/p&gt;

&lt;p&gt;When you do need to tune the behavior, you set it through the client options struct rather than wrapping calls yourself. The pattern looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RetryOptions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Construct retry options and pass them through the client's options struct&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;RetryOptions&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;exponential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GA release also added challenge-based authentication, so the clients work correctly in sovereign and private cloud environments where the token audience is discovered from a challenge response rather than assumed. If you previously special-cased Azure Government or a private cloud, that branch can likely go.&lt;/p&gt;

&lt;p&gt;Start with the defaults. The built-in policy is tuned for the common case, and premature retry tuning is a frequent way to make throttling worse rather than better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: turn on distributed tracing
&lt;/h2&gt;

&lt;p&gt;If your service already emits OpenTelemetry spans, you can thread Azure SDK calls into the same traces using the &lt;code&gt;azure_core_opentelemetry&lt;/code&gt; crate. It bridges the SDK's internal pipeline instrumentation to your OpenTelemetry tracer, so every Key Vault or Storage call shows up as a child span under your request.&lt;/p&gt;

&lt;p&gt;Add the crate:&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;$ &lt;/span&gt;cargo add azure_core_opentelemetry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You wire it in by attaching the OpenTelemetry tracer provider to the client options, after which HTTP calls the SDK makes are recorded as spans with the operation name, target, and status. The HTTP logging layer sanitizes secrets by default, so authorization headers and secret values are redacted before anything reaches your log sink. That default matters: it means turning on verbose SDK logging during an incident will not accidentally dump a Key Vault secret into your log aggregator.&lt;/p&gt;

&lt;p&gt;If you are new to wiring OpenTelemetry through an application, the mechanics of tracer setup and exporters are the same ones covered in this walkthrough on &lt;a href="https://dev.to/tutorials/how-to-set-up-llm-observability-with-opentelemetry"&gt;setting up observability with OpenTelemetry&lt;/a&gt;; the Azure crate simply feeds the SDK's own spans into that pipeline instead of you instrumenting each call by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle errors with the typed error model
&lt;/h2&gt;

&lt;p&gt;Your REST client probably branched on raw status codes pulled out of a response struct. The SDK gives you a typed &lt;code&gt;azure_core::Error&lt;/code&gt; instead, and the useful move during migration is to inspect its HTTP status when you need to distinguish a genuine "not found" from a transient failure the pipeline already gave up retrying.&lt;/p&gt;

&lt;p&gt;A common case is treating a missing secret as an expected outcome rather than a hard failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;http&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="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="nf"&gt;.get_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"optional-flag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&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="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.into_body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"found: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="py"&gt;.value&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or_default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.http_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secret not set, using default"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern is worth internalizing because it is identical across every GA client. A Key Vault 404, a Storage 404, and an Identity failure all surface through the same &lt;code&gt;azure_core::Error&lt;/code&gt; type with the same &lt;code&gt;http_status()&lt;/code&gt; accessor. That consistency is a large part of why migrating the second and third service is faster than the first: once you have written error handling for one client, you have written it for all of them.&lt;/p&gt;

&lt;p&gt;Resist the urge to match on error strings. The status accessor is stable across releases; the human-readable message is not, and matching on it will break the next time the wire format changes a word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying to AKS with workload identity
&lt;/h2&gt;

&lt;p&gt;The payoff of &lt;code&gt;DefaultAzureCredential&lt;/code&gt; shows up in production, where you want zero secrets in the container. On Azure Kubernetes Service, workload identity federates your pod's service account to an Azure managed identity, and the credential picks it up automatically through environment variables the workload-identity webhook injects. Your Rust code does not change at all between laptop and cluster, which is the point.&lt;/p&gt;

&lt;p&gt;The cluster-side wiring is three annotations and a federated credential:&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;$ &lt;/span&gt;az identity federated-credential create &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; rust-app-federated &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--identity-name&lt;/span&gt; rust-app-identity &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--issuer&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az aks show &lt;span class="nt"&gt;-g&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;cluster&amp;gt; &lt;span class="nt"&gt;--query&lt;/span&gt; oidcIssuerProfile.issuerUrl &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;service-account&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the federated credential exists and your pod's service account carries the &lt;code&gt;azure.workload.identity/client-id&lt;/code&gt; annotation, the same binary you tested locally authenticates as the managed identity with no code path difference. Grant that identity the same Key Vault and Storage roles you used during local testing, scoped to production resources, and you have a service with no long-lived credential anywhere in the deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration checklist
&lt;/h2&gt;

&lt;p&gt;Work through your codebase in this order to keep the change reviewable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inventory every place you call an Azure REST endpoint from Rust. Group them by service (Key Vault, Storage, and anything not yet GA).&lt;/li&gt;
&lt;li&gt;Add the GA crates for the services you found, and run &lt;code&gt;cargo tree&lt;/code&gt; to confirm a single &lt;code&gt;azure_core 1.x&lt;/code&gt; in the graph.&lt;/li&gt;
&lt;li&gt;Replace token acquisition with a single &lt;code&gt;DefaultAzureCredential&lt;/code&gt; built at startup and cloned into each client.&lt;/li&gt;
&lt;li&gt;Convert one service at a time. Migrate Key Vault first, since it is usually the smallest surface, then Storage.&lt;/li&gt;
&lt;li&gt;Delete your hand-written retry loops and header-signing helpers once the typed client covers that path.&lt;/li&gt;
&lt;li&gt;Leave Event Hubs and Cosmos DB on your existing code until their stable crates ship, and tag those spots with a comment so you remember to revisit.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;azure_core_opentelemetry&lt;/code&gt; last, once functionality is proven, so tracing reflects the new call paths.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Migrating incrementally like this is the same principle behind any staged platform move, including the &lt;a href="https://dev.to/blog/azure-devops-to-github-migration-ai-driven-playbook"&gt;Azure DevOps to GitHub playbook&lt;/a&gt;: change one bounded surface, verify it in production, then move to the next. Do not try to flip every service in a single pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common migration gotchas
&lt;/h2&gt;

&lt;p&gt;A few things trip people up on the first pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mixed crate versions.&lt;/strong&gt; A preview &lt;code&gt;0.x&lt;/code&gt; client against a &lt;code&gt;1.0&lt;/code&gt; &lt;code&gt;azure_core&lt;/code&gt; produces confusing trait errors. Pin everything to the 1.0 line and re-run &lt;code&gt;cargo tree&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;.into_body()&lt;/code&gt;.&lt;/strong&gt; The first &lt;code&gt;.await?&lt;/code&gt; gives you the response, not the parsed model. The typed value comes from the second &lt;code&gt;.into_body().await?&lt;/code&gt;. Skipping it is a frequent compile-time confusion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuilding credentials per request.&lt;/strong&gt; Constructing &lt;code&gt;DefaultAzureCredential&lt;/code&gt; inside a request handler defeats the token cache and adds latency. Build once, clone many.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC, not code.&lt;/strong&gt; A 401 usually means the audience or tenant is wrong; a 403 almost always means a missing role assignment. Check &lt;code&gt;az role assignment list&lt;/code&gt; before you suspect the SDK.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;After this migration, your Azure access code in Rust is smaller, typed, and consistent with how the rest of your Azure fleet is written in other languages. Authentication is one credential built at startup. Retries and secret redaction are handled by the pipeline. Tracing plugs into the OpenTelemetry setup you already run. The two gaps to watch are Event Hubs and Cosmos DB, both of which have stable crates on the roadmap, so keep those integration points isolated and ready to swap.&lt;/p&gt;

&lt;p&gt;Start with a single non-critical service, prove the pattern end to end in a staging environment, and use the checklist above to roll it out service by service. The &lt;a href="https://learn.microsoft.com/en-us/azure/developer/rust/sdk/overview" rel="noopener noreferrer"&gt;crate status page on Microsoft Learn&lt;/a&gt; is worth a bookmark, because the GA surface is still expanding and the next wave will let you delete even more REST plumbing.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>security</category>
      <category>observability</category>
      <category>opentelemetry</category>
    </item>
    <item>
      <title>Manage OTel Collectors at Scale with OpAMP</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Tue, 28 Jul 2026 09:56:39 +0000</pubDate>
      <link>https://dev.to/devopsstart/manage-otel-collectors-at-scale-with-opamp-468h</link>
      <guid>https://dev.to/devopsstart/manage-otel-collectors-at-scale-with-opamp-468h</guid>
      <description>&lt;p&gt;If you run more than a handful of OpenTelemetry Collectors, you already know the pain: a config change means SSHing into boxes, redeploying DaemonSets, or babysitting a Git pipeline per cluster, and you never quite trust that every agent is running the config you think it is. OpAMP fixes exactly that. It is a protocol that lets a central server push configuration to a fleet of Collectors, watch their health, and roll changes out in stages, without you touching each host. This post walks through how OpAMP works, the two ways a Collector can speak it, and the config you need to wire one up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem OpAMP solves
&lt;/h2&gt;

&lt;p&gt;A single Collector is easy. A hundred of them, spread across clusters, VMs, and edge nodes, is a fleet-management problem that has nothing to do with telemetry itself. Every observability team eventually builds some version of the same thing: a way to ship a new pipeline config, confirm it actually applied, and back it out when a processor starts dropping spans.&lt;/p&gt;

&lt;p&gt;Without a management protocol you end up gluing that together from ConfigMaps, Ansible runs, and dashboards that only tell you an agent is alive, not what config it is actually running. Config drift creeps in. One node keeps an old sampling rate for months because its rollout quietly failed and nobody noticed.&lt;/p&gt;

&lt;p&gt;OpAMP, the Open Agent Management Protocol, is the OpenTelemetry answer to this. Splunk donated it to the project in 2022, and it has since become the standard control channel for the Collector. It is worth pairing with a clear-eyed view of what a Collector actually is versus lighter agents; the &lt;a href="https://dev.to/comparisons/opentelemetry-collector-vs-grafana-alloy-2026-guide"&gt;OpenTelemetry Collector vs Grafana Alloy comparison&lt;/a&gt; covers that trade-off if you are still choosing a data plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OpAMP actually is
&lt;/h2&gt;

&lt;p&gt;OpAMP is a client/server network protocol for remote management of large fleets of data-collection agents. It is transport-flexible: agents connect to the server over either plain HTTP or a WebSocket, and the WebSocket path gives you a persistent bidirectional channel so the server can push a new config the moment you save it.&lt;/p&gt;

&lt;p&gt;The protocol is deliberately narrow. It handles a specific set of jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Remote configuration&lt;/strong&gt;: the server sends a config, the agent applies it and reloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health and status reporting&lt;/strong&gt;: agents report whether they are healthy and what they are doing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Effective config reporting&lt;/strong&gt;: agents send back the config they are actually running, so you can detect drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own-telemetry reporting&lt;/strong&gt;: agents can stream their own metrics, logs, and traces about themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package and version management&lt;/strong&gt;: the server can discover an agent's version and, optionally, push binary updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what is not in that list: OpAMP does not define what your telemetry pipeline looks like. It carries an opaque config blob to the agent and lets the agent decide what to do with it. For a Collector, that blob is just your normal Collector YAML. OpAMP is the envelope, not the letter.&lt;/p&gt;

&lt;p&gt;That separation is the whole design. The &lt;a href="https://opentelemetry.io/docs/collector/management/" rel="noopener noreferrer"&gt;OpenTelemetry management docs&lt;/a&gt; describe OpAMP as the recommended path for fleet management, and the protocol spec is published at &lt;a href="https://opentelemetry.io/docs/specs/opamp/" rel="noopener noreferrer"&gt;opentelemetry.io/docs/specs/opamp&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways a Collector speaks OpAMP
&lt;/h2&gt;

&lt;p&gt;There are two distinct integration points, and mixing them up is the most common early mistake.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it manages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;opamp&lt;/code&gt; extension&lt;/td&gt;
&lt;td&gt;An extension compiled into the Collector&lt;/td&gt;
&lt;td&gt;Reports health, effective config, and identity to the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpAMP Supervisor&lt;/td&gt;
&lt;td&gt;A separate process that wraps the Collector&lt;/td&gt;
&lt;td&gt;Full lifecycle: applies remote config, restarts, and reports on the Collector's behalf&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The built-in &lt;code&gt;opamp&lt;/code&gt; extension is the lightweight option. It lets a Collector announce itself to an OpAMP server and report status, but the extension alone cannot rewrite the Collector's config and restart it, because a running process cannot swap out the config file it booted from and cleanly reload every component.&lt;/p&gt;

&lt;p&gt;The Supervisor closes that gap. It is a small parent process that launches the Collector as a child, holds the OpAMP connection itself, and owns the Collector's lifecycle. When a new config arrives, the Supervisor writes it to disk, restarts the Collector against it, and reports the result upstream. For actual remote-configuration-driven fleet management, the Supervisor is the path you want. It lives in the &lt;code&gt;opentelemetry-collector-contrib&lt;/code&gt; repository under &lt;code&gt;cmd/opampsupervisor&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring up the Supervisor
&lt;/h2&gt;

&lt;p&gt;The Supervisor takes its own config file, usually &lt;code&gt;supervisor.yaml&lt;/code&gt;, which is separate from the Collector config it manages. Here is a representative example:&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;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wss://opamp.example.com:4320/v1/opamp&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;insecure_skip_verify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;accepts_remote_config&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;reports_effective_config&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;reports_own_metrics&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;reports_own_logs&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;reports_health&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;reports_remote_config&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;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;executable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/otelcol-contrib&lt;/span&gt;
&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/otelcol-supervisor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three blocks matter here.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;server&lt;/code&gt; block points at your OpAMP backend. The &lt;code&gt;wss://&lt;/code&gt; scheme selects the WebSocket transport, and &lt;code&gt;4320&lt;/code&gt; is the conventional OpAMP port used across the project's examples. Keep &lt;code&gt;insecure_skip_verify&lt;/code&gt; at &lt;code&gt;false&lt;/code&gt; in anything real; you are opening a control channel that can change what runs on your hosts, so certificate verification is not optional.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;capabilities&lt;/code&gt; block is a set of explicit opt-ins. Nothing is implied. If you want the server to be able to push config, you must set &lt;code&gt;accepts_remote_config: true&lt;/code&gt;. If you want drift detection, &lt;code&gt;reports_effective_config: true&lt;/code&gt; is what sends the running config back. Turning these on individually means you can start conservative (health only) and add remote config later once you trust the setup.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;agent&lt;/code&gt; block tells the Supervisor which binary to run and manage, and &lt;code&gt;storage&lt;/code&gt; is where it persists the last-known remote config so a restart does not lose it.&lt;/p&gt;

&lt;p&gt;You start the Supervisor, not the Collector, and let it own the child process:&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;$ &lt;/span&gt;otelcol-supervisor &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/otelcol/supervisor.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here on you never start the Collector directly. The Supervisor connects to the server, sends an &lt;code&gt;AgentDescription&lt;/code&gt; that identifies this instance, and waits for config. When you push a new pipeline from the server, the Supervisor lands it on disk and cycles the Collector.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a remote config flow looks like
&lt;/h2&gt;

&lt;p&gt;Once an agent is connected with &lt;code&gt;accepts_remote_config&lt;/code&gt; enabled, the loop is simple to reason about:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You edit a Collector config in the server's UI or API and target a set of agents.&lt;/li&gt;
&lt;li&gt;The server sends the config over the open connection.&lt;/li&gt;
&lt;li&gt;The Supervisor writes it to its storage directory and restarts the Collector against it.&lt;/li&gt;
&lt;li&gt;The Collector boots, and the Supervisor reports back the new effective config and health.&lt;/li&gt;
&lt;li&gt;The server marks the rollout applied for that agent, or surfaces an error if the Collector rejected the config.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the part teams underrate. Because the agent reports its &lt;em&gt;effective&lt;/em&gt; config, you get a closed loop: the server does not just assume the push worked, it sees the config the Collector is genuinely running. That is how you catch the node that silently kept an old sampling rate. A dashboard built on &lt;code&gt;reports_effective_config&lt;/code&gt; shows you real drift instead of a green checkmark that means nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health, identity, and self-telemetry
&lt;/h2&gt;

&lt;p&gt;The reporting capabilities are worth turning on even before you trust remote config. With &lt;code&gt;reports_health: true&lt;/code&gt;, each agent tells the server whether it is up and functioning, which beats inferring liveness from whether metrics happen to be flowing. Health here means the Collector's own view of itself, including whether its pipelines started cleanly.&lt;/p&gt;

&lt;p&gt;Identity comes from the &lt;code&gt;AgentDescription&lt;/code&gt; message. Every connecting agent sends a set of attributes about itself: hostname, OS, Collector version, and any custom labels you attach. Those labels are the backbone of fleet management, because they are how you target a subset of agents. You push a config to &lt;code&gt;service.namespace=payments&lt;/code&gt; and only those Collectors receive it. Getting your labeling scheme right early is more important than the config content itself; without good labels, every rollout is all-or-nothing.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;reports_own_metrics: true&lt;/code&gt;, the Collector streams its internal metrics (queue sizes, dropped spans, export failures) as part of the same channel. Feed those into your existing metrics backend. If you scrape them with Prometheus, the &lt;a href="https://prometheus.io/docs/introduction/overview/" rel="noopener noreferrer"&gt;Prometheus documentation&lt;/a&gt; covers the receiver side, and Grafana's own agent tooling documented at &lt;a href="https://grafana.com/docs/" rel="noopener noreferrer"&gt;grafana.com&lt;/a&gt; shows how a similar management model looks in a different distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Package management, and why to be careful
&lt;/h2&gt;

&lt;p&gt;OpAMP can also push binary updates. The server can discover an agent's version and, with the right capability enabled, deliver a new package so the agent upgrades or downgrades itself. On paper this is the dream: patch a Collector CVE across a thousand hosts from one console.&lt;/p&gt;

&lt;p&gt;In practice, treat auto-update as the most dangerous capability in the protocol and turn it on last. A bad config push restarts a Collector; a bad package push replaces the binary on every targeted host at once. Stage it the way you would any other production rollout: a canary group first, watch health and effective-config reporting, then widen. The protocol gives you the mechanism, not the judgment. Keep package management off until your health and config feedback loops are boringly reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling out across a real fleet
&lt;/h2&gt;

&lt;p&gt;Scale is where the labeling discipline pays off. A sane rollout pattern looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tag everything.&lt;/strong&gt; Attach environment, region, and service labels to every agent via its identifying attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary by label.&lt;/strong&gt; Push a config change to a small, clearly labeled canary group first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch effective config.&lt;/strong&gt; Confirm the canary agents report the new config as their effective config, not just that they acknowledged the push.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch health.&lt;/strong&gt; Give it long enough to catch a pipeline that starts fine but fails under load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widen in waves.&lt;/strong&gt; Expand to the next label group, then the rest, with the same two checks each time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the same staged-rollout thinking you would apply to a Kubernetes deployment, and it composes well with cost and reliability work already in your pipeline. If you are instrumenting application workloads at the same time, the collector fleet you manage with OpAMP is what those pipelines feed into; see &lt;a href="https://dev.to/tutorials/how-to-set-up-llm-observability-with-opentelemetry"&gt;How to Set Up LLM Observability with OpenTelemetry&lt;/a&gt; and, for cluster-scale patterns, &lt;a href="https://dev.to/tutorials/llm-observability-on-kubernetes-a-practical-guide"&gt;LLM Observability on Kubernetes&lt;/a&gt; for the data-plane side of the same system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a server
&lt;/h2&gt;

&lt;p&gt;OpAMP is only half a system; you also need a server that speaks it. You have two routes. You can run a managed or open-source OpAMP backend such as BindPlane, which grew out of the same observIQ work that seeded the protocol, and get a UI, agent inventory, and config management out of the box. Or you can build against &lt;code&gt;opamp-go&lt;/code&gt;, the reference server and client implementation, if you want the control channel wired directly into your own platform.&lt;/p&gt;

&lt;p&gt;For most teams, starting with an existing server is the right call. The value of OpAMP is the fleet view and the safe rollout mechanics, and rebuilding those from the reference libraries is a real engineering investment. Start managed, learn the operational patterns, and only build your own server if you have a platform reason the off-the-shelf options cannot meet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;OpAMP turns a pile of independently configured Collectors into a fleet you can actually operate. The mental model is small: the Supervisor owns the Collector's lifecycle, the &lt;code&gt;capabilities&lt;/code&gt; block is a set of explicit opt-ins, and effective-config reporting is what makes the whole thing trustworthy instead of hopeful. Start with health and effective-config reporting so you can see your fleet, add remote config once you trust the feedback loop, and leave package auto-update for last.&lt;/p&gt;

&lt;p&gt;If you are still deciding whether the full Collector is even the right data plane for your fleet, settle that first, then bring OpAMP in to manage whatever you land on. The protocol is agnostic about the pipeline; it just makes running a lot of them survivable.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>monitoring</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Validate Kubernetes Manifests with Flux Schema</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sun, 26 Jul 2026 09:26:07 +0000</pubDate>
      <link>https://dev.to/devopsstart/validate-kubernetes-manifests-with-flux-schema-4kbh</link>
      <guid>https://dev.to/devopsstart/validate-kubernetes-manifests-with-flux-schema-4kbh</guid>
      <description>&lt;p&gt;If you run GitOps with Flux, a broken manifest usually gets caught the slow way: it merges, the reconciler chokes, and you find out from a failing Kustomization. Flux Schema, the plugin that shipped with Flux 2.9, moves that check left into CI. It validates every YAML document against JSON Schema and CEL rules using the same evaluation logic as the Kubernetes API server, so a bad field fails the pull request instead of the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and run it
&lt;/h2&gt;

&lt;p&gt;Flux Schema is a CLI plugin, not part of the core binary. Install it through the plugin system:&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;$ &lt;/span&gt;flux plugin &lt;span class="nb"&gt;install &lt;/span&gt;schema
&lt;span class="nv"&gt;$ &lt;/span&gt;flux schema &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin a version in CI so a new release never changes your gate's behavior mid-sprint:&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;$ &lt;/span&gt;flux plugin &lt;span class="nb"&gt;install &lt;/span&gt;schema@0.5.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point it at a directory of manifests and it validates each document:&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;$ &lt;/span&gt;flux schema validate ./manifests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ships with built-in schemas for Kubernetes, OpenShift, Gateway API, and the Flux CRDs, so a fresh install already knows your &lt;code&gt;HelmRelease&lt;/code&gt; and &lt;code&gt;Kustomization&lt;/code&gt; kinds without any setup. Strict validation flags unknown fields, wrong types, and missing required properties as hard errors, which catches the typos &lt;code&gt;kubectl apply --dry-run=client&lt;/code&gt; quietly ignores.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CEL adds over plain schema checks
&lt;/h2&gt;

&lt;p&gt;JSON Schema catches shape problems: a string where an int belongs, a misspelled key. CEL rules catch logic problems. Because Flux Schema runs the &lt;code&gt;x-kubernetes-validations&lt;/code&gt; rules embedded in CRDs through the same CEL engine the API server uses, a manifest that violates a cross-field constraint (say, a replica count that must stay below a limit, or two mutually exclusive fields both set) fails in CI with the exact message the cluster would have returned. You are testing against the real admission logic, not a stale copy of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into a config file
&lt;/h2&gt;

&lt;p&gt;Drop a &lt;code&gt;.fluxschema.yml&lt;/code&gt; at your repo root to control what gets checked. The file uses the &lt;code&gt;schema.plugin.fluxcd.io/v1beta1&lt;/code&gt; API and a &lt;code&gt;Config&lt;/code&gt; kind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;schema.plugin.fluxcd.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Config&lt;/span&gt;
&lt;span class="na"&gt;skipKind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;skipJSONPath&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;$.metadata.annotations"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;skipKind&lt;/code&gt;, &lt;code&gt;skipFile&lt;/code&gt;, and &lt;code&gt;skipJSONPath&lt;/code&gt; let you exclude the things that legitimately fail strict checks, like sealed secrets or generated fields. Then the command reads it automatically:&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;$ &lt;/span&gt;flux schema validate ./manifests &lt;span class="nt"&gt;--config&lt;/span&gt; .fluxschema.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Put it in the pull request gate
&lt;/h2&gt;

&lt;p&gt;The real payoff is in CI. Flux Schema ships two composite GitHub Actions: &lt;code&gt;setup&lt;/code&gt; installs the CLI on the runner, and &lt;code&gt;validate&lt;/code&gt; auto-detects your kustomize overlays, renders them, and validates every rendered document. A minimal gate looks like this:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;validate-manifests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flux-schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fluxcd/flux-schema/actions/validate@main&lt;/span&gt;
        &lt;span class="na"&gt;with&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="s"&gt;.fluxschema.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the Ecosystem Catalog behind the plugin refreshes daily from upstream releases, your CI validates against the current API versions rather than whatever was frozen months ago. That matters most right after a Kubernetes minor bump, when a deprecated field you have used for a year suddenly needs to change.&lt;/p&gt;

&lt;p&gt;One habit worth keeping: run &lt;code&gt;flux schema validate&lt;/code&gt; locally before you push, not just in CI. The feedback loop is a second or two, and it saves a round trip through the runner. If you are still deciding between Flux and Argo CD for this kind of workflow, our &lt;a href="https://dev.to/blog/argo-cd-vs-flux-a-guide-for-multi-cluster-gitops"&gt;Argo CD vs Flux guide&lt;/a&gt; compares them for multi-cluster setups, and our &lt;a href="https://dev.to/blog/gitops-testing-strategies-validate-deployments-with-argocd"&gt;GitOps testing strategies&lt;/a&gt; piece covers where manifest validation fits in a broader test pyramid.&lt;/p&gt;

&lt;p&gt;Read the &lt;a href="https://fluxcd.io/blog/2026/07/flux-schema-validation/" rel="noopener noreferrer"&gt;Flux Schema announcement on fluxcd.io&lt;/a&gt; for the full catalog details, then add the action to one repo and watch the first bad manifest fail its PR instead of your cluster.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>flux</category>
    </item>
    <item>
      <title>Fix GitLab CI "dial tcp: lookup docker" no such host error</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:13:31 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-gitlab-ci-dial-tcp-lookup-docker-no-such-host-error-3913</link>
      <guid>https://dev.to/devopsstart/fix-gitlab-ci-dial-tcp-lookup-docker-no-such-host-error-3913</guid>
      <description>&lt;h2&gt;
  
  
  The fast fix
&lt;/h2&gt;

&lt;p&gt;If your GitLab CI job dies with &lt;code&gt;error during connect: ... dial tcp: lookup docker on 127.0.0.11:53: no such host&lt;/code&gt;, your &lt;code&gt;docker&lt;/code&gt; client resolved &lt;code&gt;DOCKER_HOST&lt;/code&gt; fine but the DNS name &lt;code&gt;docker&lt;/code&gt; does not exist on the job's network. That name is the alias of the &lt;code&gt;docker:dind&lt;/code&gt; service, and it only registers when the service container actually starts. The usual fix is to define the service with the &lt;code&gt;-dind&lt;/code&gt; image tag and an explicit alias, and point &lt;code&gt;DOCKER_HOST&lt;/code&gt; at TLS port 2376:&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;build&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;docker:28.3&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker:28.3-dind&lt;/span&gt;
      &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker info&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t my-app .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the common case on the &lt;code&gt;docker&lt;/code&gt; executor. If you are on the Kubernetes executor, or the service still refuses to resolve, keep reading. The name resolution is the whole game here, and there are three distinct reasons it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this error is not the daemon-connection error
&lt;/h2&gt;

&lt;p&gt;The address in the message tells you exactly how far the client got. This DNS variant is different from the two connection variants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error during connect: Post "https://docker:2376/v1.44/info":
dial tcp: lookup docker on 127.0.0.11:53: no such host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client tried to resolve the hostname &lt;code&gt;docker&lt;/code&gt; through the container DNS resolver (&lt;code&gt;127.0.0.11&lt;/code&gt; on a Docker bridge network) and got back nothing. That is a name-resolution failure, not a refused connection. Compare it to the &lt;code&gt;tcp://docker:2375&lt;/code&gt; form, where the name resolves but the daemon is unreachable, covered in the &lt;a href="https://dev.to/troubleshooting/fix-gitlab-ci-docker-daemon-connection-error-in-3-steps"&gt;Docker daemon connection error write-up&lt;/a&gt;, and the &lt;code&gt;unix:///var/run/docker.sock&lt;/code&gt; form, where &lt;code&gt;DOCKER_HOST&lt;/code&gt; was never set at all, covered in &lt;a href="https://dev.to/troubleshooting/gitlab-ci-cannot-connect-unix-var-run-docker-sock"&gt;the socket-variant fix&lt;/a&gt;. If your message says &lt;code&gt;no such host&lt;/code&gt;, the client never reached any daemon because the name pointed at nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three real causes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The dind service never started, so its alias never registered
&lt;/h3&gt;

&lt;p&gt;This is the most common cause and the least obvious. GitLab registers the &lt;code&gt;docker&lt;/code&gt; alias on the build network only when the &lt;code&gt;docker:dind&lt;/code&gt; service container comes up. If that container fails to start, the alias is missing and every lookup returns &lt;code&gt;no such host&lt;/code&gt;. Three things stop it from starting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You used the plain image, not the &lt;code&gt;-dind&lt;/code&gt; tag.&lt;/strong&gt; &lt;code&gt;docker:28.3&lt;/code&gt; ships only the client. You need &lt;code&gt;docker:28.3-dind&lt;/code&gt;, which bundles &lt;code&gt;dockerd&lt;/code&gt;. A plain &lt;code&gt;docker&lt;/code&gt; service starts, exits immediately (no daemon to run), and takes its alias down with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The runner is not in privileged mode.&lt;/strong&gt; &lt;code&gt;docker:dind&lt;/code&gt; runs its own daemon and needs &lt;code&gt;privileged = true&lt;/code&gt; in the runner &lt;code&gt;config.toml&lt;/code&gt;. Without it the container cannot start the daemon and dies during boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The image failed to pull.&lt;/strong&gt; A registry rate limit or a typo in the tag means the service container never exists. Check the job log's &lt;code&gt;Preparing the "docker" ...&lt;/code&gt; service lines near the top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pin the runner config and confirm privileged mode:&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;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A3&lt;/span&gt; &lt;span class="s1"&gt;'\[runners.docker\]'&lt;/span&gt; /etc/gitlab-runner/config.toml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want to see &lt;code&gt;privileged = true&lt;/code&gt;. If it says &lt;code&gt;false&lt;/code&gt; or is absent, that is your problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A renamed service image broke the derived alias
&lt;/h3&gt;

&lt;p&gt;GitLab derives a service's default alias from its image name. &lt;code&gt;docker:28.3-dind&lt;/code&gt; becomes the alias &lt;code&gt;docker&lt;/code&gt;. But if you pull the image through a mirror or a private registry, the derived alias changes and &lt;code&gt;docker&lt;/code&gt; stops resolving:&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="c1"&gt;# WRONG: alias becomes "my-mirror.example.com__docker", not "docker"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-mirror.example.com/library/docker:28.3-dind&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The daemon is running, but under a name your &lt;code&gt;DOCKER_HOST&lt;/code&gt; never asks for. Always set the alias explicitly when the image path is anything other than the bare &lt;code&gt;docker&lt;/code&gt; name:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-mirror.example.com/library/docker:28.3-dind&lt;/span&gt;
    &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The Kubernetes executor puts services on localhost
&lt;/h3&gt;

&lt;p&gt;On the &lt;code&gt;docker&lt;/code&gt; executor, the service is a linked container with its own alias, so &lt;code&gt;tcp://docker:2376&lt;/code&gt; is correct. The Kubernetes executor is different: every service runs as a container in the same Pod as the build, sharing one network namespace. They all reach each other on &lt;code&gt;localhost&lt;/code&gt;, and the &lt;code&gt;docker&lt;/code&gt; alias may not resolve at all. If you copied a working &lt;code&gt;docker&lt;/code&gt;-executor config onto a Kubernetes runner, this is why it broke.&lt;/p&gt;

&lt;p&gt;Set the host to &lt;code&gt;localhost&lt;/code&gt; for the Kubernetes executor:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://localhost:2376&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behavior here has shifted across runner versions, so verify against your own version rather than trusting a blog snippet. If &lt;code&gt;tcp://localhost:2376&lt;/code&gt; gives you &lt;code&gt;connection refused&lt;/code&gt; instead of &lt;code&gt;no such host&lt;/code&gt;, the name resolved and you are back to a daemon-startup problem (see cause 1). The two messages tell you which side of the wall you are on.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist that isolates the cause in under a minute
&lt;/h2&gt;

&lt;p&gt;Work through these in order the next time the job goes red:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the address in the error.&lt;/strong&gt; &lt;code&gt;no such host&lt;/code&gt; is DNS. &lt;code&gt;connection refused&lt;/code&gt; is a live-but-unreachable daemon. Do not fix the wrong one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm the &lt;code&gt;-dind&lt;/code&gt; tag.&lt;/strong&gt; Grep your &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; for the service image. No &lt;code&gt;-dind&lt;/code&gt;, no daemon, no alias.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check privileged mode&lt;/strong&gt; in the runner &lt;code&gt;config.toml&lt;/code&gt; as shown above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match &lt;code&gt;DOCKER_HOST&lt;/code&gt; to your executor.&lt;/strong&gt; &lt;code&gt;tcp://docker:2376&lt;/code&gt; for the &lt;code&gt;docker&lt;/code&gt; executor, &lt;code&gt;tcp://localhost:2376&lt;/code&gt; for Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the port to TLS.&lt;/strong&gt; With &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; set, the daemon listens on &lt;code&gt;2376&lt;/code&gt;. Blank it out and it listens on the plain &lt;code&gt;2375&lt;/code&gt;. A mismatch here surfaces as a connection error once the name resolves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan the top of the job log&lt;/strong&gt; for the service &lt;code&gt;Preparing&lt;/code&gt;/&lt;code&gt;Waiting&lt;/code&gt; lines. A service that logs an exit code never registered its alias.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Confirm the daemon is reachable before you build
&lt;/h2&gt;

&lt;p&gt;Once the service is up and named correctly, prove the client can talk to it before your real build steps run. A one-line &lt;code&gt;docker info&lt;/code&gt; at the start of &lt;code&gt;script&lt;/code&gt; fails fast and loud instead of letting a ten-minute build collapse at the push step:&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;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker info&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t my-app .&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push my-app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The daemon's TCP listener and its 2375-versus-2376 split are documented in the &lt;a href="https://docs.docker.com/reference/cli/dockerd/" rel="noopener noreferrer"&gt;Docker daemon reference&lt;/a&gt;; the TLS port only exists because &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; generated certificates on boot. Keep the port and the TLS variables in sync and the &lt;code&gt;no such host&lt;/code&gt; error stays gone. Once your images build cleanly, tightening them is the next job, and &lt;a href="https://dev.to/blog/docker-multi-stage-builds-smaller-secure-production-images"&gt;multi-stage builds&lt;/a&gt; are where that starts.&lt;/p&gt;

</description>
      <category>gitlabcidialtcplookupdocker</category>
      <category>dockerdindservicealias</category>
      <category>gitlabcidockerdnsfailure</category>
      <category>dockerhost</category>
    </item>
    <item>
      <title>GitLab CI "Cannot connect to unix:///var/run/docker.sock"</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:39:01 +0000</pubDate>
      <link>https://dev.to/devopsstart/gitlab-ci-cannot-connect-to-unixvarrundockersock-1doj</link>
      <guid>https://dev.to/devopsstart/gitlab-ci-cannot-connect-to-unixvarrundockersock-1doj</guid>
      <description>&lt;h2&gt;
  
  
  The fast fix
&lt;/h2&gt;

&lt;p&gt;If your GitLab CI job fails with &lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock&lt;/code&gt;, your &lt;code&gt;docker&lt;/code&gt; client is looking for a local socket that does not exist inside the job container, because &lt;code&gt;DOCKER_HOST&lt;/code&gt; is not set. Point the client at the &lt;code&gt;docker:dind&lt;/code&gt; service over TCP and the error goes away:&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;build&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;docker:28.3&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker:28.3-dind&lt;/span&gt;
      &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker info&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t my-app .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole fix for the common case. The rest of this page explains why the socket variant of the error is different from the &lt;code&gt;tcp://docker:2375&lt;/code&gt; variant, and covers the two other setups (socket-mounted runners and the Kubernetes executor) where the same message shows up for a different reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you get the unix socket variant specifically
&lt;/h2&gt;

&lt;p&gt;This error is not the same as &lt;code&gt;Cannot connect to the Docker daemon at tcp://docker:2375&lt;/code&gt;. The address in the message tells you exactly what the client tried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;DOCKER_HOST&lt;/code&gt; is empty, the Docker CLI falls back to its compiled-in default, the local unix socket at &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. Inside a GitLab CI job that uses the &lt;code&gt;docker&lt;/code&gt; executor, that socket file simply is not there. The daemon runs in a separate &lt;code&gt;docker:dind&lt;/code&gt; service container, not in your job container, so there is nothing listening on the local socket. The client connects, finds no socket, and prints the message above.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;tcp://docker:2375&lt;/code&gt; form is the opposite problem: &lt;code&gt;DOCKER_HOST&lt;/code&gt; is set correctly but the dind service is not reachable (missing service, no privileged mode, or a TLS mismatch). If you are seeing that address instead, read the companion write-up on the &lt;a href="https://dev.to/troubleshooting/fix-gitlab-ci-docker-daemon-connection-error-in-3-steps"&gt;tcp://docker:2375 form of this error&lt;/a&gt;, which walks the service and privileged-mode causes in detail. This page is about the case where the client never even tried the network, because nothing told it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three real causes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. DOCKER_HOST is unset
&lt;/h3&gt;

&lt;p&gt;This is the usual cause. You added &lt;code&gt;services: - docker:dind&lt;/code&gt; but never set &lt;code&gt;DOCKER_HOST&lt;/code&gt;, so the client ignores the service container and hits the local socket. Set it as a job or top-level variable:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use port &lt;code&gt;2376&lt;/code&gt; (TLS) when &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; is set, and &lt;code&gt;2375&lt;/code&gt; (plain) when you disable TLS with &lt;code&gt;DOCKER_TLS_CERTDIR: ""&lt;/code&gt;. Mixing them is the second most common mistake, covered next.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. TLS is half-configured
&lt;/h3&gt;

&lt;p&gt;Docker Engine 19.03 and later turns on TLS between the client and the daemon by default. The dind service generates certificates into the path named by &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt;. If you set the certs directory but point &lt;code&gt;DOCKER_HOST&lt;/code&gt; at the plain-text port &lt;code&gt;2375&lt;/code&gt;, or you set neither cert variable, the handshake fails and the client can end up falling back to the socket. Keep the three TLS variables consistent:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather skip TLS for an internal runner, disable it cleanly and use port &lt;code&gt;2375&lt;/code&gt;:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2375&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick one style and set every variable it needs. Do not leave &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; set while talking to &lt;code&gt;2375&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The runner cannot start dind at all
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;DOCKER_HOST&lt;/code&gt; is right but the dind container never boots, the client still fails, sometimes reporting the socket address after a retry. The &lt;code&gt;docker:dind&lt;/code&gt; service needs privileged mode in the runner's &lt;code&gt;config.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[[runners]]
  executor = "docker"
  [runners.docker]
    privileged = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;privileged = false&lt;/code&gt; or the key absent, dind cannot create its own daemon and no address will work. Confirm this on the runner host before touching your pipeline file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Socket-mounted runners are the exception
&lt;/h2&gt;

&lt;p&gt;Some self-managed runners deliberately mount the host's Docker socket instead of running dind. In that setup &lt;code&gt;/var/run/docker.sock&lt;/code&gt; is supposed to exist inside the job, and the same error means the mount is missing or the path is wrong. The runner's &lt;code&gt;config.toml&lt;/code&gt; binds the host socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[[runners]]
  executor = "docker"
  [runners.docker]
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this style you do not set &lt;code&gt;DOCKER_HOST&lt;/code&gt; at all, because the local socket is the daemon. If you get the unix socket error here, check that the host actually has Docker running and that the bind path in &lt;code&gt;volumes&lt;/code&gt; matches the real socket location. This approach shares the host daemon with every job, so treat it as a security tradeoff, not a default. The dind service is the safer choice for untrusted pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kubernetes executor needs the same variables
&lt;/h2&gt;

&lt;p&gt;Running GitLab Runner on Kubernetes does not change the fix, but it adds one gotcha. Each dind service runs as a sidecar container in the same Pod, so &lt;code&gt;DOCKER_HOST: tcp://docker:2376&lt;/code&gt; still resolves through the service alias. What breaks people is TLS cert sharing between containers in the Pod. Set an explicit shared volume for the certs directory in the runner's Helm values or pin &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; to a path both containers can read. If certs land in a directory only the dind container sees, the client falls back to the socket and you get this exact error inside Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the fix
&lt;/h2&gt;

&lt;p&gt;Add a one-line probe to the top of your job and rerun the pipeline:&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;$ &lt;/span&gt;docker info &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.ServerVersion}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints a version string, the client reached the daemon and your &lt;code&gt;docker build&lt;/code&gt; will work. If it still fails, echo the variable to confirm the pipeline actually applied it:&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;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DOCKER_HOST=&lt;/span&gt;&lt;span class="nv"&gt;$DOCKER_HOST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty value here means your &lt;code&gt;variables&lt;/code&gt; block is scoped wrong (defined under the wrong job, or shadowed by a group or project variable). Move it to the top level or the specific job that runs Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Always set &lt;code&gt;DOCKER_HOST&lt;/code&gt; explicitly when you use &lt;code&gt;docker:dind&lt;/code&gt;. Never rely on the default socket in a CI job.&lt;/li&gt;
&lt;li&gt;Keep the TLS variables consistent: &lt;code&gt;2376&lt;/code&gt; with a cert dir, or &lt;code&gt;2375&lt;/code&gt; with &lt;code&gt;DOCKER_TLS_CERTDIR: ""&lt;/code&gt;. Never mix them.&lt;/li&gt;
&lt;li&gt;Pin the image and the dind service to the same tag, for example &lt;code&gt;docker:28.3&lt;/code&gt; and &lt;code&gt;docker:28.3-dind&lt;/code&gt;, so client and daemon versions match.&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;privileged = true&lt;/code&gt; in the runner &lt;code&gt;config.toml&lt;/code&gt; before pushing jobs that need dind.&lt;/li&gt;
&lt;li&gt;On Kubernetes, give the certs directory a shared volume so both containers in the Pod can read it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the daemon flags behind all of this, including the &lt;code&gt;-H&lt;/code&gt; host option and the default &lt;code&gt;unix:///var/run/docker.sock&lt;/code&gt; binding, see the &lt;a href="https://docs.docker.com/reference/cli/dockerd/" rel="noopener noreferrer"&gt;Docker daemon reference&lt;/a&gt;. Once your builds connect reliably, the &lt;a href="https://dev.to/blog/docker-multi-stage-builds-smaller-secure-production-images"&gt;multi-stage build guide&lt;/a&gt; is a good next step for shrinking the images those pipelines produce.&lt;/p&gt;

</description>
      <category>gitlabcidockerdaemon</category>
      <category>dockersockconnectionrefused</category>
      <category>gitlabcidockerdind</category>
      <category>dockerhost</category>
    </item>
    <item>
      <title>Fix Flux SOPS MAC mismatch in kustomize-controller</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Tue, 21 Jul 2026 09:41:48 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-flux-sops-mac-mismatch-in-kustomize-controller-4654</link>
      <guid>https://dev.to/devopsstart/fix-flux-sops-mac-mismatch-in-kustomize-controller-4654</guid>
      <description>&lt;p&gt;A SOPS &lt;code&gt;MAC mismatch&lt;/code&gt; in Flux almost always means one thing: the encrypted file was changed outside of sops. SOPS signs every file with a message authentication code computed over the plaintext at encrypt time. When &lt;code&gt;kustomize-controller&lt;/code&gt; decrypts the file and recomputes that code, it no longer matches, so Flux refuses the Secret and stops reconciling. You cannot patch the ciphertext by hand to fix this. The reliable fix is to recover the real values and re-encrypt the file from scratch, which is what the rest of this page walks through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error looks like
&lt;/h2&gt;

&lt;p&gt;The failure shows up on the Kustomization, not the Secret. Check the object status and the controller logs:&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;$ &lt;/span&gt;flux get kustomizations &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; flux-system logs deploy/kustomize-controller | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"mac mismatch"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see a line similar to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kustomization/flux-system/apps: Reconciliation failed after 1.2s:
failed to decrypt secret 'db-credentials': Error getting data key:
Error decrypting tree: MAC mismatch. Expected '9f8c...', got 'a71b...'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two hashes are the point. "Expected" is the MAC that sops stored when the file was last encrypted correctly. "Got" is the MAC recomputed from the plaintext it just decrypted. They differ, so the content changed since the last clean encryption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;Four situations produce a MAC mismatch in practice.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Fix path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File hand-edited outside sops&lt;/td&gt;
&lt;td&gt;Recent commit touched the &lt;code&gt;.enc.yaml&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;Recover plaintext, re-encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git merge conflict resolved by hand&lt;/td&gt;
&lt;td&gt;Merge commit on the encrypted file&lt;/td&gt;
&lt;td&gt;Re-merge from plaintext&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;encrypted_regex&lt;/code&gt; / &lt;code&gt;mac_only_encrypted&lt;/code&gt; changed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.sops.yaml&lt;/code&gt; edited since last encrypt&lt;/td&gt;
&lt;td&gt;Align rules, re-encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy-paste truncation or corruption&lt;/td&gt;
&lt;td&gt;Value looks short or malformed&lt;/td&gt;
&lt;td&gt;Restore from git, re-encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row covers most incidents. Opening an encrypted YAML in a text editor and changing a value, a key name, or even reindenting it alters the plaintext that sops will recompute the MAC over. The same goes for resolving a Git merge conflict by editing the encrypted file directly: you end up with a document sops never signed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mac_only_encrypted&lt;/code&gt; case is subtler. When your &lt;code&gt;.sops.yaml&lt;/code&gt; sets &lt;code&gt;mac_only_encrypted: true&lt;/code&gt;, only the encrypted values feed the MAC. Flip that flag, or change which fields &lt;code&gt;encrypted_regex&lt;/code&gt; selects, and the recomputed MAC covers a different set of values than the stored one, even though nothing looks wrong in the diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Confirm the file is the problem, not the key
&lt;/h3&gt;

&lt;p&gt;Decrypt the file locally with the same age key Flux uses. If you get a MAC mismatch here too, the file is corrupt and the cluster key secret is fine:&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;$ &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SOPS_AGE_KEY_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.config/sops/age/keys.txt
&lt;span class="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If instead you see &lt;code&gt;no key could decrypt&lt;/code&gt; or a base64 error, that is a different failure. The related &lt;a href="https://dev.to/troubleshooting/fix-flux-sops-illegal-base64-data-at-input-byte-0"&gt;Flux SOPS illegal base64 error fix&lt;/a&gt; covers the key-encoding case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Recover the real plaintext
&lt;/h3&gt;

&lt;p&gt;You need a trustworthy copy of the values. Pick whichever source you actually trust.&lt;/p&gt;

&lt;p&gt;Restore the last known-good version from Git history:&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;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;git show &amp;lt;good-sha&amp;gt;:clusters/prod/db-credentials.enc.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/recovered.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; /tmp/recovered.enc.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/plain.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you trust the current values but only the MAC is stale (for example the file was reindented, not semantically changed), decrypt while skipping MAC verification:&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;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; &lt;span class="nt"&gt;--ignore-mac&lt;/span&gt; clusters/prod/db-credentials.enc.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/plain.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: &lt;code&gt;--ignore-mac&lt;/code&gt; does not work with &lt;code&gt;--in-place&lt;/code&gt;, and it will not rescue a file whose ciphertext was actually modified. Use it only to pull known-good plaintext back out so you can re-encrypt it.&lt;/p&gt;

&lt;p&gt;As a last resort, if the Secret already applied to the cluster at least once, read the live values back:&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;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; app get secret db-credentials &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Re-encrypt from clean plaintext
&lt;/h3&gt;

&lt;p&gt;Encrypt the recovered plaintext into a fresh file with a valid MAC:&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;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; &lt;span class="nt"&gt;--age&lt;/span&gt; age1yourclusterpublickey /tmp/plain.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/plain.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you keep a &lt;code&gt;.sops.yaml&lt;/code&gt; with &lt;code&gt;creation_rules&lt;/code&gt; (recommended, so everyone encrypts identically), sops picks up the key and regex automatically:&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;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; /tmp/plain.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Verify before you push
&lt;/h3&gt;

&lt;p&gt;Never push an encrypted file you have not decrypted at least once. This one command is the difference between a clean reconcile and another red Kustomization:&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;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; clusters/prod/db-credentials.enc.yaml | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns plaintext with no MAC error, the file is good.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Commit and reconcile
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git add clusters/prod/db-credentials.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: re-encrypt db-credentials to repair sops mac"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git push
&lt;span class="nv"&gt;$ &lt;/span&gt;flux reconcile kustomization apps &lt;span class="nt"&gt;--with-source&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the Kustomization go &lt;code&gt;Ready&lt;/code&gt; again with &lt;code&gt;flux get kustomizations&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you tell Flux to ignore the MAC?
&lt;/h2&gt;

&lt;p&gt;No. The &lt;code&gt;kustomize-controller&lt;/code&gt; always verifies the MAC during decryption and exposes no ignore-mac option, by design: a Secret that fails integrity checks should not be applied to a cluster. Tested with Flux v2.4.0 and sops v3.9.4, there is no &lt;code&gt;spec.decryption&lt;/code&gt; field that disables MAC verification. The only durable fix is a file that decrypts cleanly, so treat the mismatch as a signal to re-encrypt rather than something to bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Never open a &lt;code&gt;.enc.yaml&lt;/code&gt; in a plain text editor. Run &lt;code&gt;sops clusters/prod/db-credentials.enc.yaml&lt;/code&gt;, which decrypts into your editor and recomputes the MAC on save.&lt;/li&gt;
&lt;li&gt;Add a CI check or pre-commit hook that runs &lt;code&gt;sops --decrypt&lt;/code&gt; on every changed encrypted file, so a broken MAC fails the pull request instead of the cluster.&lt;/li&gt;
&lt;li&gt;Commit a &lt;code&gt;.sops.yaml&lt;/code&gt; with &lt;code&gt;creation_rules&lt;/code&gt; so &lt;code&gt;encrypted_regex&lt;/code&gt; and key groups are identical for everyone. Inconsistent rules are a quiet source of MAC drift.&lt;/li&gt;
&lt;li&gt;Resolve merge conflicts on encrypted files by decrypting both sides, merging the plaintext, and re-encrypting. Do not hand-edit ciphertext to settle a conflict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the canonical setup, the &lt;a href="https://fluxcd.io/flux/guides/mozilla-sops/" rel="noopener noreferrer"&gt;official Flux SOPS guide&lt;/a&gt; documents the age and key-secret wiring end to end. If you are still deciding how to structure encrypted secrets across environments, the &lt;a href="https://dev.to/blog/argo-cd-vs-flux-a-guide-for-multi-cluster-gitops"&gt;Argo CD vs Flux multi-cluster GitOps guide&lt;/a&gt; and the &lt;a href="https://dev.to/tutorials/how-to-set-up-argo-cd-gitops-for-kubernetes-automation"&gt;Argo CD GitOps setup tutorial&lt;/a&gt; walk through the surrounding reconciliation model that this Secret plugs into.&lt;/p&gt;

</description>
      <category>fluxsops</category>
      <category>sopsmacmismatch</category>
      <category>kustomizecontroller</category>
      <category>gitopssecrets</category>
    </item>
    <item>
      <title>Fix vLLM CUDA OutOfMemoryError in Kubernetes</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sun, 19 Jul 2026 09:18:51 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-vllm-cuda-outofmemoryerror-in-kubernetes-3cij</link>
      <guid>https://dev.to/devopsstart/fix-vllm-cuda-outofmemoryerror-in-kubernetes-3cij</guid>
      <description>&lt;p&gt;If your vLLM pod dies at startup with &lt;code&gt;torch.cuda.OutOfMemoryError: CUDA out of memory&lt;/code&gt;, the model plus its KV cache needs more VRAM than the GPU allocated to that pod can give. The fastest fix is to cap two things: pass &lt;code&gt;--gpu-memory-utilization 0.85&lt;/code&gt; and &lt;code&gt;--max-model-len 4096&lt;/code&gt; on the serve command, then redeploy. If you have more than one GPU in the pod, add &lt;code&gt;--tensor-parallel-size N&lt;/code&gt; to shard the weights across them. Those three flags resolve most of these crashes. The rest of this guide explains when each one matters and the Kubernetes-specific traps that make the error worse than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is a GPU error, not a pod OOMKill
&lt;/h2&gt;

&lt;p&gt;The first thing to get straight: &lt;code&gt;torch.cuda.OutOfMemoryError&lt;/code&gt; is not the same failure as an &lt;code&gt;OOMKilled&lt;/code&gt; pod. An OOMKill happens when your container exceeds its host RAM limit and the kernel sends Exit Code 137. A CUDA OOM happens entirely inside the GPU's own memory, which the Linux OOM killer and your pod memory limit know nothing about. You can have gigabytes of free node RAM and still hit this. If you are chasing an Exit Code 137 instead, the diagnosis path is different and covered in &lt;a href="https://dev.to/troubleshooting/how-to-debug-oomkilled-pods-in-kubernetes-a-step-by-step-gui"&gt;debugging OOMKilled pods&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full error usually looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 2.00 GiB.
GPU 0 has a total capacity of 39.38 GiB of which 224.00 MiB is free.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;vLLM does a memory profiling run at startup. It loads the weights, runs a forward pass to measure peak activation memory, then claims the remaining GPU memory (up to &lt;code&gt;gpu_memory_utilization&lt;/code&gt;) as a static KV cache pool. The crash happens when weights plus activations already exceed what is free, or when the KV cache target overcommits memory that another process on the GPU is holding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm what is actually on the GPU
&lt;/h2&gt;

&lt;p&gt;Before changing flags, look at the GPU from inside the running or crash-looping pod. Guessing wastes redeploys.&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;$ &lt;/span&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; deploy/vllm-server &lt;span class="nt"&gt;--&lt;/span&gt; nvidia-smi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read two numbers from the output: total GPU memory and current used memory. If used memory is already high before vLLM starts, something else is sharing the card. That is common on shared or MIG-partitioned GPUs, where &lt;code&gt;gpu_memory_utilization&lt;/code&gt; of 0.9 (the vLLM default) is a fraction of the full physical card, not of your slice, so the target quietly overcommits.&lt;/p&gt;

&lt;p&gt;Then confirm how many GPUs the pod was actually granted:&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;$ &lt;/span&gt;kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vllm &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].spec.containers[0].resources.limits}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this shows &lt;code&gt;nvidia.com/gpu: "1"&lt;/code&gt; but you set &lt;code&gt;--tensor-parallel-size 2&lt;/code&gt;, vLLM will try to place shards on GPUs that were never scheduled to the pod, and you get an OOM (or a hang) instead of a clean error. GPUs reach the pod through the &lt;a href="https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/" rel="noopener noreferrer"&gt;Kubernetes device plugin&lt;/a&gt;, so the limit you request is the hard ceiling vLLM sees. The tensor parallel size must equal the GPU count in the resource limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four flags that fix it
&lt;/h2&gt;

&lt;p&gt;Each flag trades a different resource. Reach for them in this order.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--gpu-memory-utilization&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;Fraction of GPU memory vLLM may claim for weights plus KV cache. Lower it to leave headroom on a shared card.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-model-len&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;model max&lt;/td&gt;
&lt;td&gt;Caps context length. KV cache size scales with this, so a 128k model capped at 8k frees a large block.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-num-seqs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;Max sequences batched at once. Fewer concurrent requests means a smaller KV cache.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--tensor-parallel-size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Shards model weights across N GPUs in the pod. The main lever for models too big for one card.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start by trimming the KV cache, since that is where most waste lives:&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;$ &lt;/span&gt;vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.85 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 4096 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the model weights alone do not fit on one GPU, no amount of KV cache trimming helps. That is when you shard:&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;$ &lt;/span&gt;vllm serve meta-llama/Llama-3.1-70B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.90 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 8192
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rough sizing check: a model in FP16 needs about 2 GB of VRAM per billion parameters just for weights, before any KV cache. A 70B model is roughly 140 GB, so it will not fit on a single 80 GB A100 no matter how you tune the cache. Shard it across GPUs or quantize it with &lt;code&gt;--quantization fp8&lt;/code&gt; to halve the weight footprint. If you are still deciding which serving engine to run, the tradeoffs are compared in &lt;a href="https://dev.to/blog/choosing-an-llm-serving-engine-vllm-vs-tgi"&gt;choosing an LLM serving engine&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes-specific traps
&lt;/h2&gt;

&lt;p&gt;The same flags behave differently under Kubernetes than on a bare workstation. Three traps account for most repeat incidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Shared memory is too small for tensor parallelism
&lt;/h3&gt;

&lt;p&gt;vLLM uses shared memory for inter-GPU communication when &lt;code&gt;--tensor-parallel-size&lt;/code&gt; is greater than 1. Containers default &lt;code&gt;/dev/shm&lt;/code&gt; to 64 MB, which is far too small, and the symptom is often a confusing OOM or NCCL hang rather than a clear message. Mount a memory-backed volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm-server&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&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;vllm/vllm-openai:latest&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4"&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dshm&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/dev/shm&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dshm&lt;/span&gt;
        &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;medium&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Memory&lt;/span&gt;
          &lt;span class="na"&gt;sizeLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;8Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Memory fragmentation on long-running pods
&lt;/h3&gt;

&lt;p&gt;If the error says a large amount is reserved but unallocated, the allocator has fragmented the pool. Set the PyTorch allocator to use expandable segments so freed blocks can be reused:&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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PYTORCH_CUDA_ALLOC_CONF&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expandable_segments:True"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. CUDA graph capture spikes memory
&lt;/h3&gt;

&lt;p&gt;vLLM captures CUDA graphs at startup for lower latency, and the capture itself needs extra memory. If the OOM lands during capture rather than during the profiling run, disable it with &lt;code&gt;--enforce-eager&lt;/code&gt;. You lose some throughput but the pod starts:&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;$ &lt;/span&gt;vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.85 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--enforce-eager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify the fix
&lt;/h2&gt;

&lt;p&gt;After redeploying, watch the startup logs for the KV cache report. A healthy start prints the number of GPU blocks it allocated:&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;$ &lt;/span&gt;kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/vllm-server | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"kv cache"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line appears and the readiness probe passes, the crash is resolved. Then send a real request so a full sequence actually fills the cache, since a crash can still surface under load rather than at boot:&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;$ &lt;/span&gt;kubectl port-forward deploy/vllm-server 8000:8000 &amp;amp;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://localhost:8000/v1/completions &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "meta-llama/Llama-3.1-8B-Instruct", "prompt": "ping", "max_tokens": 16}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once serving is stable, keep an eye on GPU memory over time. A slow climb points to fragmentation or a KV cache sized too close to the limit, and wiring GPU metrics into your dashboards early makes that obvious before the next crash. See &lt;a href="https://dev.to/tutorials/llm-observability-on-kubernetes-a-practical-guide"&gt;LLM observability on Kubernetes&lt;/a&gt; for the metrics worth tracking, and the &lt;a href="https://dev.to/comparisons/top-llmops-tools-deploying-managing-llms-in-production"&gt;top LLMOps tools&lt;/a&gt; for the wider serving stack.&lt;/p&gt;

&lt;p&gt;The vLLM documentation on &lt;a href="https://docs.vllm.ai/en/latest/configuration/conserving_memory/" rel="noopener noreferrer"&gt;conserving memory&lt;/a&gt; lists the full set of flags and their interactions. For most Kubernetes deployments, though, the pattern is consistent: cap the KV cache first, shard the weights only when a single GPU genuinely cannot hold the model, and give tensor parallelism the shared memory it needs.&lt;/p&gt;

</description>
      <category>vllm</category>
      <category>llmops</category>
      <category>kubernetes</category>
      <category>gpumemory</category>
    </item>
    <item>
      <title>Fix "Resource not accessible by integration" in GitHub Actions</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sat, 18 Jul 2026 09:00:34 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-resource-not-accessible-by-integration-in-github-actions-5c24</link>
      <guid>https://dev.to/devopsstart/fix-resource-not-accessible-by-integration-in-github-actions-5c24</guid>
      <description>&lt;p&gt;Your workflow logs a red &lt;code&gt;Error: Resource not accessible by integration&lt;/code&gt; and the job dies the moment it tries to write something back: a label, a comment, a commit, a release. The cause is almost always the same. The &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; your job runs with is read-only, so any API call that mutates the repository gets a 403. The fix is to grant that token the specific scope the failing step needs, using the &lt;code&gt;permissions&lt;/code&gt; key in your workflow. There are three other situations where that fix alone is not enough, and knowing which one you are in saves you an hour of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error actually means
&lt;/h2&gt;

&lt;p&gt;Every workflow run gets a short-lived &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, generated per job and revoked when the job finishes. It authenticates as a bot identity (&lt;code&gt;github-actions[bot]&lt;/code&gt;) against the GitHub API. When a step calls the API to change repository state and the token lacks the matching permission, the API answers &lt;code&gt;403 Resource not accessible by integration&lt;/code&gt;. The word "integration" is GitHub API language for the app behind the token, not a hint that some external integration is misconfigured.&lt;/p&gt;

&lt;p&gt;So the message is really saying: this token is not allowed to do that. Two things decide what it is allowed to do: the repository or organization default, and any &lt;code&gt;permissions&lt;/code&gt; block in your workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: the default token is read-only
&lt;/h2&gt;

&lt;p&gt;Since 2023, new repositories default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; to read-only. Many organizations also flip existing repos to read-only as a hardening step, which is the right call. You can confirm the setting under Settings, Actions, General, Workflow permissions. If it says "Read repository contents and packages permissions", the default token cannot write anything.&lt;/p&gt;

&lt;p&gt;Do not fix this at the repository level by switching the default back to read/write. That grants every workflow in the repo broad access it does not need. Instead, grant the scope in the one workflow that needs 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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;        &lt;span class="c1"&gt;# push commits or tags&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# comment on or label PRs&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/tag-release.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule trips people up constantly. The moment you add a &lt;code&gt;permissions&lt;/code&gt; block, GitHub switches that scope from permissive defaults to explicit mode: every permission you do not list becomes &lt;code&gt;none&lt;/code&gt;. If your job also needs to read packages or write to the Checks API, you have to list those too. A job that reads and writes typically needs a handful of scopes spelled out, not one.&lt;/p&gt;

&lt;p&gt;You can also scope permissions per job, which is stricter and what I reach for by default. A build job gets &lt;code&gt;contents: read&lt;/code&gt;, and only the publish job gets &lt;code&gt;contents: write&lt;/code&gt;:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make build&lt;/span&gt;

  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full list of scopes and their defaults lives in the &lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication" rel="noopener noreferrer"&gt;GitHub Docs on automatic token authentication&lt;/a&gt;. Keep least privilege in mind here; over-scoping the token is one of the quiet ways CI becomes a security liability, a theme covered in &lt;a href="https://dev.to/blog/github-actions-security-how-to-stop-secret-leaks-in-cicd"&gt;GitHub Actions Security: How to Stop Secret Leaks in CI/CD&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: the pull request came from a fork
&lt;/h2&gt;

&lt;p&gt;This one catches teams with public repos. When a &lt;code&gt;pull_request&lt;/code&gt; event fires from a forked repository, GitHub deliberately hands the workflow a read-only &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and withholds secrets, no matter what your &lt;code&gt;permissions&lt;/code&gt; block says. An attacker could otherwise open a PR that runs arbitrary code with write access to your repo. The read-only downgrade is a security boundary, not a bug, and you cannot override it with the &lt;code&gt;permissions&lt;/code&gt; key.&lt;/p&gt;

&lt;p&gt;If you need to write back on a fork PR (post a comment, apply a label), use the &lt;code&gt;pull_request_target&lt;/code&gt; event instead. It runs in the context of the base repository, so the token can be granted write scopes:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handle &lt;code&gt;pull_request_target&lt;/code&gt; carefully. It runs with repository secrets available, so never check out and execute untrusted PR code inside it. Check out the base branch, or only run trusted logic like labeling. The safer pattern for anything that needs the PR's build output is a two-workflow split: an untrusted &lt;code&gt;pull_request&lt;/code&gt; job that builds and uploads an artifact, and a trusted &lt;code&gt;workflow_run&lt;/code&gt; job that downloads it and writes results back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: the PR was opened by Dependabot
&lt;/h2&gt;

&lt;p&gt;Dependabot PRs look like internal PRs, but since March 2021 GitHub treats workflow runs triggered by Dependabot as if they came from a fork. The &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is read-only and repository secrets are unavailable. Since October 2021 those runs do respect the &lt;code&gt;permissions&lt;/code&gt; key, so a workflow that labels or auto-merges Dependabot PRs can work, but you still have to know two things.&lt;/p&gt;

&lt;p&gt;First, secrets your job expects are missing on a Dependabot event. Reference &lt;code&gt;secrets.DEPENDABOT_*&lt;/code&gt; values from the separate Dependabot secrets store, not the Actions secrets store. Second, the token is still fork-grade, so write operations that GitHub blocks for forks stay blocked. GitHub documents the exact event matrix in &lt;a href="https://docs.github.com/en/code-security/dependabot/troubleshooting-dependabot/troubleshooting-dependabot-on-github-actions" rel="noopener noreferrer"&gt;Troubleshooting Dependabot on GitHub Actions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A common working shape for auto-approving patch bumps:&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;automerge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.actor == 'dependabot[bot]'&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gh pr merge --auto --squash "$PR_URL"&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;PR_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.html_url }}&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause 4: the action creates or approves a PR
&lt;/h2&gt;

&lt;p&gt;Some steps hit a separate switch. Actions that open a pull request or approve one (for example, &lt;code&gt;peter-evans/create-pull-request&lt;/code&gt;) need both &lt;code&gt;pull-requests: write&lt;/code&gt; on the token and a repository setting that is off by default. Under Settings, Actions, General, look for "Allow GitHub Actions to create and approve pull requests" and enable it. Without that box checked, the API returns the same &lt;code&gt;Resource not accessible by integration&lt;/code&gt; even when your &lt;code&gt;permissions&lt;/code&gt; block looks correct. This is the one case where the scope is right and the error still fires, so check it early if the token clearly has &lt;code&gt;pull-requests: write&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A diagnosis checklist
&lt;/h2&gt;

&lt;p&gt;Work through this in order. The first match is almost always your fix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the failing step. Which API call 403'd (contents, issues, pull-requests, packages, deployments)? That names the scope you are missing.&lt;/li&gt;
&lt;li&gt;Is the trigger a fork &lt;code&gt;pull_request&lt;/code&gt; or a Dependabot PR? If yes, the token is read-only by design. Move the write logic to &lt;code&gt;pull_request_target&lt;/code&gt; or a &lt;code&gt;workflow_run&lt;/code&gt; job (fork) or accept fork-grade limits (Dependabot).&lt;/li&gt;
&lt;li&gt;Does the repo default to read-only? Add a &lt;code&gt;permissions&lt;/code&gt; block granting only the scope from step 1.&lt;/li&gt;
&lt;li&gt;Is the step creating or approving a PR? Enable the repository setting for it.&lt;/li&gt;
&lt;li&gt;Re-run and confirm.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To see what the token actually carries at runtime, print the scopes near the top of the failing job:&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;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | gh auth login &lt;span class="nt"&gt;--with-token&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;gh api rate_limit &lt;span class="nt"&gt;-i&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'x-oauth-scopes'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"using GITHUB_TOKEN, scopes set by permissions block"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the workflow is a Terraform or infrastructure pipeline that comments plans back on PRs, the same permission model applies; a full working example lives in &lt;a href="https://dev.to/tutorials/how-to-automate-terraform-reviews-with-github-actions"&gt;How to Automate Terraform Reviews with GitHub Actions&lt;/a&gt;. And if you are moving a mutating workflow behind a gate before it touches production, the patterns in &lt;a href="https://dev.to/blog/testing-in-production-guide-to-progressive-delivery"&gt;Testing in Production: Guide to Progressive Delivery&lt;/a&gt; pair well with least-privilege tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the fix
&lt;/h2&gt;

&lt;p&gt;After adding the scope, re-run the job and confirm the failing API call now succeeds. A green run is the real signal, but you can also confirm intent by reading the run's permissions in the logs: expand the "Set up job" step, and GitHub prints the resolved &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; permissions for that job. If the scope you added shows there and the call still 403s, you are in Cause 2, 3, or 4, not a missing scope. That distinction is the whole game with this error: decide whether the token could carry the permission at all, then whether you actually granted it.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>githubtoken</category>
      <category>permissions</category>
    </item>
    <item>
      <title>k8s-aibom: Automated AI BOM for Kubernetes Workloads</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:13:43 +0000</pubDate>
      <link>https://dev.to/devopsstart/k8s-aibom-automated-ai-bom-for-kubernetes-workloads-5g3m</link>
      <guid>https://dev.to/devopsstart/k8s-aibom-automated-ai-bom-for-kubernetes-workloads-5g3m</guid>
      <description>&lt;p&gt;If you run a shared Kubernetes cluster, you already have AI workloads you don't know about. Someone shipped a vLLM inference service last sprint, a data team stood up a RAG pipeline behind a plain Deployment, and a contractor left an Ollama pod running in a namespace nobody audits. &lt;code&gt;k8s-aibom&lt;/code&gt;, the controller Google Cloud open-sourced this month, exists to find exactly those. It watches your live workloads and writes a CycloneDX 1.6 ML-BOM for every AI system it can identify, so the answer to "what AI is running in this cluster right now" stops being a guess.&lt;/p&gt;

&lt;p&gt;That "right now" is the whole point. A build-time SBOM tells you what your CI pipeline thought it was shipping. It says nothing about the pod a teammate &lt;code&gt;kubectl apply&lt;/code&gt;'d by hand at 2am, or the image that pulled a new model layer since the last scan. Shadow AI is a runtime problem, and &lt;code&gt;k8s-aibom&lt;/code&gt; is a runtime tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a runtime AI BOM is different
&lt;/h2&gt;

&lt;p&gt;An AI Bill of Materials answers the same question a software BOM does, scoped to AI: which models, frameworks, and inference engines are in play, and where did they come from. The category matters now because regulators started asking. The EU AI Act's logging and transparency obligations, the NIST AI Risk Management Framework's "know what you deployed" controls, and ISO/IEC 42001's inventory clauses all assume you can produce a current, accurate list of your AI systems. You cannot produce that list from a spreadsheet someone updates quarterly.&lt;/p&gt;

&lt;p&gt;Build-time and runtime BOMs solve different halves of the problem. Your build pipeline can attest to what it produced, and tools that live there are a good idea. But the pipeline never sees the workload that skipped it. If your supply-chain story stops at the CI system, read our take on why that is not enough in &lt;a href="https://dev.to/blog/supply-chain-security-proxy-move-beyond-vulnerability-scanni"&gt;Supply Chain Security Proxy: Move Beyond Vulnerability Scanning&lt;/a&gt;. &lt;code&gt;k8s-aibom&lt;/code&gt; fills the runtime gap: it reconciles against what the API server actually reports, not what a pipeline claims it built.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the controller actually watches
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;k8s-aibom&lt;/code&gt; is a standard Kubernetes controller, not a DaemonSet or a privileged agent. It reconciles a set of workload kinds and emits a BOM per workload. The kinds it tracks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deployments&lt;/li&gt;
&lt;li&gt;StatefulSets&lt;/li&gt;
&lt;li&gt;DaemonSets&lt;/li&gt;
&lt;li&gt;Jobs and CronJobs&lt;/li&gt;
&lt;li&gt;KServe &lt;code&gt;InferenceService&lt;/code&gt; resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list covers the shapes AI actually takes in a cluster. Inference services and agent stacks run as Deployments, batch training and evaluation runs as Jobs, and model servers packaged for KServe show up as &lt;code&gt;InferenceService&lt;/code&gt; objects. Because it reconciles against the API server, a workload created outside your GitOps flow is just as visible as one that went through it. That is the property that makes it useful against shadow AI: you did not have to know the workload existed for the controller to catalog it.&lt;/p&gt;

&lt;p&gt;Detection works by pattern-matching signals the workload already carries: container image references, command-line arguments, environment variables such as &lt;code&gt;HF_MODEL_ID&lt;/code&gt;, mounted volumes, and workload annotations. From those signals it recognizes a broad set of AI software:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Examples it identifies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inference runtimes&lt;/td&gt;
&lt;td&gt;vLLM, Hugging Face TGI, NVIDIA Triton, Ollama, Ray Serve, SGLang&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent frameworks&lt;/td&gt;
&lt;td&gt;LangChain, LangGraph, AutoGen, CrewAI, Langflow, Flowise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector databases&lt;/td&gt;
&lt;td&gt;Milvus, Qdrant, Weaviate, Chroma, pgvector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training frameworks&lt;/td&gt;
&lt;td&gt;PyTorch, KubeRay, JAX, Hugging Face Accelerate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation tools&lt;/td&gt;
&lt;td&gt;lm-evaluation-harness, Ragas, Trulens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are already running two inference engines and cannot decide whether that is a problem, our comparison &lt;a href="https://dev.to/blog/choosing-an-llm-serving-engine-vllm-vs-tgi"&gt;Choosing an LLM Serving Engine: vLLM vs TGI&lt;/a&gt; covers the tradeoffs &lt;code&gt;k8s-aibom&lt;/code&gt; will happily inventory for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What detection looks like on a real workload
&lt;/h2&gt;

&lt;p&gt;Concretely, picture a RAG API someone shipped as a plain Deployment. Nothing about the object name says "AI", but the pod spec gives it away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rag-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;team-ml&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server&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;vllm/vllm-openai:v0.6.3&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--model"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta-llama/Llama-3.1-8B-Instruct"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HF_MODEL_ID&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta-llama/Llama-3.1-8B-Instruct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller does not need a label saying this is AI. The &lt;code&gt;vllm/vllm-openai&lt;/code&gt; image matches its inference-runtime catalog, the &lt;code&gt;--model&lt;/code&gt; arg and &lt;code&gt;HF_MODEL_ID&lt;/code&gt; env var name the model, and both get recorded on the BOM. The image tag lands as &lt;code&gt;declared&lt;/code&gt; because it is read straight from the spec; the runtime identification lands as &lt;code&gt;inferred&lt;/code&gt; because it came from a heuristic. A workload that went out of its way to hide, say a custom image with the model passed through a mounted config file, would still surface the pieces the controller can see and mark the rest &lt;code&gt;unresolved&lt;/code&gt;. That gap is visible in the BOM, which is the honest behavior: you get told what the tool is unsure about instead of a confident lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying it
&lt;/h2&gt;

&lt;p&gt;Deployment is a Helm install into its own namespace. You build and push the image, then install the chart:&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;$ &lt;/span&gt;git clone https://github.com/GoogleCloudPlatform/k8s-aibom.git
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;k8s-aibom
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;IMG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-registry.example.com/k8s-aibom:v1.0.0
&lt;span class="nv"&gt;$ &lt;/span&gt;make image
&lt;span class="nv"&gt;$ &lt;/span&gt;make docker-push
&lt;span class="nv"&gt;$ &lt;/span&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;k8s-aibom ./charts/k8s-aibom &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--namespace&lt;/span&gt; k8s-aibom-system &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; image.repository&lt;span class="o"&gt;=&lt;/span&gt;my-registry.example.com/k8s-aibom &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; image.tag&lt;span class="o"&gt;=&lt;/span&gt;v1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller does not scan every namespace by default, which is the right call on a busy cluster. You opt a namespace in with a label:&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;$ &lt;/span&gt;kubectl label namespace team-ml aibom.k8saibom.dev/enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opt-in model is deliberate. On a large platform you probably want to start with the namespaces where AI is likely, confirm the BOMs look right, then widen the net. Rolling it cluster-wide on day one buries you in output before you have tuned anything.&lt;/p&gt;

&lt;p&gt;The custom resources live under the &lt;code&gt;aibom.k8saibom.dev/v1alpha1&lt;/code&gt; API group. There are two kinds: &lt;code&gt;AIBOM&lt;/code&gt;, a namespace-scoped resource holding the BOM for one workload, and &lt;code&gt;AIBOMControllerConfig&lt;/code&gt;, a cluster-scoped singleton that configures where BOMs are sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a BOM
&lt;/h2&gt;

&lt;p&gt;Once a namespace is enabled, the controller starts producing &lt;code&gt;AIBOM&lt;/code&gt; resources. You read them like any other object:&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;$ &lt;/span&gt;kubectl get aibom &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl describe aibom &lt;span class="nt"&gt;-n&lt;/span&gt; team-ml deployment-rag-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a CycloneDX 1.6 ML-BOM. If the document is small it lives inline in the resource status; if it is large the status carries a reference to the externalized copy instead, so you are not stuffing megabytes into etcd.&lt;/p&gt;

&lt;p&gt;The detail worth understanding is the confidence model. &lt;code&gt;k8s-aibom&lt;/code&gt; does not pretend every field is a hard fact. Each attribute is tagged as one of three states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;declared&lt;/code&gt;: taken straight from the workload spec or an explicit annotation, so it is authoritative.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inferred&lt;/code&gt;: derived from a heuristic, such as recognizing an inference runtime from its image and args.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unresolved&lt;/code&gt;: the controller saw a signal but could not pin it down with confidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That grading is what makes the output auditable rather than a pile of guesses. When a reviewer asks why a BOM claims a workload runs a particular model, the answer is a field-level provenance tag, not a shrug. If you have been burned by AI tools that state everything with false certainty, our writeup &lt;a href="https://dev.to/blog/ai-agent-risks-lessons-from-snyks-10000-environment-audit"&gt;AI Agent Risks: Lessons from Snyk's 10,000 Environment Audit&lt;/a&gt; is a good reminder of why that provenance matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending BOMs somewhere durable
&lt;/h2&gt;

&lt;p&gt;A BOM that only lives in a cluster resource disappears when the workload does, which is useless for an audit trail. &lt;code&gt;AIBOMControllerConfig&lt;/code&gt; defines sinks that push each BOM out. Three sink types exist: the always-on CR status (no external egress), a Google Cloud Storage bucket, and a generic webhook. A config with both external sinks looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aibom.k8saibom.dev/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AIBOMControllerConfig&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sinks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;audit-archive&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GCS&lt;/span&gt;
      &lt;span class="na"&gt;gcs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-aibom-archive&lt;/span&gt;
        &lt;span class="na"&gt;pathTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aibom/{namespace}/{kind}-{name}/{timestamp}.json"&lt;/span&gt;
        &lt;span class="na"&gt;workloadIdentity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s-aibom-controller@my-project.iam.gserviceaccount.com&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;graph-ingest&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Webhook&lt;/span&gt;
      &lt;span class="na"&gt;webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://guac.internal.example.com/ingest&lt;/span&gt;
        &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;bearerToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;secretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;graph-ingest-creds&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GCS sink has a property worth calling out: writes use a &lt;code&gt;DoesNotExist&lt;/code&gt; precondition, so a stored BOM cannot be overwritten once created. That turns the bucket into an append-only historical record. For anyone who has ever tried to reconstruct "what was running when the incident happened" from mutable logs, an immutable, timestamped BOM per workload is a real upgrade. Pair the &lt;code&gt;pathTemplate&lt;/code&gt; above with a bucket retention policy and you have a compliance artifact that survives the workload that produced it.&lt;/p&gt;

&lt;p&gt;The webhook sink is how you feed a graph database or an SBOM platform. A common pattern is pushing into a supply-chain graph so AI components sit alongside your other software inventory instead of in a separate silo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;k8s-aibom&lt;/code&gt; is narrow on purpose, and that is a strength. It does not scan for vulnerabilities, enforce policy, or block anything. It builds an accurate inventory of AI workloads and gets it somewhere durable. Everything downstream, such as CVE correlation, policy gates, and drift alerts, is a separate tool consuming the BOM. Trying to make one controller do all of that is how you end up with a privileged agent nobody trusts.&lt;/p&gt;

&lt;p&gt;Keep three limitations in mind before you lean on it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detection is pattern-based, so it has a coverage frontier.&lt;/strong&gt; A homegrown inference server with no recognizable image, args, or environment signals may land as &lt;code&gt;unresolved&lt;/code&gt; or be missed. The &lt;code&gt;v1alpha1&lt;/code&gt; API group is a fair signal that the detection catalog is still moving. Treat the BOM as a strong lead, not a guarantee of completeness, and watch what shows up &lt;code&gt;unresolved&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It reports, it does not enforce.&lt;/strong&gt; Finding a shadow workload and doing something about it are different jobs. You still need policy tooling, whether that is an admission controller or a governance layer, to act on what the BOM reveals. If you are building that layer, &lt;a href="https://dev.to/blog/governing-ai-agents-in-cicd-with-opa-and-mcp"&gt;Governing AI Agents in CI/CD with OPA and MCP&lt;/a&gt; covers the policy side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The GCS sink is Google Cloud native.&lt;/strong&gt; The webhook sink is portable and works anywhere, but the tightest integration, immutable object writes via Workload Identity, assumes GKE. On other platforms you wire the webhook into your own durable store.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Shadow AI is not going away, and "we think we know what's running" is not an answer an auditor accepts. &lt;code&gt;k8s-aibom&lt;/code&gt; gives you a runtime, provenance-tagged inventory of the AI workloads actually live in your cluster, written to an immutable store you can hand to a compliance review. It is early software with a moving detection catalog, so verify its output rather than trusting it blindly. But as a way to turn shadow AI from an unknown into a tracked list, it is a genuinely useful addition to a Kubernetes security stack.&lt;/p&gt;

&lt;p&gt;Start small: install it, enable one namespace where you suspect unmanaged AI, and read the first few BOMs. The controller runs as a lightweight reconciler with no privileged access, so there is little downside to letting it watch. For the broader context on how Google frames this problem, their &lt;a href="https://cloud.google.com/security/solutions/software-supply-chain-security" rel="noopener noreferrer"&gt;software supply chain security&lt;/a&gt; guidance and the &lt;a href="https://cloud.google.com/blog/products/containers-kubernetes/how-gke-powers-ai-innovation" rel="noopener noreferrer"&gt;how GKE powers AI innovation&lt;/a&gt; writeup are worth reading. For the Kubernetes primitives the controller builds on, the upstream &lt;a href="https://kubernetes.io/docs/concepts/architecture/controller/" rel="noopener noreferrer"&gt;controllers documentation&lt;/a&gt; covers the reconcile loop it uses.&lt;/p&gt;

</description>
      <category>aisupplychainsecurity</category>
      <category>shadowai</category>
      <category>kubernetessecurity</category>
      <category>aibom</category>
    </item>
    <item>
      <title>Fix OpenTofu Ephemeral Value in Non-Ephemeral Context</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Wed, 15 Jul 2026 09:23:01 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-opentofu-ephemeral-value-in-non-ephemeral-context-2m63</link>
      <guid>https://dev.to/devopsstart/fix-opentofu-ephemeral-value-in-non-ephemeral-context-2m63</guid>
      <description>&lt;p&gt;You added an ephemeral resource or an &lt;code&gt;ephemeral = true&lt;/code&gt; variable in OpenTofu 1.11, wired it into a normal resource argument, and the plan died with &lt;code&gt;Ephemeral value used in non-ephemeral context&lt;/code&gt;. The short version: OpenTofu refuses to let a value it promised never to persist flow into a place that would write it to state or plan. The fix is almost always one of three moves: send the value into a write-only (&lt;code&gt;_wo&lt;/code&gt;) argument instead of a regular one, mark the output or variable that carries it as &lt;code&gt;ephemeral = true&lt;/code&gt;, or keep the whole chain ephemeral so nothing downstream tries to store it. This walks through why the error fires and how to pick the right fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error is actually telling you
&lt;/h2&gt;

&lt;p&gt;Ephemeral values are OpenTofu's mechanism for handling secrets and other temporary data that must never land in &lt;code&gt;terraform.tfstate&lt;/code&gt; or a plan file. An ephemeral resource block, an ephemeral input variable, and an ephemeral output all produce values that exist only during a single operation. OpenTofu tracks that "ephemeral" taint through every expression, and the moment a tainted value reaches a context that persists data, it stops the run rather than silently leaking the secret.&lt;/p&gt;

&lt;p&gt;The restricted contexts are consistent and worth memorizing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Ephemeral value allowed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A regular (stateful) resource argument&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A write-only argument (suffix &lt;code&gt;_wo&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A root or child module output&lt;/td&gt;
&lt;td&gt;No, unless the output is &lt;code&gt;ephemeral = true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;local&lt;/code&gt; that feeds a non-ephemeral context&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A provider or provisioner configuration block&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Another ephemeral resource's arguments&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;Read that table as one rule: an ephemeral value can only go somewhere that also refuses to persist it. Everything else is a hard error by design. If you have hit persistence problems from the other direction, where values you wanted in state got locked or lost, the mechanics of what OpenTofu keeps and why are covered in &lt;a href="https://dev.to/blog/terraform-state-locking-a-guide-for-growing-teams"&gt;Terraform State Locking: A Guide for Growing Teams&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it in ten lines
&lt;/h2&gt;

&lt;p&gt;Here is the smallest config that triggers the error. It reads a database password from an ephemeral resource and tries to hand it to a normal argument:&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;ephemeral&lt;/span&gt; &lt;span class="s2"&gt;"random_password"&lt;/span&gt; &lt;span class="s2"&gt;"db"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;identifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-db"&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;random_password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run a plan and OpenTofu rejects it before touching the provider:&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;$ &lt;/span&gt;tofu plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Ephemeral value used in non-ephemeral context

  on main.tf line 7, in resource "aws_db_instance" "main":
   7:   password = ephemeral.random_password.db.result

Ephemeral values cannot be assigned to arguments that OpenTofu persists to
state. Use a write-only argument or mark the destination as ephemeral.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider never runs. This is a static check in the language layer, which is why no AWS call is made and no partial state is written. That is the whole point: the guard fires before the value can escape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: send it to a write-only argument
&lt;/h2&gt;

&lt;p&gt;Most real cases are this one. You have a secret and you want it on a managed resource without storing it. That is exactly what write-only arguments exist for. They carry the &lt;code&gt;_wo&lt;/code&gt; suffix, accept ephemeral values, and are always written to state and plan as &lt;code&gt;null&lt;/code&gt;. Many provider resources expose a &lt;code&gt;_wo&lt;/code&gt; twin of their sensitive argument, paired with a &lt;code&gt;_wo_version&lt;/code&gt; argument you bump to force a new write.&lt;/p&gt;

&lt;p&gt;Rewrite the failing example against a resource that supports write-only arguments:&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;ephemeral&lt;/span&gt; &lt;span class="s2"&gt;"random_password"&lt;/span&gt; &lt;span class="s2"&gt;"db"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret_version"&lt;/span&gt; &lt;span class="s2"&gt;"db"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&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;secret_string_wo&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;random_password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;
  &lt;span class="nx"&gt;secret_string_wo_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;secret_string_wo&lt;/code&gt; takes the ephemeral value and never records it. When you rotate the password, you change the value and increment &lt;code&gt;secret_string_wo_version&lt;/code&gt; so OpenTofu knows to send the new secret on the next apply. The version integer is the only thing that lands in state. HashiCorp's &lt;a href="https://developer.hashicorp.com/terraform/language/manage-sensitive-data/write-only" rel="noopener noreferrer"&gt;write-only arguments reference&lt;/a&gt; documents the same model OpenTofu implements, including which core arguments pair with a version.&lt;/p&gt;

&lt;p&gt;The common mistake here is assuming every argument has a &lt;code&gt;_wo&lt;/code&gt; version. They do not. Write-only support is per-argument and per-provider, so check the resource's schema. If the argument you need has no write-only variant yet, that is a provider gap, not something you can force from configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: mark the output ephemeral
&lt;/h2&gt;

&lt;p&gt;The second trigger is exporting an ephemeral value. This fails:&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;output&lt;/span&gt; &lt;span class="s2"&gt;"db_password"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;random_password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An ordinary output is stored, so OpenTofu blocks it. If a parent module genuinely needs to consume this value during the same operation (to feed it into another ephemeral context), mark the output ephemeral:&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;output&lt;/span&gt; &lt;span class="s2"&gt;"db_password"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;random_password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;
  &lt;span class="nx"&gt;ephemeral&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;ephemeral = true&lt;/code&gt; output can only be consumed by another ephemeral context in the calling module. You cannot mark an output ephemeral and then assign it to a normal resource argument upstream; you would just move the same error one module up. The &lt;a href="https://developer.hashicorp.com/terraform/language/manage-sensitive-data/ephemeral" rel="noopener noreferrer"&gt;ephemeral values documentation&lt;/a&gt; spells out the propagation rules across module boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: keep the whole chain ephemeral
&lt;/h2&gt;

&lt;p&gt;The subtle version of this error comes through a &lt;code&gt;local&lt;/code&gt;. A local value built from an ephemeral expression inherits the ephemeral taint:&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;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;conn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres://admin:${ephemeral.random_password.db.result}@db:5432"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;local.conn&lt;/code&gt; is now ephemeral. Use it in a provisioner or a provider block and OpenTofu is happy. Assign it to a stored argument and you get the same error, now pointing at the local instead of the resource. The fix is not to launder the value through the local; it is to make sure the local's destination is also ephemeral. If you find yourself wanting to store &lt;code&gt;local.conn&lt;/code&gt;, step back, because that means you are trying to persist a secret, which is the exact thing the ephemeral system is stopping.&lt;/p&gt;

&lt;h2&gt;
  
  
  A full working example
&lt;/h2&gt;

&lt;p&gt;Here is the pattern most teams actually want: pull a secret from a store at apply time and set a database password without ever writing the secret to state.&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;ephemeral&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret_version"&lt;/span&gt; &lt;span class="s2"&gt;"db"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod/app/db-password"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;identifier&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-db"&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_class&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db.t3.medium"&lt;/span&gt;
  &lt;span class="nx"&gt;allocated_storage&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
  &lt;span class="nx"&gt;username&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"appuser"&lt;/span&gt;
  &lt;span class="nx"&gt;password_wo&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_secretsmanager_secret_version&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret_string&lt;/span&gt;
  &lt;span class="nx"&gt;password_wo_version&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it and check what got stored:&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;$ &lt;/span&gt;tofu apply
&lt;span class="nv"&gt;$ &lt;/span&gt;tofu show &lt;span class="nt"&gt;-json&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'"password_wo"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The password value is absent from state; only &lt;code&gt;password_wo_version = 3&lt;/code&gt; is recorded. Rotate by updating the secret in Secrets Manager and bumping the version integer. This is the design working as intended: the secret transits the operation and vanishes, and your state file is safe to store in the same backend as everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OpenTofu is this strict
&lt;/h2&gt;

&lt;p&gt;It is tempting to read the error as OpenTofu being pedantic, but the strictness is the feature. State files are the single most common source of leaked infrastructure secrets, because they get committed, copied into CI logs, and shared in backends with loose access. By making it a compile-time error to route an ephemeral value anywhere persistent, OpenTofu removes the entire class of "oops, the password is in the plan output" incidents. The tradeoff is that you have to be explicit about where secrets are allowed to flow, which is a good constraint to have enforced by the tool rather than by a code review someone was too rushed to do. If your review process is where these decisions get made, &lt;a href="https://dev.to/blog/terraform-testing-best-practices-beyond-plan-and-pray"&gt;Terraform Testing Best Practices: Beyond Plan and Pray&lt;/a&gt; covers how to catch this class of problem earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick triage checklist
&lt;/h2&gt;

&lt;p&gt;When you hit &lt;code&gt;Ephemeral value used in non-ephemeral context&lt;/code&gt;, work through this in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the line the error points to. It names the exact argument, output, or local that broke the rule.&lt;/li&gt;
&lt;li&gt;If it is a resource argument, look for a &lt;code&gt;_wo&lt;/code&gt; variant of that argument and switch to it, adding the matching &lt;code&gt;_wo_version&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If it is an output, decide whether the consumer is ephemeral. If yes, add &lt;code&gt;ephemeral = true&lt;/code&gt;. If no, you are trying to persist a secret and should not.&lt;/li&gt;
&lt;li&gt;If it is a local, trace where the local is used and make that destination ephemeral too.&lt;/li&gt;
&lt;li&gt;If no write-only variant exists for the argument you need, check the provider version and its changelog. Write-only support is still expanding across providers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nine times out of ten it is case two: a value that should have gone into a &lt;code&gt;_wo&lt;/code&gt; argument was pointed at the regular one. Fix that and the plan goes green. For a different OpenTofu failure mode that also blocks a clean run, see &lt;a href="https://dev.to/troubleshooting/fix-opentofu-registry-timeout-errors"&gt;Fix OpenTofu Registry Timeout Errors&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>opentofu</category>
      <category>terraform</category>
      <category>ephemeralvalues</category>
      <category>writeonlyattributes</category>
    </item>
    <item>
      <title>Azure DevOps to GitHub Migration: An AI-Driven Playbook</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Tue, 14 Jul 2026 09:18:51 +0000</pubDate>
      <link>https://dev.to/devopsstart/azure-devops-to-github-migration-an-ai-driven-playbook-16ba</link>
      <guid>https://dev.to/devopsstart/azure-devops-to-github-migration-an-ai-driven-playbook-16ba</guid>
      <description>&lt;p&gt;If you are moving repositories from Azure DevOps to GitHub, the tool you want is the &lt;code&gt;gh ado2gh&lt;/code&gt; extension of the GitHub CLI, part of GitHub Enterprise Importer (GEI). It migrates Git history, branches, and pull requests from Azure DevOps Services to GitHub Enterprise Cloud in one command per repo. What it does not touch: Azure Boards work items, Azure Pipelines, Azure Artifacts, and wikis. Those need separate tools or a manual plan. Get that split clear before you write a single migration script, because most failed migrations are not technical failures. They are teams that assumed one tool moved everything and found out on cutover day that it did not.&lt;/p&gt;

&lt;p&gt;The reason this migration is worth doing in 2026 is not just consolidation. It is that the AI tooling on the GitHub side, specifically the Copilot coding agent, only works against repositories that live on GitHub. If your code sits in Azure Repos, you are locked out of agentic workflows that can now open pull requests, run your tests, and self-review a patch before a human ever looks at it. That is the real incentive, and it changes how you should plan the move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GEI actually migrates (and what it drops)
&lt;/h2&gt;

&lt;p&gt;Be precise here, because the gap is where teams get burned. GitHub Enterprise Importer, when pointed at Azure DevOps, moves the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Git source, including full commit history and all branches&lt;/li&gt;
&lt;li&gt;Pull requests, both active and merged, with their comments&lt;/li&gt;
&lt;li&gt;User history and attribution on commits&lt;/li&gt;
&lt;li&gt;Work item links that were attached to pull requests&lt;/li&gt;
&lt;li&gt;Attachments and branch policies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not move:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Azure Boards work items.&lt;/strong&gt; There is no built-in path from Boards to GitHub Issues. You export and re-import, or you leave history in a read-only Azure project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Pipelines.&lt;/strong&gt; These need the separate GitHub Actions Importer tool, which forecasts and partially automates the conversion to GitHub Actions workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Artifacts feeds.&lt;/strong&gt; You download existing packages, repoint your package source at GitHub Packages, and republish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wikis.&lt;/strong&gt; Manual export, or clone the wiki Git repo and push it as a normal repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more sharp edge: GEI migrates Azure DevOps &lt;em&gt;Services&lt;/em&gt; (the cloud product), not Azure DevOps &lt;em&gt;Server&lt;/em&gt; (the on-prem product). If you are still on Server, you have to get to Services first or use a different path entirely. And abandoned pull requests whose branches were already deleted do not come across, so if audit history matters to you, snapshot it before cutover.&lt;/p&gt;

&lt;p&gt;The official &lt;a href="https://docs.github.com/en/migrations/using-github-enterprise-importer/migrating-from-azure-devops-to-github-enterprise-cloud/migrating-repositories-from-azure-devops-to-github-enterprise-cloud" rel="noopener noreferrer"&gt;Azure DevOps to GitHub migration docs&lt;/a&gt; are the source of truth for the current feature matrix, and it does shift, so check it against your own inventory rather than trusting a blog post's list six months from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: inventory before you touch anything
&lt;/h2&gt;

&lt;p&gt;Do not start with a migration command. Start with an inventory. You cannot plan waves, estimate effort, or spot the repos nobody has committed to in three years without one.&lt;/p&gt;

&lt;p&gt;Install the extension and generate a report:&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;$ &lt;/span&gt;gh extension &lt;span class="nb"&gt;install &lt;/span&gt;github/gh-ado2gh
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ADO_PAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-azure-devops-token"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GH_PAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-github-token"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;gh ado2gh inventory-report &lt;span class="nt"&gt;--ado-org&lt;/span&gt; YOUR_ADO_ORG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;inventory-report&lt;/code&gt; command hits the Azure DevOps API and writes CSV files covering organizations, projects, repositories, and pipelines. Read them. You are looking for three things: repos small enough to migrate in the first pilot wave, repos with heavy pipeline coupling that will need Actions conversion, and dead repos you should archive instead of move. The token you use for this needs full access, so create a dedicated one and revoke it when the migration is done.&lt;/p&gt;

&lt;p&gt;If you have never run a live cutover before, the &lt;a href="https://dev.to/blog/github-actions-security-how-to-stop-secret-leaks-in-cicd"&gt;GitHub Actions security guide&lt;/a&gt; is worth reading first, because the moment your code lands on GitHub, its secrets handling model changes and the old Azure variable groups do not come with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: create the personal access tokens
&lt;/h2&gt;

&lt;p&gt;Both PATs need the right scopes or the migration fails halfway, which is worse than failing at the start. On the Azure side, the &lt;code&gt;ADO_PAT&lt;/code&gt; needs read access to code and, if you want work item links and board integration, full access. On the GitHub side, the &lt;code&gt;GH_PAT&lt;/code&gt; needs &lt;code&gt;repo&lt;/code&gt;, &lt;code&gt;admin:org&lt;/code&gt;, and &lt;code&gt;workflow&lt;/code&gt; scopes at minimum for a repository migration into an org.&lt;/p&gt;

&lt;p&gt;Set them as environment variables so they do not end up in your shell history as literals:&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;$ &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-rs&lt;/span&gt; ADO_PAT &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;ADO_PAT
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-rs&lt;/span&gt; GH_PAT &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;export &lt;/span&gt;GH_PAT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;read -rs&lt;/code&gt; pattern keeps the token off the screen and out of history. Small thing, but on a migration you are handling org-admin credentials, so treat them like it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: generate and run the migration script
&lt;/h2&gt;

&lt;p&gt;For anything beyond one or two repos, do not hand-write &lt;code&gt;migrate-repo&lt;/code&gt; calls. Let the tooling generate them:&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;$ &lt;/span&gt;gh ado2gh generate-script &lt;span class="nt"&gt;--ado-org&lt;/span&gt; YOUR_ADO_ORG &lt;span class="nt"&gt;--github-org&lt;/span&gt; YOUR_GH_ORG &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces a PowerShell script, &lt;code&gt;migrate.ps1&lt;/code&gt;, with one migration call per repository it found. Open it before you run it. The generated script is a starting point, not gospel, and there are known cases where you need to edit repo name mappings or split it into waves. This is the moment to remove the dead repos you flagged in the inventory and to reorder so your pilot team's repos go first.&lt;/p&gt;

&lt;p&gt;A single repository migration, if you want to run one by hand to test the path, looks 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;&lt;span class="nv"&gt;$ &lt;/span&gt;gh ado2gh migrate-repo &lt;span class="nt"&gt;--ado-org&lt;/span&gt; YOUR_ADO_ORG &lt;span class="nt"&gt;--ado-team-project&lt;/span&gt; YOUR_PROJECT &lt;span class="nt"&gt;--ado-repo&lt;/span&gt; YOUR_REPO &lt;span class="nt"&gt;--github-org&lt;/span&gt; YOUR_GH_ORG &lt;span class="nt"&gt;--github-repo&lt;/span&gt; YOUR_REPO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Migrations are queued server-side and run asynchronously, so the command returns a migration ID rather than blocking until the repo is fully copied. Pull the logs afterward with the importer's log command to confirm history and pull requests came across cleanly. Do not mark a repo done because the command exited zero. Check the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: pipelines are a separate project
&lt;/h2&gt;

&lt;p&gt;This is where teams underestimate the work. Your Azure Pipelines do not migrate with GEI. GitHub Actions Importer is a distinct tool that audits your existing pipelines, forecasts how much converts automatically, and generates draft GitHub Actions workflows.&lt;/p&gt;

&lt;p&gt;Expect the audit to tell you that maybe 70 to 80 percent of a typical pipeline converts mechanically, and the rest, custom tasks, marketplace extensions, and complex approval gates, needs hand translation. A converted workflow lands as a YAML file in &lt;code&gt;.github/workflows/&lt;/code&gt;:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and test&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npm ci&lt;/span&gt;
          &lt;span class="s"&gt;npm test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Budget real engineering time for the pipeline conversion. If you already run infrastructure-as-code reviews through Actions, the pattern in &lt;a href="https://dev.to/tutorials/how-to-automate-terraform-reviews-with-github-actions"&gt;how to automate Terraform reviews with GitHub Actions&lt;/a&gt; shows the shape of a mature Actions pipeline you are converging toward, not the thin line-by-line port the importer gives you first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: turn on the AI tooling you migrated for
&lt;/h2&gt;

&lt;p&gt;Here is the payoff. Once a repository lives on GitHub, the Copilot coding agent can work it like a background peer developer. You assign it an issue, it spins up its own environment powered by GitHub Actions, makes changes, runs your tests, and opens a pull request. As of 2026 it also runs Copilot code review against its own diff before it asks for human eyes, so the patch that reaches you has already been through one revision loop.&lt;/p&gt;

&lt;p&gt;The guardrails matter and they are sensible defaults. The agent can only push to branches it creates, conventionally &lt;code&gt;copilot/*&lt;/code&gt;, so your &lt;code&gt;main&lt;/code&gt; and team-managed branches stay untouched. Every pull request still needs independent human review because the agent cannot approve or merge its own work. And CI in GitHub Actions will not run on the agent's PR without your approval. Those three rules are why enterprises can actually turn this on.&lt;/p&gt;

&lt;p&gt;You can go further with custom agents. Drop a file under &lt;code&gt;.github/agents/&lt;/code&gt; to codify a repeatable approach, for example a performance agent told to benchmark first, make the change, then measure the difference before opening the PR:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;perf-optimizer&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Benchmarks, optimizes a hot path, then verifies the improvement.&lt;/span&gt;
&lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;Run the existing benchmark suite and record the baseline.&lt;/span&gt;
  &lt;span class="s"&gt;Make the smallest change that improves the target metric.&lt;/span&gt;
  &lt;span class="s"&gt;Re-run the benchmark and include before-and-after numbers in the PR body.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For governing what these agents are allowed to touch across an org, the approach in &lt;a href="https://dev.to/blog/governing-ai-agents-in-cicd-with-opa-and-mcp"&gt;governing AI agents in CI/CD with OPA and MCP&lt;/a&gt; maps cleanly onto GitHub's own AI Controls, which give enterprise admins a central page to enable or disable the coding agent, code review, and custom agents per organization.&lt;/p&gt;

&lt;p&gt;If your developers are still deciding which assistant to standardize on inside the editor, the tradeoffs in &lt;a href="https://dev.to/blog/cursor-vs-copilot-vs-cody-best-ai-editor-for-devops"&gt;Cursor vs Copilot vs Cody&lt;/a&gt; are worth reading, but note that the coding agent described here is a GitHub-native background worker, not an in-editor completion tool, and it is one of the concrete capabilities you gain by being on GitHub in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to sequence the rollout
&lt;/h2&gt;

&lt;p&gt;Do not big-bang a large org. The pattern that works is waves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pilot wave.&lt;/strong&gt; Pick one team with a handful of small, low-risk repos and light pipeline coupling. Migrate their repos, convert their pipelines, and have them work on GitHub for two weeks. Fix what breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early adopters.&lt;/strong&gt; Expand to teams that asked for the AI tooling. Their motivation carries them through the rough edges, and their feedback tunes your generated scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long tail.&lt;/strong&gt; Everyone else, in batches sized to what your team can support in a week. Archive dead repos instead of migrating them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep the Azure DevOps org in read-only mode for a defined window after each wave rather than deleting it. You will need to reference old work items and abandoned PRs that did not migrate, and a read-only source is cheap insurance against a rollback you did not plan for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;The Git migration itself is the easy part. One &lt;code&gt;generate-script&lt;/code&gt;, one reviewed run, and your history is on GitHub with pull requests intact. The work that decides whether the migration succeeds is everything GEI does not do: converting pipelines to Actions, deciding what happens to Boards and Artifacts, and rolling out in waves small enough to recover from. Do the inventory first, treat pipelines as their own project, and keep the old org read-only until you are sure.&lt;/p&gt;

&lt;p&gt;The reason to spend that effort is not tidiness. It is that the AI development tooling, the coding agent that opens and self-reviews pull requests, only exists for code that lives on GitHub. Migrating the repos is the price of entry to agentic development, and in 2026 that is a price a lot of enterprises have decided is worth paying.&lt;/p&gt;

</description>
      <category>azuredevops</category>
      <category>github</category>
      <category>migration</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>OpenTelemetry Collector vs Grafana Alloy: 2026 Guide</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Mon, 13 Jul 2026 15:14:40 +0000</pubDate>
      <link>https://dev.to/devopsstart/opentelemetry-collector-vs-grafana-alloy-2026-guide-1en1</link>
      <guid>https://dev.to/devopsstart/opentelemetry-collector-vs-grafana-alloy-2026-guide-1en1</guid>
      <description>&lt;h2&gt;
  
  
  Which one should you run
&lt;/h2&gt;

&lt;p&gt;If you want a vendor-neutral collector that any backend can consume and any engineer can read, run the OpenTelemetry Collector. If you live inside the Grafana stack or you are migrating off the now-dead Grafana Agent, run Grafana Alloy. Both wrap the same upstream OTel components, so the decision is not about signal support. It is about configuration language, pipeline shape, and how tied you want to be to one vendor's ecosystem.&lt;/p&gt;

&lt;p&gt;Grafana Alloy is not a fork of the Collector. It is a separate codebase that bundles OpenTelemetry Collector components and drives them with its own configuration syntax. That single fact explains most of the trade-offs below: you get the same receivers and exporters under the hood, wrapped in a very different operator experience.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;OpenTelemetry Collector&lt;/th&gt;
&lt;th&gt;Grafana Alloy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Config language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;YAML, declarative, receivers/processors/exporters&lt;/td&gt;
&lt;td&gt;Alloy syntax (formerly River), HCL-inspired, programmable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pipeline shape&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Linear pipeline per signal (one for metrics, one for logs, one for traces)&lt;/td&gt;
&lt;td&gt;Directed graph (DAG); components reference each other's exports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Signals&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Metrics, logs, traces (profiles in progress)&lt;/td&gt;
&lt;td&gt;Metrics, logs, traces, and profiles (Pyroscope)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vendor neutrality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vendor-neutral by design; swap backends via one exporter&lt;/td&gt;
&lt;td&gt;OTLP-compatible, but tuned for the Grafana stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Live UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None built in&lt;/td&gt;
&lt;td&gt;Web UI on port 12345 with a live component graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Component library&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The contrib repo: hundreds of receivers and exporters&lt;/td&gt;
&lt;td&gt;Wraps OTel components plus native Prometheus, Loki, Pyroscope blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best fit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multi-vendor or vendor-agnostic pipelines&lt;/td&gt;
&lt;td&gt;Grafana stack shops and Grafana Agent migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Configuration: YAML versus a real language
&lt;/h2&gt;

&lt;p&gt;The Collector uses YAML. You declare receivers, processors, and exporters, then wire them into a pipeline per signal. It is boring in the best way. Anyone who has read a Kubernetes manifest can read 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;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;protocols&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:4317&lt;/span&gt;
&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlphttp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://backend.example.com&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlphttp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alloy replaces YAML with its own syntax, an HCL-inspired language where each block is a component with named inputs and outputs. Components reference each other by their exported fields, so the config describes a graph rather than a fixed list.&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;otelcol&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;otlp&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;grpc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0:4317"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;traces&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;otelcol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&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="nx"&gt;otelcol&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processor&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;traces&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;otelcol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;otlphttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&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="nx"&gt;otelcol&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exporter&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;otlphttp&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://backend.example.com"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Alloy version is more verbose for this trivial case, and that is the honest trade-off. The payoff shows up when pipelines get complex: one component's output can fan out to several downstream components, and you can express Prometheus scraping, relabeling, and OTLP forwarding in one coherent graph instead of stitching YAML blocks by hand. The cost is a language your team has to learn, and that most tooling does not yet lint or format as well as YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipeline architecture: linear versus DAG
&lt;/h2&gt;

&lt;p&gt;The Collector runs a linear pipeline per signal type. Data flows receiver, then processors in order, then exporters. It is simple to reason about and simple to audit. When something drops a span, you walk the line.&lt;/p&gt;

&lt;p&gt;Alloy evaluates a directed acyclic graph. Because components reference each other's exports, you build branches and joins directly. That flexibility is real, but it adds a small evaluation cost, and a graph is harder to trace by eye than a straight line when you are debugging at 2 a.m. For most workloads the overhead is negligible; the Collector is the more predictable of the two on memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Component ecosystem
&lt;/h2&gt;

&lt;p&gt;The Collector's contrib repository is the center of gravity for OpenTelemetry. Hundreds of receivers, processors, and exporters live there, and most observability vendors ship their own component into it. If a backend exists, an exporter for it almost certainly exists too.&lt;/p&gt;

&lt;p&gt;Alloy wraps those same OTel components (its &lt;code&gt;otelcol.*&lt;/code&gt; blocks are the upstream components), then adds native Grafana-stack blocks: Prometheus remote write, Loki push, and Pyroscope profiling that are tightly integrated and well tested. If your telemetry ends up in Grafana Cloud or a self-hosted Grafana stack, those native blocks are smoother than the generic OTLP path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational experience
&lt;/h2&gt;

&lt;p&gt;Here Alloy pulls ahead. Run it and hit &lt;code&gt;http://&amp;lt;alloy-host&amp;gt;:12345&lt;/code&gt; for a live graph of every component, its health, and the data moving through it. When a pipeline misbehaves, you see which component is red without grepping logs.&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;$ &lt;/span&gt;alloy run config.alloy &lt;span class="nt"&gt;--server&lt;/span&gt;.http.listen-addr&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0:12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Collector has no equivalent built-in UI. You get internal telemetry (its own metrics endpoint and zpages), which is capable but nowhere near as approachable when you are onboarding a new engineer or triaging fast. If a visual pipeline view matters to your on-call rotation, that is a point for Alloy.&lt;/p&gt;

&lt;p&gt;If you are weighing collectors as part of a broader platform decision, the same trade-offs (vendor lock-in versus integration depth) show up across the space; the &lt;a href="https://dev.to/comparisons/datadog-vs-aws-ops-agents-ai-observability-showdown"&gt;Datadog vs AWS Ops Agents comparison&lt;/a&gt; walks the SaaS side of that same tension.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Grafana Agent migration angle
&lt;/h2&gt;

&lt;p&gt;If you are running Grafana Agent in any mode (static, flow, or the operator), this comparison is not academic. Grafana Agent reached end of life on November 1, 2025, and no longer receives security or bug fixes. Grafana's own guidance is to migrate to Alloy, which is the successor to Flow mode and shares its component model. For those teams the choice is effectively made: Alloy is the supported path forward, and its config maps closely from Agent Flow.&lt;/p&gt;

&lt;p&gt;For a greenfield deployment with no Grafana Agent history, the field is open again, and the vendor-neutrality argument for the plain Collector carries more weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;Both tools are solid, and both are built on the same OpenTelemetry foundation, so you are not choosing between good and bad telemetry. You are choosing an operator model.&lt;/p&gt;

&lt;p&gt;Reach for the OpenTelemetry Collector when portability is the priority: multi-vendor backends, YAML that any engineer can read, and a pipeline you can swap to a new backend by editing one exporter. Reach for Grafana Alloy when you are committed to the Grafana stack, want the live pipeline UI, or are migrating off Grafana Agent before its unpatched code becomes a liability.&lt;/p&gt;

&lt;p&gt;A common production pattern uses both: the Collector as a lightweight sidecar in application pods, forwarding OTLP up to Alloy as the cluster aggregator that fans telemetry into the Grafana stack. You do not have to pick one collector for the whole estate.&lt;/p&gt;

&lt;p&gt;To go deeper on instrumenting workloads with OpenTelemetry itself, see &lt;a href="https://dev.to/tutorials/how-to-set-up-llm-observability-with-opentelemetry"&gt;How to Set Up LLM Observability with OpenTelemetry&lt;/a&gt; and, for cluster-scale patterns, &lt;a href="https://dev.to/tutorials/llm-observability-on-kubernetes-a-practical-guide"&gt;LLM Observability on Kubernetes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Verify the current component list and syntax against the &lt;a href="https://grafana.com/docs/alloy/latest/" rel="noopener noreferrer"&gt;Grafana Alloy documentation&lt;/a&gt; and the &lt;a href="https://opentelemetry.io/docs/collector/" rel="noopener noreferrer"&gt;OpenTelemetry Collector documentation&lt;/a&gt; before you commit a production config; both projects move quickly.&lt;/p&gt;

</description>
      <category>opentelemetry</category>
      <category>grafanaalloy</category>
      <category>observability</category>
      <category>otelcollector</category>
    </item>
  </channel>
</rss>
