<?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: Manoj Kumar Kagitha</title>
    <description>The latest articles on DEV Community by Manoj Kumar Kagitha (@manojkagitha).</description>
    <link>https://dev.to/manojkagitha</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%2F4151562%2F47172455-168c-42e8-98fd-cbd3d4f57c9d.png</url>
      <title>DEV Community: Manoj Kumar Kagitha</title>
      <link>https://dev.to/manojkagitha</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/manojkagitha"/>
    <language>en</language>
    <item>
      <title>Terraform in CI/CD: Plan/Apply Gates with GitHub Actions</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:07:54 +0000</pubDate>
      <link>https://dev.to/manojkagitha/terraform-in-cicd-planapply-gates-with-github-actions-1onn</link>
      <guid>https://dev.to/manojkagitha/terraform-in-cicd-planapply-gates-with-github-actions-1onn</guid>
      <description>&lt;p&gt;Versioned modules mean nothing if engineers still run apply from their laptops. Laptop applies are unreviewed, unlogged, and unrepeatable. The fix is a pipeline: every change goes through plan on pull request, review, then apply from CI with short-lived credentials. This post builds that pipeline on GitHub Actions from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CI/CD runs apply
&lt;/h2&gt;

&lt;p&gt;Four reasons, in order of how much pain each one prevents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review.&lt;/strong&gt; A plan posted as a PR comment lets reviewers see exactly what will change before it changes. No more "I applied and something broke, let me check what I did."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit trail.&lt;/strong&gt; Every apply is tied to a commit, a PR, and a GitHub Actions run. When something breaks at 2 AM, you know exactly which change did it and who approved it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No credential sprawl.&lt;/strong&gt; Engineers do not need cloud credentials on their laptops. The pipeline assumes a role with OIDC. Credentials live for the duration of the run, then vanish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeatability.&lt;/strong&gt; The same code, the same Terraform version, the same provider versions, every time. "Works on my machine" stops being a sentence anyone says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipeline stages
&lt;/h2&gt;

&lt;p&gt;A solid Terraform pipeline has four stages. Each one gates the next.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;fmt.&lt;/strong&gt; &lt;code&gt;terraform fmt -check&lt;/code&gt; fails the run if any file is misformatted. Cheap, fast, non-negotiable. Formatting arguments are not worth human review time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;validate.&lt;/strong&gt; &lt;code&gt;terraform init -backend=false&lt;/code&gt; followed by &lt;code&gt;terraform validate&lt;/code&gt;. Catches syntax errors and invalid references without touching state or cloud APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;plan.&lt;/strong&gt; Full init against the real backend, then &lt;code&gt;terraform plan -out=tfplan&lt;/code&gt;. The plan file is saved as an artifact. On pull requests, the human-readable plan is posted as a PR comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;apply.&lt;/strong&gt; Only on merge to main. Downloads the exact plan artifact from the plan stage and applies it. Never re-plans at apply time. You apply what was reviewed, not something subtly different.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Linting with &lt;code&gt;tflint&lt;/code&gt; slots in between validate and plan. Security scanning with &lt;code&gt;tfsec&lt;/code&gt; or Checkov goes there too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan artifact pattern
&lt;/h2&gt;

&lt;p&gt;The critical detail: apply consumes the saved plan file, not a fresh plan. Here is why it matters.&lt;/p&gt;

&lt;p&gt;Between plan and apply, time passes. A reviewer takes an hour. Someone merges another PR. The cloud state drifts. If apply re-plans, it applies something nobody reviewed. By saving the plan with &lt;code&gt;-out=tfplan&lt;/code&gt; and applying that exact file, the reviewed change is the applied change. If the world moved underneath, the apply fails loudly instead of silently doing something unexpected. That failure is correct behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Posting plan output as a PR comment
&lt;/h2&gt;

&lt;p&gt;A plan artifact is a binary file. Reviewers cannot read it on GitHub. So the pipeline also renders the plan as text and posts it as a comment:&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="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;Render plan&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;terraform show -no-color tfplan &amp;gt; tfplan.txt&lt;/span&gt;
  &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&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;Post plan comment&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.event_name == 'pull_request'&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/github-script@v7&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;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;const fs = require('fs');&lt;/span&gt;
      &lt;span class="s"&gt;const plan = fs.readFileSync(process.env.WORKING_DIR + '/tfplan.txt', 'utf8');&lt;/span&gt;
      &lt;span class="s"&gt;const body = `## Terraform Plan\n\n\`\`\`\n${plan.substring(0, 60000)}\n\`\`\``;&lt;/span&gt;
      &lt;span class="s"&gt;github.rest.issues.createComment({&lt;/span&gt;
        &lt;span class="s"&gt;issue_number: context.issue.number,&lt;/span&gt;
        &lt;span class="s"&gt;owner: context.repo.owner,&lt;/span&gt;
        &lt;span class="s"&gt;repo: context.repo.repo,&lt;/span&gt;
        &lt;span class="s"&gt;body: body&lt;/span&gt;
      &lt;span class="s"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long plans get truncated at 60,000 characters, which fits GitHub's comment limits. For most changes, the full plan fits. Reviewers see adds, changes, and destroys inline, right where they already review code.&lt;/p&gt;

&lt;h2&gt;
  
  
  OIDC auth: no stored secrets
&lt;/h2&gt;

&lt;p&gt;The old way: a long-lived AWS access key or Azure service principal secret sitting in GitHub Secrets, rotating never, shared by everyone. The new way: OpenID Connect. GitHub mints a short-lived token per run. The cloud trusts GitHub's identity provider and hands back temporary credentials scoped to one IAM role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS setup.&lt;/strong&gt; Create an IAM role with a trust policy that allows GitHub's OIDC provider to assume it, scoped to your repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Federated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"StringLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repo:myorg/infra:*"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sub&lt;/code&gt; condition restricts assumption to your infrastructure repo. Tighten it further with branch conditions if you want: only &lt;code&gt;main&lt;/code&gt; can assume the production role, while any branch can assume the dev role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Azure setup.&lt;/strong&gt; Register an app, add a federated credential trusting &lt;code&gt;repo:myorg/infra:ref:refs/heads/main&lt;/code&gt;, and log in with &lt;code&gt;azure/login@v2&lt;/code&gt; using client ID, tenant ID, and subscription ID. No secret needed.&lt;/p&gt;

&lt;p&gt;The workflow needs one permission block:&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;id-token&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;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;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;&lt;code&gt;id-token: write&lt;/code&gt; is what lets the run mint the OIDC token. Without it, the credential step fails with an unhelpful error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply gates
&lt;/h2&gt;

&lt;p&gt;Not every merge should auto-apply to production. GitHub Environments give you gates.&lt;/p&gt;

&lt;p&gt;Define an environment per target: &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;. Attach rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dev:&lt;/strong&gt; auto-apply on merge. Fast feedback, low blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;staging:&lt;/strong&gt; apply on merge, but the job waits for no one. Actually, make staging auto-apply too. Staging exists to catch what dev missed, and manual gates on staging just slow the signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;production:&lt;/strong&gt; required reviewers. The apply job pauses until a designated approver clicks approve. The approver sees the plan diff from the PR. This is the gate that matters.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apply-prod&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;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan-prod&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.ref == 'refs/heads/main'&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-prod&lt;/span&gt;
    &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;environment: production&lt;/code&gt; line is what triggers the approval gate. Configure the required reviewers in the repo settings under Environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full working workflow
&lt;/h2&gt;

&lt;p&gt;Here is a complete workflow for one environment. It runs fmt and validate on every PR, plans and comments, and applies only on merge to main behind the production gate.&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;Terraform Prod&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;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;envs/prod/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;envs/prod/**'&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;id-token&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;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;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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;TF_VERSION&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.9.0"&lt;/span&gt;
  &lt;span class="na"&gt;WORKING_DIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envs/prod&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;fmt&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;hashicorp/setup-terraform@v3&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;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.TF_VERSION }}&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;terraform fmt -check -recursive&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&lt;/span&gt;

  &lt;span class="na"&gt;validate&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;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fmt&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;hashicorp/setup-terraform@v3&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;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.TF_VERSION }}&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;terraform init -backend=false&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&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;terraform validate&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&lt;/span&gt;

  &lt;span class="na"&gt;plan&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;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;validate&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;hashicorp/setup-terraform@v3&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;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.TF_VERSION }}&lt;/span&gt;
          &lt;span class="na"&gt;terraform_wrapper&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;Configure AWS credentials&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;aws-actions/configure-aws-credentials@v4&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;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/terraform-ci-prod&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&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;terraform init -input=false&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&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;terraform plan -out=tfplan -input=false&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&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;terraform show -no-color tfplan &amp;gt; tfplan.txt&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&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/upload-artifact@v4&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfplan-prod&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}/tfplan&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;Post plan comment&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.event_name == 'pull_request'&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/github-script@v7&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;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;const fs = require('fs');&lt;/span&gt;
            &lt;span class="s"&gt;const plan = fs.readFileSync(process.env.WORKING_DIR + '/tfplan.txt', 'utf8');&lt;/span&gt;
            &lt;span class="s"&gt;const body = `## Terraform Plan (prod)\n\n\`\`\`\n${plan.substring(0, 60000)}\n\`\`\``;&lt;/span&gt;
            &lt;span class="s"&gt;github.rest.issues.createComment({&lt;/span&gt;
              &lt;span class="s"&gt;issue_number: context.issue.number,&lt;/span&gt;
              &lt;span class="s"&gt;owner: context.repo.owner,&lt;/span&gt;
              &lt;span class="s"&gt;repo: context.repo.repo,&lt;/span&gt;
              &lt;span class="s"&gt;body: body&lt;/span&gt;
            &lt;span class="s"&gt;});&lt;/span&gt;

  &lt;span class="na"&gt;apply&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;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan&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.ref == 'refs/heads/main' &amp;amp;&amp;amp; github.event_name == 'push'&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-prod&lt;/span&gt;
      &lt;span class="na"&gt;cancel-in-progress&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;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;hashicorp/setup-terraform@v3&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;terraform_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.TF_VERSION }}&lt;/span&gt;
          &lt;span class="na"&gt;terraform_wrapper&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;Configure AWS credentials&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;aws-actions/configure-aws-credentials@v4&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;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/terraform-ci-prod&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&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/download-artifact@v4&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfplan-prod&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&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;terraform init -input=false&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&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;terraform apply -input=false tfplan&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.WORKING_DIR }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out. First, &lt;code&gt;terraform_wrapper: false&lt;/code&gt; on the setup-terraform action. The wrapper captures output for PR comments in the action's own way, which conflicts with the manual plan-comment pattern. Disable it when you post comments yourself.&lt;/p&gt;

&lt;p&gt;Second, the apply job downloads the plan artifact and applies that file. It does not run plan again. Reviewed plan in, applied plan out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-environment promotion
&lt;/h2&gt;

&lt;p&gt;One repo, one workflow per environment, or one workflow with a matrix. The promotion flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Merge to main. Dev applies automatically. No gate, fast feedback.&lt;/li&gt;
&lt;li&gt;Staging applies automatically after dev succeeds. Use &lt;code&gt;needs: [apply-dev]&lt;/code&gt; to serialize.&lt;/li&gt;
&lt;li&gt;Production waits. The &lt;code&gt;environment: production&lt;/code&gt; gate pauses for a human approver.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sequential, not parallel. Prod never applies before staging succeeds. If staging apply fails, prod never starts, and the team fixes forward instead of debating whether prod is safe.&lt;/p&gt;

&lt;p&gt;Keep one workflow file per environment rather than one mega-workflow with conditionals. &lt;code&gt;terraform-prod.yml&lt;/code&gt;, &lt;code&gt;terraform-staging.yml&lt;/code&gt;, &lt;code&gt;terraform-dev.yml&lt;/code&gt;. Each is readable on its own. Shared steps go into composite actions or reusable workflows if duplication bothers you, but duplication across three files is cheaper than one clever file nobody understands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency groups: serialize applies per environment
&lt;/h2&gt;

&lt;p&gt;Two merges in quick succession can queue two applies against the same state. State locking makes the second one wait or fail, but it is cleaner to serialize at the workflow level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-prod&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cancel-in-progress: false&lt;/code&gt; is the important part. You want the second run to wait, not to cancel the first run mid-apply. Cancelling mid-apply is how you get tainted resources and half-created infrastructure.&lt;/p&gt;

&lt;p&gt;One concurrency group per environment. Dev, staging, and prod apply independently. Two environments never block each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  State locking and CI
&lt;/h2&gt;

&lt;p&gt;State locking still applies in CI. If a run crashes mid-apply, the lock can stick. The next run fails with a lock error. The fix is the same as locally: &lt;code&gt;terraform force-unlock&lt;/code&gt;, but run it deliberately, not in the pipeline.&lt;/p&gt;

&lt;p&gt;Do not add auto-unlock to the pipeline. An automatic force-unlock can release a lock held by a genuinely running apply, recreating the race condition locking exists to prevent. When CI reports a stuck lock, a human checks that nothing is actually applying, runs force-unlock once, and re-runs the workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  When apply fails mid-run
&lt;/h2&gt;

&lt;p&gt;It happens. A network blip, a cloud API quota, a resource that fails to create. Terraform marks the failed resource as tainted. The next apply destroys and recreates it. Usually that is the right behavior.&lt;/p&gt;

&lt;p&gt;When it is not, for example a database that took twenty minutes to provision and failed on a tagging step, you have options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;terraform apply -replace="aws_db_instance.main"&lt;/code&gt; recreates one specific resource on the next run, without waiting for a failure to taint it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform state rm&lt;/code&gt; followed by &lt;code&gt;terraform import&lt;/code&gt; detaches a resource from management when you need to stop Terraform from touching it while you fix things by hand. Use sparingly.&lt;/li&gt;
&lt;li&gt;As a last resort, &lt;code&gt;-target&lt;/code&gt; applies to a subset of resources. It bypasses dependency ordering guarantees, so treat it as emergency surgery, not routine practice. Every &lt;code&gt;-target&lt;/code&gt; apply should be followed by a full, untargeted plan to confirm the state is coherent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After any recovery, run a full plan. If the plan is empty, you are back to a clean state. If it is not, keep going until it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] fmt, validate, plan, apply as separate gated stages&lt;/li&gt;
&lt;li&gt;[ ] Plan saved with &lt;code&gt;-out=tfplan&lt;/code&gt;, applied from the artifact. No re-planning at apply time.&lt;/li&gt;
&lt;li&gt;[ ] Plan output posted as a PR comment for review&lt;/li&gt;
&lt;li&gt;[ ] OIDC auth configured. No long-lived credentials in GitHub Secrets.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;id-token: write&lt;/code&gt; permission set on the workflow&lt;/li&gt;
&lt;li&gt;[ ] Production apply behind a GitHub Environment approval gate&lt;/li&gt;
&lt;li&gt;[ ] Concurrency group per environment, &lt;code&gt;cancel-in-progress: false&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Promotion order enforced: dev, then staging, then prod&lt;/li&gt;
&lt;li&gt;[ ] No auto-unlock in the pipeline. Stuck locks get human attention.&lt;/li&gt;
&lt;li&gt;[ ] Terraform, provider, and module versions pinned (see article 2)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;The pipeline applies cleanly and every change is reviewed. But infrastructure does not stay as Terraform left it. Someone tweaks a setting in the console. A cloud provider changes a default. The next plan shows diffs nobody made.&lt;/p&gt;

&lt;p&gt;Next article: drift detection and remediation. Scheduled scans, telling real drift from cosmetic noise, and the workflow for bringing reality back in line with code.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
      <category>github</category>
      <category>automation</category>
    </item>
    <item>
      <title>Who Is Paying for This? Token Metering at the LLM Gateway</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Fri, 23 Oct 2026 14:52:27 +0000</pubDate>
      <link>https://dev.to/manojkagitha/who-is-paying-for-this-token-metering-at-the-llm-gateway-3jlp</link>
      <guid>https://dev.to/manojkagitha/who-is-paying-for-this-token-metering-at-the-llm-gateway-3jlp</guid>
      <description>&lt;p&gt;Once you put a large language model behind a company API gateway, someone asks a simple question.&lt;/p&gt;

&lt;p&gt;Who is paying for this?&lt;/p&gt;

&lt;p&gt;The model bill arrives as one number. Finance sees one number. Every team in the company sees one endpoint. Nobody can tell you which team burned forty percent of the budget, which use case is growing, or which experiment someone forgot to shut off six weeks ago. That gap between one bill and one endpoint is where metering lives.&lt;/p&gt;

&lt;p&gt;We built metering into our LLM gateway before we scaled usage, and it changed how teams behaved. Here is what we learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why LLM metering is not like API metering
&lt;/h2&gt;

&lt;p&gt;With a normal REST API, metering is easy. One request costs one request. You count requests per subscription key, and you are done.&lt;/p&gt;

&lt;p&gt;LLM calls do not work that way. One request to a chat endpoint can cost five tokens or five thousand. A prompt injection test script someone ran in a loop overnight can spend more than an entire product team's monthly budget in eight hours. If you count requests, you learn nothing. The unit of cost is the token, and the token count lives in the response, not the request.&lt;/p&gt;

&lt;p&gt;That detail shapes everything. Your metering code has to read the response body, extract the usage block, and attribute it before the data is gone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1240&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;312&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1552&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Azure OpenAI returns this on every response. If you do not capture it at the gateway, you have to reconstruct it later from logs, and that is a losing fight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture it at the gateway, not in the app
&lt;/h2&gt;

&lt;p&gt;Every team wanted to do metering in their own application code. We said no. The gateway is the one layer every call passes through, regardless of language, framework, or team. A metering implementation that depends on each team instrumenting their own service is an implementation that covers sixty percent of traffic on a good month.&lt;/p&gt;

&lt;p&gt;We put a policy on the gateway's inbound path to stamp each request with metadata: the subscription the key belongs to, the product, the environment. On the outbound path, another policy parses the response, pulls the token counts, and ships an event to our analytics pipeline. The policy does not touch the request or response bodies in ways the caller can see. It observes and reports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;policies&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;outbound&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;choose&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;when&lt;/span&gt; &lt;span class="na"&gt;condition=&lt;/span&gt;&lt;span class="s"&gt;"@(context.Response.StatusCode == 200)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;set-body&amp;gt;&lt;/span&gt;@{
                    // parse the usage block and forward a metering event
                    // key: subscription id, product, environment, model
                    // value: prompt_tokens, completion_tokens, latency
                }&lt;span class="nt"&gt;&amp;lt;/set-body&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/when&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/choose&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/outbound&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/policies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is pseudo-code, not a real policy file, because every gateway has its own syntax. The shape matters more than the syntax: stamp metadata on the way in, extract cost on the way out, send it somewhere durable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribute everything, including the failures
&lt;/h2&gt;

&lt;p&gt;The first version of our metering only counted successful calls. That lasted about two weeks. Someone ran a batch job against a deprecated model deployment that returned 400s all day. The calls were cheap individually, but there were hundreds of thousands of them, and the model provider still charged for the input tokens on each failed request.&lt;/p&gt;

&lt;p&gt;Failed requests still consume input tokens. Rate-limited requests still consume them. Requests that hit a content filter still consume them. If you only meter the 200s, your bill and your dashboard will disagree, and finance will believe the bill.&lt;/p&gt;

&lt;p&gt;Our outbound policy records every response code. Failures get their own bucket so teams can see them separately, but they are not excluded from the total. The first time a team saw how much they were spending on 400s, they fixed their client code that afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give teams their own numbers, not the whole company
&lt;/h2&gt;

&lt;p&gt;A company-wide dashboard that says the LLM bill is fifty thousand dollars a month helps nobody. A dashboard that says your team spent nine thousand, up forty percent from last month, on a specific model, changes behavior overnight.&lt;/p&gt;

&lt;p&gt;We publish per-team views: tokens and estimated cost by team, by model, by day. The estimated cost part matters. Tokens are abstract. Dollars are not. We maintain a small price table per model and multiply. The estimate is not perfect, and we say that on the dashboard. It is close enough to drive decisions.&lt;/p&gt;

&lt;p&gt;The behavioral change was immediate. Teams started batching requests. They cached repeated prompts. One team switched a summarization job from a large model to a smaller one after seeing the per-run cost. None of that happened when the bill was a single line item.&lt;/p&gt;

&lt;h2&gt;
  
  
  Budgets need teeth, but start with visibility
&lt;/h2&gt;

&lt;p&gt;Once teams can see their spend, the next question is limits. We added budget alerts first, hard stops second. The sequence matters.&lt;/p&gt;

&lt;p&gt;Alerts went to the team's channel when they crossed fifty, eighty, and one hundred percent of their monthly budget. Most teams self-corrected at fifty. For the ones that did not, a hard stop at the gateway rate-limits further calls until someone raises the budget.&lt;/p&gt;

&lt;p&gt;We deliberately did not lead with hard stops. An engineer running a time-sensitive demo who hits a hard limit learns that the gateway is the enemy. An engineer who gets a warning at fifty percent learns to watch their budget. The hard stop exists for the runaway loops, not for normal work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would do differently
&lt;/h2&gt;

&lt;p&gt;Three things we would change if we started over.&lt;/p&gt;

&lt;p&gt;First, we would meter streaming responses from day one. Token counts arrive at the end of a streamed response, and our first implementation missed them. Streaming became popular faster than we expected.&lt;/p&gt;

&lt;p&gt;Second, we would standardize the metadata stamp earlier. We let teams add custom attributes organically, and we ended up with three different keys for the same project name. Normalizing that retroactively was tedious.&lt;/p&gt;

&lt;p&gt;Third, we would keep the price table in version control with a review step. Model prices change. When they do, old estimates look wrong. We now treat the price table like code: a pull request, a reviewer, a changelog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson
&lt;/h2&gt;

&lt;p&gt;Metering is not a billing feature. It is a feedback loop. The moment teams could see what their calls cost, they started engineering like the cost mattered. Architecture improved. Waste fell. Nobody needed a policy document telling them to be careful with the expensive model, because the numbers told the story better than any document could.&lt;/p&gt;

&lt;p&gt;If you are standing up an LLM gateway and nobody has asked about cost attribution yet, they will. Build the metering in before the bill becomes a conversation.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>azure</category>
      <category>devops</category>
      <category>apimanagement</category>
    </item>
    <item>
      <title>Cross-cloud cost math: when serverless beats fixed tiers</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Fri, 23 Oct 2026 02:33:42 +0000</pubDate>
      <link>https://dev.to/manojkagitha/cross-cloud-cost-math-when-serverless-beats-fixed-tiers-3og3</link>
      <guid>https://dev.to/manojkagitha/cross-cloud-cost-math-when-serverless-beats-fixed-tiers-3og3</guid>
      <description>&lt;h1&gt;
  
  
  Cross-cloud cost math: when serverless beats fixed tiers
&lt;/h1&gt;

&lt;p&gt;Every API consumer asks the same question sooner or later: which gateway&lt;br&gt;
tier should this run on? Most teams answer from habit. The right answer is&lt;br&gt;
arithmetic.&lt;/p&gt;

&lt;p&gt;I added a &lt;code&gt;cost&lt;/code&gt; command to&lt;br&gt;
&lt;a href="https://github.com/kagithamanoj/api-onboarding-kit" rel="noopener noreferrer"&gt;api-onboarding-kit&lt;/a&gt;&lt;br&gt;
because onboarding should include the answer. It reads the consumer spec,&lt;br&gt;
does the multiplication across every gateway tier, and prints the options&lt;br&gt;
cheapest first. The point is not that the numbers are perfect. They are&lt;br&gt;
from public list pricing and they move. The point is that the method is&lt;br&gt;
boring and it beats arguing from gut feeling.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape of the math
&lt;/h2&gt;

&lt;p&gt;Every tier is one of two shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serverless:&lt;/strong&gt; &lt;code&gt;cost = requests x per-million price&lt;/code&gt;, no base.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed:&lt;/strong&gt; &lt;code&gt;cost = flat monthly base&lt;/code&gt;, no per-request term.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Requests per month come straight from the spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monthly_requests = expected_rps x 86,400 x 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a consumer at 10 rps average does about 25.9 million requests a month.&lt;br&gt;
Run that through the pricing table in the repo and you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Azure APIM Consumption: 25.92 x $4.00 = &lt;strong&gt;$103.68&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;AWS API Gateway HTTP API: 25.92 x $1.00 = &lt;strong&gt;$25.92&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;GCP API Gateway: 25.92 x $3.00 = &lt;strong&gt;$77.76&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Azure APIM Standard: &lt;strong&gt;$700.00&lt;/strong&gt; flat&lt;/li&gt;
&lt;li&gt;Apigee X pay-as-you-go: 25.92 x $40.00 = &lt;strong&gt;$1,036.80&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this traffic level, serverless wins everywhere and the fixed tier looks&lt;br&gt;
absurd. That reverses fast.&lt;/p&gt;
&lt;h2&gt;
  
  
  The crossover point
&lt;/h2&gt;

&lt;p&gt;Fixed beats serverless when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monthly_millions &amp;gt; base_usd / per_million_usd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take Azure APIM Standard ($700 flat) against Consumption ($4 per million):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 700 / 4 = 175 million requests per month
&lt;/span&gt;&lt;span class="n"&gt;crossover_millions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;700.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;4.00&lt;/span&gt;
&lt;span class="n"&gt;crossover_rps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crossover_millions&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1e6&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;86_400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; ~67 rps sustained, all month
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below ~67 rps sustained, the consumption tier is cheaper. Above it, the&lt;br&gt;
fixed tier wins and every request past the line is effectively free.&lt;br&gt;
That is the whole decision. One division.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the math misses
&lt;/h2&gt;

&lt;p&gt;Cheapest is not always right. Two things I learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Serverless tiers drop features.&lt;/strong&gt; In APIM, the consumption tier does&lt;br&gt;
not give you VNet support or the production SLA. If your security review&lt;br&gt;
requires private networking, the "cheaper" tier is not an option and the&lt;br&gt;
comparison is over before it starts. The estimator flags these notes on&lt;br&gt;
every row for a reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Averages lie.&lt;/strong&gt; &lt;code&gt;expectedRps&lt;/code&gt; drives the cost, but &lt;code&gt;burstRps&lt;/code&gt;&lt;br&gt;
decides whether the tier survives. A consumer that averages 5 rps and&lt;br&gt;
bursts to 500 rps can blow through a per-request tier's limits during the&lt;br&gt;
spike while costing nothing on paper. Burst is a capacity question, not a&lt;br&gt;
pricing question. Model it separately.&lt;/p&gt;

&lt;p&gt;So the working rule I give teams: run the arithmetic to pick the tier,&lt;br&gt;
then check the feature list and the burst behavior to confirm the tier&lt;br&gt;
is even allowed. Cost picks the winner among legal options.&lt;/p&gt;
&lt;h2&gt;
  
  
  The command
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;onboard cost &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--spec&lt;/span&gt; payments-team.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It reads &lt;code&gt;expectedRps&lt;/code&gt; from the spec and prints every gateway, every&lt;br&gt;
tier, cheapest first. Adding a tier is one dictionary entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PRICING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;azure-apim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tiers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;premium&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2600.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;per_million_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Multi-region, higher throughput&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the crossover math just works, because it is arithmetic, not logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Gateway cost is not a negotiation or a vendor pitch. It is one division&lt;br&gt;
problem per tier. Do it in CI, on every new consumer, before the traffic&lt;br&gt;
exists. The numbers move and the features change, but the method does not.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/kagithamanoj/api-onboarding-kit" rel="noopener noreferrer"&gt;kagithamanoj/api-onboarding-kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>azure</category>
      <category>aws</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Every API Breaks Eventually. Versioning Decides Who Feels It.</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Wed, 21 Oct 2026 14:12:37 +0000</pubDate>
      <link>https://dev.to/manojkagitha/every-api-breaks-eventually-versioning-decides-who-feels-it-20jl</link>
      <guid>https://dev.to/manojkagitha/every-api-breaks-eventually-versioning-decides-who-feels-it-20jl</guid>
      <description>&lt;p&gt;If you have run APIs at enterprise scale, you know the call. A partner's app starts throwing 400s at noon on a Tuesday. Someone renamed a field in a response payload. Nobody versioned anything, so every client got the change at once. The fix takes ten minutes. The trust takes months to rebuild.&lt;/p&gt;

&lt;p&gt;Breaking changes are normal. Software grows. Fields get renamed, auth schemes get tightened, pagination shows up where it did not exist before. What is not normal is shipping a breaking change without a version. Versioning decides who feels a breaking change: nobody, or everyone.&lt;/p&gt;

&lt;p&gt;Here is how we run API versioning across a multi-gateway estate. I work with Apigee X and Azure APIM side by side, plus backing services on Azure. The estate is the hard part. One versioning policy only works if it holds on every gateway at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick one versioning scheme and mean it
&lt;/h2&gt;

&lt;p&gt;There are three schemes people argue about: path versioning (&lt;code&gt;/v1/orders&lt;/code&gt;), header versioning (&lt;code&gt;Accept: application/vnd.orders.v2+json&lt;/code&gt;), and query params (&lt;code&gt;?version=2&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Path versioning wins in practice. Here is why. Every client knows how to hit a different path. Every log aggregator groups by path without custom parsing. Every gateway routes on path without policy gymnastics. Headers get stripped by proxies you do not control. Query params get swallowed by caches. Paths survive.&lt;/p&gt;

&lt;p&gt;The rule is simple: the version lives in the path, it is mandatory, and it never changes meaning. &lt;code&gt;/v1&lt;/code&gt; stays &lt;code&gt;/v1&lt;/code&gt; forever. If v1 needs a fix, you ship &lt;code&gt;/v2&lt;/code&gt; and deprecate v1. You never mutate v1 under the same URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the policy in the gateway, not in every backend team
&lt;/h2&gt;

&lt;p&gt;The biggest versioning failure I have seen was not a bad scheme. It was a good scheme enforced nowhere. Teams were told to version their APIs. Half did it their way, half forgot, and one shipped &lt;code&gt;/v1.2-final-2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The gateway is the enforcement point. It sits in front of every service. That is where the versioning policy lives, written once, applied to every API that crosses it.&lt;/p&gt;

&lt;p&gt;A practical gateway policy does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rejects requests with unknown or missing versions with a clear 400.&lt;/li&gt;
&lt;li&gt;Routes each version to the right backend. &lt;code&gt;/v1/orders&lt;/code&gt; and &lt;code&gt;/v2/orders&lt;/code&gt; can point at the same service or different ones. The client never knows.&lt;/li&gt;
&lt;li&gt;Normalizes version metadata for logging, so analytics can answer "how much traffic is still on v1" without a forensic exercise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Azure APIM this is an inbound policy on the product or gateway scope. Something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;policies&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;inbound&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;base&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;choose&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;when&lt;/span&gt; &lt;span class="na"&gt;condition=&lt;/span&gt;&lt;span class="s"&gt;"@(context.Request.OriginalUrl.Path.StartsWith("&lt;/span&gt;&lt;span class="err"&gt;/v1")&lt;/span&gt;
          &lt;span class="err"&gt;||&lt;/span&gt; &lt;span class="err"&gt;context.Request.OriginalUrl.Path.StartsWith("/v2"))"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;set-header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"x-api-version"&lt;/span&gt; &lt;span class="na"&gt;exists-action=&lt;/span&gt;&lt;span class="s"&gt;"override"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;value&amp;gt;&lt;/span&gt;@(context.Request.OriginalUrl.Path.Substring(1, 2))&lt;span class="nt"&gt;&amp;lt;/value&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/set-header&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/when&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;otherwise&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;return-response&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;set-status&lt;/span&gt; &lt;span class="na"&gt;code=&lt;/span&gt;&lt;span class="s"&gt;"400"&lt;/span&gt; &lt;span class="na"&gt;reason=&lt;/span&gt;&lt;span class="s"&gt;"Bad Request"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;set-body&amp;gt;&lt;/span&gt;{"error": "unknown API version. Use /v1 or /v2."}&lt;span class="nt"&gt;&amp;lt;/set-body&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/return-response&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/otherwise&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/choose&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/inbound&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/policies&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same logic exists in Apigee as a set of conditional flows and a RaiseFault policy. Different syntax, same behavior. That is the point. The behavior is one policy, expressed twice. We keep a short spec doc for "version enforcement behavior" and review both gateway implementations against it whenever either changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Versions need a registry, not just a URL pattern
&lt;/h2&gt;

&lt;p&gt;A URL pattern tells clients where to go. It does not tell anyone what is live, what is deprecated, or who still calls the old version. For that you need a registry: every API, every active version, the owner team, the deprecation date, and the known consumers.&lt;/p&gt;

&lt;p&gt;This sounds like paperwork. It is the thing that makes deprecation possible. Without a consumer list, deprecating v1 means guessing. With one, it means sending each consumer a date and a migration guide, then watching the traffic shift on the dashboard.&lt;/p&gt;

&lt;p&gt;The registry does not need to be fancy. A structured API catalog backed by the same onboarding flow teams already use works. The important part is that publishing a new version and updating the registry are the same action, not two separate chores that drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deprecation is a schedule, not an announcement
&lt;/h2&gt;

&lt;p&gt;Announcing a deprecation and executing one are different jobs. Announcements get read and forgotten. Schedules get followed. A deprecation schedule has four parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A published end-of-life date, at least 90 days out for internal consumers, longer for partners. Nobody gets surprised.&lt;/li&gt;
&lt;li&gt;Sunset headers on responses from the deprecated version, so clients see the date every time they call. &lt;code&gt;Sunset&lt;/code&gt; and &lt;code&gt;Deprecation&lt;/code&gt; headers exist for exactly this.&lt;/li&gt;
&lt;li&gt;Staged traffic reduction: monitor v1 traffic weekly, reach out to the stragglers directly. The last 5 percent of traffic is always the hardest and always belongs to the team you least expect.&lt;/li&gt;
&lt;li&gt;A hard cutoff, enforced at the gateway, on the published date. The gateway returns 410 Gone with a link to the migration guide. Soft cutoffs become permanent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where the gateway policy pays for itself a second time. The cutoff is a one-line change in the routing policy, not a coordinated deploy across backend teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the contract, not just the shape
&lt;/h2&gt;

&lt;p&gt;Versioning keeps clients on the contract they coded against. Contract validation keeps the contract honest. At the gateway, that means schema validation on requests and responses where it is cheap enough to run.&lt;/p&gt;

&lt;p&gt;The practical version: JSON Schema validation on the inbound side for critical endpoints. It catches the "we added a required field without bumping the version" mistake before it ships. Pair it with contract tests in CI on the backend side. The gateway check is the backstop, not the test suite.&lt;/p&gt;

&lt;p&gt;Keep validation scoped. Validating every payload on a high-throughput API adds latency you will feel at P99. Validate the versioned endpoints that carry breaking-change risk, and let observability cover the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Breaking changes are inevitable. Surprises are optional. One versioning scheme, enforced at every gateway, tracked in a registry, deprecated on a schedule, with contract validation at the choke point. That is the whole playbook. It is unglamorous work. It is also the difference between a Tuesday afternoon incident call and a migration that finishes before anyone notices.&lt;/p&gt;

</description>
      <category>api</category>
      <category>azure</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Reusable Terraform Modules for API Gateway Provisioning Across Azure and AWS</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Mon, 19 Oct 2026 14:07:00 +0000</pubDate>
      <link>https://dev.to/manojkagitha/reusable-terraform-modules-for-api-gateway-provisioning-across-azure-and-aws-1hm2</link>
      <guid>https://dev.to/manojkagitha/reusable-terraform-modules-for-api-gateway-provisioning-across-azure-and-aws-1hm2</guid>
      <description>&lt;p&gt;Every team starts by copy-pasting Terraform. One folder becomes two. Two become ten. Then someone fixes a bug in one copy and the other nine keep the bug. Modules exist to end that cycle. A module is a packaged, versioned, reusable unit of infrastructure. Done well, it is the difference between a team that ships gateways in an afternoon and a team that hand-builds each one.&lt;/p&gt;

&lt;p&gt;This post builds two real modules: one for Azure API Management, one for AWS API Gateway. Along the way: module anatomy, the common-interface pattern for multi-cloud teams, versioning, composition, testing, and the anti-patterns to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why modules
&lt;/h2&gt;

&lt;p&gt;Three reasons.&lt;/p&gt;

&lt;p&gt;First, DRY. An API gateway has dozens of settings: SKUs, policies, products, subscriptions, backends. Define them once in a module. Every team consumes the same hardened configuration.&lt;/p&gt;

&lt;p&gt;Second, consistency. When security requires a new policy on every gateway, you change it in one place and roll it out by bumping a version pin. Without modules, you are editing ten folders and hoping you caught them all.&lt;/p&gt;

&lt;p&gt;Third, onboarding. A new engineer does not need to learn every APIM setting to ship an API. They call the module with five variables and get a production-ready gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anatomy of a good module
&lt;/h2&gt;

&lt;p&gt;A module is a folder with a contract. The contract has three parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variables with validation.&lt;/strong&gt; Every input declares its type, a description, and where it matters, a validation block. Validation catches bad input at plan time instead of apply time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outputs.&lt;/strong&gt; A module that creates things but tells you nothing about them is a black box. Output the IDs, URLs, and names callers need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;README.&lt;/strong&gt; Document the purpose, inputs, outputs, and one full example. If the README does not show a working example, the module is not done.&lt;/p&gt;

&lt;p&gt;Standard layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;modules/apim/&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;main.tf&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;resources&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;variables.tf&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;inputs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;validation&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;outputs.tf&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;what&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;callers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;get&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;back&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;versions.tf&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;required&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;providers&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;README.md&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;purpose,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;example&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;versions.tf&lt;/code&gt; pins the provider so the module never silently picks up a breaking provider release:&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.5"&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 3.0, &amp;lt; 4.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Azure APIM module
&lt;/h2&gt;

&lt;p&gt;Here is a module that provisions an API Management service and registers a list of APIs on it, each with a rate-limit policy and a product assignment.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;variables.tf&lt;/code&gt;:&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;variable&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Name of the API Management service."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"location"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Azure region for the service."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"resource_group_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Resource group for the APIM service."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"sku_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"APIM SKU."&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Developer_1"&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"Developer_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Basic_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Standard_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Premium_1"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sku_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SKU must be one of Developer_1, Basic_1, Standard_1, Premium_1."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"publisher_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Publisher name shown in the developer portal."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"publisher_email"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Publisher contact email."&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"^[^@]+@[^@]+&lt;/span&gt;&lt;span class="err"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.[^@]+$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publisher_email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Publisher email must be a valid email address."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"apis"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;display_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;protocols&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;backend_url&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"APIs to register on the gateway."&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;main.tf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_api_management"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;publisher_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publisher_name&lt;/span&gt;
  &lt;span class="nx"&gt;publisher_email&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publisher_email&lt;/span&gt;
  &lt;span class="nx"&gt;sku_name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sku_name&lt;/span&gt;

  &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SystemAssigned"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_api_management_api"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apis&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;name&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;api_management_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;revision&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
  &lt;span class="nx"&gt;display_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;display_name&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;path&lt;/span&gt;
  &lt;span class="nx"&gt;protocols&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;protocols&lt;/span&gt;
  &lt;span class="nx"&gt;service_url&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;backend_url&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;"azurerm_api_management_api_policy"&lt;/span&gt; &lt;span class="s2"&gt;"rate_limit"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;

  &lt;span class="nx"&gt;api_name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;name&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;api_management_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;xml_content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;XML&lt;/span&gt;&lt;span class="sh"&gt;
&amp;lt;policies&amp;gt;
  &amp;lt;inbound&amp;gt;
    &amp;lt;rate-limit calls="100" renewal-period="60" /&amp;gt;
    &amp;lt;base /&amp;gt;
  &amp;lt;/inbound&amp;gt;
  &amp;lt;backend&amp;gt;
    &amp;lt;base /&amp;gt;
  &amp;lt;/backend&amp;gt;
  &amp;lt;outbound&amp;gt;
    &amp;lt;base /&amp;gt;
  &amp;lt;/outbound&amp;gt;
  &amp;lt;on-error&amp;gt;
    &amp;lt;base /&amp;gt;
  &amp;lt;/on-error&amp;gt;
&amp;lt;/policies&amp;gt;
&lt;/span&gt;&lt;span class="no"&gt;XML
&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;"azurerm_api_management_product"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;product_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"default-product"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;api_management_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;display_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Default Product"&lt;/span&gt;
  &lt;span class="nx"&gt;subscription_required&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;approval_required&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;published&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;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_api_management_product_api"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;

  &lt;span class="nx"&gt;api_name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;name&lt;/span&gt;
  &lt;span class="nx"&gt;product_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management_product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product_id&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_group_name&lt;/span&gt;
  &lt;span class="nx"&gt;api_management_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;outputs.tf&lt;/code&gt;:&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;"gateway_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The gateway URL of the APIM service."&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;azurerm_api_management&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway_url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"service_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The resource ID of the APIM service."&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;azurerm_api_management&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"api_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Map of API name to resource ID."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;azurerm_api_management_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the pattern: &lt;code&gt;for_each&lt;/code&gt; over a map keyed by name. This keeps resource addresses stable when the list order changes. If you use &lt;code&gt;count&lt;/code&gt; with a list, reordering the list destroys and recreates APIs. With &lt;code&gt;for_each&lt;/code&gt; keyed by name, only real changes trigger updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS API Gateway module
&lt;/h2&gt;

&lt;p&gt;Same idea, AWS REST API flavor. The module takes a list of endpoints and builds resources, methods, proxy integrations, a deployment, and a stage.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;variables.tf&lt;/code&gt;:&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;variable&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Name of the REST API."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"description"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Description of the REST API."&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Managed by Terraform"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"stage_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deployment stage name."&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"^[a-z0-9-]+$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Stage name must be lowercase alphanumeric with dashes."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"endpoints"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;path_part&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;http_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;backend_uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Endpoints to expose through the gateway."&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alltrue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"PATCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"DELETE"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;http_method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP methods must be one of GET, POST, PUT, PATCH, DELETE."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;main.tf&lt;/code&gt;:&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;endpoints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"${e.http_method}-${e.path_part}"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_api_gateway_rest_api"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;

  &lt;span class="nx"&gt;endpoint_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;types&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"REGIONAL"&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_api_gateway_resource"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt;

  &lt;span class="nx"&gt;rest_api_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;parent_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;root_resource_id&lt;/span&gt;
  &lt;span class="nx"&gt;path_part&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;path_part&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_api_gateway_method"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt;

  &lt;span class="nx"&gt;rest_api_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&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;http_method&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;http_method&lt;/span&gt;
  &lt;span class="nx"&gt;authorization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NONE"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_api_gateway_integration"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt;

  &lt;span class="nx"&gt;rest_api_id&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&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;http_method&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;http_method&lt;/span&gt;
  &lt;span class="nx"&gt;integration_http_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP_PROXY"&lt;/span&gt;
  &lt;span class="nx"&gt;uri&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;backend_uri&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_api_gateway_deployment"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rest_api_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;triggers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;redeploy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;aws_api_gateway_method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;aws_api_gateway_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;create_before_destroy&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;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_api_gateway_stage"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;deployment_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;rest_api_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;stage_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting. First, the &lt;code&gt;triggers&lt;/code&gt; block with a SHA hash: API Gateway deployments are immutable snapshots. Without the trigger, changing a method does not create a new deployment and your change never goes live. The hash forces redeployment whenever the API configuration changes. This is the single most common gotcha with Terraform-managed API Gateway.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;create_before_destroy&lt;/code&gt; on the deployment. Without it, Terraform destroys the old deployment before creating the new one, and the stage briefly points at nothing. With it, the new deployment exists before the old one is removed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;outputs.tf&lt;/code&gt;:&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;"invoke_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The invoke URL of the deployed stage."&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;aws_api_gateway_stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoke_url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"rest_api_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The ID of the REST API."&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;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"execution_arn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The execution ARN for IAM permissions."&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;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execution_arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The common-interface pattern
&lt;/h2&gt;

&lt;p&gt;Here is the move that pays off for multi-cloud teams: give both modules the same variable names for the same concepts.&lt;/p&gt;

&lt;p&gt;Both modules accept &lt;code&gt;name&lt;/code&gt;. Both accept a list of backend definitions shaped as objects with a path, a method, and a backend URL. Callers learn one interface. When a team moves a workload from Azure to AWS, the module call changes but the shape of the inputs does not.&lt;/p&gt;

&lt;p&gt;This does not mean the modules are identical inside. Cloud services differ, and each module should use its platform well. APIM gets products and XML policies. API Gateway gets deployments and stages. The interface is shared. The implementation is native.&lt;/p&gt;

&lt;p&gt;Where the clouds genuinely diverge, let the variables diverge too. Do not force a fake common variable that maps to nothing on one side. Common where it is real, specific where it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Versioned module sources
&lt;/h2&gt;

&lt;p&gt;Never consume a module from a moving target. Pin to a Git tag:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"apim"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"git::https://github.com/myorg/terraform-modules.git//modules/apim?ref=v1.2.0"&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apim-prod"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;publisher_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Platform Team"&lt;/span&gt;
  &lt;span class="nx"&gt;publisher_email&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform@example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;apis&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apis&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;?ref=v1.2.0&lt;/code&gt; is the whole game. Without it, every &lt;code&gt;terraform init&lt;/code&gt; can pull different code. With it, upgrades are deliberate: bump the ref, run plan, review the diff, apply.&lt;/p&gt;

&lt;p&gt;Tag releases with semantic versioning. Breaking input changes bump the major version. New optional variables bump the minor. Fixes bump the patch. Write a short changelog per release so consumers know what changed before they bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  Private registry option
&lt;/h2&gt;

&lt;p&gt;If Git URLs get unwieldy across dozens of call sites, Terraform Cloud and Terraform Enterprise offer a private registry. Modules get versioned releases with a changelog UI, and the call site becomes:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"apim"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app.terraform.io/myorg/apim/azurerm"&lt;/span&gt;
  &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.2.0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleaner to read, easier to browse. It costs money and ties you to the platform. For most teams, Git tags are enough until the module count gets large. Start with tags. Move to a registry when discovery becomes the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composition pattern
&lt;/h2&gt;

&lt;p&gt;Modules compose. A root module for an environment calls child modules and wires outputs to inputs:&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;module&lt;/span&gt; &lt;span class="s2"&gt;"network"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"git::https://github.com/myorg/terraform-modules.git//modules/network?ref=v2.0.1"&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vnet-prod"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;address_space&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"apim"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"git::https://github.com/myorg/terraform-modules.git//modules/apim?ref=v1.2.0"&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"apim-prod"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;publisher_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Platform Team"&lt;/span&gt;
  &lt;span class="nx"&gt;publisher_email&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform@example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;apis&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apis&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"dns"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"git::https://github.com/myorg/terraform-modules.git//modules/dns?ref=v1.0.3"&lt;/span&gt;

  &lt;span class="nx"&gt;zone_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api.example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;gateway_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway_url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each child owns one concern. The root owns the wiring. Nobody edits child module internals from the root, and no child reaches into another child's state. Data flows through outputs, in one direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version pinning discipline
&lt;/h2&gt;

&lt;p&gt;Pin everything. Module sources get &lt;code&gt;?ref=&lt;/code&gt;. Providers get version constraints in &lt;code&gt;versions.tf&lt;/code&gt;. Terraform itself gets &lt;code&gt;required_version&lt;/code&gt; in the root.&lt;/p&gt;

&lt;p&gt;Then automate the upgrades. Renovate or Dependabot can open pull requests when new module tags land. Each PR shows a plan diff against the new version. You review the diff, merge, and apply. Upgrades become routine instead of scary, and you never discover six months later that half your environments run a different module version than the other half.&lt;/p&gt;

&lt;p&gt;One rule for the team: no PR merges with an unpinned module source. Enforce it in CI with a simple grep if you have to. The discipline matters more than the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Module testing basics
&lt;/h2&gt;

&lt;p&gt;Untested modules rot. Someone changes a default, nothing validates it, and three teams discover the breakage on their next apply. Three levels of testing, cheapest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;terraform validate&lt;/code&gt; and &lt;code&gt;tflint&lt;/code&gt; in CI on every pull request. Catches syntax errors, deprecated syntax, and style issues. Fast and free.&lt;/li&gt;
&lt;li&gt;Example configurations in &lt;code&gt;examples/&lt;/code&gt; folders that get planned in CI. If the example does not plan cleanly, the module is broken. This catches the most common real-world breakage: a variable change that no longer matches how callers use the module.&lt;/li&gt;
&lt;li&gt;Terratest (Go) for modules that must actually apply. It provisions real infrastructure, asserts on outputs, then destroys. Slower and costs cloud spend, so reserve it for widely used modules where a bad release hurts many teams.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At minimum, do levels 1 and 2. They catch most breakage for almost no cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-patterns
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mega-modules.&lt;/strong&gt; One module that provisions networking, gateways, databases, and DNS is not reusable. It is a snowflake with inputs. Split by concern. A module should do one thing well enough that a team can adopt it without adopting everything around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unpinned versions.&lt;/strong&gt; A source with no &lt;code&gt;?ref=&lt;/code&gt; is a time bomb. Today it works. Tomorrow someone pushes a breaking change and your next init breaks every environment at once. Pin always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provider config in child modules.&lt;/strong&gt; Child modules must never declare &lt;code&gt;provider&lt;/code&gt; blocks with credentials or aliases they invent. Providers are configured in the root and passed down. A child that configures its own provider cannot be reused across accounts, regions, or environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boolean sprawl.&lt;/strong&gt; A module with fifteen boolean flags (&lt;code&gt;enable_x&lt;/code&gt;, &lt;code&gt;create_y&lt;/code&gt;, &lt;code&gt;skip_z&lt;/code&gt;) is twelve modules wearing a trench coat. If the flags cluster into real use cases, split the module along those lines. A few well-chosen flags are fine. Fifteen is a design smell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outputs nobody uses.&lt;/strong&gt; Every output is a promise to maintain. If no caller reads it, delete it. Review outputs when you review the module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] One concern per module. No mega-modules.&lt;/li&gt;
&lt;li&gt;[ ] Every variable has a type, a description, and validation where it matters.&lt;/li&gt;
&lt;li&gt;[ ] Outputs expose what callers need: IDs, URLs, names. Nothing more.&lt;/li&gt;
&lt;li&gt;[ ] README with a working example that actually plans.&lt;/li&gt;
&lt;li&gt;[ ] All module sources pinned with &lt;code&gt;?ref=&lt;/code&gt; tags. No exceptions.&lt;/li&gt;
&lt;li&gt;[ ] Providers declared in &lt;code&gt;versions.tf&lt;/code&gt;, configured only in the root module.&lt;/li&gt;
&lt;li&gt;[ ] Semantic versioning on releases. Breaking changes bump major.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;tflint&lt;/code&gt;, and example plans running in CI.&lt;/li&gt;
&lt;li&gt;[ ] Common variable names across cloud-specific modules where the concepts genuinely match.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Modules give you reusable building blocks with versioned releases. But who runs them, and when? If engineers still apply from laptops, you have versioned modules and unversioned process.&lt;/p&gt;

&lt;p&gt;Next article: Terraform in CI/CD with GitHub Actions. Plan/apply gates, OIDC auth with no stored secrets, plan output posted as PR comments for review, and multi-environment promotion from dev to prod.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>aws</category>
      <category>api</category>
    </item>
    <item>
      <title>Terraform Remote State at Enterprise Scale: Backends, Locking, Bootstrapping, and Team Workflows</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Wed, 14 Oct 2026 04:59:01 +0000</pubDate>
      <link>https://dev.to/manojkagitha/terraform-remote-state-at-enterprise-scale-backends-locking-bootstrapping-and-team-workflows-m6e</link>
      <guid>https://dev.to/manojkagitha/terraform-remote-state-at-enterprise-scale-backends-locking-bootstrapping-and-team-workflows-m6e</guid>
      <description>&lt;p&gt;When you are the only one running Terraform, local state is fine. The moment a second engineer runs apply, you are one race condition away from a broken environment. And when you are running dozens of environments across teams, the questions get harder: who creates the state bucket, how do you split state so one bad apply cannot take down everything, and how do you give a team of twenty engineers safe access without handing everyone the keys to production?&lt;/p&gt;

&lt;p&gt;This post covers the full picture: choosing a backend, the bootstrapping problem nobody warns you about, state layout strategy, locking, surgery when things go wrong, and the team workflow that holds it together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why local state breaks
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;terraform.tfstate&lt;/code&gt; on your laptop is a single point of failure. It cannot be shared. It cannot be locked. If your laptop dies, your state dies with it. Worse, if two people apply at the same time, the last write wins and the loser’s changes vanish from state while still existing in the cloud. That gap between state and reality is called drift, and it is painful to fix.&lt;/p&gt;

&lt;p&gt;Remote state solves three problems: shared access, locking, and durability. But setting it up raises a question most tutorials skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bootstrapping problem
&lt;/h2&gt;

&lt;p&gt;Here is the chicken-and-egg problem: Terraform needs a backend to store state, but the backend itself (an S3 bucket, a DynamoDB table, a storage account) is infrastructure. Who creates it?&lt;/p&gt;

&lt;p&gt;You have three options, and only one of them is right for teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: ClickOps.&lt;/strong&gt; Someone creates the bucket by hand in the console. This works exactly once. Then nobody remembers the settings, the bucket has no versioning, no encryption, no lifecycle policy, and six months later someone asks why state history only goes back two weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: A separate bootstrap Terraform project with local state.&lt;/strong&gt; This is the standard answer. You keep one tiny Terraform project whose only job is to create the backend infrastructure. Its state stays local (or in a second-level backend), because it has to exist before everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 3: Terraform managing its own backend.&lt;/strong&gt; Do not do this. A Terraform project cannot reliably create the bucket that holds its own state. The init sequence makes this circular and fragile.&lt;/p&gt;

&lt;p&gt;Here is what a bootstrap project looks like on AWS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# bootstrap/main.tf - run once per account, state stays local&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.5"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&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_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"tfstate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"myorg-terraform-state-prod"&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_s3_bucket_versioning"&lt;/span&gt; &lt;span class="s2"&gt;"tfstate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tfstate&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;versioning_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Enabled"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_server_side_encryption_configuration"&lt;/span&gt; &lt;span class="s2"&gt;"tfstate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tfstate&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;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;apply_server_side_encryption_by_default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;sse_algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws:kms"&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_public_access_block"&lt;/span&gt; &lt;span class="s2"&gt;"tfstate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tfstate&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;block_public_acls&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;block_public_policy&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_public_acls&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;restrict_public_buckets&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;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_dynamodb_table"&lt;/span&gt; &lt;span class="s2"&gt;"tflocks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-locks"&lt;/span&gt;
  &lt;span class="nx"&gt;billing_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PAY_PER_REQUEST"&lt;/span&gt;
  &lt;span class="nx"&gt;hash_key&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LockID"&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LockID"&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S"&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;Versioning matters more than people think. If a state file gets corrupted, versioning lets you roll back to the last good version. Without it, you are reconstructing state from &lt;code&gt;terraform import&lt;/code&gt;, resource by resource, which is exactly as fun as it sounds.&lt;/p&gt;

&lt;p&gt;Run this once per account. Commit it to git. Then every other project points at the backend it created.&lt;/p&gt;

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

&lt;p&gt;Every major cloud has a native backend. The pattern is the same everywhere: an object store for the state file plus a locking mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS (S3 + DynamoDB):&lt;/strong&gt;&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"myorg-terraform-state-prod"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"network/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-locks"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Azure (Blob Storage):&lt;/strong&gt;&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tfstate-rg"&lt;/span&gt;
    &lt;span class="nx"&gt;storage_account_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tfstatestore"&lt;/span&gt;
    &lt;span class="nx"&gt;container_name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"network.terraform.tfstate"&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;Azure handles locking natively through blob leases. No separate lock table to manage. One less moving part, and one less thing to misconfigure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GCP (GCS):&lt;/strong&gt;&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"gcs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"myorg-terraform-state"&lt;/span&gt;
    &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"network"&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;GCS locking is native as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  State layout: one file per blast radius
&lt;/h2&gt;

&lt;p&gt;Do not put your entire infrastructure in one state file. Split by environment and by component. A layout that works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prod/
  network/terraform.tfstate
  compute/terraform.tfstate
  data/terraform.tfstate
  dns/terraform.tfstate
staging/
  network/terraform.tfstate
  compute/terraform.tfstate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three reasons for this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius.&lt;/strong&gt; If the network state gets corrupted, compute and data are untouched. A bad apply can only destroy what is in its state file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed.&lt;/strong&gt; A plan against a 5,000-resource state file is slow. A plan against 200 resources is fast. Engineers stop running plan when it takes ten minutes. Then they apply blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access control.&lt;/strong&gt; Not everyone needs access to every state file. The data team does not need write access to network state. Smaller state files map cleanly to IAM boundaries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tradeoff is cross-component references. When compute needs the VPC ID from network state, use &lt;code&gt;terraform_remote_state&lt;/code&gt; data sources or, better, pass values through a shared outputs pattern. It adds a little friction. The blast radius protection is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  State locking in practice
&lt;/h2&gt;

&lt;p&gt;Locking is automatic once the backend supports it. When you run apply, Terraform acquires the lock. Anyone else who tries gets an error instead of corrupting your state.&lt;/p&gt;

&lt;p&gt;Two rules I follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Never use &lt;code&gt;-lock=false&lt;/code&gt; in a shared workflow. It exists for emergencies, like a stuck lock after a crashed apply. If you use it, tell the team first.&lt;/li&gt;
&lt;li&gt;If a lock gets stuck, &lt;code&gt;terraform force-unlock &amp;lt;lock-id&amp;gt;&lt;/code&gt; clears it. Check with the team before you do. Someone might actually be mid-apply, and force-unlocking under them recreates the exact race condition locking was built to prevent.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  State surgery: when you need to move resources
&lt;/h2&gt;

&lt;p&gt;Sometimes the layout needs to change. You split a monolith state into components, or you move a resource from one team’s state to another’s. Terraform has tools for this, and they all operate on state without touching real infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move a resource between modules or addresses:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform state &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s1"&gt;'aws_instance.web'&lt;/span&gt; &lt;span class="s1"&gt;'module.compute.aws_instance.web'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rewrites the state file. The EC2 instance keeps running. Nothing is destroyed. But get the address wrong and Terraform will plan to destroy the old address and create the new one. Always run &lt;code&gt;terraform plan&lt;/code&gt; immediately after a state mv. If the plan shows creates or destroys, you got the address wrong. Fix it before anyone applies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull state down, fix it, push it back:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform state pull &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/state.json
&lt;span class="c"&gt;# edit carefully&lt;/span&gt;
terraform state push /tmp/state.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the escape hatch for corrupted state. It is also the fastest way to make things worse if you edit the JSON by hand without understanding the schema. Use it as a last resort, and keep a backup of the original pull.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remove something from state without destroying it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform state &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s1"&gt;'aws_s3_bucket.legacy'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bucket keeps existing in AWS. Terraform just stops managing it. Useful when decommissioning Terraform management of a resource, or when handing a resource to another team’s state file (rm here, import there).&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating from local to remote state
&lt;/h2&gt;

&lt;p&gt;If you already have local state and need to move it, the process is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the backend block to your configuration.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;terraform init&lt;/code&gt;. Terraform detects the migration and asks: "Do you want to copy existing state to the new backend?" Say yes.&lt;/li&gt;
&lt;li&gt;Verify with &lt;code&gt;terraform plan&lt;/code&gt;. It should show no changes.&lt;/li&gt;
&lt;li&gt;Delete the local &lt;code&gt;terraform.tfstate&lt;/code&gt; and the backup. Commit the backend config.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The init migration is one of the few genuinely smooth operations in Terraform. It just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep secrets out of hardcoded config
&lt;/h2&gt;

&lt;p&gt;Do not hardcode bucket names or credentials in your backend block. Use partial backend configuration and pass values at init time:&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bucket=myorg-terraform-state-prod"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"key=network/terraform.tfstate"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=us-east-1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"dynamodb_table=terraform-state-locks"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps environment-specific values out of version control and lets the same code work across dev, staging, and prod. In CI/CD, these values come from pipeline variables, not from the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  State files contain secrets
&lt;/h2&gt;

&lt;p&gt;This one catches people. Terraform state stores every attribute of every resource, including passwords, keys, and connection strings, in plain text. Treat your state bucket like a secrets store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable encryption at rest (the &lt;code&gt;encrypt = true&lt;/code&gt; above, or the KMS config in the bootstrap).&lt;/li&gt;
&lt;li&gt;Restrict IAM access to the state bucket to the CI/CD role and break-glass admins only. Engineers get read access for planning, not write.&lt;/li&gt;
&lt;li&gt;Never commit a state file to git. Add &lt;code&gt;*.tfstate&lt;/code&gt; and &lt;code&gt;*.tfstate.backup&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt; on day one.&lt;/li&gt;
&lt;li&gt;Enable bucket versioning so a corrupted state can be rolled back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are already using Vault or a secrets manager for application secrets, that does not cover Terraform state. State is a separate secrets surface. Treat it as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The team workflow
&lt;/h2&gt;

&lt;p&gt;The setup that works for most teams:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Engineers run &lt;code&gt;terraform plan&lt;/code&gt; locally against the remote state. Plans are safe. They change nothing. Read access to state is enough.&lt;/li&gt;
&lt;li&gt;Pull requests include the plan output. Reviewers check what will change before it changes.&lt;/li&gt;
&lt;li&gt;Only CI/CD runs &lt;code&gt;terraform apply&lt;/code&gt;, after the plan is approved. The pipeline uses a dedicated IAM role with write access to state.&lt;/li&gt;
&lt;li&gt;Applies run serially per state file. The lock enforces this mechanically, but the pipeline should also avoid queuing parallel applies to the same state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Local plan, reviewed apply, locked state. That is the whole discipline. It scales from two engineers to two hundred without changing shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-flight checklist
&lt;/h2&gt;

&lt;p&gt;Before you call your remote state setup done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Backend infrastructure created via a bootstrap project, not by hand&lt;/li&gt;
&lt;li&gt;[ ] Versioning enabled on the state bucket&lt;/li&gt;
&lt;li&gt;[ ] Encryption at rest enabled&lt;/li&gt;
&lt;li&gt;[ ] Public access blocked on the state bucket&lt;/li&gt;
&lt;li&gt;[ ] Locking configured and tested (run two applies, confirm the second waits or fails cleanly)&lt;/li&gt;
&lt;li&gt;[ ] State split by environment and component, not one giant file&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;.gitignore&lt;/code&gt; covers &lt;code&gt;*.tfstate&lt;/code&gt; and &lt;code&gt;*.tfstate.backup&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] IAM: engineers read, CI/CD writes, break-glass admins only&lt;/li&gt;
&lt;li&gt;[ ] Backend values passed via &lt;code&gt;-backend-config&lt;/code&gt;, not hardcoded&lt;/li&gt;
&lt;li&gt;[ ] Team knows the &lt;code&gt;force-unlock&lt;/code&gt; procedure and when not to use it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Remote state is not exciting work. Nobody demos a backend block at a conference. But every production Terraform setup stands on it, and every Terraform horror story I have heard starts with someone skipping one of the items on that checklist.&lt;/p&gt;

&lt;p&gt;Get the backend right, split your state by blast radius, lock everything, and keep secrets out of git. The rest of your Terraform practice gets easier from there.&lt;/p&gt;

&lt;p&gt;Next week: reusable Terraform modules for API gateway provisioning across Azure and AWS, with versioned module registries and composition patterns that survive team growth.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>azure</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Gave My AI Agents an Org Chart</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Thu, 08 Oct 2026 21:31:44 +0000</pubDate>
      <link>https://dev.to/manojkagitha/i-gave-my-ai-agents-an-org-chart-3f0h</link>
      <guid>https://dev.to/manojkagitha/i-gave-my-ai-agents-an-org-chart-3f0h</guid>
      <description>&lt;h1&gt;
  
  
  I Gave My AI Agents an Org Chart
&lt;/h1&gt;

&lt;p&gt;Most agent frameworks hand you a pile of scripts and wish you luck. You wire up three agents, write the orchestration glue yourself, and six weeks later you have a system nobody can explain. I kept running into this, so I built something with a different shape: a company.&lt;/p&gt;

&lt;p&gt;It is called Botropolis. Twenty specialist agents across ten departments, one CEO orchestrator, and a training pipeline so each agent can eventually get its own fine-tuned model. It is open source, MIT licensed, and it runs with no API keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Companies figured out coordination centuries ago. When work gets complex, you do not throw twenty generalists into a chat room. You create departments, give people job titles, and let a manager break big requests into pieces. Botropolis is that idea in Python.&lt;/p&gt;

&lt;p&gt;You ask Manoj, the CEO, for something. He looks at the request, decides which departments need to be involved, and routes the work. Research goes to Scout, code goes to Coder, the numbers go to Analyst. Each agent has a YAML spec: name, title, specialty, tools it may call, example tasks. Anyone reading the repo can see who does what, the way an org chart tells you who owns what.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;p&gt;The roster covers ten departments: research, health, finance, code, data, legal, marketing, ops, security, and support. Some highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Teaming.&lt;/strong&gt; One agent alone is fine, a team is better. You name the agents and the rounds, and they draft, critique, and revise in sequence. Each agent sees everything its teammates produced before it. Manoj writes the final synthesis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real tools, no vendor lock-in.&lt;/strong&gt; Agents with toolkits run a think-act-observe loop over a plain-text protocol. No provider function calling involved, so it works with OpenAI, Anthropic, Gemini, Ollama, or a model you trained yourself. Fourteen tools ship in the box: web search, shell, file tools, Gmail, calendar, a shared company notebook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A web UI with no build step.&lt;/strong&gt; Plain HTML, CSS, and JS served by the same FastAPI process. Chat with the CEO, a war room for talking to one agent directly with live tool-call streaming, teaming controls, per-agent analytics, and a roster browser. It works on a phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training pipeline.&lt;/strong&gt; Datasets, configs, an eval harness, and model cards are all in the repo. I trained Scout-tiny from scratch as a proof of concept: a 5.49M-parameter transformer on 300 Q&amp;amp;A pairs, validation perplexity 5.17. Demo scale, honestly labeled as such. The real path is LoRA on a small open model, and the scaffolding for that is documented.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest parts
&lt;/h2&gt;

&lt;p&gt;It runs with zero API keys against an offline stub that labels its output as stub output. Clone it, run &lt;code&gt;python examples/demo.py&lt;/code&gt;, and watch the whole company work before spending anything.&lt;/p&gt;

&lt;p&gt;The safety model is pragmatic, not paranoid. File and shell tools are sandboxed to a workspace directory with timeouts and a denylist for destructive patterns. Agents draft emails; humans send them. Health and legal agents carry explicit disclaimers in their prompts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kagithamanoj/botropolis.git
&lt;span class="nb"&gt;cd &lt;/span&gt;botropolis
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[dev]"&lt;/span&gt;
python examples/demo.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or skip the clone and talk to the CEO directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botropolis.core.orchestrator&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CEO&lt;/span&gt;

&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CEO&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Research vector databases and compare pricing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The org chart grows by pull request. If you have ever wanted an agent with a job title, come add a department.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Botropolis is built and maintained by Manoj Kumar Kagitha, a Senior Cloud Engineer working on production cloud infrastructure. Nine papers, seven books, and now one company of bots.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>We Cut API Tenant Onboarding From Two Days to Three Hours With a Self-Service Portal</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:58:48 +0000</pubDate>
      <link>https://dev.to/manojkagitha/we-cut-api-tenant-onboarding-from-two-days-to-three-hours-with-a-self-service-portal-54bi</link>
      <guid>https://dev.to/manojkagitha/we-cut-api-tenant-onboarding-from-two-days-to-three-hours-with-a-self-service-portal-54bi</guid>
      <description>&lt;p&gt;Before the portal existed, onboarding a new team to our API ecosystem looked like this: a service request, a ticket queue, two or three handoffs between teams, a manual runbook that nobody kept updated, and about two business days of waiting. Not because anyone was slow. Because every step needed a human.&lt;/p&gt;

&lt;p&gt;We run a multi-tenant API platform on Azure, with Azure APIM as the front door, Key Vault for secrets, and Terraform underneath. The traffic scale forced us to treat onboarding like a product, not a favor. Here is what we built and what broke along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What onboarding actually costs without self-service
&lt;/h2&gt;

&lt;p&gt;The hidden cost was never the two days. It was the shape of those two days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A developer submits a request form that half the time misses a field, which restarts the clock.&lt;/li&gt;
&lt;li&gt;Someone in my team creates the API subscription, the product assignment in APIM, and the service principal in Azure AD, all by hand.&lt;/li&gt;
&lt;li&gt;Credentials get emailed or dropped in a chat, which is a security conversation I no longer want to have.&lt;/li&gt;
&lt;li&gt;When something breaks at 2 AM, nobody remembers which team owns which subscription.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every manual step is a queue. Queues are where latency lives. This was true of our request pipeline exactly the same way it is true of API traffic, which is a fun mirror I keep noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The portal, in concrete terms
&lt;/h2&gt;

&lt;p&gt;We built a self-service onboarding portal backed by a Node.js API. The frontend gives requesting teams a single form. The backend does the actual work through automation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validates the request (tenant id, contact, required scopes, environment).&lt;/li&gt;
&lt;li&gt;Creates the subscription and product assignment in APIM through the management API.&lt;/li&gt;
&lt;li&gt;Provisions credentials into Key Vault, never shown in plaintext after creation.&lt;/li&gt;
&lt;li&gt;Wires the Terraform modules that own the tenant's networking and access, so nothing is a snowflake.&lt;/li&gt;
&lt;li&gt;Registers the tenant in our traffic analytics, so day-one dashboards include max RPS and P99 latency per endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important design decision: the portal does not bypass our existing automation. It calls it. Terraform remains the source of truth for infrastructure. The portal is an interface over the same modules we already run in Azure DevOps pipelines. That meant onboarding a tenant and running the deployment pipeline used the same code paths, which removed an entire class of drift between what the portal did and what IaC expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we got wrong first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;We automated the happy path and forgot the exceptions.&lt;/strong&gt; The first version handled the standard request perfectly and failed loudly on anything unusual, like a team that needed a nonstandard scope or an existing tenant adding a second region. Those requests fell back to tickets anyway, and for a month our metrics looked great while real users quietly went around the portal. Fix: every rejection path in the portal now produces a specific, actionable message, and edge cases route to a human with full context attached instead of a blank ticket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We treated secrets as a delivery problem.&lt;/strong&gt; Early on the portal returned API keys in the response payload. It worked and it was wrong. Credentials now go straight into Key Vault with an RBAC assignment for the requesting team, and the portal only ever confirms that provisioning succeeded. If a team loses a key, rotation is one click and the old one is revoked automatically. This is the same rotation discipline I wrote about for certificates: never let a secret's lifecycle depend on a human remembering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We skipped the audit trail.&lt;/strong&gt; When an incident involved a tenant's credentials, we could not answer "who created this, when, and with what scopes" without digging. Every provisioning action now writes a structured log entry with requester, approver (when required), timestamp, and resulting scope. Boring. Essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multi-tenant part is where it gets interesting
&lt;/h2&gt;

&lt;p&gt;Single-tenant onboarding is a form. Multi-tenant onboarding is policy. The questions that took real design work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolation boundaries.&lt;/strong&gt; Tenants share APIM products but get separate subscriptions, separate Key Vault secrets, and scoped RBAC. Blast radius is a first-class design input, not an afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits per tenant.&lt;/strong&gt; Global rate limits protect the platform; per-tenant limits protect tenants from each other. We set both, and the portal assigns them at onboarding time based on the tier the team requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment parity.&lt;/strong&gt; Dev, staging, and production onboarding go through the same portal flow. The Terraform workspace differs, not the process. A team that can self-serve in dev already knows how prod works.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changed, measured honestly
&lt;/h2&gt;

&lt;p&gt;Onboarding went from roughly two business days to about three hours, measured from form submission to working credentials. The three hours is mostly pipeline runtime and approvals, not queueing. The number I actually care about is support tickets: onboarding-related tickets dropped to near zero because the failure modes are now either automated or self-explanatory.&lt;/p&gt;

&lt;p&gt;The portal also changed my team's job. We stopped being the onboarding bottleneck and started being the platform owners who review policy changes and handle exceptions. That is a much better use of a cloud engineering team.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are about to build one
&lt;/h2&gt;

&lt;p&gt;A few things I would tell myself at the start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build the portal on top of your existing automation, not beside it. If the portal and your IaC pipelines can disagree, they will.&lt;/li&gt;
&lt;li&gt;Ship the audit trail in version one. You will need it before you think you will.&lt;/li&gt;
&lt;li&gt;Automate rejection paths, not just success paths. The quality of an onboarding system is measured by what happens to the request that does not fit.&lt;/li&gt;
&lt;li&gt;Never return a secret in an API response. Not once, not for convenience.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Self-service is not about removing humans. It is about moving humans from the critical path to the exception path. That is where judgment belongs.&lt;/p&gt;

</description>
      <category>api</category>
      <category>azure</category>
      <category>devops</category>
    </item>
    <item>
      <title>What Max RPS and P99 Latency Actually Tell You About Your API</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Mon, 05 Oct 2026 14:41:05 +0000</pubDate>
      <link>https://dev.to/manojkagitha/what-max-rps-and-p99-latency-actually-tell-you-about-your-api-10n3</link>
      <guid>https://dev.to/manojkagitha/what-max-rps-and-p99-latency-actually-tell-you-about-your-api-10n3</guid>
      <description>&lt;p&gt;Our global API serves requests across several regions and teams. A few years ago we started treating traffic analytics as a first class part of running it, not something we look at only after an incident. The two numbers that ended up mattering most were max requests per second and P99 latency. Here is what we actually do with them, and what they taught us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these two numbers
&lt;/h2&gt;

&lt;p&gt;Average latency lies. If one in ten requests is slow, the average still looks fine and users complain anyway. P99 tells you how the slowest one percent of your traffic behaves. When P99 is healthy, almost everyone is having a good experience. When it is not, you have a problem that affects a real slice of your users.&lt;/p&gt;

&lt;p&gt;Max RPS tells you how close you are to the ceiling. Every backend, every gateway route, every downstream service has a limit. Knowing the peak rate you actually handled in the last week is more useful than the capacity number someone wrote on a slide. When the peak keeps climbing toward the limit, you plan. When it spikes past it, you scramble.&lt;/p&gt;

&lt;p&gt;We track both per endpoint and per consumer where we can. A global number hides the fact that one tenant's burst is what keeps tripping your alarms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the data comes from
&lt;/h2&gt;

&lt;p&gt;Our APIs run behind gateways, Apigee X and Azure API Management, and both emit rich telemetry. Every request produces timing and status data. We aggregate it into dashboards that slice by endpoint, consumer, region, and time window. Nothing exotic. The work is in keeping the dashboards honest and the alerts tuned.&lt;/p&gt;

&lt;p&gt;One thing that surprised me early on: gateway timing and backend timing are different numbers, and the difference is where the interesting bugs live. If the gateway reports a request took 800ms but the backend says it took 300ms, the other 500ms went somewhere in between. That gap is policies, retries, connection setup, or network path. We watch the gap per route now. It catches misconfigurations that no amount of backend monitoring would find.&lt;/p&gt;

&lt;h2&gt;
  
  
  What P99 taught us about retries
&lt;/h2&gt;

&lt;p&gt;Our first P99 problem turned out to be our own retries. A downstream service was occasionally slow, so clients retried. The retries doubled the load on exactly the requests that were already struggling. The average looked okay. P99 climbed steadily for weeks before anyone noticed.&lt;/p&gt;

&lt;p&gt;The fix was boring and effective. We added backoff with jitter to the retry policy at the gateway, and we set a hard timeout so a slow request fails fast instead of dragging on. P99 dropped immediately. The lesson: retries are the easiest way to turn a small latency problem into a large one, and P99 is where you see it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What max RPS taught us about capacity
&lt;/h2&gt;

&lt;p&gt;We once had a batch job from a partner team that ran every night and hammered one endpoint at many times the normal rate. It was within their quota, so no alarms fired. But it was eating the headroom we needed for real traffic spikes during the day.&lt;/p&gt;

&lt;p&gt;Max RPS made this visible. The nightly peak was clearly out of proportion to daytime traffic. We moved the job to a dedicated rate limit tier with its own quota, and daytime headroom went back to normal. Quotas protect you, but they do not tell you that one consumer is distorting your capacity picture. The peak numbers do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerting on these numbers
&lt;/h2&gt;

&lt;p&gt;We set alerts on sustained P99 degradation per endpoint, not on single spikes. A single slow request is noise. Thirty minutes of elevated P99 is a signal. We also alert on max RPS approaching a configurable fraction of known capacity, which gives us a heads up before the limit is real.&lt;/p&gt;

&lt;p&gt;The alerts only work because we keep thresholds per endpoint. A login endpoint and a heavy reporting endpoint have completely different latency profiles. One global threshold is either always firing or never firing. Per-endpoint thresholds take a week to tune and then they stay quiet until they matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;I would have started tracking the gateway-to-backend gap sooner. It is the single most diagnostic number we have now, and we found it late. I would also have labeled traffic by purpose earlier: interactive user traffic versus batch jobs versus health checks. Mixed together, batch traffic makes your P99 and RPS numbers tell a story that is not really about your users.&lt;/p&gt;

&lt;p&gt;The unglamorous truth is that traffic analytics is mostly plumbing and patience. You collect the numbers, you tune the alerts, you look at the dashboards when they page you. The payoff is that incidents stop being mysteries. When something is slow, you already know which endpoint, which consumer, and which part of the path. That is worth more than any single fix we ever shipped.&lt;/p&gt;

</description>
      <category>api</category>
      <category>azure</category>
      <category>devops</category>
      <category>observability</category>
    </item>
    <item>
      <title>I built a company of bots</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Wed, 30 Sep 2026 23:49:17 +0000</pubDate>
      <link>https://dev.to/manojkagitha/i-built-a-company-of-bots-fea</link>
      <guid>https://dev.to/manojkagitha/i-built-a-company-of-bots-fea</guid>
      <description>&lt;p&gt;Most agent demos are one clever prompt. I wanted to see what happens when you organize agents the way a real company is organized: departments, specialists, and someone at the top deciding who does what.&lt;/p&gt;

&lt;p&gt;That is Botropolis: 20 specialist agents across 10 departments. Research has Scout. Code has Coder and Reviewer. Finance, legal, health, data, marketing, ops, security, support all have their people. A CEO agent reads your request, routes it to the right departments, runs them, and compiles one report.&lt;/p&gt;

&lt;p&gt;Each agent is a YAML spec: name, title, specialty, model, tools, system prompt, example tasks. The registry loads them, the CEO orchestrates, and a FastAPI server exposes it all. There is a browser UI with three views: chat with the CEO, a war room for talking to any single agent directly, and analytics showing per-agent calls, latency, and token usage.&lt;/p&gt;

&lt;p&gt;The part I am proudest of is the training pipeline. I trained botropolis-scout-tiny, a 5.5M-parameter transformer, from scratch on CPU on 300 synthetic Q&amp;amp;A pairs. It is a demo-scale model and honest about it: it learned the shape of research answers, not reliable facts. The pipeline is the point. On top of that I built a 2,940-example curated dataset and a Colab script that fine-tunes SmolLM2-135M with LoRA on a free GPU. That is the path to a real model.&lt;/p&gt;

&lt;p&gt;What I learned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Routing is the whole game. Twenty agents are useless if the wrong ones get the work.&lt;/li&gt;
&lt;li&gt;YAML specs make agents reviewable. You can read the whole company in an afternoon.&lt;/li&gt;
&lt;li&gt;Usage analytics change how you build. Once you see per-agent latency and tokens, you start routing like a manager watching a budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roadmap: streaming responses, tool execution wired into the model clients, and fine-tunes for more agents.&lt;/p&gt;

&lt;p&gt;Repo (MIT): &lt;a href="https://github.com/kagithamanoj/botropolis" rel="noopener noreferrer"&gt;https://github.com/kagithamanoj/botropolis&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Certificates Expire on the Worst Possible Day</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Wed, 30 Sep 2026 14:17:24 +0000</pubDate>
      <link>https://dev.to/manojkagitha/certificates-expire-on-the-worst-possible-day-34fa</link>
      <guid>https://dev.to/manojkagitha/certificates-expire-on-the-worst-possible-day-34fa</guid>
      <description>&lt;p&gt;Every team I have worked with has a certificate story. The classic version goes like this. A certificate expires on a holiday weekend. The renewal was assigned to someone who left the team months earlier. Production starts serving TLS errors, and the fix takes eleven minutes while the explanation takes three weeks.&lt;/p&gt;

&lt;p&gt;Certificates are the most boring part of infrastructure. They are also the part that fails most loudly. A database can degrade slowly. A certificate either works or it does not. One day the handshake succeeds. The next day every client refuses to connect. There is no graceful mode.&lt;/p&gt;

&lt;p&gt;This is a walkthrough of how I think about certificate lifecycle management in a large Azure environment: discovery, automation, alerting, and rotation. Nothing here requires exotic tooling. It requires discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find every certificate first
&lt;/h2&gt;

&lt;p&gt;You cannot manage what you cannot see. Before automation, before policies, before dashboards, you need a list. Every certificate in every environment, with its expiry date, its issuer, and the service that serves it.&lt;/p&gt;

&lt;p&gt;In a real enterprise this is never one place. Certificates live in Azure Key Vault, in API Management custom domains, on Application Gateways, on Front Door endpoints, in Kubernetes secrets, and in that one VM nobody documented. In my experience, the first job on certificate work is not renewal. It is inventory.&lt;/p&gt;

&lt;p&gt;One Azure CLI query gives you the foundation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az keyvault certificate list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vault-name&lt;/span&gt; &lt;span class="nv"&gt;$VAULT&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[].{name:name, expires:attributes.expires}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop that across every vault in every subscription on a schedule and you have expiry visibility. Pipe the output into your monitoring system and you have the start of alerting.&lt;/p&gt;

&lt;p&gt;The inventory needs to live somewhere queryable. A spreadsheet dies the day someone forgets to update it. Pull the data into a dashboard or at least a scheduled report. And every certificate needs an owner. Anything marked "unknown" gets one assigned within a week. The unowned certificates are the ones that expire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renewal has to be automatic
&lt;/h2&gt;

&lt;p&gt;Manual renewal does not scale, and it does not survive team changes. The person who knows the renewal procedure today will not be on the team in two years. If the process depends on a person, it will fail.&lt;/p&gt;

&lt;p&gt;Azure Key Vault can auto-renew certificates issued through integrated CAs. For public CAs, you set up the renewal once and let the vault handle it from there. For Kubernetes workloads, ACME-based renewal with cert-manager removes the human from the loop entirely.&lt;/p&gt;

&lt;p&gt;The hard part is not the renewal. It is the deployment. Renewing a certificate in the vault means nothing if the Application Gateway or API Management instance keeps serving the old one. The renewal pipeline has to push the new certificate to every consumer. Key Vault references in App Service and Functions pick up new versions automatically. For gateways and load balancers, you need a pipeline step or an event-driven function that notices the new version and applies it. Test this end to end in a non-production environment first. The renewal ceremony is the whole chain, not the issuance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alert long before expiry
&lt;/h2&gt;

&lt;p&gt;Automation fails too. CAs have outages. Permissions drift. Someone changes a firewall rule and the renewal challenge cannot reach the domain. You need alerts that fire early enough to fix the problem by hand.&lt;/p&gt;

&lt;p&gt;The schedule I recommend is 60-30-7. Sixty days out: informational, goes to the team channel. Thirty days out: warning, gets a ticket. Seven days out: page someone. Most certificates are fine at sixty days. The point of the early alert is not urgency. It is to catch the broken automation while you still have options.&lt;/p&gt;

&lt;p&gt;The alert must go to a team, not a person. Certificates outlive people. Route expiry alerts to a distribution list or an on-call rotation. If the alert goes to an individual inbox, it will eventually go to a departed employee's inbox, which is the same as no alert at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotate without downtime
&lt;/h2&gt;

&lt;p&gt;Renewing the certificate is step one. Serving it without dropping connections is step two. Most Azure services handle this well now. Application Gateway lets you update the listener certificate without downtime. API Management custom domains can be updated in place. Kubernetes ingress controllers pick up new secrets on reload.&lt;/p&gt;

&lt;p&gt;The pattern that keeps you safe: renew early, deploy the new certificate while the old one is still valid, and verify the serving path before the old one expires. Keep the old certificate in the vault for a short overlap period. If something goes wrong with the new one, you roll back in minutes instead of scrambling for a reissue.&lt;/p&gt;

&lt;p&gt;Test your rollback. I mean actually test it, in staging, with someone watching the metrics. A rollback plan you have never run is a theory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not forget internal certificates
&lt;/h2&gt;

&lt;p&gt;Public certificates get all the attention because they are customer facing. Internal certificates expire just as hard. Service-to-service mTLS, internal APIs, webhook endpoints between systems: all of them have expiry dates, and they tend to be managed by nobody in particular.&lt;/p&gt;

&lt;p&gt;If your organization runs an internal CA, treat its certificates with the same lifecycle as public ones. Same inventory, same alerts, same automation. The internal CA root itself needs a plan. Root rotation is rare and painful, which is exactly why it needs to be written down before it is urgent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do on day one
&lt;/h2&gt;

&lt;p&gt;If you inherited certificate management tomorrow, here is the order I would work in.&lt;/p&gt;

&lt;p&gt;First, build the inventory. Script discovery across every Key Vault and every TLS endpoint you can reach. Assign an owner to each certificate.&lt;/p&gt;

&lt;p&gt;Second, set up expiry alerting on the 60-30-7 schedule, routed to a team channel and an on-call rotation.&lt;/p&gt;

&lt;p&gt;Third, pick the certificates closest to expiry or most critical, and automate their renewal end to end, including deployment to the serving infrastructure.&lt;/p&gt;

&lt;p&gt;Fourth, extend automation to the rest, starting with the public-facing ones.&lt;/p&gt;

&lt;p&gt;Fifth, document the rollback procedure and test it once a quarter.&lt;/p&gt;

&lt;p&gt;None of this is exciting work. That is the point. Certificates should be boring. The goal is a system where expiry is a non-event: the certificate renews, the pipeline deploys it, the alert never fires, and nobody remembers it happened. Boring is the victory condition.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>azure</category>
      <category>security</category>
      <category>cloud</category>
    </item>
    <item>
      <title>An Enterprise LLM Gateway: Centralized Access, Metered Usage, and Guardrails for Production AI on Azure</title>
      <dc:creator>Manoj Kumar Kagitha</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:50:43 +0000</pubDate>
      <link>https://dev.to/manojkagitha/an-enterprise-llm-gateway-centralized-access-metered-usage-and-guardrails-for-production-ai-on-2joc</link>
      <guid>https://dev.to/manojkagitha/an-enterprise-llm-gateway-centralized-access-metered-usage-and-guardrails-for-production-ai-on-2joc</guid>
      <description>&lt;p&gt;Manoj Kumar Kagitha&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Eficens Systems Inc., Dallas, TX, USA&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="mailto:mymanoj11@gmail.com"&gt;mymanoj11@gmail.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Abstract: Every enterprise AI effort starts the same way. A team gets an API key, wires it into an application, and ships. Then the second team does the same, and the third. Months later nobody can answer basic questions. Who is calling the models? Which teams spend what? Which data crosses the boundary, and under whose authority? This paper describes an enterprise LLM gateway built to answer those questions: a central control plane for model access on Azure, with identity-aware routing, per-tenant metering, rate limiting, and guardrails. We cover the requirements that shaped it, the architecture, how metering and cost attribution work, how policy is enforced at the gateway, how the platform is provisioned with Terraform and delivered through CI/CD, and what operating it taught us. The aim is practical: a reference design for teams facing the same sprawl. We state plainly what this paper does not do. It reports no controlled experiments and no benchmark numbers. It is an experience report from production work, and the lessons are the contribution.&lt;/p&gt;

&lt;p&gt;Index Terms: LLM gateway, AI governance, metering, guardrails, Azure, MLOps, cost attribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  I. Introduction
&lt;/h2&gt;

&lt;p&gt;The pattern is familiar to anyone who has run platform engineering for a large company. A new capability appears. Early teams adopt it directly, each wiring their own credentials and their own plumbing. It works, so it spreads. Then scale arrives with its usual questions, and nobody has answers. With large language models the cycle ran faster than usual, because the capability was easy to adopt and the costs were easy to ignore. Tokens are invisible until the invoice. Data flows are invisible until the audit.&lt;/p&gt;

&lt;p&gt;In our environment the sprawl took a specific shape. Product teams built LLM features against Azure OpenAI and Gemini. Each team held its own keys. Some proxied through small in-house services. Most did not. When finance asked which teams spent what, the answer was a spreadsheet of guesses. When security asked which data reached which model, the answer was worse. The models were the easy part. Governing access to them was the missing system.&lt;/p&gt;

&lt;p&gt;The gateway idea is old. Enterprises have run API gateways for years: one front door for authentication, rate limiting, and audit. Our earlier work on onboarding APIs into multi-tenant Azure environments at scale covered exactly that plumbing [1]. An LLM gateway applies the same instinct to model traffic, with three additions that API gateways never needed. Tokens must be metered per call. Prompts and responses must be screened against policy. And requests must be routed across models by task, because model choice is now a cost and quality decision on every call.&lt;/p&gt;

&lt;p&gt;This paper describes the gateway we built: a central control plane for LLM access on Azure. Every model call in the enterprise flows through it. It authenticates the caller, resolves the tenant, checks policy, routes to a model, meters the tokens, enforces budgets, and logs everything. Around it sit the provisioning and delivery practices that keep it running: Terraform for infrastructure, CI/CD for safe change, and guardrails from Google's Model Armor on the screening side.&lt;/p&gt;

&lt;p&gt;We make five contributions. First, the requirements that an enterprise actually imposes on model access, drawn from finance, security, and platform teams. Second, a reference architecture for the gateway itself. Third, a metering and cost attribution design that turns the loudest budget argument into a dashboard. Fourth, a guardrail and policy enforcement design built on check-before-execute discipline. Fifth, operating lessons from running the gateway under real load. Readers who want agent orchestration patterns should look at our companion work. This paper is about the control plane underneath them.&lt;/p&gt;

&lt;h2&gt;
  
  
  II. Background and Related Work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A. From API Gateways to LLM Gateways
&lt;/h3&gt;

&lt;p&gt;The enterprise already solved a version of this problem. API gateways centralize authentication, rate limiting, transformation, and audit for service traffic. Our SSRN paper documents onboarding APIs into multi-tenant Azure environments at scale, including the identity and policy plumbing such gateways need [1]. Much of that transfers directly: authenticate every call, resolve the tenant, enforce per-tenant limits, log every decision. What does not transfer is the payload. API gateways meter requests. LLM gateways must meter tokens, screen natural language against policy, and route across models with different cost and capability profiles. The gateway shape is familiar. The metering and screening are new.&lt;/p&gt;

&lt;h3&gt;
  
  
  B. Metering and Observability
&lt;/h3&gt;

&lt;p&gt;You cannot govern what you cannot measure. Our earlier work on AI-driven observability for hyperscale data centers argued for instrumenting first and analyzing second [2]. The GreenOps work that followed built telemetry pipelines for carbon analysis, which transfer cleanly to token analysis: the same collection, aggregation, and attribution machinery applies [3]. Our book on hyperscale telemetry covers the pipeline thinking in full [4]. The gateway applies it to model traffic. Every call emits token counts, latency, model, tenant, and team. The metering pipeline turns that stream into budgets, alerts, and chargeback.&lt;/p&gt;

&lt;h3&gt;
  
  
  C. Policy Enforcement
&lt;/h3&gt;

&lt;p&gt;Policy that lives in documentation does not hold. Our 2021 work on automated security compliance in CI/CD used NLP to flag infrastructure-as-code vulnerabilities before deployment, on the principle that the check must run before the action executes [5]. Our Cognitive DevOps book traced the same idea through pipelines: safety properties have to survive automation, not sit beside it [6]. The gateway is the enforcement point where this principle meets model traffic. Policy is expressed as versioned artifacts, evaluated on every call, with denials logged. A policy nobody evaluates is a hope, not a control.&lt;/p&gt;

&lt;h3&gt;
  
  
  D. Guard Models and Screening
&lt;/h3&gt;

&lt;p&gt;Screening prompts and responses is now a commodity capability. Llama Guard showed that a small fine-tuned model can screen both sides of a conversation at low cost [7]. Google's Model Armor provides the same function as a managed service, which is what our deployment uses. Screening text is necessary but not sufficient. The tool call is where damage happens, and Section VI covers the action side. The text side belongs in the gateway because every prompt and every response already flows through it.&lt;/p&gt;

&lt;h3&gt;
  
  
  E. Traffic Management
&lt;/h3&gt;

&lt;p&gt;Rate limiting model traffic borrows directly from classic traffic management. Our 2019 work on load balancing in distributed cloud environments covered the scheduling instincts: protect the shared resource, isolate noisy neighbors, shed load gracefully [8]. Tokens are the constrained resource here. Per-tenant rate limits, per-model concurrency caps, and queueing with clear backpressure signals all come from the same playbook, applied to a new bottleneck.&lt;/p&gt;

&lt;h3&gt;
  
  
  F. Deployment Discipline
&lt;/h3&gt;

&lt;p&gt;A gateway in the critical path must change safely. Our book on the engineering realities of deploying enterprise AI makes the case at length: the unglamorous disciplines of versioning, canarying, and rollback decide whether AI systems survive production [9]. The Sentient Cloud book covers autonomous operations from the infrastructure side [10], and our most recent book unifies the AIOps and hyperscale picture [11]. Section VII applies that discipline to the gateway: versioned configuration, staged promotion, and rollback that covers policy as well as code.&lt;/p&gt;

&lt;h2&gt;
  
  
  III. Requirements
&lt;/h2&gt;

&lt;p&gt;The requirements came from three groups who rarely agree: finance, security, and the product teams shipping features. Finance wanted attribution. Every token had to map to a team and a tenant, with budgets and a way to do chargeback. Security wanted control. Every call had to carry an identity, cross a policy check, and land in an audit log. Product teams wanted speed. One endpoint, one SDK, one key per team, and no ticket queue to start building. The gateway had to satisfy all three without becoming the reason features shipped late.&lt;/p&gt;

&lt;p&gt;Identity and tenancy came first. Every call carries who is asking: user, team, tenant, and the agent role when an agent calls. Tenancy is enforced in the data plane. Retrieval filters by tenant at the index, caches are keyed by tenant, and credentials are minted per tenant. A prompt instruction to respect tenancy is not a control. Filtered data access is.&lt;/p&gt;

&lt;p&gt;Metering had to be per call and per token, attributed to teams, with budgets that actually stop traffic. Alerts alone do not change behavior. Hard stops do, so budgets come with enforcement, not just notification. Rate limiting sits beside metering: per-tenant limits, per-model concurrency caps, and clear signals when a caller is being throttled.&lt;/p&gt;

&lt;p&gt;Guardrails had to cover text and actions. Prompts and responses go through screening. Tool calls, where agents are involved, go through allowlists scoped to the caller's role. High-risk actions wait for human approval. Every denial is logged, because a mitigation you cannot audit is a hope, not a control.&lt;/p&gt;

&lt;p&gt;Reliability was non-negotiable. The gateway sits in the critical path of every AI feature, so it cannot become the outage. Stateless design, horizontal scaling, and graceful degradation: if the screening service is slow, the gateway fails closed on policy but stays open on traffic where policy allows cached decisions. Developer experience closed the list. One endpoint, one SDK, one key per team. If using the gateway is harder than going around it, teams will go around it, and the whole exercise fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  IV. Architecture
&lt;/h2&gt;

&lt;p&gt;The gateway is a set of stateless services behind a load balancer, with state kept in dedicated stores. Fig. 1 in a longer version of this paper would show the request path. In prose it runs like this. A call arrives at ingress with a team key or a managed identity token. Ingress authenticates the caller and resolves the tenant. The policy engine evaluates the request against the tenant's policy bundle: which models are allowed, what the budget state is, whether the caller is within rate limits. The screener checks the prompt text. The router picks a model and an endpoint. The call executes. The screener checks the response. The metering pipeline records tokens, latency, model, tenant, and team. The audit log records the decisions. Every stage can deny, and every denial is logged with its reason.&lt;/p&gt;

&lt;p&gt;Four components deserve detail. The policy engine evaluates versioned policy bundles, one per tenant, reviewed like firewall rules. Policies name allowed models, budget caps, rate limits, screening strictness, and data handling rules. The router applies model routing rules: the strongest model for planning and judgment-heavy calls, smaller models for bulk execution and classification. The general lesson is old. Our 2016 comparison of machine learning algorithms found, unsurprisingly, that no single algorithm wins everywhere [12]. The same holds for models, where reasoning strength, context length, and cost pull in different directions. Routing is how the gateway turns that observation into savings.&lt;/p&gt;

&lt;p&gt;The metering pipeline is a stream processor, not a batch job. Token counts arrive per call and are aggregated into per-team, per-tenant, and per-model series within seconds. Budgets are evaluated against live aggregates, which is what makes hard stops possible. The pipeline design follows the telemetry thinking from our GreenOps and observability work: collect everything, aggregate fast, attribute precisely [2][3]. The audit log is append-only and tenant-scoped. When security asks who called what with which data, the answer comes from this log, not from anyone's memory.&lt;/p&gt;

&lt;p&gt;Identity flows through the whole path. Human callers authenticate with team keys or Entra ID. Service callers use managed identities, which Azure rotates automatically. Agent callers carry the agent role plus the user and tenant they act for, so tool calls made through the gateway inherit the right scope. Long-lived keys are the exception, issued only where managed identities cannot reach, with rotation enforced. Our earlier work on secure transmission in IoT systems covered the same ground at the device layer: credentials should be short-lived, scoped, and rotated on schedule [13].&lt;/p&gt;

&lt;h3&gt;
  
  
  A. Tenancy in the Data Plane
&lt;/h3&gt;

&lt;p&gt;This deserves its own subsection because it is where designs fail. Tenancy enforced only in prompts is not enforcement. The gateway enforces it in the data plane at four points. Retrieval filters by tenant at the index, so a query can never match another tenant's documents. Caches are keyed by tenant, so a cached result never crosses the boundary. Credentials are minted per tenant, so a tool call cannot borrow another tenant's authority. And the audit log is partitioned by tenant, so investigations stay scoped. Each of these is a deliberate inefficiency. Sharing caches or credentials across tenants would be cheaper. The cost of a cross-tenant leak dwarfs any infrastructure saving, so we do not share.&lt;/p&gt;

&lt;h3&gt;
  
  
  B. Reliability
&lt;/h3&gt;

&lt;p&gt;The gateway is in the critical path, so it is designed to fail safely. Services are stateless and scale horizontally. The policy engine caches compiled policy bundles locally with a short TTL, so a control-plane outage does not stop the data plane. Screening calls have tight timeouts with a defined fallback: fail closed for tenants under strict policy, allow with a logged exception where policy permits. Rate limiting uses token buckets per tenant, which degrade gracefully under load instead of collapsing. We monitor the gateway the way we monitor any critical service: latency percentiles at each stage, error rates, screening decision rates, and metering pipeline lag. If the metering pipeline lags, budgets evaluate against stale data, which is itself an alert condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  V. Metering and Cost Attribution
&lt;/h2&gt;

&lt;p&gt;Agents spend tokens the way services spend CPU: silently, then all at once. The gateway makes the spending visible and attributable. Every call records prompt tokens, completion tokens, model, endpoint, tenant, team, and latency. Attribution is exact because identity is resolved before the call executes, not inferred after.&lt;/p&gt;

&lt;p&gt;Three controls matter most. Budgets per team and per tenant, with hard stops when the budget is exhausted and warnings before that. Caching of repeated work: identical prompts within a short window, repeated retrieval results, and repeated tool outputs, all cached with tenant-scoped keys and short TTLs. And model routing, which moves bulk work to smaller models and reserves the strongest models for planning and judgment. Track cost per completed task, not just total spend. A workflow that costs twice as much per task needs to succeed enough more to justify it.&lt;/p&gt;

&lt;p&gt;Chargeback turned the loudest argument into the quietest dashboard. Before the gateway, AI spend was a single line item that every team disowned. After it, each team sees its own series: spend by model, by endpoint, by day, against its budget. Finance gets the allocation it needs. Teams get the feedback they need to optimize. The interesting effect was behavioral. Once teams could see their own spend, they started asking for the cheaper routing themselves. Visibility did more than any mandate.&lt;/p&gt;

&lt;p&gt;Cache design deserves care because caches are where tenancy and correctness meet. Prompt caches key on the exact prompt plus model plus tenant, with a short TTL. Retrieval caches key on query plus corpus version plus tenant. Tool result caches key on arguments plus tenant, with TTLs matched to how fast the underlying data changes. Nothing caches across tenants. Stale caches cause wrong answers, so every cache entry carries its age and the gateway exposes cache hit rates per endpoint. A sudden drop in hit rate is usually a sign that something upstream changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  VI. Guardrails and Policy Enforcement
&lt;/h2&gt;

&lt;p&gt;The gateway enforces policy at three points in a call. The prompt is screened before it reaches the model. The tool call, where agents are involved, is checked against an allowlist scoped to the caller's role. The response is screened before it returns to the caller. Each point can deny, and every denial lands in the audit log with the policy rule that fired.&lt;/p&gt;

&lt;p&gt;Text screening uses Model Armor on both sides of the call. It catches the obvious categories: disallowed content, prompt injection patterns, and sensitive data in either direction. Screening is tuned per tenant, because a strictness setting that suits a customer-facing assistant will strangle an internal engineering tool. False positives are reviewed weekly, and the tuning notes go back into the policy bundle. A guardrail that nobody tunes becomes either decoration or an obstacle.&lt;/p&gt;

&lt;p&gt;The action side is where our earlier compliance work applies directly. Our 2021 paper used NLP to flag infrastructure-as-code vulnerabilities before deployment, on the check-before-execute principle [5]. The gateway applies the same principle to tool calls. Tools are tiered by risk. Read-only tools get broad approval. Tools that write to non-production systems need task-scoped approval. Tools that touch production or move money need human approval, every time, no exceptions. The allowlist is versioned with the agent and reviewed like firewall rules. When a new tool arrives, its tier is the first decision, before any prompt is written.&lt;/p&gt;

&lt;p&gt;Indirect prompt injection gets special handling because it defeats input screening. The attack hides instructions in tool output: a document, a ticket comment, a search result that the agent reads mid-run. The gateway tags retrieved and tool-returned content with its source and treats it as data, never as instructions. Output screening re-examines content that crossed a trust boundary mid-run. This is the same boundary discipline as the rest of the gateway. Data from outside the tenant's control is untrusted until checked.&lt;/p&gt;

&lt;p&gt;Human approval gates cover the small set of actions that truly need a person. Each approval shows the exact action in plain terms, not the agent's plan paraphrased. Approval fatigue is real: if everything needs approval, approvers stop reading. We reserve the gate for production writes, money movement, and irreversible actions, and we measure approval latency as a service metric. A gate that takes an hour is a gate teams will route around.&lt;/p&gt;

&lt;h2&gt;
  
  
  VII. Provisioning and Delivery
&lt;/h2&gt;

&lt;p&gt;The gateway runs on Azure, and everything it stands on is defined as code. Terraform provisions the Azure OpenAI instances, private networking, Key Vault, managed identities, RBAC assignments, Container Apps environments, and monitoring. Environment configuration is separated from code, so the same definitions produce dev, staging, and production with different parameters. Secrets live in Key Vault and are referenced, never embedded.&lt;/p&gt;

&lt;p&gt;Delivery runs through GitHub Actions. Every change builds container images, runs security validation, and promotes through environments with approvals. Gateway configuration, routes, policy bundles, budgets, and model routing rules, moves through the same pipeline as code. A policy change is a pull request with review, a canary deployment to a subset of tenants, and a rollback path. This is the Cognitive DevOps discipline applied to the control plane: automation with safety properties intact [6].&lt;/p&gt;

&lt;p&gt;Two rules proved their worth. First, version everything the gateway decides by: prompts, tool bindings, allowlists, policy bundles, routing rules. When a model update changes behavior, the fastest fix is often the previous configuration version, and it should be one command away. Second, canary every rollout. New policies go to an internal tenant first, then to a friendly pilot team, then wide. The canary tenants are chosen for their tolerance, not their importance. Our book on enterprise AI deployment covers why this boring discipline matters more than model choice [9].&lt;/p&gt;

&lt;h2&gt;
  
  
  VIII. Operating Experience
&lt;/h2&gt;

&lt;p&gt;Running the gateway taught us things the design did not. Each lesson below cost real time. We record them so other teams can pay less.&lt;/p&gt;

&lt;p&gt;The shared cache near-miss. Early in the design, a shared cache across tenants looked like an easy efficiency win. Identical prompts from different tenants would hit the same entry. We caught it in review, before it shipped, and the scare shaped the tenancy rules in Section IV-A. The lesson generalizes. Every shared component in a multi-tenant system is a place where tenancy can leak. Audit the seams on a schedule: caches, batch jobs, logs, exports. The efficient design and the safe design are different designs, and the safe one wins.&lt;/p&gt;

&lt;p&gt;Rate limiting is a product feature. Tenants do not experience rate limiting as infrastructure. They experience it as the gateway saying no. Clear error messages, per-tenant dashboards showing limit consumption, and a documented path to higher limits turned throttling from a complaint into a planning input. The noisy neighbor problem is real: one tenant's bulk job can starve interactive traffic without per-tenant buckets. Our load balancing background made this familiar territory [8]. Isolate first, optimize second.&lt;/p&gt;

&lt;p&gt;Keys leak, so design for it. Long-lived keys end up in code, in chat logs, in screenshots. We moved every caller we could to managed identities and short-lived tokens, kept team keys only where nothing else worked, and built rotation into the admin API instead of treating it as an incident response. The 2018 work on secure transmission at the device layer taught the same lesson at a different scale: credential lifetime should match the trust you actually have, not the trust you wish you had [13].&lt;/p&gt;

&lt;p&gt;Budgets need teeth. Warning emails about budget consumption get filtered. Hard stops get noticed. We set budgets with a warning threshold and a stop threshold, and the stop is automatic. When a stop fires on a real workload, the conversation about budgets gets serious in a way no dashboard achieves. Set the stop threshold with the team, not for them, or the gateway becomes the adversary.&lt;/p&gt;

&lt;p&gt;The gateway sees everything, so it becomes the debugging tool. When an agent misbehaved, the first question was always what it did and what it cost. The trace answered both. This was the direct payoff of the observability-first approach from our data center work [2]. Totals are for finance. Traces are for engineering. Build the traces first, and the gateway's central position makes them complete by construction.&lt;/p&gt;

&lt;p&gt;Prompt changes are deployments. A routing rule change or a screening strictness change can alter behavior as much as a code deploy. We learned to treat them identically: pull request, review, canary, rollback plan. The teams that treated policy as configuration learned this the painful way, debugging behavior changes with no deploy to blame. Version everything the gateway decides by, as Section VII requires.&lt;/p&gt;

&lt;p&gt;Guardrail tuning never ends. Strictness that blocks real work gets disabled by frustrated teams, which is worse than no guardrail at all. We review false positives weekly and tune per tenant. The tuning notes are part of the policy bundle, so the reasoning survives turnover. A guardrail is a living configuration, not a one-time setup.&lt;/p&gt;

&lt;p&gt;Automate in bounds. Where the gateway automates (throttling abusive callers, rotating exposed credentials, failing over), every action stays bounded and logged, with a human override path that works in seconds. Our work on self-healing AKS clusters with hybrid reinforcement learning followed the same shape: closed loops are fine when every action is bounded and every bound is visible [14]. The day the automation surprises you is the day the bounds were wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  IX. Conclusion
&lt;/h2&gt;

&lt;p&gt;The enterprise LLM gateway turns AI sprawl into a managed platform. One front door for model access, with identity, metering, rate limiting, guardrails, and audit built in. The architecture is deliberately unglamorous: stateless services, versioned policy, tenant-scoped everything, and the same deployment discipline as any critical service. The contribution is not novelty. It is a reference design with the sharp edges labeled, drawn from production work.&lt;/p&gt;

&lt;p&gt;Two directions look promising from here. First, agent-to-agent protocols such as MCP and A2A will need mediation at the gateway: discovery, identity, and policy for traffic that never touches a human. Second, cost control will get finer. Per-task budgets, semantic caching with correctness guarantees, and routing driven by measured quality per task are all within reach of the metering pipeline described here. The gateway's central position makes it the natural place to build them.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;[1] M. K. Kagitha, "Scalable onboarding architectures for global API ecosystems in multi-tenant Azure environments," SSRN, 2026. doi: 10.2139/ssrn.6151926.&lt;/p&gt;

&lt;p&gt;[2] M. K. Kagitha, "AI-driven observability for hyperscale colocation data centers," J. Computational Analysis and Applications, vol. 27, no. 7, 2019. doi: 10.5281/zenodo.22836377.&lt;/p&gt;

&lt;p&gt;[3] M. K. Kagitha, "GreenOps in the cloud: AI-driven telemetry analysis for reducing carbon footprint," J. Computational Analysis and Applications, vol. 28, no. 6, 2020. doi: 10.5281/zenodo.22836379.&lt;/p&gt;

&lt;p&gt;[4] M. K. Kagitha, The Telemetry Blueprint: Hyperscale Data Lakes and Machine Learning for Infrastructure Intelligence. Geh Press, 2023. ISBN 978-1-972547-16-8.&lt;/p&gt;

&lt;p&gt;[5] M. K. Kagitha, "Automated security compliance in CI/CD: An NLP approach to IaC vulnerabilities," Computer Fraud &amp;amp; Security, vol. 2021, no. 12, 2021. doi: 10.5281/zenodo.22836366.&lt;/p&gt;

&lt;p&gt;[6] M. K. Kagitha, Cognitive DevOps: Integrating LLMs and Predictive Analytics into the CI/CD Pipeline. Geh Press, 2021. ISBN 978-1-972547-14-4.&lt;/p&gt;

&lt;p&gt;[7] I. Fedorov et al., "Llama Guard 3-1B-INT4: Compact and efficient safeguard for human-AI conversations," Meta technical report, arXiv:2411.17713, 2024.&lt;/p&gt;

&lt;p&gt;[8] M. K. Kagitha, "Load balancing techniques in distributed cloud environments," J. Critical Reviews, vol. 6, no. 1, 2019. doi: 10.5281/zenodo.22836373.&lt;/p&gt;

&lt;p&gt;[9] M. K. Kagitha, Beyond the Algorithm: The Engineering Realities of Deploying Enterprise AI. Geh Press, 2024. ISBN 978-1-972547-17-5.&lt;/p&gt;

&lt;p&gt;[10]    M. K. Kagitha, The Sentient Cloud: Architecting Autonomous Data Centers with AIOps and Azure. Geh Press, 2022. ISBN 978-1-972547-15-1.&lt;/p&gt;

&lt;p&gt;[11]    M. K. Kagitha, The Future-Proof Enterprise: Unifying AIOps, Hyperscale Cloud, and Quantum Readiness. Geh Press, 2026. ISBN 978-1-972547-20-5.&lt;/p&gt;

&lt;p&gt;[12]    M. K. Kagitha, "Comparative analysis of machine learning algorithms for predictive analytics," Global J. Eng. Sci. Res., vol. 3, no. 12, 2016. doi: 10.5281/zenodo.19041609.&lt;/p&gt;

&lt;p&gt;[13]    M. K. Kagitha, "Secure data transmission in IoT-based smart home systems," Int. J. Eng. Researches and Management Studies, vol. 5, no. 11, 2018. doi: 10.5281/zenodo.22836375.&lt;/p&gt;

&lt;p&gt;[14]    M. K. Kagitha, "Self-healing cloud infrastructure: Hybrid RL framework for AKS," Computer Fraud &amp;amp; Security, vol. 2023, no. 12, 2023. doi: 10.5281/zenodo.22836371.&lt;/p&gt;

&lt;p&gt;[15]    Google Cloud, "Model Armor documentation." [Online]. Available: &lt;a href="https://cloud.google.com/model-armor/docs" rel="noopener noreferrer"&gt;https://cloud.google.com/model-armor/docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[16]    OpenTelemetry, "Semantic conventions for generative AI systems." [Online]. Available: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai" rel="noopener noreferrer"&gt;https://github.com/open-telemetry/semantic-conventions-genai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>azure</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
