Deploying an Azure DevOps Server Build Artifact to IIS Without Using an Agent

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 drop artifact 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:

  1. Polls for the newest successful build
  2. Downloads the artifact ZIP
  3. Extracts it
  4. Deploys it to an IIS folder with robocopy
  5. 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.


Posted

in

by

Tags:

Comments

Leave a Reply

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