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:
- The correct IIS server was being targeted
- 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.htmlwith 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 branchDefault(pool name) → your build agent poolUAT.IIS01→ your Environment + Resource nameD:\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= success8+= 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 stringHashtype Dedup64K
Stick with:
PublishBuildArtifacts@1DownloadBuildArtifacts@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→/MIRto 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
Leave a Reply