Zero-Downtime Deployments for .NET Apps Using Azure Deployment Slots

Learn how to achieve zero downtime deployment for Windows .NET applications by cloning production content into a deployment slot using Kudu API, Azure CLI, and Azure Pipelines. Step-by-step guide for handling large wwwroot files and ensuring seamless slot swaps.

In production environments where application uptime is crucial, even a few minutes of downtime during deployments can cause user frustration and potential revenue loss. Our organization recently addressed this by adopting Azure App Service Deployment Slots for our Windows-based .NET applications. The goal was to patch pre-production, validate the changes, and then swap the slot into production, resulting in zero customer disruption.

This article walks through how we engineered a cloning-based slot deployment process using Azure CLI, Kudu APIs, and DevOps pipelines—complete with fallbacks for large deployments

Why Slot Deployment?

When an app requires 6–10 minutes of patching, causing site offline, doing that directly in production is risky or sometimes customers doesn’t approve it. Slot deployment solves this, and we can plan this:

  • We patch a pre-production slot with new artifact.
  • After verification, we swap the slot into production.
  • Slot swapping keeps the site online causing a 10-15 sec slowness during swap, but site stays alive customers doesn’t see any downtime.
  • Optionally, we repatch the original pre-production slot or discard it.

Our Approach: Cloning the Production Content into Slot

Instead of relying on build artifacts, we cloned the current production content to ensure consistency because multiple apps have different content inside the wwwroot. Here’s how:

  1. Download wwwroot from production using the Kudu API.
  2. Store the zip in pipeline’s agent.
  3. Create a new deployment slot using the Azure CLI.
  4. Deploy the zip to the new slot using Zip Deploy or FTPS+Kudu if the zip size exceeds 2GB.
  5. Verify the slot before swapping or repatching.

Pipeline YAML Overview

Here’s the sanitized Azure DevOps pipeline we used to orchestrate the deployment.

Note: Real app and org names are masked.

trigger:
- none

parameters:
- name: configurations
  type: object
  default:
    - app: 'APP-NAME'
      db: 'DB-NAME'
      group: 'RESOURCE-GROUP'
      slot: 'pre-production'
      url: 'yourapp.example.com'

variables:
  - group: Shared-Variable-Group
  - name: output_file
    value: '$(Build.ArtifactStagingDirectory)\wwwroot.zip'

jobs:
- job: DeployApps
  displayName: Deploy All WebApps
  pool:
    vmImage: 'windows-latest'

  steps:
    - task: UsePythonVersion@0
      inputs:
        versionSpec: '3.x'
        addToPath: true

    - powershell: |
        az login --service-principal -u $(AppID) -p $(SecretValue) --tenant $(TenantID)
      displayName: "Azure Login"

    - powershell: |
        $azPath = (Get-Command az).Source
        echo "##vso[task.setvariable variable=AZ_CLI_PATH]$azPath"
      displayName: 'Resolve az CLI path'

    - script: |
        python -m pip install --upgrade pip
        pip install requests
      displayName: 'Install required Python packages'

    - ${{ each configuration in parameters.configurations }}:
      - task: PythonScript@0
        displayName: 'Download wwwroot.zip from Azure WebApp - ${{ configuration.app }}'
        enabled: true
        inputs:
          scriptSource: 'inline'
          script: |
            import os
            import subprocess
            import requests
            from pathlib import Path

            az_cli_path = os.environ.get("AZ_CLI_PATH")
            resource_group = os.environ.get("RESOURCE_GROUP")
            webapp_name = os.environ.get("WEBAPP_NAME")
            slot_name = ""
            destination_path = os.environ.get("OUTPUT_FILE")

            def get_azure_token():
                print("Getting Azure token...")
                result = subprocess.run(
                    [az_cli_path, "account", "get-access-token", "--query", "accessToken", "-o", "tsv"],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True
                )
                if result.returncode != 0:
                    raise Exception(f"Failed to get Azure token: {result.stderr}")
                return result.stdout.strip()

            def get_kudu_url(webapp_name, slot):
                if slot:
                    return f"https://{webapp_name}-{slot}.scm.azurewebsites.net/api/zip/site/wwwroot/"
                else:
                    return f"https://{webapp_name}.scm.azurewebsites.net/api/zip/site/wwwroot/"

            access_token = get_azure_token()
            download_url = get_kudu_url(webapp_name, slot_name)

            print(f"Downloading from: {download_url}")
            headers = {
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/octet-stream"
            }

            Path(destination_path).parent.mkdir(parents=True, exist_ok=True)

            response = requests.get(download_url, headers=headers)
            if response.status_code == 200:
                with open(destination_path, "wb") as f:
                    f.write(response.content)
                print(f"Downloaded to: {destination_path}")
            else:
                print(f"Failed to download. Status code: {response.status_code}")
                print(response.text)
        env:
          RESOURCE_GROUP: ${{ configuration.group }}
          WEBAPP_NAME: ${{ configuration.app }}
          # SLOT_NAME: 'Pre-Production' ## Only Required if You're cloning content (wwwroot) from another slot
          OUTPUT_FILE: $(output_file)

      - powershell: |
          az webapp deployment slot create `
            --name "${{ configuration.app }}" `
            --resource-group "${{ configuration.group }}" `
            --slot "${{ configuration.slot }}" `
            --configuration-source "${{ configuration.app }}"
        displayName: "Create deployment slot - ${{ configuration.app }}"
        enabled: true

      - powershell: |
          $slotCheck = az webapp deployment slot list --name ${{ configuration.app }} --resource-group ${{ configuration.group }} --query "[?name=='${{ configuration.slot }}']"
          if (-not $slotCheck) {
              Write-Error "Slot '${{ configuration.slot }}' not found."
          } else {
              Write-Output "Slot '${{ configuration.slot }}' found."
          }
          
        displayName: 'Checking Webapp slot ${{ configuration.app }}'
        enabled: true

      - task: PythonScript@0
        displayName: 'Upload wwwroot.zip to Azure WebApp Slot - ${{ configuration.app }}'
        enabled: false #make it true if your Artifact Size is 2GB. And make zip deployment false
        inputs:
          scriptSource: 'inline'
          script: |
            import os
            import subprocess
            import json
            from ftplib import FTP

            def get_slot_ftp_details(web_app_name, resource_group_name, slot_name):
                print(f"Fetching FTP details for slot: {slot_name}")
                command_ftp_address = (
                    f"az webapp deployment list-publishing-profiles "
                    f"--name {web_app_name} --resource-group {resource_group_name} --slot {slot_name} "
                    f"--query \"[?ends_with(profileName, 'FTP')].[publishUrl]\" --output json"
                )
                output_ftp_address = subprocess.check_output(command_ftp_address, shell=True)
                ftp_address = json.loads(output_ftp_address.decode("utf-8"))[0][0]

                command_credentials = (
                    f"az webapp deployment list-publishing-credentials "
                    f"--resource-group {resource_group_name} --name {web_app_name} --slot {slot_name}"
                )
                output_credentials = subprocess.check_output(command_credentials, shell=True)
                credentials = json.loads(output_credentials.decode("utf-8"))

                return ftp_address, credentials["publishingUserName"], credentials["publishingPassword"]

            def ftp_delete_file(ftp, path):
                try:
                    ftp.delete(path)
                    print(f"Deleted: {path}")
                except Exception as e:
                    print(f"Could not delete {path}: {e}")

            def ftp_upload_file(ftp, local_file, remote_dir):
                with open(local_file, 'rb') as f:
                    file_name = os.path.basename(local_file)
                    ftp.cwd(remote_dir)
                    ftp.storbinary(f'STOR {file_name}', f)
                    print(f"Uploaded {file_name} to {remote_dir}")

            webapp_name = os.environ.get("WEBAPP_NAME")
            slot_name = os.environ.get("SLOT_NAME")
            resource_group = os.environ.get("RESOURCE_GROUP")
            zip_file_path = os.environ.get("OUTPUT_FILE")

            if not webapp_name or not slot_name or not resource_group or not zip_file_path:
                raise Exception("Missing required environment variables.")

            ftp_host, username, password = get_slot_ftp_details(webapp_name, resource_group, slot_name)
            ftp_host_clean = ftp_host.replace("ftps://", "").replace("ftp://", "").split('/')[0]

            ftp = FTP(ftp_host_clean)
            ftp.login(user=username, passwd=password)

            print("Removing hostingstart.html from /site/wwwroot if it exists...")
            ftp_delete_file(ftp, '/site/wwwroot/hostingstart.html')

            print(f"Uploading {zip_file_path} to /site/")
            ftp_upload_file(ftp, zip_file_path, '/site')

            ftp.quit()
            print("FTP upload to slot completed.")
        env:
          WEBAPP_NAME: ${{ configuration.app }}
          SLOT_NAME: ${{ configuration.slot }}
          RESOURCE_GROUP: ${{ configuration.group }}
          OUTPUT_FILE: $(output_file)

      - task: AzureRmWebAppDeployment@5
        displayName: 'Azure App Service Deploy: ${{ configuration.app }}'
        enabled: true
        inputs:
          azureSubscription: 'Serverguy (12300013-f0ed-1453-df65-546fl3alk09c)'
          WebAppName: '${{ configuration.app }}'
          deployToSlotOrASE: true
          ResourceGroupName: ${{ configuration.group}}
          SlotName: '${{ configuration.slot }}'
          package: '$(Build.ArtifactStagingDirectory)/wwwroot.zip'
          enableCustomDeployment: true
          DeploymentType: zipDeploy
          TakeAppOfflineFlag: false

      - powershell: |
          $zipPath = "$(output_file)"
          if (Test-Path $zipPath) {
            Remove-Item $zipPath -Force
            Write-Host "Cleaned up: $zipPath"
          } else {
            Write-Host "No file found at: $zipPath"
          }
        displayName: "Clean wwwroot.zip - ${{ configuration.app }}"
        enabled: true

Steps Explained:

If the artifact size is over 2GB. ‘Upload wwwroot.zip to Azure WebApp Slot – ${{ configuration.app }}’

Azure App Services has a known limitation—Zip Deploy fails for files over 2GB.

Our Alternative: FTPS + Kudu Unzip

For larger wwwroot.zip files:

  1. Upload via FTPS using credentials from az webapp deployment list-publishing-profiles.
  2. Access Kudu Web Console (via https://<slot>.scm.azurewebsites.net).
  3. Run the unzip wwwroot.zip -d site/wwwroot/ command from the Debug Console.

To enable this step disable the Azure App Service Deploy step and enable Upload wwwroot.zip to Azure WebApp Slot step. Then run manual unzip command unzip wwwroot.zip -d wwwroot in the Debug console from kudu.

Resolve az CLI path

This step is required for downloading the wwwroot using the agent’s command line and ensuring it’s using az CLI.

Post-Deployment Verification

Once deployed to the slot, we perform:

  • Smoke tests (manual or automated)
  • Endpoint health checks
  • URL validation on the new slot

If everything checks out, we can execute the slot swapping command.

az webapp deployment slot swap --name APP-NAME --resource-group RESOURCE-GROUP --slot pre-production

Final Thoughts

The previous .yml is to download cloning the production slot into pre-production or staging slot. If you have more than 100 apps it’s necessary to clone the production content into the slot and for specific apps it’s wwwroot content varies so we can’t actually download one wwwroot.zip and deploy into all slot.

For the zero downtime we do all the regular patching into the slot like this

  - task: AzureWebApp@1
    displayName: "Azure Web App Deploy ${{ configuration.app }}"
    inputs:
      azureSubscription: "Serverguy (12300013-f0ed-1453-df65-546fl3alk09c)"
      appType: webApp
      ResourceGroupName: ${{ configuration.group }}
      appName: ${{ configuration.app }}
      deployToSlotOrASE: true
      slotName: ${{ configuration.slot }}
      package: '/home/vsts/work/1/a/$(Build.BuildId).zip'
      deploymentMethod: zipDeploy
    enabled: true

And swap the slot after this step:

az webapp deployment slot swap --name ${{ configuration.app }} --resource-group ${{ configuration.group }} --slot ${{ configuration.slot }}

**After this we can repatch the slot (in our case pre-production) because pre-production have old content but we can keep it as it is because in next patch the slot is getting new content and getting swap.

Have questions or need help with advanced deployment patterns? Let’s connect on LinkedIn or drop your thoughts in the comments below! See how to deploy ASP.NET Core in Linux

Leave a Reply

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

Contabo opens DC in Singapore