← Back to list

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..

raw-hitt · 2026-04-19 23:06 · 0 claps · 11.0 min read
#cicd-pipeline #multi-vm-deployment #dev-qa-prod-setup #iis-deployment #enterprise-cicd
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

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:

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.

  1. In azure portal search azure vm and click on create.
  2. Select subscription and resource group.
  3. Give a name to vm and select nearest location.
  4. Select vm image (In this case it will be windows server).
  5. Select size for your VM.
  6. Enter username & password you will use to log in into your VM.
  7. 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.exe in 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.

[embed]Download .NET (Linux, macOS, and Windows) | .NET Free downloads for building and running .NET apps on Linux, macOS, and Windows. Runtimes, SDKs, and developer packs for…dotnet.microsoft.com

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 —

  1. 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)
  2. Open IIS Manager and create an IIS Website
  3. Give a site name: MyMvcApp-ProdMax
  4. Physical Path: C:\inetpub\MyMvcApp-ProdMax (Same as the folder we created).
  5. In bindings give port no81 .
  6. Configure Azure DevOps Agent on NEW VM. (This is critical since this is a different VM)
  7. Create NEW Agent Pool (Recommended).
  8. In Azure DevOps go to Project Settings → Agent Pools.
  9. Click on New Pool and give name to pool: prodmax-agent-pool.
  10. To install Agent on VM, simply navigate to your organization settings “Agent pools” > Default> New Agent.
  11. Download agent which meets your configuration.

  1. Once agent downloaded, run powershell as admin and navigate to C Drive and run the 1st command i.e mkdir agent ; cd agent.

  2. This will simply create a directory with name agent and navigate you to the folder.

  3. 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

Extracted Files

  1. Now run the command .\config.cmd

  2. Now command prompt will ask you the server url, which is nothing but your organization url eg : https://dev.azure.com/YOUR_ORGANIZATION.

  3. Now you will have to enter the authentication type, in this case we will be using personal authentication token (PAT), so click enter.

  4. 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.

  5. 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.

  1. Now PowerShell will ask for folder path, Enter the path where all your files will be placed and hit enter.

  2. 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
  1. Now navigate to organization settings > Agent Pools > Default > Agents, you can see currently your agent is online.

Create Variable Group & Environment

  1. In your Azure Devops, navigate to Pipelines → Library and click on +Variable Group.
  2. Give a variable group — prodmax-config.
  3. Add variables API_URL = https://prodmax-api DB_CONN = your-prodmax-db-connection
  4. Create Environment.
  5. 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

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:

  1. Open Services
  2. Find your agent service
  3. Right-click → Properties
  4. Go to Log On tab
  5. Change to: Local System account OR A user with Administrator rights
  6. Click Apply
  7. Restart service
  8. Now you can see the deployment is done on another env in another VM

Related Articles —

[embed]CI/CD Part 2 — Implementing Multi-Environment Pipelines using Azure DevOps (Dev, QA, Prod) In modern application development, deploying code across multiple environments like Development, QA, and Production is…medium.com

[embed]CI/CD Part 1 — Automating .NET Application Deployment on Azure VM using CI/CD In modern software development, automation is key to delivering applications faster and more reliably. In this article…medium.com

[embed]Docker & Containers: Powering the Next Wave of Modern Software Development medium.com

[embed]Step-by-Step Guide to Setting Up an Azure CI/CD Pipeline with .Net A CI/CD pipeline is essential for modern software development as it… Steps involved in CI/CD are : 1)Source Code…medium.com

[embed]Azure DevOps — Fixed — “No hosted parallelism has been purchased or granted ” When creating a pipeline in your DevOps some user might get the above error.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