From Polling Scripts to On‑Box Azure DevOps Deployments for IIS

Absolutely — here’s the fully updated blog article, rewritten cleanly end-to-end, and using the sanitized names consistently (no CDIS, no real server names, no identifiable paths beyond generic IIS defaults).


From Polling Scripts to On-Box Azure DevOps Deployments for IIS (Without PATs)

TL;DR: We replaced a custom polling script (using PAT + REST) with a clean Azure DevOps YAML pipeline that builds a stamped index.html and push-deploys directly on the IIS server via a self-hosted agent. We also added a quick “bogus deploy” smoke test to validate write access without touching artifacts.


Why We Changed

We originally deployed to IIS by running a PowerShell loop on the server that:

  • Polled Azure DevOps for the latest succeeded build
  • Downloaded the artifact ZIP
  • Extracted and robocopied into the site folder

It worked — but it required:

  • A PAT stored on the server
  • A long-running polling process
  • Extra maintenance
  • Limited traceability inside Azure DevOps

We wanted to simplify and modernize the flow using YAML pipelines and a self-hosted agent that runs directly on the IIS server.


Target Architecture

Before

Build → Publish Artifact → [Polling Script on IIS Box] → Deploy

After

YAML Pipeline
 ├─ Build (create + publish HTML)
 └─ Deploy (runs on IIS server via environment resource)
    └─ Download artifact → Robocopy to site path

Key Ideas We Adopted

  • Deployments run on the IIS box using a self-hosted agent
  • No more PATs or REST loops
  • Traceable, auditable deployments through Azure DevOps Environments

The “Bogus Deploy” Smoke Test

Before we wired in build artifacts, we validated that:

  1. The correct IIS server was being targeted
  2. The self-hosted agent could write into the IIS site directory

The fastest way to prove this was not a full build/deploy — it was simply creating a timestamped text file on the server.

Bogus Deploy YAML (Sanitized)

trigger:
- main

stages:
- stage: Bogus_Deploy
  displayName: 'Bogus Deploy on IIS01'
  jobs:
  - deployment: SmokeToIIS
    displayName: 'Write timestamp file to IIS path'
    environment: 'UAT.IIS01'  # exact environment.resource
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: none
          - powershell: |
              $deployDest = 'D:\inetpub\wwwroot\MyWebApp'
              $now = Get-Date
              $file = Join-Path $deployDest ("deploy-smoke_{0}_{1}.txt" -f $env:AGENT_NAME, $now.ToString('yyyyMMdd_HHmmss'))

              Write-Host "Agent: $env:AGENT_NAME on $env:COMPUTERNAME"
              Write-Host "Target: $deployDest"

              New-Item -ItemType Directory -Force -Path $deployDest | Out-Null

              "Agent=$env:AGENT_NAME`r`nMachine=$env:COMPUTERNAME`r`nWhen=$($now.ToString('yyyy-MM-dd HH:mm:ss'))" |
                Out-File -FilePath $file -Encoding utf8 -Force

              if (!(Test-Path $file)) { throw "Failed to create test file at $file" }
              Write-Host "Created file: $file"

Tip: Don’t Fight the Scheduler

When using Azure DevOps Environments, bind to the specific machine resource like this:

environment: 'UAT.IIS01'

Also:

Don’t set a top-level pool: for deployment jobs.
Let the Environment select the correct agent.


The Final Build → Deploy Pipeline

Once the smoke test proved the server targeting + permissions were correct, we moved to the full pipeline.

This pipeline:

  • Builds a stamped index.html with metadata:
    • Build ID
    • Build Number
    • Branch
    • Commit
    • Timestamp
  • Publishes a Build Artifact named drop
  • Deploys the artifact on the IIS server by:
    • downloading it into the agent workspace
    • robocopying it into the IIS site folder
  • Does not recycle the app pool (by request)

Final Pipeline YAML (Sanitized, Working)

trigger:
- main

variables:
  artifactName: 'drop'
  packageFolder: '$(Build.ArtifactStagingDirectory)\package'
  deployDest: 'D:\inetpub\wwwroot\MyWebApp'
  appPoolName: 'DefaultAppPool'   # kept for future use, not used now

stages:
# =========================
# 1) BUILD: produce HTML and publish as Build Artifact
# =========================
- stage: Build
  displayName: 'Build & Publish Artifact'
  jobs:
  - job: BuildJob
    displayName: 'Build Job'
    pool:
      name: Default
    steps:
    - powershell: |
        New-Item -ItemType Directory -Force -Path "$(packageFolder)" | Out-Null

        $html = @'
        <!doctype html>
        <html lang="en">
        <head>
          <meta charset="utf-8" />
          <meta name="viewport" content="width=device-width,initial-scale=1" />
          <title>Deploy Test</title>
          <style>
            body { font-family: Arial, sans-serif; margin: 40px; }
            .meta { margin-top: 1rem; color: #333; }
            code { background:#f2f2f2; padding:2px 4px; }
          </style>
        </head>
        <body>
          <h1>Deploy Test</h1>
          <div class="meta">
            <p>Built: <code>__BUILT__</code></p>
            <p>Build Number: <code>__BUILDNUMBER__</code></p>
            <p>Build ID: <code>__BUILDID__</code></p>
            <p>Source Branch: <code>__BRANCH__</code></p>
            <p>Commit: <code>__COMMIT__</code></p>
          </div>
        </body>
        </html>
        '@

        $html = $html.Replace("__BUILT__", (Get-Date -Format "yyyy-MM-dd HH:mm:ss"))
        $html = $html.Replace("__BUILDNUMBER__", "$(Build.BuildNumber)")
        $html = $html.Replace("__BUILDID__", "$(Build.BuildId)")
        $html = $html.Replace("__BRANCH__", "$(Build.SourceBranch)")
        $html = $html.Replace("__COMMIT__", "$(Build.SourceVersion)")

        $outFile = Join-Path "$(packageFolder)" "index.html"
        $html | Out-File -FilePath $outFile -Encoding utf8

        Write-Host "Generated:"
        Get-ChildItem "$(packageFolder)" | Format-Table -AutoSize
      displayName: 'Create index.html'

    - task: PublishBuildArtifacts@1
      displayName: 'Publish Build Artifact'
      inputs:
        PathtoPublish: '$(packageFolder)'
        ArtifactName: '$(artifactName)'
        publishLocation: 'Container'

# =========================
# 2) DEPLOY: run on IIS01, copy to IIS (no recycle, no verify)
# =========================
- stage: Deploy
  displayName: 'Deploy to IIS (IIS01)'
  dependsOn: Build
  condition: succeeded()
  jobs:
  - deployment: DeployToIIS01
    displayName: 'Copy to D:\inetpub\wwwroot\MyWebApp'
    environment: 'UAT.IIS01'   # ensures execution on the IIS server
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: none

          - task: DownloadBuildArtifacts@1
            displayName: 'Download Build Artifact (current run)'
            inputs:
              buildType: 'current'
              downloadType: 'single'
              artifactName: '$(artifactName)'
              downloadPath: '$(Pipeline.Workspace)\$(artifactName)'

          - powershell: |
              New-Item -ItemType Directory -Force -Path "$(deployDest)" | Out-Null
              Write-Host "Destination exists: $(deployDest)"
            displayName: 'Prep destination'

          - powershell: |
              $src  = "$(Pipeline.Workspace)\$(artifactName)"
              $dest = "$(deployDest)"
              Write-Host "Copying from $src to $dest"
              # Use /MIR instead of /E if you want to mirror deletions
              robocopy $src $dest /E /R:2 /W:2 /NFL /NDL /NP
              $rc = $LASTEXITCODE
              Write-Host "Robocopy exit code: $rc"
              if ($rc -ge 8) { throw "Robocopy failed with exit code $rc" }
              $global:LASTEXITCODE = 0
              Write-Host "Copy complete."
            displayName: 'Deploy files'

What You Need to Replace

This sanitized pipeline still requires you to replace a few values for your environment:

  • main → your trigger branch
  • Default (pool name) → your build agent pool
  • UAT.IIS01 → your Environment + Resource name
  • D:\inetpub\wwwroot\MyWebApp → your IIS site folder

Lessons Learned (and Gotchas)

1) Environment Targeting Matters

Use:

environment: 'UAT.IIS01'

If you only rely on pool: Default, Azure DevOps may schedule the deployment on the wrong agent.


2) Robocopy Exit Codes Will Break Your Pipeline If You Don’t Handle Them

Robocopy exit codes are not like normal Windows commands:

  • 0..7 = success
  • 8+ = failure

So even “files copied” may return 1, which can break a pipeline unless you normalize it:

if ($rc -ge 8) { throw "Robocopy failed with exit code $rc" }
$global:LASTEXITCODE = 0

3) Artifact Task Types Must Match

To avoid errors like:

  • Invalid hex string
  • Hashtype Dedup64K

Stick with:

  • PublishBuildArtifacts@1
  • DownloadBuildArtifacts@1

Don’t mix these with Pipeline Artifact tasks unless you fully switch.


4) Smoke Testing Is Faster Than Debugging

For quick validation:

  • Bogus deploy = seconds
  • Full artifact deploy = minutes
  • Debugging a mis-targeted environment = hours

5) Security Improves Instantly

This approach removes:

  • PATs on servers
  • on-box REST scripts
  • long-running polling loops

And replaces them with:

  • a pipeline-managed deployment
  • auditable history
  • environment-based controls

Minimal Checklist

Before this works, you need:

  • ✅ Self-hosted agent installed on the IIS server
  • ✅ IIS server registered as a Resource under an Environment
  • ✅ Deployment job uses environment: '<ENV>.<RESOURCE>'
  • ✅ Build publishes Build Artifact (drop)
  • ✅ Deploy downloads same artifact name
  • ✅ Robocopy exit code normalized ($global:LASTEXITCODE = 0)
  • ✅ (Optional) HTTP smoke test with cache-busting

What’s Next

Once this is stable, the next improvements are easy:

  • Add Environment approvals/checks for UAT/Prod
  • Flip /E/MIR to keep the target folder strictly in sync
  • Add rollback (timestamped backup + rollback stage)
  • Expand from a single HTML file into a real IIS app deployment


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *