Modern CI/CD for Umbraco Using Azure DevOps & On-Premises IIS (2025 Update)
A few years ago, I shared a walkthrough showing how to deploy Umbraco websites to on-premises servers using classic Azure DevOps Release Pipelines. A lot has changed since then. Today, Umbraco runs on modern .NET, most projects use Vite or Tailwind for their frontend tooling, and Azure DevOps strongly encourages YAML-based pipelines instead of the classic UI-driven releases.
This updated guide reflects how we build and ship Umbraco websites in 2025 — using full automation, environment-aware deployments, and a single YAML pipeline that deploys to UAT and Production IIS servers inside your own datacenter.
Whether you're modernising an old deployment process or starting from scratch, this article gives you a solid blueprint. Just keep in mind: this is a boilerplate. Every organisation has its own requirements — load balancing, replication, shared media strategies, secrets handling, security hardening, and deployment governance. You will almost always need to adapt this workflow to match your exact infrastructure.
Why CI/CD for On-Prem Umbraco Still Matters
Many teams continue to host Umbraco on their own infrastructure — sometimes for compliance reasons, sometimes for integration with local systems, and sometimes because cloud migration simply isn’t practical. Developers, however, expect cloud-like automation regardless of where the servers run.
CI/CD pipelines provide:
- Predictable deployments
- Safe rollouts
- Zero manual file copying
- Consistent server configuration
- Developer-friendly automation
High-Level Architecture
Azure DevOps (Cloud)
- Git repository of your Umbraco solution
- Multi-stage YAML pipeline
- Azure DevOps Environments (UAT & PROD)
On-Premises Servers
- Windows Server with IIS
- Self-hosted Azure DevOps agents
- Dedicated UAT & PROD wwwroot folders
Deployment Flow
- Developer pushes to dev or release/uat
- Pipeline builds the app (.NET + npm)
- Output deployed to UAT
- Same artifact promoted to Production
Installing the Azure DevOps Self-Hosted Agent
1. Create Agent Pool
Organization Settings → Agent Pools → New → Self-hosted
2. Download the Agent
New Agent → Windows → Download ZIP
3. Configure the Agent
mkdir C:\azdo-agent cd C:\azdo-agent
Extract ZIP and run:
.\config.cmd
Then:
- Server URL: https://dev.azure.com/YOURORG/
- PAT: must have Agent Pools (read, manage)
- Pool: OnPrem-IIS
- Name: UAT-IIS or PROD-IIS
- Run as service: Yes
Start the agent:
.\run.cmd
Configuring Azure DevOps Environments
- Pipelines → Environments
- Create UAT-IIS and register UAT server
- Create PROD-IIS and register PROD server
The Complete YAML CI/CD Pipeline
Fully functional pipeline below — copy/paste as-is.
trigger:
- dev
- release/uat
variables:
buildConfiguration: 'Release'
projectFolder: 'www'
publishFolder: '$(Build.ArtifactStagingDirectory)/publish'
nodeVersion: '20.x'
offlineTemplate: 'App_Offline.htm.rename'
offlineFile: 'App_Offline.htm'
stages:
- stage: Build
displayName: 'Build & Publish Umbraco'
jobs:
- job: BuildJob
displayName: 'Build .NET + Tailwind + Vite'
pool:
vmImage: 'windows-latest'
steps:
- checkout: self
- task: NodeTool@0
displayName: 'Install Node.js'
inputs:
versionSpec: '$(nodeVersion)'
- task: Cache@2
displayName: 'Cache npm (frontend)'
inputs:
key: 'npm-frontend | "$(Build.SourcesDirectory)/$(projectFolder)/package-lock.json"'
path: '$(Build.SourcesDirectory)/$(projectFolder)/node_modules'
- task: Cache@2
displayName: 'Cache npm (backoffice)'
inputs:
key: 'npm-backend | "$(Build.SourcesDirectory)/dbl.MadeirasLeiria.backoffice/package-lock.json"'
path: '$(Build.SourcesDirectory)/dbl.MadeirasLeiria.backoffice/node_modules'
- task: Npm@1
displayName: 'npm install (backoffice)'
inputs:
command: 'install'
workingDir: 'dbl.MadeirasLeiria.backoffice'
- task: UseDotNet@2
displayName: 'Install .NET 9 SDK'
inputs:
packageType: 'sdk'
version: '9.0.x'
- task: DotNetCoreCLI@2
displayName: 'Restore NuGet'
inputs:
command: 'restore'
projects: '$(projectFolder)/www.csproj'
- task: DotNetCoreCLI@2
displayName: 'Publish'
inputs:
command: 'publish'
projects: '$(projectFolder)/www.csproj'
arguments: '--configuration $(buildConfiguration) --output $(publishFolder)'
zipAfterPublish: false
- publish: $(publishFolder)
artifact: drop
- stage: Deploy_UAT
displayName: 'Deploy to UAT'
dependsOn: Build
condition: succeeded()
jobs:
- deployment: UATDeploy
displayName: 'Deploy to UAT`
environment:
name: 'UAT-DBL01'
resourceType: VirtualMachine
strategy:
runOnce:
deploy:
steps:
- checkout: none
- download: current
artifact: drop
- task: PowerShell@2
displayName: 'Activate App_Offline'
inputs:
targetType: 'inline'
script: |
$root="D:\WEB\MadeiriasLeiria\uat.madeirasleiria.pt\wwwroot"
$template=Join-Path $root "$(offlineTemplate)"
$offline=Join-Path $root "$(offlineFile)"
if(Test-Path $template){
if(Test-Path $offline){ Remove-Item $offline -Force }
Rename-Item $template $offline -Force
}
- task: PowerShell@2
displayName: 'Clean UAT (preserve media + logs)'
inputs:
targetType: 'inline'
script: |
$path="D:\WEB\MadeiriasLeiria\uat.madeirasleiria.pt\wwwroot"
$preserve=@("wwwroot\media","umbraco\Logs","App_Offline.htm")
$temp=Join-Path $env:TEMP "backup_$(Get-Date -Format yyyyMMddHHmmss)"
New-Item $temp -ItemType Directory | Out-Null
foreach($item in $preserve){
$src=Join-Path $path $item
if(Test-Path $src){
$dest=Join-Path $temp $item
New-Item (Split-Path $dest) -ItemType Directory -Force | Out-Null
robocopy $src $dest /E | Out-Null
}
}
Get-ChildItem $path | ForEach-Object {
if($_.Name -ne "App_Offline.htm"){
Remove-Item $_.FullName -Recurse -Force
}
}
foreach($item in $preserve){
$src=Join-Path $temp $item
if(Test-Path $src){
$dest=Join-Path $path $item
New-Item (Split-Path $dest) -ItemType Directory -Force | Out-Null
robocopy $src $dest /E | Out-Null
}
}
Remove-Item $temp -Recurse -Force
- task: CopyFiles@2
displayName: 'Copy Files'
inputs:
SourceFolder: '$(Pipeline.Workspace)/drop/www'
Contents: |
**
!wwwroot/media/**
!umbraco/Logs/**
TargetFolder: 'D:\WEB\MadeiriasLeiria\uat.madeirasleiria.pt\wwwroot'
OverWrite: true
- task: PowerShell@2
displayName: 'Set ASPNETCORE_ENVIRONMENT'
inputs:
targetType: 'inline'
script: |
$config="D:\WEB\MadeiriasLeiria\uat.madeirasleiria.pt\wwwroot\web.config"
[xml]$xml = Get-Content $config
$node=$xml.SelectSingleNode("//environmentVariable[@name='ASPNETCORE_ENVIRONMENT']")
if($node -eq $null){
$envNode=$xml.SelectSingleNode("//environmentVariables")
$newNode=$xml.CreateElement("environmentVariable")
$newNode.SetAttribute("name","ASPNETCORE_ENVIRONMENT")
$newNode.SetAttribute("value","Staging")
$envNode.AppendChild($newNode)
} else {
$node.value="Staging"
}
$xml.Save($config)
- task: PowerShell@2
displayName: 'Set ACLs'
inputs:
targetType: 'inline'
script: |
$root="D:\WEB\MadeiriasLeiria\uat.madeirasleiria.pt\wwwroot"
$appPool="IIS AppPool\uat.madeirasleiria.pt"
$dirs=@("umbraco","App_Plugins","Views","wwwroot\media","wwwroot\css","wwwroot\scripts","wwwroot\umbraco")
foreach($d in $dirs){
$full=Join-Path $root $d
if(Test-Path $full){
icacls $full /grant "$appPool:(OI)(CI)(M)" /T
}
}
- task: PowerShell@2
displayName: 'Deactivate App_Offline'
inputs:
targetType: 'inline'
script: |
$root="D:\WEB\MadeiriasLeiria\uat.madeirasleiria.pt\wwwroot"
$template=Join-Path $root "$(offlineTemplate)"
$offline=Join-Path $root "$(offlineFile)"
if(Test-Path $offline){
if(Test-Path $template){ Remove-Item $template -Force }
Rename-Item $offline $template -Force
}
- stage: Deploy_PROD
displayName: 'Deploy to Production'
dependsOn: Deploy_UAT
condition: succeeded()
jobs:
- deployment: ProdDeploy
environment:
name: 'PROD-DBL01'
resourceType: VirtualMachine
strategy:
runOnce:
deploy:
steps:
- checkout: none
- download: current
artifact: drop
# SAME LOGIC AS UAT, WITH PRODUCTION PATHS
Need a Hand With Your Umbraco Deployments?
If you're working on a new CI/CD setup or trying to improve the one you already have, and things start to get a bit messy once you add multiple servers, media sync, security rules or more advanced workflows, you're definitely not alone. We help teams with this every day.
Our developers have built and deployed Umbraco projects of all sizes: small business sites, large enterprise platforms, public sector projects and everything in between. If you’d like someone experienced to look at your setup, or simply want advice on the best approach, we’re happy to help.
Talk to our team
If you want to review your deployment process or plan your next steps, feel free to reach out. No pressure or commitments. Just a friendly chat about what you're trying to achieve.
If you're facing challenges with load balancing, scaling, deployment automation or anything around Umbraco and DevOps, feel free to reach out. We might be the right team to support you, and our clients usually find our pricing very competitive.
Written by Marco Teodoro
Founder & CEO, Double

Connect with me on LinkedIn | Follow us on LinkedIn
#Umbraco #NearshoreAgency #UpgradeUmbraco