CI/CD Part 3: Multi-VM CI/CD Pipeline Setup for Scalable Applications
In the first part of this series, we set up a basic CI/CD pipeline to automate the build and deployment process. In the second part, we..
CI/CD Part 3 — Isolating Environments Using Different VMs
In the first part of this series, we set up a basic CI/CD pipeline to automate the build and deployment process. In the second part, we extended this setup by configuring multiple environments (Dev, QA, and Production) on the same virtual machine using IIS to logically separate them.
If you’re starting fresh, I highly recommend going through the previous parts before continuing, as this article builds directly on top of that foundation:
- 🔹 CI/CD Part 1: CI/CD Part 1 — Automating .NET Application Deployment on Azure VM using CI/CD
- 🔹 CI/CD Part 2 — Implementing Multi-Environment Pipelines using Azure DevOps (Dev, QA, Prod)
While the single VM approach works well for smaller applications or cost-optimized setups, real-world and enterprise applications often require better isolation, scalability, and security. In this part, we take the next step by deploying each environment on separate virtual machines, making the overall architecture more robust and production-ready.
Please note while performing this you might encounter various errors, I have given remedy to all the errors I had faced at the end of the implementation.
Consider an enterprise application where Dev, QA, and Production environments are hosted on separate virtual machines. Each VM has its own IIS server and configuration tailored to its purpose. The CI/CD pipeline is configured such that deployments are triggered based on branch or approval gates — Dev deployments happen automatically, QA requires validation, and Production is deployed after approvals. This setup reduces risk, enhances security, and allows teams to test changes in isolated environments before releasing to users.
Creating VM & other dependencies
In the previous article we have already created a Virtual Machine, I will be creating another one as we will be doing the deployment on multiple Virtual Machines.
You can skip this step if you already have created a VM.
- In azure portal search azure vm and click on create.
- Select subscription and resource group.
- Give a name to vm and select nearest location.
- Select vm image (In this case it will be windows server).
- Select size for your VM.
- Enter username & password you will use to log in into your VM.
- Click on review + create and then click on create.
Now connect your newly created VM using RDP.
Configuring IIS —
In order to host your web application you need IIS on your virtual machine.
- In your VM, Open Server Manager: Click the Start menu and select Server Manager (or type
servermanager.exein the Run box). - Add Roles and Features: In the top-right corner, click Manage and then select Add Roles and Features.
- Before You Begin: Click Next on the introductory screen.
- Installation Type: Leave Role-based or feature-based installation selected and click Next.
- Server Selection: Ensure your local server is highlighted in the server pool and click Next.
- Server Roles: Scroll down and check the box for Web Server (IIS).
- Add Features: A popup will appear asking to add required management tools. Click Add Features, then click Next.
- Features: No additional features are required for a basic setup. Click Next.
- Web Server Role (IIS): Click Next after reading the brief overview.
- Role Services: Keep the default selections (Static Content, Default Document, etc.) and click Next.
- Confirmation: Review your selections and click Install.
Installing Dependencies —
Install .net runtime, the same version as your app & also .net sdk and hosting bundle. Make sure you install the correct version as you need these run your application in the VM.
Note — Make sure you have 2 Virtual Machines with Similar configuration
Both the VM should have IIS installed along with .Net runtime, .Net SDK and .Net hosting bundle same as the version of your .Net application.
Follow the below steps to configure pipeline —
- Now in your new VM create Deployment Folder and a website in IIS (Currently we will be deploying at the below path. C:\inetpub\MyMvcApp-ProdMax)
- Open IIS Manager and create an IIS Website
- Give a site name:
MyMvcApp-ProdMax - Physical Path: C:\inetpub\MyMvcApp-ProdMax (Same as the folder we created).
- In bindings give port no
81. - Configure Azure DevOps Agent on NEW VM. (This is critical since this is a different VM)
- Create NEW Agent Pool (Recommended).
- In Azure DevOps go to Project Settings → Agent Pools.
- Click on New Pool and give name to pool: prodmax-agent-pool.
- To install Agent on VM, simply navigate to your organization settings “Agent pools” > Default> New Agent.
- Download agent which meets your configuration.

-
Once agent downloaded, run powershell as admin and navigate to C Drive and run the 1st command i.e
mkdir agent ; cd agent. -
This will simply create a directory with name agent and navigate you to the folder.
-
Now run the 2nd command which will extract the downloaded files in your agent folder. (Please note you might need to change the path in which the zip file is downloaded)
Add-Type -AssemblyName System.IO.Compression.FileSystem ; [System.IO.Compression.ZipFile]::ExtractToDirectory("$HOME\Downloads\vsts-agent-win-x64-3.243.0.zip", "$PWD")

Extracted Files
-
Now run the command .\config.cmd
-
Now command prompt will ask you the server url, which is nothing but your organization url eg : https://dev.azure.com/YOUR_ORGANIZATION.
-
Now you will have to enter the authentication type, in this case we will be using personal authentication token (PAT), so click enter.
-
You can enter the old PAT which we had created when configuring in the 1st VM no need to create a new PAT. Note — To create a new PAT (personal access token), at the top go to user settings and click on Personal access tokens, create new token, give a suitable name to your token, grant full access and create the token.
-
PowerShell will ask for agent pool name and agent name, in this scenario the agent pool name will be prodmax-agent-pool and agent name will be agent-prod.

-
Now PowerShell will ask for folder path, Enter the path where all your files will be placed and hit enter.
-
Later Powershell will ask to run agent as service if required you can configure it. This will enable your agent and the agent will be online. You can verify it by navigating to organization settings > Agent Pools > Default > Agents, you can see currently your agent is in online state.
OR
You can start the agent, by running the run command in your agent folder. So in powershell run the below command.
.\run.cmd
- Now navigate to organization settings > Agent Pools > Default > Agents, you can see currently your agent is online.

Create Variable Group & Environment
- In your Azure Devops, navigate to Pipelines → Library and click on +Variable Group.
- Give a variable group — prodmax-config.
- Add variables API_URL = https://prodmax-api DB_CONN = your-prodmax-db-connection
- Create Environment.
- Add: name — prodmax

If you are enjoying this Article, leave a clap and 👉 Follow me on Medium for more informational articles like this. 👨💻Connect with me on LinkedIn & Git
UPDATE YAML (Add ProdMax Stage)
Now we need to add configuration for ProdMax, so add the below after Prod stage
# 🟣 PRODMAX STAGE
- stage: ProdMax
displayName: 'Deploy to PRODMAX'
dependsOn: Prod # 👈 runs after Prod
variables:
- group: prodmax-config
- name: deployPath
value: 'C:\inetpub\MyMvcApp-ProdMax' # 👈 NEW VM folder
jobs:
- deployment: DeployProdMax
environment: prodmax # 👈 for approval
strategy:
runOnce:
deploy:
steps:
# 📥 Download Artifact
- task: DownloadBuildArtifacts@0
inputs:
buildType: 'current'
downloadType: 'single'
artifactName: 'drop'
downloadPath: '$(Pipeline.Workspace)'
# 🌍 Set PRODMAX Environment Variables
- powershell: |
Write-Host "Setting PRODMAX environment variables..."
[System.Environment]::SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production", "Machine")
[System.Environment]::SetEnvironmentVariable("API_URL", "$(API_URL)", "Machine")
[System.Environment]::SetEnvironmentVariable("DB_CONN", "$(DB_CONN)", "Machine")
displayName: 'Set PRODMAX Environment Variables'
# 🔴 STOP IIS
- script: |
echo Stopping IIS...
iisreset /stop
displayName: 'Stop IIS'
# 🧹 Clean PRODMAX folder
- powershell: |
Write-Host "Cleaning PRODMAX deployment folder..."
if (Test-Path "$(deployPath)") {
Remove-Item "$(deployPath)\*" -Recurse -Force -ErrorAction SilentlyContinue
}
displayName: 'Clean PRODMAX Deployment Folder'
# 📁 Copy files
- task: CopyFiles@2
inputs:
SourceFolder: '$(Pipeline.Workspace)/drop'
Contents: '**'
TargetFolder: '$(deployPath)'
OverWrite: true
# 🟢 START IIS
- script: |
echo Starting IIS...
iisreset /start
displayName: 'Start IIS'
This stage defines a PRODMAX deployment that runs after the Production stage and targets a separate VM/environment with its own configuration. It downloads the build artifacts, sets environment variables, stops IIS, cleans the deployment folder, and copies the latest files to the server. Finally, it restarts IIS to bring the updated application live with controlled approval using the prodmax environment.
IMPORTANT CHANGE (Agent Pool Routing)
Right now our YAML has:
pool:
name: 'vm-agent-pool'
This means ALL stages run on SAME VM ❌
Add this fix for ProdMax only to override pool in ProdMax stage:
- stage: ProdMax
pool:
name: 'prodmax-agent-pool' # 👈 NEW VM
Now you can see a new stage is added in the pipeline.

The entire yaml file looks like this once the configuration is done.
# ASP.NET Core (.NET Framework)
# Build and test ASP.NET Core projects targeting the full .NET Framework.
# Add steps that publish symbols, save build artifacts, and more:
# https://docs.microsoft.com/azure/devops/pipelines/languages/dotnet-core
trigger:
- master
pool:
name: 'vm-agent-pool'
variables:
buildConfiguration: 'Release'
deployPath: 'C:\inetpub\MyMvcApp-Test' # Default (overridden per stage)
stages:
# 🔵 DEV STAGE
- stage: Dev
displayName: 'Deploy to Dev'
variables:
- group: dev-config
- name: deployPath
value: 'C:\inetpub\MyMvcApp-Dev' # 👈 DEV folder
jobs:
- job: DeployDev
steps:
# ✅ Install .NET SDK
- task: UseDotNet@2
inputs:
packageType: 'sdk'
version: '9.0.x'
# ✅ Verify .NET
- script: dotnet --version
displayName: 'Check .NET Version'
# ✅ Clean old build (IMPORTANT)
- script: dotnet clean
displayName: 'Clean Solution'
# ✅ Restore
- script: dotnet restore
displayName: 'Restore'
# ✅ Build
- script: dotnet build --configuration $(buildConfiguration)
displayName: 'Build'
# ✅ Publish
- script: dotnet publish -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)
displayName: 'Publish'
# 📦 Publish Artifact (IMPORTANT)
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'drop'
publishLocation: 'Container'
# 🌍 Set DEV Environment Variables
- powershell: |
Write-Host "Setting DEV environment variables..."
[System.Environment]::SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development", "Machine")
[System.Environment]::SetEnvironmentVariable("API_URL", "$(API_URL)", "Machine")
[System.Environment]::SetEnvironmentVariable("DB_CONN", "$(DB_CONN)", "Machine")
displayName: 'Set DEV Environment Variables'
# 🔴 STOP IIS (releases DLL lock)
- script: |
echo Stopping IIS...
iisreset /stop
displayName: 'Stop IIS'
# 🧹 Delete old files (VERY IMPORTANT - ensures clean deployment)
- powershell: |
Write-Host "Cleaning deployment folder..."
if (Test-Path "$(deployPath)") {
Remove-Item "$(deployPath)\*" -Recurse -Force -ErrorAction SilentlyContinue
}
displayName: 'Clean Deployment Folder'
# 📁 Copy new files
- task: CopyFiles@2
inputs:
SourceFolder: '$(Build.ArtifactStagingDirectory)'
Contents: '**'
TargetFolder: '$(deployPath)'
OverWrite: true
# 🟢 START IIS
- script: |
echo Starting IIS...
iisreset /start
displayName: 'Start IIS'
# 🟡 QA STAGE
- stage: QA
displayName: 'Deploy to QA'
dependsOn: Dev
variables:
- group: qa-config
- name: deployPath
value: 'C:\inetpub\MyMvcApp-QA' # 👈 QA folder
jobs:
- deployment: DeployQA # ✅ CHANGED
environment: qa # ✅ REQUIRED FOR APPROVAL
strategy:
runOnce:
deploy:
steps:
# 📥 Download Artifact
- task: DownloadBuildArtifacts@0
inputs:
buildType: 'current'
downloadType: 'single'
artifactName: 'drop'
downloadPath: '$(Pipeline.Workspace)'
# 🌍 Set QA Environment Variables
- powershell: |
Write-Host "Setting QA environment variables..."
[System.Environment]::SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "QA", "Machine")
[System.Environment]::SetEnvironmentVariable("API_URL", "$(API_URL)", "Machine")
[System.Environment]::SetEnvironmentVariable("DB_CONN", "$(DB_CONN)", "Machine")
displayName: 'Set QA Environment Variables'
# 🔴 STOP IIS
- script: |
echo Stopping IIS...
iisreset /stop
displayName: 'Stop IIS'
# 🧹 Clean QA folder
- powershell: |
Write-Host "Cleaning QA deployment folder..."
if (Test-Path "$(deployPath)") {
Remove-Item "$(deployPath)\*" -Recurse -Force -ErrorAction SilentlyContinue
}
displayName: 'Clean QA Deployment Folder'
# 📁 Copy files to QA
- task: CopyFiles@2
inputs:
SourceFolder: '$(Pipeline.Workspace)/drop'
Contents: '**'
TargetFolder: '$(deployPath)'
OverWrite: true
# 🟢 START IIS
- script: |
echo Starting IIS...
iisreset /start
displayName: 'Start IIS'
# 🔴 PROD STAGE
- stage: Prod
displayName: 'Deploy to PROD'
dependsOn: QA
variables:
- group: prod-config
- name: deployPath
value: 'C:\inetpub\MyMvcApp-Prod' # 👈 PROD folder
jobs:
- deployment: DeployProd # ✅ CHANGED
environment: prod # ✅ REQUIRED FOR APPROVAL
strategy:
runOnce:
deploy:
steps:
# 📥 Download Artifact
- task: DownloadBuildArtifacts@0
inputs:
buildType: 'current'
downloadType: 'single'
artifactName: 'drop'
downloadPath: '$(Pipeline.Workspace)'
# 🌍 Set PROD Environment Variables
- powershell: |
Write-Host "Setting PROD environment variables..."
[System.Environment]::SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production", "Machine")
[System.Environment]::SetEnvironmentVariable("API_URL", "$(API_URL)", "Machine")
[System.Environment]::SetEnvironmentVariable("DB_CONN", "$(DB_CONN)", "Machine")
displayName: 'Set PROD Environment Variables'
# 🔴 STOP IIS
- script: |
echo Stopping IIS...
iisreset /stop
displayName: 'Stop IIS'
# 🧹 Clean PROD folder
- powershell: |
Write-Host "Cleaning PROD deployment folder..."
if (Test-Path "$(deployPath)") {
Remove-Item "$(deployPath)\*" -Recurse -Force -ErrorAction SilentlyContinue
}
displayName: 'Clean PROD Deployment Folder'
# 📁 Copy files to PROD
- task: CopyFiles@2
inputs:
SourceFolder: '$(Pipeline.Workspace)/drop'
Contents: '**'
TargetFolder: '$(deployPath)'
OverWrite: true
# 🟢 START IIS
- script: |
echo Starting IIS...
iisreset /start
displayName: 'Start IIS'
# 🟣 PRODMAX STAGE
- stage: ProdMax
displayName: 'Deploy to PRODMAX'
dependsOn: Prod
pool:
name: 'prodmax-agent-pool' # 👈 NEW VM AGENT POOL
variables:
- group: prodmax-config
- name: deployPath
value: 'C:\inetpub\MyMvcApp-ProdMax' # 👈 PRODMAX folder (NEW VM)
jobs:
- deployment: DeployProdMax # ✅ SAME PATTERN AS QA/PROD
environment: prodmax # ✅ REQUIRED FOR APPROVAL
strategy:
runOnce:
deploy:
steps:
# 📥 Download Artifact
- task: DownloadBuildArtifacts@0
inputs:
buildType: 'current'
downloadType: 'single'
artifactName: 'drop'
downloadPath: '$(Pipeline.Workspace)'
# 🌍 Set PRODMAX Environment Variables
- powershell: |
Write-Host "Setting PRODMAX environment variables..."
[System.Environment]::SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production", "Machine")
[System.Environment]::SetEnvironmentVariable("API_URL", "$(API_URL)", "Machine")
[System.Environment]::SetEnvironmentVariable("DB_CONN", "$(DB_CONN)", "Machine")
displayName: 'Set PRODMAX Environment Variables'
# 🔴 STOP IIS
- script: |
echo Stopping IIS...
iisreset /stop
displayName: 'Stop IIS'
# 🧹 Clean PRODMAX folder
- powershell: |
Write-Host "Cleaning PRODMAX deployment folder..."
if (Test-Path "$(deployPath)") {
Remove-Item "$(deployPath)\*" -Recurse -Force -ErrorAction SilentlyContinue
}
displayName: 'Clean PRODMAX Deployment Folder'
# 📁 Copy files to PRODMAX
- task: CopyFiles@2
inputs:
SourceFolder: '$(Pipeline.Workspace)/drop'
Contents: '**'
TargetFolder: '$(deployPath)'
OverWrite: true
# 🟢 START IIS
- script: |
echo Starting IIS...
iisreset /start
displayName: 'Start IIS'
Once everything is configured, Push a commit on your solution and you can see after the prod approval, the pipeline will wait for the approval of prodMax stage.

Once you approve prodMax stage, you can see the deployment done in the folder path we have configured.

You can also browse the website from IIS and check the the variables values are also changed.

Conclusion
Using separate VMs for each environment is a best practice for scalable and enterprise-level applications. It ensures high availability, better fault isolation, and secure deployments. Although it may increase infrastructure cost, the benefits in reliability and control make it a preferred approach for serious production workloads.

Troubleshooting
Deployment Failed — Powershell exited with Code 1

So in this case my Azure DevOps self-hosted agent is NOT running as Administrator Go to your VM:
- Open Services
- Find your agent service
- Right-click → Properties
- Go to Log On tab
- Change to: Local System account OR A user with Administrator rights
- Click Apply
- Restart service
- Now you can see the deployment is done on another env in another VM

Related Articles —
[embed]Docker & Containers: Powering the Next Wave of Modern Software Development medium.com
메타데이터
- post_id
- 32bf1fc1a2f9
- slug
- ci-cd-part-3-multi-vm-ci-cd-pipeline-setup-for-scalable-applications-32bf1fc1a2f9
- url
- https://medium.com/@rp99452/ci-cd-part-3-multi-vm-ci-cd-pipeline-setup-for-scalable-applications-32bf1fc1a2f9
- canonical_url
- https://medium.com/@rp99452/ci-cd-part-3-multi-vm-ci-cd-pipeline-setup-for-scalable-applications-32bf1fc1a2f9
- author_url
- https://medium.com/@rp99452
- status
- ok
- fetched_at
- 2026-06-15 20:49:13