Sometimes the “proper” way to deploy an application in Azure DevOps is to use a Release pipeline, an environment, or a deployment agent.
And sometimes… that simply isn’t an option.
In our case, we needed a working solution where:
- Azure DevOps Server could build successfully
- The build output could be published as an artifact
- A separate IIS server could poll for the latest successful build
- The IIS server could download and deploy the artifact automatically
- No Azure DevOps agent was required on the IIS machine
This post explains the working pattern we ended up using.
The Core Idea
We broke deployment into two simple pieces:
✅ 1. Build Pipeline Publishes an Artifact
The build pipeline publishes a Build Artifact (Container) named:
drop
✅ 2. IIS Server Polls for New Builds
A PowerShell script running on the IIS server:
- checks for the latest successful build
- downloads the
dropartifact as a ZIP - extracts it
- copies the output into the IIS folder using
robocopy
Why We Used Build Artifacts (Not Pipeline Artifacts)
Azure DevOps supports two artifact types:
- Build Artifacts (PublishBuildArtifacts@1)
- Pipeline Artifacts (PublishPipelineArtifact@1)
We used Build Artifacts, because they support a clean REST download endpoint that can be fetched directly as a ZIP:
.../builds/{id}/artifacts?artifactName=drop&$format=zip
This is perfect for a lightweight polling approach.
Step 1 — The Build Pipeline YAML
Here is a minimal working pipeline that generates a simple index.html file and publishes it as the artifact.
This is useful as a “proof of deployment” because you can browse the IIS site and immediately see the build metadata.
trigger:
- main
pool:
name: Default
variables:
artifactName: 'drop'
packageFolder: '$(Build.ArtifactStagingDirectory)\package'
stages:
- stage: Build
displayName: 'Build & Publish Artifact'
jobs:
- job: BuildJob
displayName: 'Build Job'
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>
</head>
<body>
<h1>Deploy Test</h1>
<p>Built: __BUILT__</p>
<p>Build Number: __BUILDNUMBER__</p>
<p>Build ID: __BUILDID__</p>
<p>Source Branch: __BRANCH__</p>
<p>Commit: __COMMIT__</p>
</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)")
$html | Out-File -FilePath "$(packageFolder)\index.html" -Encoding utf8
displayName: 'Create index.html'
- task: PublishBuildArtifacts@1
displayName: 'Publish Build Artifact (Container)'
inputs:
PathtoPublish: '$(packageFolder)'
ArtifactName: '$(artifactName)'
publishLocation: 'Container'
Step 2 — The IIS Poll + Deploy Script
On the IIS machine, we run a PowerShell script that:
- Polls for the newest successful build
- Downloads the artifact ZIP
- Extracts it
- Deploys it to an IIS folder with robocopy
- Tracks the last deployed build ID so it doesn’t redeploy repeatedly
Key deployment detail
We intentionally did not run:
iisreset
Because that restarts IIS globally and can disrupt other sites.
Instead, we just copied files into place. ASP.NET Core under IIS will typically reload on the next request.
Common Gotchas We Hit
1) Branch mismatch (master vs main)
This was the biggest “it’s not working” trap.
If your poll script is filtering on:
refs/heads/master
but your pipeline runs on:
refs/heads/main
you will never see new builds.
Fix: either remove branch filtering entirely or set the correct branch.
2) Wrong project scope
Azure DevOps Server REST endpoints behave differently depending on whether you are using:
- Collection-level URLs
- Project-level URLs
If you get unexpected 404s, your REST base URL is probably wrong.
3) Definition IDs don’t match
Pipeline “names” are not enough.
Your script needs the actual definition ID.
The easiest way to confirm it is by listing definitions:
.../_apis/build/definitions
4) Build artifact ZIP contains another ZIP
This surprised us at first.
Depending on how the build output is staged, you may end up with:
artifact.zip
drop/
SomePackage.zip
So the script needs to handle a “ZIP inside ZIP” case.
Why This Approach Works Well
This pattern is:
✅ simple
✅ reliable
✅ auditable
✅ easy to debug
✅ works without an agent
✅ doesn’t require release pipelines
And most importantly:
It’s extremely practical in locked-down environments.
Final Thoughts
Is this approach “the official Azure DevOps way”?
Not really.
But it’s a clean, repeatable deployment method that works in environments where:
- installing agents is painful
- environment approvals are blocked
- classic release pipelines are not available
- you just need the thing deployed to IIS
And once you have it working, it’s rock solid.
Leave a Reply