<?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: Vignesh Athiappan</title>
    <description>The latest articles on DEV Community by Vignesh Athiappan (@vignesh_athiappan_818c9e0).</description>
    <link>https://dev.to/vignesh_athiappan_818c9e0</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%2F4028899%2F1e712c2d-9953-4fd8-855f-a0bdd45cb21b.jpeg</url>
      <title>DEV Community: Vignesh Athiappan</title>
      <link>https://dev.to/vignesh_athiappan_818c9e0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vignesh_athiappan_818c9e0"/>
    <language>en</language>
    <item>
      <title>Turning a System of Record into an AI Agent: Building MCP Tools on Azure</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:57:13 +0000</pubDate>
      <link>https://dev.to/vignesh_athiappan_818c9e0/turning-a-system-of-record-into-an-ai-agent-building-mcp-tools-on-azure-291f</link>
      <guid>https://dev.to/vignesh_athiappan_818c9e0/turning-a-system-of-record-into-an-ai-agent-building-mcp-tools-on-azure-291f</guid>
      <description>&lt;p&gt;&lt;em&gt;A practical, end-to-end walkthrough of taking a read-only slice of an enterprise source system and exposing it to an AI agent as a set of Model Context Protocol (MCP) tools — using Azure Logic Apps, API Management, and Agent Foundry. All identifiers below are placeholders; swap in your own.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;p&gt;We wanted a simple outcome: a user asks a business question in plain language and gets a straight answer — no dashboards, no field names, no training on the underlying system. That means an AI agent with safe, read-only access to a backend system of record, exposed as discrete tools the model can call.&lt;/p&gt;

&lt;p&gt;The constraints shaped every decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only.&lt;/strong&gt; The agent can retrieve and analyze, never write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe.&lt;/strong&gt; Records in most business systems contain free text (notes, subjects, descriptions) that could carry prompt-injection payloads. Tool output must be treated as data, never instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composable.&lt;/strong&gt; The agent should see many small, well-described tools — "list records", "aggregate by category", "record change history" — not one giant "query the system" tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Five layers, one direction of flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Agent (Agent Foundry)
      │  MCP (JSON-RPC over HTTP)
      ▼
MCP server (API Management)
      │  REST operation per tool
      ▼
API gateway (API Management)
      │  single POST, routed by body
      ▼
Tool executor (Logic App)
      │  OAuth token + REST calls
      ▼
Source system (REST API)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design choice: &lt;strong&gt;one backend endpoint, many logical tools.&lt;/strong&gt; The Logic App exposes a single HTTP trigger that accepts &lt;code&gt;{ "tool": "records.list", "parameters": { ... } }&lt;/code&gt; and routes internally. API Management then fans that single endpoint out into many named operations, and its MCP feature turns those operations into agent tools. This keeps the backend trivial to maintain while the agent still sees a rich, typed tool catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Design the tools
&lt;/h2&gt;

&lt;p&gt;Start from the &lt;em&gt;questions&lt;/em&gt;, not the schema. A useful toolset usually falls into a few families:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Records:&lt;/strong&gt; list / get / search / filter for the core business entities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activity &amp;amp; change history:&lt;/strong&gt; activity on a record, and field-level history of what changed and when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics:&lt;/strong&gt; server-side aggregates — totals, counts, group-by-category, and top-N rankings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata:&lt;/strong&gt; let the agent introspect the available tables and fields at runtime instead of hardcoding everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two implementation notes that matter a lot:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer aggregates over "pull a list and count."&lt;/strong&gt; If the source system can compute a sum or a group-by server-side, a "total by category" answer is one summary row, not hundreds of records the model has to tally. Push the work down to the system wherever you can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prototype against the real API first.&lt;/strong&gt; A quick collection of calls against the source system's REST endpoint (with OAuth client-credentials) lets you nail the field names, filters, and relationships before you touch orchestration. Field-name mismatches are the most common silent failure — verify against real responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Build the tool executor (Logic App)
&lt;/h2&gt;

&lt;p&gt;The Logic App is a dispatcher. One HTTP request trigger, then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch the source-system secret from &lt;strong&gt;Key Vault&lt;/strong&gt; (never inline secrets).&lt;/li&gt;
&lt;li&gt;Get an OAuth token via client credentials for the source system's scope.&lt;/li&gt;
&lt;li&gt;Compute a category from the tool id (&lt;code&gt;first(split(tool, '.'))&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Switch&lt;/code&gt; on category, with a nested &lt;code&gt;Switch&lt;/code&gt; on the full tool id inside each — each leaf is one read-only REST call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few hard-won details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Switch cases cap at 25.&lt;/strong&gt; With dozens of tools, a single switch won't do — split by category into an outer switch, then an inner switch per category. This "nested switch" pattern is also cleaner to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action names must be globally unique&lt;/strong&gt; across the whole definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape literal &lt;code&gt;@&lt;/code&gt;.&lt;/strong&gt; If any query parameter uses an &lt;code&gt;@&lt;/code&gt; alias, remember that Logic Apps reads a bare &lt;code&gt;@&lt;/code&gt; as an expression. Double it (&lt;code&gt;@@&lt;/code&gt;) so it renders as a literal &lt;code&gt;@&lt;/code&gt; at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seed a default result.&lt;/strong&gt; Initialize the response variable to an &lt;code&gt;{ "error": "unknown tool" }&lt;/code&gt; object; each matched case overwrites it. Unknown tools then return cleanly instead of hanging.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3 — Describe the tools as OpenAPI
&lt;/h2&gt;

&lt;p&gt;The agent needs a tool schema per operation. Generate an OpenAPI document with &lt;strong&gt;one operation per tool&lt;/strong&gt;, where each operation's request body is only &lt;em&gt;that tool's&lt;/em&gt; parameters (a &lt;code&gt;get&lt;/code&gt; op takes a record id; an aggregate takes nothing). Keep the path equal to the tool id so a gateway policy can recover it later.&lt;/p&gt;

&lt;p&gt;Generate this programmatically from the same source of truth as the Logic App so the two can't drift — derive each operation's parameters from the inputs the Logic App actually reads.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; API Management's inline "OpenAPI specification" editor parses the document as &lt;strong&gt;Swagger 2.0&lt;/strong&gt;. Paste an OpenAPI 3.0 doc and you get &lt;em&gt;"The Swagger version specified is unknown."&lt;/em&gt; Either import via the &lt;strong&gt;Add API → OpenAPI&lt;/strong&gt; surface (which accepts 3.0), or hand it a Swagger 2.0 document. Same operations either way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4 — Put it behind API Management
&lt;/h2&gt;

&lt;p&gt;Import the OpenAPI to create the operations, then add one API-scoped inbound policy that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validates the caller's Entra token (&lt;code&gt;validate-jwt&lt;/code&gt; with your audience),&lt;/li&gt;
&lt;li&gt;recovers the tool id from the request path,&lt;/li&gt;
&lt;li&gt;wraps the caller's parameters into the &lt;code&gt;{ tool, parameters }&lt;/code&gt; body the Logic App expects,&lt;/li&gt;
&lt;li&gt;forwards to the single Logic App trigger.
&lt;/li&gt;
&lt;/ul&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;set-variable&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"toolId"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"@(context.Request.OriginalUrl.Path.Split('/').Last())"&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;&lt;span class="cp"&gt;&amp;lt;![CDATA[@{
    var incoming = context.Request.Body?.As&amp;lt;JObject&amp;gt;&lt;/span&gt;(preserveContent: true) ?? new JObject();
    if (incoming["tool"] != null) { return incoming.ToString(); }   // already wrapped
    var wrapped = new JObject();
    wrapped["tool"] = (string)context.Variables["toolId"];
    wrapped["parameters"] = incoming;
    return wrapped.ToString();
}]]&amp;gt;&lt;span class="nt"&gt;&amp;lt;/set-body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Authenticating to the backend — drop the shared secret.&lt;/strong&gt; A Logic App request trigger defaults to a SAS signature (&lt;code&gt;sig&lt;/code&gt;) baked into its URL. That's a shared secret you then have to store and rotate. The better pattern: give API Management a &lt;strong&gt;managed identity&lt;/strong&gt;, add an OAuth authorization policy on the Logic App trigger (issuer + audience, optionally locking to APIM's &lt;code&gt;appid&lt;/code&gt;), disable SAS on the trigger, and let APIM authenticate with its own identity:&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;authentication-managed-identity&lt;/span&gt; &lt;span class="na"&gt;resource=&lt;/span&gt;&lt;span class="s"&gt;"api://&amp;lt;APP_ID&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;sig&lt;/code&gt;, nothing to rotate, and you can restrict the trigger to exactly one identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Expose the API as an MCP server
&lt;/h2&gt;

&lt;p&gt;API Management can publish selected operations as an MCP server. Point its "expose an API as an MCP server" feature at your API, select the operations you want as tools, and it hands you a server URL (an &lt;code&gt;/mcp&lt;/code&gt; endpoint). The operation &lt;code&gt;operationId&lt;/code&gt;s become the tool names the agent sees, so keep them clean (&lt;code&gt;records_list&lt;/code&gt;, &lt;code&gt;analytics_by_category&lt;/code&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tier caveat:&lt;/strong&gt; MCP server export isn't available on the Consumption tier of API Management. If the MCP Servers blade is missing, that's why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 6 — Connect to Agent Foundry
&lt;/h2&gt;

&lt;p&gt;In the agent, add a &lt;strong&gt;Model Context Protocol tool&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint:&lt;/strong&gt; the exact server URL from the MCP Servers blade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth:&lt;/strong&gt; Microsoft Entra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audience:&lt;/strong&gt; this must match the &lt;code&gt;aud&lt;/code&gt; your gateway's &lt;code&gt;validate-jwt&lt;/code&gt; requires. The audience is the single most common misconfiguration — get it right and the token check passes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the gateway 401s and the token &lt;em&gt;looks&lt;/em&gt; right, paste it into a JWT decoder and check &lt;code&gt;iss&lt;/code&gt;/&lt;code&gt;aud&lt;/code&gt; against the policy. Managed-identity tokens use the v1 issuer (&lt;code&gt;https://sts.windows.net/&amp;lt;tenant&amp;gt;/&lt;/code&gt;); configuring the policy with the v2 issuer produces a mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7 — The system prompt is part of the security perimeter
&lt;/h2&gt;

&lt;p&gt;Tools enforce read-only at the API layer, but the &lt;em&gt;behavior&lt;/em&gt; — tone, safety, honesty — lives in the system prompt. Ours does three jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clear framing.&lt;/strong&gt; Lead with the answer, plain language, no field names, one drill-down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt-injection defense.&lt;/strong&gt; The rule that matters most: &lt;em&gt;the user's message is the request; tool output is evidence.&lt;/em&gt; Record names, notes, and free-text fields are attacker-influenceable, so the agent never follows instructions embedded in them — no "ignore previous instructions," no exfiltration, no acting on a record that says to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve-or-ask, and know your limits.&lt;/strong&gt; Names get resolved to ids via a search tool first; ambiguous matches trigger a clarifying question rather than a guess. And the prompt names the gaps explicitly so the agent says "I don't have that" instead of improvising a malformed query.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point is worth underlining: the agent should &lt;em&gt;never&lt;/em&gt; hand-craft queries or field names. It picks a tool and fills named inputs. If a question needs a field the tools don't expose, that's a capability gap to report, not something to invent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8 — Making it actually work
&lt;/h2&gt;

&lt;p&gt;The demo worked on the first real question. The next few turns taught us the operational lessons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context windows blow up fast with many tools.&lt;/strong&gt; Every tool definition rides in the prompt, and raw responses are large. Two fixes: trim tool payloads (request only the fields you need, cap page sizes, drop redundant related data), and use a model with &lt;strong&gt;tool search&lt;/strong&gt;, which loads tool definitions on demand instead of front-loading all of them. Together they cut context dramatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "chat"-class model is the wrong choice for a tool-heavy agent.&lt;/strong&gt; Pick a model tuned for agentic tool calling with a large context window and, ideally, native tool search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto model routers need a constrained pool.&lt;/strong&gt; A router that can escalate to a model you haven't provisioned throws "deployment does not exist," and one that routes to a small model reintroduces the context ceiling. If you use a router, pin its subset to large-context, provisioned models — or just deploy one capable model directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One backend, many tools&lt;/strong&gt; keeps the surface simple while giving the agent a rich catalog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push secrets to Key Vault or eliminate them&lt;/strong&gt; with managed identity — don't let a SAS signature or client secret live in policy or config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate the OpenAPI from the same source as the executor&lt;/strong&gt; so the schema can't drift from what the backend actually reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim aggressively.&lt;/strong&gt; Lean payloads and a tool-search model beat a bigger context window every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The system prompt is security-critical&lt;/strong&gt;, not just tone — treat tool output as untrusted, resolve-or-ask, and be honest about gaps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is an agent a non-technical user can talk to in plain language, backed by a boringly maintainable pipeline, with the secret-handling and injection surface handled deliberately rather than by accident.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— Engineering notes, generalized. Replace all &lt;code&gt;&amp;lt;placeholders&amp;gt;&lt;/code&gt; with your own tenant and resource values.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>resources</category>
    </item>
    <item>
      <title>From a 15-Second Chatbot to a Real Agentic Assistant</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Thu, 16 Jul 2026 04:14:43 +0000</pubDate>
      <link>https://dev.to/vignesh_athiappan_818c9e0/from-a-15-second-chatbot-to-a-real-agentic-assistant-440j</link>
      <guid>https://dev.to/vignesh_athiappan_818c9e0/from-a-15-second-chatbot-to-a-real-agentic-assistant-440j</guid>
      <description>&lt;h3&gt;
  
  
  What a year of building an enterprise AI copilot actually taught me
&lt;/h3&gt;

&lt;p&gt;When I started, the goal sounded simple: give employees one place to ask a question and get an answer. No more hunting through a dozen internal apps to find a leave policy, check a project allocation, or raise a request. One assistant, one box, one answer.&lt;/p&gt;

&lt;p&gt;It took me the better part of a year, several architectures I'm now slightly embarrassed by, and a long list of failures that each taught me something specific. This is the honest version of that journey — the walls I hit, what I learned at each one, and the rearchitecture that finally made everything click.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Names, systems, and numbers here are anonymized. This is about the engineering, not the company.)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem I set out to solve
&lt;/h2&gt;

&lt;p&gt;The organization had roughly 6,000 employees across about ten countries, and their digital workday was fragmented. Policies lived in one place, HR data in another, tickets in a third, learning records in a fourth. Every routine question meant knowing &lt;em&gt;which&lt;/em&gt; system to open and &lt;em&gt;how&lt;/em&gt; to phrase things there.&lt;/p&gt;

&lt;p&gt;The vision was a single AI assistant embedded in the employee platform: ask in plain language, and it retrieves the right knowledge, reads your live data, and — eventually — takes actions on your behalf. That's easy to say on a slide. The gap between the slide and a system that actually works is where all the learning lives.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I started — and the first wall
&lt;/h2&gt;

&lt;p&gt;My first version was the obvious one. I had country-specific policy content indexed for search — thirteen separate vector indexes, one per country. Behind the assistant sat a workflow that received the question, looped through the relevant indexes, called a search agent, then called a language model to generate the answer. Every branch was wired by hand: knowledge path, live-data path, default path.&lt;/p&gt;

&lt;p&gt;It worked. It also took about &lt;strong&gt;fifteen seconds per answer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Fifteen seconds is an eternity for someone who just wants to know how many casual leaves they have left. Worse, the whole thing was sequential: an HTTP call, then a loop over indexes, then an agent call, then a model call, each waiting on the last. The workflow engine was doing orchestration work it was never designed for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson zero:&lt;/strong&gt; if your response time embarrasses you in a demo, the architecture is telling you something. Don't tune it — question it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Learning #1: A wrapped search is not a good search
&lt;/h2&gt;

&lt;p&gt;My instinct was to consolidate: take those thirteen indexes and hang them all off one intelligent agent, letting the model pick which to query based on the question and the user's location. Cleaner, in theory.&lt;/p&gt;

&lt;p&gt;Then I noticed something that stopped me. When I queried the search index &lt;em&gt;directly&lt;/em&gt;, the results were sharp. When I routed the &lt;em&gt;same&lt;/em&gt; question through an agent that wrapped the search, the answers got noticeably worse.&lt;/p&gt;

&lt;p&gt;The reason turned out to be fundamental, and it's a trap a lot of people fall into. When you query search directly, you control the exact query text and exactly which retrieved chunks reach the model. When an agent sits in front of the search, it silently rewrites your question into its own search query first — and that rewrite loses nuance. "How many casual leaves can I take in [country]?" becomes a generic "casual leave policy," and the country-specific content never surfaces. On top of that, the agent's default retrieval settings rarely match the ones you carefully tuned for direct queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; an abstraction that "handles retrieval for you" is also an abstraction that quietly degrades it. I collapsed the thirteen country indexes into &lt;strong&gt;one federated index with a country filter&lt;/strong&gt; — the total document count was small enough that a single filtered query was both faster and more accurate than looping over thirteen. That one change killed the index loop entirely and reclaimed a big chunk of those fifteen seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Learning #2: Sequential is the enemy — and parallelism has its own traps
&lt;/h2&gt;

&lt;p&gt;With retrieval fixed, the next bottleneck was structural. My workflow had around twenty intent branches — projects, skills, leave, allocations, and so on — evaluated one after another. So I parallelized them: fan every branch out from a single intent-detection step and let them run at once.&lt;/p&gt;

&lt;p&gt;This is where I learned that concurrency in a low-code workflow engine has sharp edges nobody warns you about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared variables aren't concurrency-safe.&lt;/strong&gt; My branches each appended their result to one shared string variable. Under parallel execution, writes stepped on each other and results came back garbled. The fix was to stop using a mutable shared variable inside parallel branches entirely and give each branch its own scoped output object, then aggregate once at the end with a single atomic write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A variable cannot reference itself in its own assignment.&lt;/strong&gt; I had a fallback that set a variable to a value derived from &lt;em&gt;that same variable&lt;/em&gt; — and the platform rejected it outright with a "self-reference is not supported" error. The fix was to wrap the assignment in a condition: only overwrite the variable when the upstream step actually succeeded; otherwise leave it holding whatever it already had.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every action name must be globally unique&lt;/strong&gt;, even across nested scopes. Duplicate an action into a second path and forget to rename it, and the whole definition fails to save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch containers have hard caps.&lt;/strong&gt; The switch construct I was leaning on maxes out at twenty-five cases. The moment my tool set grew past that, I had to nest switches inside switches — which is exactly as fun to hand-maintain as it sounds. (More on how I stopped hand-maintaining it below.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; parallelism buys you latency but charges you correctness. Every shortcut that was fine in a sequential flow — shared state, casual naming, one big switch — becomes a bug the instant things run at the same time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Learning #3: Routing is a first-class problem, not an afterthought
&lt;/h2&gt;

&lt;p&gt;Once the assistant could answer fast, it started answering &lt;em&gt;wrong&lt;/em&gt; — not factually wrong, but routed to the wrong place. A question like "who is the admin for the [city] office?" got sent to a live-data lookup instead of the knowledge base. "How do I apply for leave?" got answered from policy documents instead of the how-to system manual, because both mention the word "leave."&lt;/p&gt;

&lt;p&gt;I'd been routing on keywords. Keywords can't tell the difference between &lt;em&gt;asking about&lt;/em&gt; a topic and &lt;em&gt;asking how to do&lt;/em&gt; something in that topic.&lt;/p&gt;

&lt;p&gt;Two changes fixed this:&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;intent-first routing&lt;/strong&gt;. Before matching any topic, classify what the user actually wants — a how-to, a policy lookup, a data read, an action. Classify intent, &lt;em&gt;then&lt;/em&gt; pick the source. Ambiguous words like "leave" stop being landmines once you've already decided the user wants instructions rather than policy.&lt;/p&gt;

&lt;p&gt;Second, a &lt;strong&gt;hybrid answer pattern&lt;/strong&gt; instead of a single winner-take-all route. The knowledge base runs as a default for every question. A specialist path runs only when the router judges it relevant. Then a final model scores both outputs and either picks the stronger one or merges them when both clear a relevance bar. I also added a dedicated query-rewriting step before every search so the query hitting the index was optimized rather than raw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; don't make routing an all-or-nothing bet on one classifier. Let a cheap default always run, let specialists run when warranted, and let a synthesizer decide at the end. It's more forgiving of the router being occasionally wrong — which it will be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Learning #4: Identity is the hard part nobody puts on the roadmap
&lt;/h2&gt;

&lt;p&gt;This one cost me more time than any clever routing logic. The assistant doesn't just need to know &lt;em&gt;what&lt;/em&gt; you asked — it needs to know &lt;em&gt;who you are&lt;/em&gt; across every system, and every system identifies you differently.&lt;/p&gt;

&lt;p&gt;The core HR system keyed people one way. The skills and project systems used a different internal integer ID. The engagement platform used yet another. Passing the wrong identifier to the wrong API didn't throw a clean error — it silently returned someone else's data, or nothing, or results from years ago.&lt;/p&gt;

&lt;p&gt;I learned to treat identity as its own resolution step. &lt;strong&gt;Resolve the user once, up front&lt;/strong&gt;, using a universal lookup key (email worked as the fallback that every system understood), map that to each system's internal ID, and then inject the correct ID into every downstream call. I also established one firm rule: there is exactly one canonical cross-system employee identifier, and local, system-specific IDs &lt;em&gt;never&lt;/em&gt; leave their own system — no matter how tempting the parameter name makes it look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; in any multi-system integration, identity mapping is not plumbing you can bolt on later. It's foundational. Get it wrong and every feature built on top inherits a silent data-correctness bug.&lt;/p&gt;

&lt;p&gt;And a related one that kept biting me: &lt;strong&gt;field-name mismatches fail silently.&lt;/strong&gt; An API expects &lt;code&gt;emp_id&lt;/code&gt;, you send &lt;code&gt;employee_id&lt;/code&gt;, and instead of an error you get an empty result you'll misread as "no data." I started defending against this with fallback chains over the likely field names — but the real fix was always to stop guessing and verify the actual field names from run history.&lt;/p&gt;




&lt;h2&gt;
  
  
  Learning #5: When the definition has 54 tools, stop editing it by hand
&lt;/h2&gt;

&lt;p&gt;By this point the assistant had grown from a handful of branches into a genuine orchestrator exposing dozens of tools — read-only lookups and read-write actions merged into a single definition with more than fifty tools and nested switches several layers deep.&lt;/p&gt;

&lt;p&gt;You cannot hand-edit a file like that reliably. One duplicated action name, one branch that points to a step that no longer exists, one switch that quietly grew past its case limit, and the whole thing refuses to deploy — usually with an error that points nowhere near the actual cause.&lt;/p&gt;

&lt;p&gt;So I stopped writing the definition by hand and started &lt;strong&gt;generating it with a script that validated as it built&lt;/strong&gt;. Every build ran the same checks before producing output: no duplicate action names anywhere in the tree, no branch referencing a non-existent predecessor, no switch over its case cap, no variable referencing itself. Structural errors got caught at build time, at my desk, instead of at deploy time in front of everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; past a certain complexity, your configuration &lt;em&gt;is&lt;/em&gt; code, and it deserves the same treatment — generated, validated, and tested before it ships. Catching a structural error in a build script is cheap. Catching it in production is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rearchitecture that finally clicked
&lt;/h2&gt;

&lt;p&gt;Every fix above made the system better, but they were all improvements to the same fundamental shape: a big hand-orchestrated workflow with an intermediate agent standing between the user and the tools. That intermediate layer was expensive — it consumed tokens on every single turn, it could only really handle one question at a time, and it leaned on data that was up to a day stale because it read from a batch-loaded store.&lt;/p&gt;

&lt;p&gt;The unlock was adopting the &lt;strong&gt;Model Context Protocol (MCP)&lt;/strong&gt; and letting the model talk to the tools directly through a standard interface, removing the intermediate orchestrator entirely. Three things fell out of that at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Multi-question support.&lt;/strong&gt; A user could now ask "what's my leave policy &lt;em&gt;and&lt;/em&gt; my current balance?" in one message and get both, because the model could invoke multiple tools in a single turn instead of being funneled through a single-intent pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower cost.&lt;/strong&gt; Deleting the intermediate agent deleted its per-turn token overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time data.&lt;/strong&gt; Direct tool calls hit live sources instead of a day-old snapshot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That was the moment the thing stopped feeling like a chatbot bolted onto some search indexes and started feeling like an assistant that could actually reason about which tools to use and combine their results.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;The assistant went from a fifteen-second single-answer bot to something most of the workforce uses, resolving a large share of routine queries on its own, in a fraction of the original response time, across every country the organization operates in. It reads live data, answers policy questions grounded in the right regional content, and is moving steadily toward safely taking actions on the user's behalf.&lt;/p&gt;

&lt;p&gt;The number I'm proudest of isn't a latency figure. It's that the architecture is now boring in the right way — a clean tool interface, generated and validated configuration, identity resolved once and correctly, routing that fails gracefully. Boring, at this scale, is the achievement.&lt;/p&gt;




&lt;h2&gt;
  
  
  The lessons that generalize
&lt;/h2&gt;

&lt;p&gt;If you're building something similar, here's what I wish I'd known at the start, in the order the project taught them to me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A slow demo is an architecture problem, not a tuning problem.&lt;/strong&gt; Question the shape before you optimize the parts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convenience abstractions over retrieval quietly degrade retrieval.&lt;/strong&gt; Know exactly what query is hitting your index and what reaches your model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallelism trades latency for correctness.&lt;/strong&gt; Shared state, loose naming, and one giant branch are all fine until things run concurrently — then they're bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route on intent, not keywords, and don't bet everything on one classifier.&lt;/strong&gt; A cheap always-on default plus a scored synthesizer is far more forgiving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity mapping across systems is foundational, not plumbing.&lt;/strong&gt; One canonical ID; local IDs never travel; resolve the user once, up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent failures are the expensive ones.&lt;/strong&gt; Empty results from a field-name mismatch will mislead you longer than any exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Past a certain size, your config is code.&lt;/strong&gt; Generate it, validate it, and catch structural errors before deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The right abstraction removes a layer instead of adding one.&lt;/strong&gt; The biggest win of the whole project came from &lt;em&gt;deleting&lt;/em&gt; the orchestrator in the middle, not making it smarter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The learning curve wasn't a smooth ramp. It was a series of walls, and each wall taught me exactly one thing I couldn't have learned any other way. That's the part worth writing down.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>One pane over 258 resources — and every one of them talking back.</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:00:54 +0000</pubDate>
      <link>https://dev.to/vignesh_athiappan_818c9e0/one-pane-over-258-resources-and-every-one-of-them-talking-back-110a</link>
      <guid>https://dev.to/vignesh_athiappan_818c9e0/one-pane-over-258-resources-and-every-one-of-them-talking-back-110a</guid>
      <description>&lt;p&gt;Fifteen enterprise systems, fifty-plus integration workflows, a CI/CD estate, and an Azure bill that grows on its own. Here is what changed when all of it started reporting to a single operations layer — including the line most teams never watch: the invoice.&lt;/p&gt;

&lt;p&gt;Vignesh Athiappan&lt;br&gt;
Engineering &amp;amp; Enterprise Systems&lt;br&gt;
~14 min read&lt;/p&gt;

&lt;p&gt;A modern enterprise platform doesn't fail loudly. It fails quietly — a sync lane that stops moving records at 2 a.m., a pipeline that has been red for three days, a single AI resource quietly eating a quarter of the monthly bill. None of these page you. All of them cost you. The gap between "something is wrong" and "we know what's wrong" is where operational credibility lives or dies, and for most teams that gap is measured in days because the truth is scattered across a dozen portals.&lt;/p&gt;

&lt;p&gt;I run an integration estate that connects fifteen-plus enterprise systems through more than fifty Azure Logic App workflows — an applicant tracking system, a CRM, a service desk, a document store, a financials platform, and the internal products stitched on top of them. On paper it's healthy. In practice, answering a simple question like "is everything moving right now?" used to mean opening the Azure portal, then Azure DevOps, then three run-history blades, then Cost Management, then a spreadsheet. By the time you'd assembled the picture, it was stale.&lt;/p&gt;

&lt;p&gt;So I built a console that assembles it for you. This is a write-up of what that unified operations layer watches, why a single pane of glass is a genuine capability rather than a dashboard vanity project, and — the part teams consistently under-invest in — how putting the Azure bill on the same screen as the health signals turns cost from a monthly surprise into an operational instrument.&lt;/p&gt;

&lt;p&gt;15+&lt;br&gt;
enterprise systems under one integration fabric&lt;br&gt;
50+&lt;br&gt;
Logic App workflows moving records across lanes&lt;br&gt;
258&lt;br&gt;
Azure resources billed on a single subscription&lt;br&gt;
69%&lt;br&gt;
of spend concentrated in the top 10 resources&lt;br&gt;
The problem&lt;/p&gt;

&lt;p&gt;The estate outgrew any single portal&lt;br&gt;
Scale doesn't announce itself as complexity. It announces itself as surface area. Every system you integrate adds a place where things can silently stall, a new failure mode, a new credential that quietly expires, and a new line on the invoice. Individually each is manageable. Collectively they exceed what any one native tool was built to show you, because the native tools are organized around resources — this Logic App, that pipeline, this SQL database — and incidents are organized around flows: a candidate that didn't reach the ATS, a project that didn't sync to financials, a report that came back empty.&lt;/p&gt;

&lt;p&gt;The console reorganizes the estate the way incidents actually arrive. Instead of a resource inventory, it's a set of questions, each with its own view:&lt;/p&gt;

&lt;p&gt;Integration · Jobs&lt;br&gt;
Is everything moving, and did last night's batch finish?&lt;br&gt;
Live run status across the workflow fabric, with the scheduled jobs that feed it.&lt;br&gt;
Platform ↔ ERP&lt;br&gt;
Are projects and financials in agreement?&lt;br&gt;
The sync lane between the core platform and the financials system, record-for-record.&lt;br&gt;
CRM ↔ ERP · CRM ↔ Platform&lt;br&gt;
Is the commercial side reconciled everywhere it needs to be?&lt;br&gt;
Two more lanes, watched with the same lens so a break in one doesn't hide behind another.&lt;br&gt;
Credentials&lt;br&gt;
What is about to expire and take a lane down with it?&lt;br&gt;
The failure mode that never shows up in a health check until it's already an outage.&lt;br&gt;
Autofill · Automation&lt;br&gt;
Are the automations still doing their job, or silently no-op?&lt;br&gt;
The quiet helpers that only get noticed when they stop.&lt;br&gt;
Observability · Data Lake&lt;br&gt;
How is each product actually performing under load?&lt;br&gt;
Each product surface, the REST APIs, and the Bronze→Silver→Gold lake, each with its own performance view.&lt;br&gt;
The point of the grid isn't that it's comprehensive. It's that each tile answers a question an engineer or an executive would otherwise have to reconstruct by hand. A view earns its place on the pane only if it collapses a five-portal investigation into a glance.&lt;/p&gt;

&lt;p&gt;Native tools are organized around resources. Incidents are organized around flows. The distance between those two shapes is where time-to-detect goes to hide.&lt;br&gt;
Delivery is a health signal too&lt;/p&gt;

&lt;p&gt;Your pipelines are telling you something — read them&lt;br&gt;
Runtime health is only half the story. The other half is whether the team can safely change the system, and that lives in the delivery pipeline. The console pulls a read-only view straight from the Azure DevOps REST API — a personal access token with read scopes only, nothing stored — and turns raw build history into an operational read on the delivery org itself.&lt;/p&gt;

&lt;p&gt;Five numbers sit at the top: pipeline runs in the window, success rate, failed runs, open pull requests, and PRs completed. On their own those are trivia. What makes them operational is what sits underneath:&lt;/p&gt;

&lt;p&gt;Failure reasons, not just failure counts. For every failed run, the view reads the build timeline, finds the task that actually broke, and surfaces the first line of its error message — so "17 failures" becomes "the same missing environment variable, seventeen times." That is the difference between an alert and a diagnosis.&lt;br&gt;
A pipeline heartbeat. Each pipeline shows its last fifteen runs as a strip of green-and-red ticks. A wall of green is trust. An alternating stripe is a flaky pipeline you'd never catch from an aggregate pass rate. Your eye finds the pattern before any threshold would fire.&lt;br&gt;
People and repositories. Who triggered runs, who opened, reviewed, and completed PRs, and where the review load actually falls — the org's real bus-factor, made visible instead of assumed.&lt;br&gt;
Detection is the whole game. A failed deploy that a human notices on Thursday when a report looks wrong is an incident with a three-day fuse. The same failure, shown as a red tick beside its own error message the moment it happens, is a five-minute fix. The console doesn't make the pipelines more reliable — it makes their unreliability impossible to ignore, which is what actually shortens mean-time-to-resolve.&lt;/p&gt;

&lt;p&gt;Follow the money&lt;/p&gt;

&lt;p&gt;The one line nobody watches: the Azure bill&lt;br&gt;
Here's the view that changes how leadership sees the whole thing. Cloud cost is usually treated as an accounting artifact — a monthly PDF someone forwards with a raised eyebrow. Put it on the operations pane, in the same visual language as everything else, and it becomes a signal you can act on in real time. For the first two weeks of the billing period, the picture looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9uxrniss323u578qkbdv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9uxrniss323u578qkbdv.png" alt=" " width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A pie chart is not an insight. What the console does is read three signals out of that spend, and each one is a decision waiting to be made:&lt;/p&gt;

&lt;p&gt;1 · Concentration — where a small change moves the whole bill&lt;br&gt;
Ten resources out of 258 account for 69% of the spend, and a single resource group — the one hosting the AI/inference stack — is 27% on its own. That's not a problem; it's leverage. It means cost optimization is a targeted exercise, not a 258-item audit. You tune the top ten and you've touched two-thirds of the invoice. The concentration view exists to point the flashlight exactly there.&lt;/p&gt;

&lt;p&gt;2 · Zero reserved capacity — money left on the table&lt;br&gt;
Every dollar here is pay-as-you-go. Reserved and savings-plan commitments sit at 0%. For steady-state workloads — the SQL estate at a quarter of spend, the always-on compute — pay-as-you-go is the most expensive way to buy predictable capacity. Surfacing "0% pre-paid" next to "100% on-demand" reframes the bill as an open savings lever, typically a double-digit percentage on the reservable share, rather than a fixed cost of doing business.&lt;/p&gt;

&lt;p&gt;3 · Footprint sprawl — 153 workflows on one line&lt;br&gt;
Logic Apps are 19% of spend across 153 resources — by far the largest resource count on the estate. That's the integration fabric doing its job, but it's also the place where an orphaned trigger or a runaway polling interval hides in plain sight. Watching the count and the cost together is how you catch the workflow that's been retrying every thirty seconds since a deploy three weeks ago.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7lf83r2r1elsjhr07dsi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7lf83r2r1elsjhr07dsi.png" alt=" " width="800" height="173"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The AI and Search slice deserves its own note: at a combined 34% it's already the largest category, and it's the one most likely to scale non-linearly as agent and vector workloads grow. Watching it weekly rather than monthly is the difference between a managed ramp and a bill shock.&lt;/p&gt;

&lt;p&gt;Cost isn't a report you receive. On the operations pane it's a gauge you read — and the three signals it shows are all decisions, not just numbers.&lt;br&gt;
Why one pane changes the work&lt;/p&gt;

&lt;p&gt;A broken sync, a red pipeline, and a cost spike are the same incident&lt;br&gt;
The real payoff of a single pane isn't convenience. It's correlation. Consider a genuinely common Tuesday: a deploy goes out, a Logic App starts failing, its retries spike the run count, the run count spikes the Logic Apps cost line, and a downstream sync lane quietly stops reconciling records. In a fragmented world those are four tickets in four tools, investigated by three people, connected by nobody until Friday.&lt;/p&gt;

&lt;p&gt;On one pane they're one story, visible in one scroll: the red tick on the pipeline heartbeat, the failure reason from the build log, the spend line bending upward, the lane going stale. You don't infer the connection — you see it. That is the entire value proposition, and it shows up in numbers that leadership cares about:&lt;/p&gt;

&lt;p&gt;Time-to-detect collapses. Silent failures — the expiring credential, the flaky pipeline, the runaway workflow — surface at a glance instead of at the next complaint.&lt;br&gt;
Time-to-resolve shrinks. Failure reasons arrive pre-diagnosed from the logs, so the first ten minutes of every incident aren't spent finding the tool that has the answer.&lt;br&gt;
Bus-factor becomes visible. The people and review views turn "only one person understands this" from an assumption into a metric you can plan around.&lt;br&gt;
Cost becomes a lever, not a surprise. Concentration, reservation gaps, and sprawl are shown as actions, and the monthly finance conversation starts from a shared, current picture instead of a stale PDF.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzdarfumrr3yw9dsvy6a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnzdarfumrr3yw9dsvy6a.png" alt=" " width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Field notes on platform &amp;amp; enterprise-systems engineering. · Figures are month-to-date on a single Azure subscription and reflect a 14-day window. · Delivery metrics via Azure DevOps REST 7.1, read-only.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>From 15+ Manual Deployments to 2 YAML Templates: Scaling Azure DevOps Pipelines for IIS-Hosted Apps</title>
      <dc:creator>Vignesh Athiappan</dc:creator>
      <pubDate>Tue, 14 Jul 2026 14:35:47 +0000</pubDate>
      <link>https://dev.to/vignesh_athiappan_818c9e0/from-15-manual-deployments-to-2-yaml-templates-scaling-azure-devops-pipelines-for-iis-hosted-apps-3884</link>
      <guid>https://dev.to/vignesh_athiappan_818c9e0/from-15-manual-deployments-to-2-yaml-templates-scaling-azure-devops-pipelines-for-iis-hosted-apps-3884</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I manage &lt;strong&gt;8 web applications&lt;/strong&gt; hosted on a single Windows VM running IIS. Each application has 2–3 deployable components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;Angular frontend&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;.NET backend API&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Often a separate &lt;strong&gt;integration API&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's roughly &lt;strong&gt;20 deployable units&lt;/strong&gt; on one server. For years, every deployment was &lt;strong&gt;manual&lt;/strong&gt; — build locally or on the server, copy files over, restart IIS, pray.&lt;/p&gt;

&lt;p&gt;When I finally decided to automate with Azure DevOps pipelines, I hit the obvious trap immediately: the naive approach means &lt;strong&gt;one pipeline per component&lt;/strong&gt;. That's 20 pipelines today — and every time a new application is built (which happens regularly), I'd be creating 2–3 more. Each pipeline would duplicate the same build/copy/restart logic. Fixing a deployment bug would mean fixing it 20 times.&lt;/p&gt;

&lt;p&gt;On top of the pipeline-count problem, the manual process had three landmines baked into it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;iisreset&lt;/code&gt; kills everything.&lt;/strong&gt; The old scripts restarted the &lt;em&gt;entire&lt;/em&gt; IIS server to deploy &lt;em&gt;one&lt;/em&gt; app — taking down all 8 applications for every single deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config file overwrites.&lt;/strong&gt; Each app has server-side config (&lt;code&gt;appsettings.json&lt;/code&gt; for .NET, &lt;code&gt;assets/config.json&lt;/code&gt; for Angular) that must &lt;strong&gt;never&lt;/strong&gt; be replaced by whatever is in source control. One careless copy and production points at the wrong database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No consistency.&lt;/strong&gt; Every app had slightly different variable names, folder conventions, and deploy steps. Nothing was reusable; everything was tribal knowledge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the real requirement wasn't "create pipelines." It was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Build a deployment system where adding a new application requires zero new pipeline logic.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Solution: Template-Based Pipeline Architecture
&lt;/h2&gt;

&lt;p&gt;Azure DevOps supports &lt;strong&gt;YAML templates&lt;/strong&gt; — reusable pipeline definitions that live in one repository and are &lt;em&gt;referenced&lt;/em&gt; by other repositories. That's the entire foundation of this design.&lt;/p&gt;

&lt;h3&gt;
  
  
  The architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    subgraph TPL["📦 pipeline-templates repo (logic lives here, ONCE)"]
        T1["build-deploy-dotnet.yml"]
        T2["build-deploy-angular.yml"]
    end

    subgraph APPS["Application repos (thin callers, ~20 lines each)"]
        A1["Portal&amp;lt;br/&amp;gt;azure-pipelines.yml"]
        A2["Orders&amp;lt;br/&amp;gt;azure-pipelines.yml"]
        A3["Ops&amp;lt;br/&amp;gt;azure-pipelines.yml"]
        A4["Billing / Reports / Notify / ...&amp;lt;br/&amp;gt;azure-pipelines.yml"]
    end

    subgraph LIB["🔑 Variable groups (one per app)"]
        V1["PORTAL-PROD"]
        V2["ORDERS-PROD"]
        V3["OPS-PROD"]
        V4["..."]
    end

    A1 --&amp;gt;|references| TPL
    A2 --&amp;gt;|references| TPL
    A3 --&amp;gt;|references| TPL
    A4 --&amp;gt;|references| TPL
    A1 -.-&amp;gt;|reads paths from| V1
    A2 -.-&amp;gt;|reads paths from| V2
    A3 -.-&amp;gt;|reads paths from| V3
    A4 -.-&amp;gt;|reads paths from| V4

    TPL ==&amp;gt;|deploys via self-hosted agent| VM["🖥️ Windows VM — IIS&amp;lt;br/&amp;gt;(all 8 apps)"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three building blocks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. One shared &lt;code&gt;pipeline-templates&lt;/code&gt; repository.&lt;/strong&gt; It contains exactly two templates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Handles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build-deploy-dotnet.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Restore → build → publish → protect configs → stop app pool → robocopy deploy → start app pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build-deploy-angular.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node setup → &lt;code&gt;npm ci&lt;/code&gt; → &lt;code&gt;ng build&lt;/code&gt; → protect configs → robocopy deploy → recycle app pool&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;2. A thin &lt;code&gt;azure-pipelines.yml&lt;/code&gt; in each app repo.&lt;/strong&gt; ~20 lines. It declares the templates repo as a resource, points at the app's variable group, and passes a handful of parameters (app pool name, artifact name). It contains &lt;strong&gt;zero deployment logic&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;   &lt;span class="c1"&gt;# manual, deliberate deployments only&lt;/span&gt;

&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;repositories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;templates&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git&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;pipeline-templates&lt;/span&gt;

&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployBackend&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
    &lt;span class="na"&gt;default&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;deployFrontend&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;boolean&lt;/span&gt;
    &lt;span class="na"&gt;default&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PORTAL-PROD&lt;/span&gt;

&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${{ if eq(parameters.deployBackend, &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="s"&gt;) }}&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;templates/build-deploy-dotnet.yml@templates&lt;/span&gt;
      &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;artifactName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Portal_API_Drop&lt;/span&gt;
        &lt;span class="na"&gt;appPoolName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PortalAPIService&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${{ if eq(parameters.deployFrontend, &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="s"&gt;) }}&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;templates/build-deploy-angular.yml@templates&lt;/span&gt;
      &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;artifactName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Portal_Web_Drop&lt;/span&gt;
        &lt;span class="na"&gt;appPoolName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PortalFrontEnd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checkbox parameters (&lt;code&gt;deployBackend&lt;/code&gt;, &lt;code&gt;deployFrontend&lt;/code&gt;, &lt;code&gt;deployIntegration&lt;/code&gt;) mean one pipeline per app covers all its components — the person running it just ticks what they want deployed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Standardized variable groups.&lt;/strong&gt; Every app gets its own variable group (&lt;code&gt;PORTAL-PROD&lt;/code&gt;, &lt;code&gt;ORDERS-PROD&lt;/code&gt;, …) — but the variable &lt;strong&gt;names inside are identical&lt;/strong&gt; across all apps. Only the values differ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BACKEND_SOURCE_PATH        (where the .sln lives in the repo)
BACKEND_DEPLOYMENT_PATH    (C:\inetpub\wwwroot\&amp;lt;App&amp;gt;\...)
FRONTEND_SOURCE_PATH
FRONTEND_DIST_PATH
FRONTEND_TARGET_PATH
NODE_VERSION
AGENT_POOL_NAME / AGENT_NAME
INTEGRATION_* (only if the app has one)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the trick that makes templates truly generic: the app's identity lives in the &lt;strong&gt;group name&lt;/strong&gt;, never in the variable names. The template just reads &lt;code&gt;$(BACKEND_DEPLOYMENT_PATH)&lt;/code&gt; and works for any app.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Deployment Flow
&lt;/h2&gt;

&lt;p&gt;Here's what one deployment actually does, end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A["▶️ Run pipeline manually&amp;lt;br/&amp;gt;(tick Backend / Frontend / Integration)"] --&amp;gt; B["Checkout app repo +&amp;lt;br/&amp;gt;templates repo"]
    B --&amp;gt; C["Build&amp;lt;br/&amp;gt;(dotnet publish / ng build)"]
    C --&amp;gt; D["🛡️ Layer 1: delete protected configs&amp;lt;br/&amp;gt;from BUILD OUTPUT&amp;lt;br/&amp;gt;(appsettings.json, assets/config.json)"]
    D --&amp;gt; E["Publish pipeline artifact"]
    E --&amp;gt; F["⏸️ Stop ONLY this app's&amp;lt;br/&amp;gt;IIS app pool"]
    F --&amp;gt; G["🛡️ Layer 2: robocopy /XF&amp;lt;br/&amp;gt;excludes protected files&amp;lt;br/&amp;gt;during copy"]
    G --&amp;gt; H{"robocopy&amp;lt;br/&amp;gt;exit code &amp;lt; 8?"}
    H --&amp;gt;|yes| I["▶️ Start app pool"]
    H --&amp;gt;|no| J["❌ Fail the pipeline&amp;lt;br/&amp;gt;(pool restarted, investigate)"]
    I --&amp;gt; K["✅ App live —&amp;lt;br/&amp;gt;other 7 apps never blinked"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design decisions here are worth calling out because they came directly from the failure modes of the manual era:&lt;/p&gt;

&lt;h3&gt;
  
  
  App-pool-level recycle, not &lt;code&gt;iisreset&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The template stops and starts &lt;strong&gt;only the app pool of the component being deployed&lt;/strong&gt;. The other applications on the VM stay up throughout. Deploying Portal no longer causes an outage for Orders, Billing, Reports, and everyone else. This alone changed deployments from "schedule it after hours" to "just deploy it."&lt;/p&gt;

&lt;h3&gt;
  
  
  Config protection at two independent layers
&lt;/h3&gt;

&lt;p&gt;Server-side config files are protected with &lt;strong&gt;defense in depth&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Layer 1 — build output cleanup:&lt;/strong&gt; protected files are deleted from the build output &lt;em&gt;before&lt;/em&gt; anything is copied. If it's not in the artifact, it can't overwrite anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layer 2 — copy-time exclusion:&lt;/strong&gt; the deploy uses &lt;code&gt;robocopy&lt;/code&gt; with the &lt;code&gt;/XF&lt;/code&gt; flag listing protected filenames, so even if layer 1 misses something, the copy itself refuses to touch &lt;code&gt;appsettings.json&lt;/code&gt; or &lt;code&gt;config.json&lt;/code&gt; at the destination.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either layer failing alone still leaves production config intact. Both would have to fail simultaneously — and the post-deploy verification step is simply checking the config file's &lt;em&gt;Date Modified&lt;/em&gt; is old.&lt;/p&gt;

&lt;p&gt;Why &lt;code&gt;robocopy&lt;/code&gt; over &lt;code&gt;xcopy&lt;/code&gt;? Reliable retry behavior (&lt;code&gt;/R:2 /W:5&lt;/code&gt;), sane exit codes you can gate on, and the &lt;code&gt;/XF&lt;/code&gt; exclusion list lives &lt;strong&gt;visibly in the pipeline YAML&lt;/strong&gt; instead of in a mystery exclude-file sitting somewhere on the server.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rollout: Pilot → Validate → Replicate
&lt;/h2&gt;

&lt;p&gt;Rolling out an unproven template to 8 production apps simultaneously is how you take prod down 8 times in one afternoon. Instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    P["🧪 Pilot: Portal&amp;lt;br/&amp;gt;(prove the templates)"] --&amp;gt; V["Validate&amp;lt;br/&amp;gt;configs untouched?&amp;lt;br/&amp;gt;only its pool recycled?&amp;lt;br/&amp;gt;app responds?"]
    V --&amp;gt; R["🏭 Assembly line:&amp;lt;br/&amp;gt;Ops → Reports →&amp;lt;br/&amp;gt;Notify → Orders → Billing →&amp;lt;br/&amp;gt;Gateway"]
    R --&amp;gt; S["All pipelines registered&amp;lt;br/&amp;gt;with SAVE only —&amp;lt;br/&amp;gt;team runs them deliberately"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Portal&lt;/strong&gt; was the pilot — including its separate integration-API repo (which gets its own thin caller file, same templates).&lt;/li&gt;
&lt;li&gt;Any template bug found during the pilot gets fixed &lt;strong&gt;once&lt;/strong&gt;, and every future app inherits the fix before it even onboards.&lt;/li&gt;
&lt;li&gt;Every subsequent app was pure assembly line: clone the variable group, update values, drop in the thin YAML, register the pipeline with &lt;strong&gt;Save (not Run)&lt;/strong&gt;. Nothing touches production until someone deliberately runs it, one component at a time, simplest apps first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Onboarding time per app after the pilot: &lt;strong&gt;~10 minutes.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Real-World Gotchas (the stuff no tutorial mentions)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Angular 20's application builder changes the dist path.&lt;/strong&gt; Modern Angular builds output to &lt;code&gt;dist/&amp;lt;project&amp;gt;/browser&lt;/code&gt; — that extra &lt;code&gt;/browser&lt;/code&gt; folder broke the copy path until &lt;code&gt;FRONTEND_DIST_PATH&lt;/code&gt; accounted for it. Older Angular apps output straight to &lt;code&gt;dist/&amp;lt;project&amp;gt;&lt;/code&gt; or plain &lt;code&gt;dist&lt;/code&gt;. This is exactly why dist path is a per-app &lt;em&gt;variable&lt;/em&gt;, not template logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variable group names must match the YAML exactly.&lt;/strong&gt; The #1 source of "variable group could not be found" errors was a mismatch between &lt;code&gt;- group: PORTAL-PROD&lt;/code&gt; in YAML and the actual group name in the Library (including accidental suffixes). Character-for-character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variable group pipeline permissions.&lt;/strong&gt; New pipelines fail with authorization errors until the group grants access. During rollout, setting the group's pipeline permissions to &lt;strong&gt;Open access&lt;/strong&gt; (fine when the group holds no secrets — ours hold only paths) removes the friction; you can tighten to per-pipeline authorization later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nested and non-standard repo layouts.&lt;/strong&gt; One app had its Angular project buried at &lt;code&gt;Frontend/Client/&amp;lt;AppName&amp;gt;&lt;/code&gt;; another had a frontend-only repo; one had suspicious folder names on the server (&lt;code&gt;...Working&lt;/code&gt;) left over from manual-deploy days. Templates survive all of this because &lt;em&gt;paths are data, not logic&lt;/em&gt; — you verify what IIS actually points at, put the truth in the variable group, done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;robocopy /XF&lt;/code&gt; matches filenames anywhere in the tree.&lt;/strong&gt; Excluding &lt;code&gt;config.json&lt;/code&gt; protects &lt;code&gt;assets/config.json&lt;/code&gt; but would also skip any other &lt;code&gt;config.json&lt;/code&gt; in the build. For config-protection purposes that's exactly the behavior you want — just know it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Generic Is This, Really?
&lt;/h2&gt;

&lt;p&gt;Very. Nothing in this design is specific to my apps. The abstraction boundary is clean:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lives in templates (shared, fixed)&lt;/th&gt;
&lt;th&gt;Lives in variable group / parameters (per app)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build steps for a .NET API&lt;/td&gt;
&lt;td&gt;Source path, .NET version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build steps for an Angular app&lt;/td&gt;
&lt;td&gt;Node version, dist path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config protection (both layers)&lt;/td&gt;
&lt;td&gt;Which files are protected (parameter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App pool stop/copy/start sequence&lt;/td&gt;
&lt;td&gt;App pool name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;robocopy flags, error gating&lt;/td&gt;
&lt;td&gt;Source and target paths&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So this pattern transfers directly if you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any number of apps on shared IIS servers&lt;/strong&gt; — the pool-level recycle is what makes multi-tenant VMs safe to deploy to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different stacks&lt;/strong&gt; — a &lt;code&gt;build-deploy-node.yml&lt;/code&gt; or &lt;code&gt;build-deploy-python.yml&lt;/code&gt; template slots in beside the existing two; app repos just reference a different template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple environments&lt;/strong&gt; — the same templates work for staging/UAT: create &lt;code&gt;PORTAL-UAT&lt;/code&gt; variable groups pointing at different paths/servers and pass a different agent. The template never changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple servers&lt;/strong&gt; — the agent name/pool is a variable too. New VM = new agent registration + new values in the groups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The invariant that makes it all work: &lt;strong&gt;templates contain behavior, variable groups contain facts.&lt;/strong&gt; As long as you never let an app-specific fact leak into a template, the system scales linearly with zero added complexity.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The system is live but young. The roadmap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Finish onboarding the last apps&lt;/strong&gt; — a couple were deferred (unclear IIS pool mappings needed verification first). They're a 10-minute job each once verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run-and-validate pass&lt;/strong&gt; — pipelines were registered Save-only; each needs its first deliberate run with the post-deploy checklist (config timestamps unchanged, neighbors still up, endpoint responds).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI triggers&lt;/strong&gt; — everything is manual today (&lt;code&gt;trigger: none&lt;/code&gt;) by design. Once trust is earned, branch-based triggers for lower environments are a one-line change in the thin YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approval gates&lt;/strong&gt; — ADO Environments support manual approval checks before prod deploys, with no extra pipeline logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smarter rollback&lt;/strong&gt; — a backup/restore step existed in early versions and was removed for speed; a versioned-artifact rollback (redeploy the previous pipeline artifact) is the cleaner long-term answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets to Key Vault&lt;/strong&gt; — variable groups currently hold only paths; when secrets enter the picture, they'll link to Azure Key Vault rather than living in the Library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment visibility&lt;/strong&gt; — an executive dashboard over the ADO REST APIs (build runs, PR activity, failure reasons) is already in progress, closing the loop from "we deploy reliably" to "leadership can see it."&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't count pipelines — count places logic lives.&lt;/strong&gt; 8 apps, 20 deployables, and the deployment logic exists in exactly 2 files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standardize variable &lt;em&gt;names&lt;/em&gt;, vary the &lt;em&gt;values&lt;/em&gt;.&lt;/strong&gt; That's the entire trick behind generic templates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect production config with two independent layers.&lt;/strong&gt; One layer is a hope; two is a design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recycle app pools, never the server.&lt;/strong&gt; Shared IIS VMs and &lt;code&gt;iisreset&lt;/code&gt; don't mix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pilot on one app before replicating.&lt;/strong&gt; Fix template bugs once, inherit fixes everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register with Save, run deliberately.&lt;/strong&gt; Automation should reduce risk, not add surprise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Onboarding the next application my team builds will take 10 minutes and zero new pipeline logic. That was the whole point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Azure DevOps YAML templates, variable groups, a self-hosted agent, robocopy, and a healthy fear of &lt;code&gt;iisreset&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
