DEV Community

From 15+ Manual Deployments to 2 YAML Templates: Scaling Azure DevOps Pipelines for IIS-Hosted Apps

The Problem

I manage 8 web applications hosted on a single Windows VM running IIS. Each application has 2–3 deployable components:

  • An Angular frontend
  • A .NET backend API
  • Often a separate integration API

That's roughly 20 deployable units on one server. For years, every deployment was manual - build locally or on the server, copy files over, restart IIS, pray.

When I finally decided to automate with Azure DevOps pipelines, I hit the obvious trap immediately: the naive approach means one pipeline per component. 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.

On top of the pipeline-count problem, the manual process had three landmines baked into it:

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

So the real requirement wasn't "create pipelines." It was: Build a deployment system where adding a new application requires zero new pipeline logic.

The Solution: Template-Based Pipeline Architecture

Azure DevOps supports YAML templates - reusable pipeline definitions that live in one repository and are referenced by other repositories. That's the entire foundation of this design.

The architecture:

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<br/>azure-pipelines.yml"]
        A2["Orders<br/>azure-pipelines.yml"]
        A3["Ops<br/>azure-pipelines.yml"]
        A4["Billing / Reports / Notify / ...<br/>azure-pipelines.yml"]
    end
    subgraph LIB["πŸ”‘ Variable groups (one per app)"]
        V1["PORTAL-PROD"]
        V2["ORDERS-PROD"]
        V3["OPS-PROD"]
        V4["..."]
    end
    A1 -->|references| TPL
    A2 -->|references| TPL
    A3 -->|references| TPL
    A4 -->|references| TPL
    A1 -.->|reads paths from| V1
    A2 -.->|reads paths from| V2
    A3 -.->|reads paths from| V3
    A4 -.->|reads paths from| V4
    TPL ==>|deploys via self-hosted agent| VM["πŸ–₯️ Windows VM - IIS<br/>(all 8 apps)"]

Three building blocks:

1. One shared pipeline-templates repository. It contains exactly two templates:

Template Handles
build-deploy-dotnet.yml Restore β†’ build β†’ publish β†’ protect configs β†’ stop app pool β†’ robocopy deploy β†’ start app pool
build-deploy-angular.yml Node setup β†’ npm ci β†’ ng build β†’ protect configs β†’ robocopy deploy β†’ recycle app pool

2. A thin azure-pipelines.yml in each app repo. ~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 zero deployment logic.

trigger: none # manual, deliberate deployments only

resources:
  repositories:
    - repository: templates
      type: git
      name: pipeline-templates

parameters:
  - name: deployBackend
    type: boolean
    default: false
  - name: deployFrontend
    type: boolean
    default: false

variables:
  - group: PORTAL-PROD

stages:
  - ${{ if eq(parameters.deployBackend, true) }}:
    - template: templates/build-deploy-dotnet.yml@templates
      parameters:
        artifactName: Portal_API_Drop
        appPoolName: PortalAPIService
  - ${{ if eq(parameters.deployFrontend, true) }}:
    - template: templates/build-deploy-angular.yml@templates
      parameters:
        artifactName: Portal_Web_Drop
        appPoolName: PortalFrontEnd

The checkbox parameters (deployBackend, deployFrontend, deployIntegration) mean one pipeline per app covers all its components - the person running it just ticks what they want deployed.

3. Standardized variable groups. Every app gets its own variable group (PORTAL-PROD, ORDERS-PROD, …) - but the variable names inside are identical across all apps. Only the values differ:

  • BACKEND_SOURCE_PATH (where the .sln lives in the repo)
  • BACKEND_DEPLOYMENT_PATH (C:\inetpub\wwwroot\<App>\...)
  • FRONTEND_SOURCE_PATH
  • FRONTEND_DIST_PATH
  • FRONTEND_TARGET_PATH
  • NODE_VERSION
  • AGENT_POOL_NAME / AGENT_NAME
  • INTEGRATION_* (only if the app has one)

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

The Deployment Flow

Here's what one deployment actually does, end to end:

flowchart TD
    A["▢️ Run pipeline manually<br/>(tick Backend / Frontend / Integration)"] --> B["Checkout app repo +<br/>templates repo"]
    B --> C["Build<br/>(dotnet publish / ng build)"]
    C --> D["πŸ›‘οΈ Layer 1: delete protected configs<br/>from BUILD OUTPUT<br/>(appsettings.json, assets/config.json)"]
    D --> E["Publish pipeline artifact"]
    E --> F["⏸️ Stop ONLY this app's<br/>IIS app pool"]
    F --> G["πŸ›‘οΈ Layer 2: robocopy /XF<br/>excludes protected files<br/>during copy"]
    G --> H{"robocopy<br/>exit code < 8?"}
    H -->|yes| I["▢️ Start app pool"]
    H -->|no| J["❌ Fail the pipeline<br/>(pool restarted, investigate)"]
    I --> K["βœ… App live -<br/>other 7 apps never blinked"]

Two design decisions here are worth calling out because they came directly from the failure modes of the manual era:

App-pool-level recycle, not iisreset. The template stops and starts only the app pool of the component being deployed. 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."

Config protection at two independent layers. Server-side config files are protected with defense in depth:

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

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 Date Modified is old.

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

The Rollout: Pilot β†’ Validate β†’ Replicate

Rolling out an unproven template to 8 production apps simultaneously is how you take prod down 8 times in one afternoon. Instead:

flowchart LR
    P["πŸ§ͺ Pilot: Portal<br/>(prove the templates)"] --> V["Validate<br/>configs untouched?<br/>only its pool recycled?<br/>app responds?"]
    V --> R["🏭 Assembly line:<br/>Ops β†’ Reports β†’<br/>Notify β†’ Orders β†’ Billing β†’<br/>Gateway"]
    R --> S["All pipelines registered<br/>with SAVE only -<br/>team runs them deliberately"]

Portal was the pilot - including its separate integration-API repo (which gets its own thin caller file, same templates). Any template bug found during the pilot gets fixed once, and every future app inherits the fix before it even onboards.

Every subsequent app was pure assembly line: clone the variable group, update values, drop in the thin YAML, register the pipeline with Save (not Run). Nothing touches production until someone deliberately runs it, one component at a time, simplest apps first.

Onboarding time per app after the pilot: ~10 minutes.

Real-World Gotchas (the stuff no tutorial mentions)

  • Angular 20's application builder changes the dist path. Modern Angular builds output to dist/<project>/browser - that extra /browser folder broke the copy path until FRONTEND_DIST_PATH accounted for it. Older Angular apps output straight to dist/<project> or plain dist. This is exactly why dist path is a per-app variable, not template logic.

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

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

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

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

How Generic Is This, Really?

Very. Nothing in this design is specific to my apps. The abstraction boundary is clean:

Lives in templates (shared, fixed) Lives in variable group / parameters (per app)
Build steps for a .NET API Source path, .NET version
Build steps for an Angular app Node version, dist path
Config protection (both layers) Which files are protected (parameter)
App pool stop/copy/start sequence App pool name
robocopy flags, error gating Source and target paths

So this pattern transfers directly if you have:

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

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

What's Next

The system is live but young. The roadmap:

  • Finish onboarding the last apps - a couple were deferred (unclear IIS pool mappings needed verification first). They're a 10-minute job each once verified.
  • Run-and-validate pass - pipelines were registered Save-only; each needs its first deliberate run with the post-deploy checklist (config timestamps unchanged, neighbors still up, endpoint responds).
  • CI triggers - everything is manual today (trigger: none) by design. Once trust is earned, branch-based triggers for lower environments are a one-line change in the thin YAML.
  • Approval gates - ADO Environments support manual approval checks before prod deploys, with no extra pipeline logic.
  • Smarter rollback - 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.
  • Secrets to Key Vault - variable groups currently hold only paths; when secrets enter the picture, they'll link to Azure Key Vault rather than living in the Library.
  • Deployment visibility - 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."

Takeaways

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

Onboarding the next application my team builds will take 10 minutes and zero new pipeline logic. That was the whole point.

Built with Azure DevOps YAML templates, variable groups, a self-hosted agent, robocopy, and a healthy fear of iisreset.

Comments

No comments yet. Start the discussion.