Skip to content

Azure Hosting & Infrastructure Guide

Status: Complete | Last updated: 2026-03-23


1. V8 vs V17 Deployment Comparison

Understanding the current v8 deployment model is essential for planning the v17 migration. The table below summarizes the key differences.

Aspect V8 (Current) V17 (Recommended)
Framework ASP.NET Framework 4.x .NET 10 (ASP.NET Core)
Build MSBuild → Web Deploy Package (.zip) docker build → container image
Pipeline model Single parameterized pipeline (dropdown of 168 CUs) Per-client pipeline (each repo has its own)
Deploy target Azure Web App (Windows/IIS) Azure Web App for Containers (Linux)
Blue-green Staging slot → manual validation → swap Staging slot swap OR container tag rollback
Per-client config web.config XML transforms + variable groups appsettings.json + environment variables (Key Vault)
Post-deploy scripts 10+ PowerShell scripts (IIS, permissions, uSync cleanup) None -- container is self-contained
Codebase Single monolithic solution (all 168 CUs) Each client = thin project from dotnet new progress-cu template
Shared code Compiled into the single solution NuGet packages (Progress.Baseline.Core, Progress.Baseline.Web)
Rollback Swap slots back (instant) Swap slots back OR redeploy previous container tag

V8 Pipeline Details

The v8 build pipeline (azure-build-pipelines-webApps.yml) produces a Web Deploy Package:

MSBuild /p:DeployOnBuild=true /p:WebPublishMethod=Package → .zip artifact

The v8 release pipeline (azure-release-pipelines-webApps.yml) is parameterized with 168 credit union names. At deploy time, an operator selects which CU to deploy from a dropdown:

parameters:
- name: CreditUnionName
  displayName: Credit Union Name
  type: string
  default: Progressie
  values:
  - 1stclasscu
  - Abbeycu
  # ... 168 credit unions total

Each CU references two variable groups: Umbraco-Common-PROD (shared settings) and Umbraco-{CreditUnion} (per-client connection strings, domains, etc.).

The release flow is:

  1. Start staging slot for the selected CU's Azure Web App
  2. Pre-deployment scripts (export variables, Kudu remote scripts)
  3. Deploy .zip to staging slot with XML transforms and variable substitution
  4. Post-deployment scripts (cleanup, permissions)
  5. Manual validation -- 30-day timeout, operator inspects staging URL
  6. Swap staging to production -- instant, zero-downtime

V17 Pipeline Architecture

Each client is a separate git repo created from the dotnet new progress-cu template. The repo contains only:

  • appsettings.json (client-specific configuration)
  • Dockerfile (standard, identical across clients)
  • CSS/theme overrides
  • azure-pipelines.yml (client-specific pipeline)

All shared functionality comes from NuGet packages referenced in the project.

graph LR
    subgraph "Per-Client Git Repo"
        REPO["Client X Repo<br/>(from dotnet template)"]
    end

    subgraph "Azure DevOps"
        PIPE["Pipeline<br/>(per-client)"]
    end

    subgraph "Azure Container Registry"
        ACR["progressacr.azurecr.io<br/>/client-x:build-id"]
    end

    subgraph "Azure"
        WEB["Web App for Containers<br/>(client-x)"]
    end

    subgraph "NuGet Feed"
        NUGET["Progress.Baseline.Core<br/>Progress.Baseline.Web"]
    end

    REPO --> PIPE
    PIPE -->|docker build + push| ACR
    ACR -->|pull image| WEB
    NUGET -->|restored during<br/>docker build| PIPE

Advantages of the V17 Approach

  • Independent deployments -- deploying one client cannot block or break another
  • Container isolation -- no shared app pool; one client crashing does not affect others
  • Easy rollback -- switch container image tag to a previous build number (2-minute recovery)
  • Consistent environments -- the same container runs locally, in staging, and in production
  • Faster deployments -- pull a pre-built image vs deploy + transform a .zip package
  • Horizontal scaling -- scale individual client Web Apps independently via multiple container instances
  • No post-deploy scripts -- the container is fully self-contained; no IIS permissions, uSync cleanup, or config transforms needed
  • Simplified pipeline -- each client pipeline is ~40 lines of YAML vs the v8's 500-line parameterized pipeline

2. Azure Infrastructure

Resource Organization

Azure Subscription
├── Resource Group: rg-progress-shared
│   ├── Azure Container Registry (ACR)
│   ├── Application Insights (shared workspace)
│   └── Azure Key Vault
├── Resource Group: rg-progress-{client}   (one per credit union)
│   ├── Web App for Containers
│   ├── Azure SQL Database (or Elastic Pool member)
│   └── Managed Certificate (SSL)
└── ...

Multi-client pattern

Each credit union gets its own Resource Group containing a Web App for Containers instance and a database. Shared infrastructure (ACR, Key Vault, App Insights) lives in a shared resource group.

Service Purpose SKU Recommendation
Azure Container Registry Store per-client Docker images Basic (sufficient for most; Standard if >100 images or geo-replication needed)
Azure Web App for Containers Host each client site B1 (basic) or P1v3 (production with auto-scale)
Azure SQL Database Managed database per client See Database section for options
Azure Blob Storage Media files (optional) Standard LRS
Application Insights Monitoring and telemetry Pay-as-you-go
Azure Key Vault Connection strings and secrets Standard

3. Container Registry (ACR)

Setup

# Create ACR (Basic tier is fine for most setups)
az acr create \
  --resource-group rg-progress-shared \
  --name progressacr \
  --sku Basic \
  --admin-enabled true

# Get login credentials
az acr credential show --name progressacr

Image Strategy: One Image Per Client

Each client repo produces its own Docker image. This ensures complete isolation -- updating one client's NuGet dependencies or theme does not affect any other client.

progressacr.azurecr.io/
├── progresscu:20260323.1
├── progresscu:latest
├── abbeycu:20260323.1
├── abbeycu:latest
├── cardiffcu:20260322.3
├── cardiffcu:latest
└── ...

Image Tagging Strategy

Tag Purpose Example
latest Most recent successful build Updated on every main branch push
{build-number} Immutable version for production 20260323.1

Production images

Never deploy latest to production. Use immutable build-number tags for production deployments to enable reliable rollbacks.


4. Per-Client Pipeline

Each client repo contains its own azure-pipelines.yml. Since the repo is generated from the dotnet new progress-cu template, the pipeline YAML is identical across clients -- only the variable values differ.

Example Pipeline

trigger:
  - main

variables:
  containerRegistry: "progressacr"          # ACR service connection name
  imageRepository: "progresscu"              # Client-specific image name
  tag: "$(Build.BuildId)"
  azureSubscription: "progress-azure-sub"
  webAppName: "progresscu-v17"
  resourceGroup: "rg-progress-progresscu"

stages:
  - stage: Build
    jobs:
      - job: BuildAndPush
        pool:
          vmImage: ubuntu-latest
        steps:
          - task: Docker@2
            displayName: Build and push container image
            inputs:
              containerRegistry: $(containerRegistry)
              repository: $(imageRepository)
              command: buildAndPush
              Dockerfile: Dockerfile
              tags: |
                $(tag)
                latest
              arguments: >-
                --build-arg ASPNETCORE_ENVIRONMENT=Production
                --build-arg CONFIGURATION=Release

  - stage: DeployStaging
    dependsOn: Build
    jobs:
      - deployment: Deploy
        environment: $(webAppName)-staging
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureWebAppContainer@1
                  inputs:
                    azureSubscription: $(azureSubscription)
                    appName: $(webAppName)
                    deployToSlotOrASE: true
                    slotName: staging
                    containers: >-
                      progressacr.azurecr.io/$(imageRepository):$(tag)

  - stage: SwapToProduction
    dependsOn: DeployStaging
    jobs:
      - job: ManualValidation
        pool: server
        steps:
          - task: ManualValidation@0
            inputs:
              instructions: >
                Validate the staging deployment at
                https://$(webAppName)-staging.azurewebsites.net
      - deployment: Swap
        dependsOn: ManualValidation
        environment: $(webAppName)-production
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureAppServiceManage@0
                  inputs:
                    azureSubscription: $(azureSubscription)
                    WebAppName: $(webAppName)
                    ResourceGroupName: $(resourceGroup)
                    SourceSlot: staging
                    SwapWithProduction: true

Rollback Strategy

Method Speed When to Use
Slot swap back Instant Previous slot still running -- just swap again
Redeploy previous tag 2-5 min Update container image to previous build number
ACR image restore 5-10 min If latest was overwritten, pull from ACR by digest
# Instant rollback via slot swap
az webapp deployment slot swap \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --slot staging

# Rollback to specific image version
az webapp config container set \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --container-image-name progressacr.azurecr.io/progresscu:20260320.5

5. Web App Configuration

Create Web App for Containers

# Create App Service Plan (Linux, container-capable)
az appservice plan create \
  --name asp-progress-prod \
  --resource-group rg-progress-shared \
  --is-linux \
  --sku P1v3

# Create Web App for a specific client
az webapp create \
  --resource-group rg-progress-progresscu \
  --plan asp-progress-prod \
  --name progresscu-v17 \
  --deployment-container-image-name progressacr.azurecr.io/progresscu:latest

Configure ACR Access

az webapp config container set \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --container-image-name progressacr.azurecr.io/progresscu:latest \
  --container-registry-url https://progressacr.azurecr.io \
  --container-registry-user progressacr \
  --container-registry-password <acr-password>

Environment Variables

Secrets and connection strings are injected as environment variables, not baked into the image. Use Key Vault references for sensitive values.

az webapp config appsettings set \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --settings \
    ASPNETCORE_ENVIRONMENT=Production \
    WEBSITES_PORT=8080 \
    ConnectionStrings__umbracoDbDSN="@Microsoft.KeyVault(SecretUri=https://kv-progress.vault.azure.net/secrets/progresscu-dbconnection/)" \
    ConnectionStrings__umbracoDbDSN_ProviderName="Microsoft.Data.SqlClient" \
    Umbraco__CMS__Unattended__InstallUnattended=true \
    Umbraco__CMS__Unattended__UpgradeUnattended=true \
    Umbraco__CMS__Global__UseHttps=true

Secrets management

Use Azure Key Vault references instead of plaintext passwords in App Settings:

@Microsoft.KeyVault(SecretUri=https://kv-progress.vault.azure.net/secrets/sql-password/)

Custom Domain + SSL

# Add custom domain
az webapp config hostname add \
  --webapp-name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --hostname www.progresscu.ie

# Create managed SSL certificate (free)
az webapp config ssl create \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --hostname www.progresscu.ie

# Bind SSL
az webapp config ssl bind \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --certificate-thumbprint <thumbprint> \
  --ssl-type SNI

Health Check

az webapp config set \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --generic-configurations '{"healthCheckPath": "/umbraco/api/keepalive/ping"}'

Continuous Deployment (Webhook)

Enable automatic redeployment when a new image is pushed to ACR:

# Enable continuous deployment
az webapp deployment container config \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --enable-cd true

# Get the webhook URL
az webapp deployment container show-cd-url \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu

Then configure the ACR webhook to call this URL on image push.


6. Database Options

The database choice depends on your budget, performance needs, and operational preferences. Below are the main options.

Fully managed, automatic backups, built-in HA. Best for production workloads with minimal DBA overhead.

# Create SQL Server
az sql server create \
  --name progress-sql \
  --resource-group rg-progress-shared \
  --location westeurope \
  --admin-user umbraco_admin \
  --admin-password '<strong-password>'

# Create database for a client
az sql db create \
  --resource-group rg-progress-shared \
  --server progress-sql \
  --name progresscu \
  --service-objective S0 \
  --backup-storage-redundancy Local

Cost optimization with Elastic Pools: With 160+ databases, most of which are low-traffic, an Elastic Pool shares DTU/vCore capacity across all databases and can reduce costs by ~70% compared to individual S0 databases.

az sql elastic-pool create \
  --resource-group rg-progress-shared \
  --server progress-sql \
  --name pool-progress \
  --edition Standard \
  --dtu 200 \
  --db-dtu-max 50 \
  --db-dtu-min 0

Option B: SQL Server on a VM

Self-managed SQL Server on an Azure VM. Lower per-database cost at scale but requires manual backups, patching, and HA setup.

Option C: Azure SQL Managed Instance

Middle ground -- SQL Server feature compatibility with managed infrastructure. Higher base cost but simpler migration from on-premises SQL Server.

Connection String Format

Server=tcp:progress-sql.database.windows.net,1433;
Database=progresscu;
User Id=umbraco_user;
Password=<password>;
Encrypt=True;
TrustServerCertificate=False;
MultipleActiveResultSets=true

Provider name

Always set both the connection string AND the provider name:

ConnectionStrings__umbracoDbDSN_ProviderName=Microsoft.Data.SqlClient

Firewall Rules (Azure SQL)

# Allow Azure services
az sql server firewall-rule create \
  --resource-group rg-progress-shared \
  --server progress-sql \
  --name AllowAzureServices \
  --start-ip-address 0.0.0.0 \
  --end-ip-address 0.0.0.0

# Allow specific IP (office/VPN)
az sql server firewall-rule create \
  --resource-group rg-progress-shared \
  --server progress-sql \
  --name OfficeAccess \
  --start-ip-address 203.0.113.10 \
  --end-ip-address 203.0.113.10

Backup Strategy (Azure SQL)

Feature Configuration
Point-in-time restore Automatic, 7-35 days retention (depends on tier)
Long-term retention Weekly full backups, 52-week retention
Geo-replication Optional, for disaster recovery
az sql db ltr-policy set \
  --resource-group rg-progress-shared \
  --server progress-sql \
  --database progresscu \
  --weekly-retention P4W \
  --monthly-retention P12M \
  --yearly-retention P5Y \
  --week-of-year 1

7. Multi-Client Management

Shared vs Per-Client Resources

Resource Shared Per-Client
App Service Plan Shared plan (cost optimization) Own Web App instance
SQL Server Shared server / Elastic Pool Own database
ACR Single registry Own image repository
NuGet packages Shared feed --
Application Insights Can share workspace Own instrumentation key
Key Vault Shared vault Own secrets
Custom domain -- Own domain + SSL
Git repo -- Own repo (from template)
Pipeline -- Own pipeline

Onboarding a New Client

  1. Create project from template: dotnet new progress-cu --name NewClientCu
  2. Customize: Edit appsettings.json (site name, theme, features), add CSS overrides
  3. Create Azure resources: Resource group, Web App, SQL Database, Key Vault secrets
  4. Create pipeline: Push to Azure DevOps, pipeline YAML is already in the template
  5. First deploy: Pipeline builds image, pushes to ACR, deploys to Web App
  6. DNS: Point client domain to the Web App, bind SSL certificate

App Service Plan Density

A single App Service Plan can host multiple Web Apps. The number depends on the plan tier and each client's resource usage.

Component What to Size Considerations
App Service Plan CPU, memory per plan Multiple low-traffic sites can share a plan; high-traffic sites should have a dedicated plan
Azure SQL DTU or vCore per database Elastic Pools can reduce cost for many small databases
ACR Storage + pull frequency Basic tier stores up to 10 GB; Standard adds geo-replication
Blob Storage Media volume per client Pay-per-GB; consider lifecycle policies for old media

Cost Estimation

Use the Azure Pricing Calculator to build an estimate based on your specific requirements. Key inputs:

  • Number of clients to host
  • Expected traffic per client (page views/month)
  • Database size per client
  • Media storage volume
  • Desired redundancy (single region vs geo-replicated)

Start small (Basic/B1 plans) and scale up based on actual usage. Azure makes it easy to change tiers without downtime.


8. Cascading Updates — Base Package Changes

When the shared baseline code (Progress.Baseline.Core, Progress.Baseline.Web) is updated, all client sites need to receive the update. There are two approaches:

Option A: Manual Update (Simple)

Update the NuGet package version in each client project:

# In each client repo
dotnet add package Progress.Baseline.Core --version 1.2.0
dotnet add package Progress.Baseline.Web --version 1.2.0
git commit -am "chore: update baseline packages to 1.2.0"
git push  # triggers client pipeline → build → deploy

Best for: Small teams, < 10 clients, infrequent base changes

Option B: Automated Fan-Out Pipeline (Scalable)

A central pipeline that automatically triggers all client deployments when new NuGet packages are published:

graph LR
    A[Baseline Code Change] --> B[NuGet Pipeline]
    B --> C[Publish Packages to Feed]
    C --> D[Fan-Out Pipeline Triggers]
    D --> E1[Client A Pipeline]
    D --> E2[Client B Pipeline]
    D --> E3[Client C Pipeline]
    D --> E4[Client N Pipeline]
    E1 --> F1[Build + Push Image A]
    E2 --> F2[Build + Push Image B]
    E3 --> F3[Build + Push Image C]
    E4 --> F4[Build + Push Image N]

How it works:

  1. Developer pushes a fix to the baseline repository
  2. NuGet pipeline builds and publishes new package versions
  3. Fan-out pipeline detects the new version and triggers each client pipeline
  4. Each client pipeline: dotnet restore (pulls new packages) → docker build → push to ACR → deploy to Web App

Azure DevOps implementation:

# fan-out-pipeline.yml — triggered after NuGet publish
trigger: none  # triggered by NuGet pipeline completion

resources:
  pipelines:
    - pipeline: nuget-publish
      source: 'Progress-NuGet-Publish'
      trigger:
        branches:
          include: [main]

jobs:
  - job: TriggerClients
    pool:
      vmImage: ubuntu-latest
    strategy:
      matrix:
        ClientA:
          repoName: 'HeritageCrediUnion'
        ClientB:
          repoName: 'ProgressCU'
        ClientC:
          repoName: 'CuisoSolutions'
        # ... add each client
    steps:
      - task: TriggerPipeline@1
        inputs:
          serviceConnection: 'AzureDevOps'
          project: '$(repoName)'
          definitionName: 'Build-and-Deploy'
          buildParameters: 'packageVersion=$(Build.BuildNumber)'

Alternatively, each client pipeline can check for new package versions on a schedule:

# In each client pipeline
schedules:
  - cron: '0 6 * * 1-5'  # 6am weekdays
    displayName: 'Check for baseline updates'
    branches:
      include: [main]
    always: true

steps:
  - script: |
      # Check if newer baseline packages are available
      LATEST=$(dotnet package search Progress.Baseline.Core --source ProgressFeed --format json | jq -r '.searchResult[0].packages[0].latestVersion')
      CURRENT=$(dotnet list package --format json | jq -r '.projects[0].frameworks[0].topLevelPackages[] | select(.id=="Progress.Baseline.Core") | .resolvedVersion')
      if [ "$LATEST" != "$CURRENT" ]; then
        echo "##vso[task.setvariable variable=needsUpdate]true"
        dotnet add package Progress.Baseline.Core --version $LATEST
        dotnet add package Progress.Baseline.Web --version $LATEST
      fi
    displayName: 'Check for baseline updates'

Recommendation

Start with Option A during initial rollout (fewer clients, more control). Move to Option B once you have 10+ clients deployed and the baseline packages are stable.

Versioning Strategy

Version Type When Example
Patch (1.0.x) Bug fixes, no breaking changes 1.0.1 → auto-deploy safe
Minor (1.x.0) New features, backward compatible 1.1.0 → test on one client first
Major (x.0.0) Breaking changes 2.0.0 → staged rollout with testing

Breaking Changes

Major version updates may require changes in client projects (e.g., renamed services, changed view models). Always test on a single client before fan-out deployment.


9. Monitoring & Maintenance

Application Insights

az webapp config appsettings set \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --settings \
    APPLICATIONINSIGHTS_CONNECTION_STRING="InstrumentationKey=<key>;IngestionEndpoint=https://westeurope-5.in.applicationinsights.azure.com/"

Or via appsettings.json:

{
  "ApplicationInsights": {
    "ConnectionString": "<from-key-vault>"
  },
  "Logging": {
    "ApplicationInsights": {
      "LogLevel": {
        "Default": "Warning",
        "Umbraco": "Information"
      }
    }
  }
}

Log Streaming

# Stream live logs
az webapp log tail \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu

# Enable container logging
az webapp log config \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu \
  --docker-container-logging filesystem

Teams Notifications

The per-client pipeline can include a Teams webhook notification step (Adaptive Card) reporting:

  • Image name and tag
  • Commit SHA, message, and author
  • Link to the deployed site and pipeline run

Scaling

Scenario Solution
Single client spike Scale up App Service Plan tier
Many low-traffic clients Share a single App Service Plan (multiple apps per plan)
High-traffic clients Dedicated App Service Plan with auto-scale
Database bottleneck Scale DTU tier or move to Elastic Pool
# Auto-scale rule (CPU-based)
az monitor autoscale create \
  --resource-group rg-progress-shared \
  --resource asp-progress-prod \
  --resource-type Microsoft.Web/serverfarms \
  --min-count 1 --max-count 5 --count 1

az monitor autoscale rule create \
  --resource-group rg-progress-shared \
  --autoscale-name <autoscale-name> \
  --scale out 1 \
  --condition "CpuPercentage > 70 avg 5m"

az monitor autoscale rule create \
  --resource-group rg-progress-shared \
  --autoscale-name <autoscale-name> \
  --scale in 1 \
  --condition "CpuPercentage < 30 avg 10m"

Cost Optimization Summary

Strategy Savings
Elastic Pool for 160+ DBs ~70% vs individual S0 databases
Shared App Service Plan ~80% vs individual plans
Reserved instances (1yr) ~30-40% on compute
Basic tier for staging slots Keep staging stopped when not in use
Blob Storage for media Offload static files from app containers

Appendix: Dockerfile Reference

The application Dockerfile uses a multi-stage build:

# Stage 1: Build with .NET 10 SDK + Node.js 18
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
# Restores NuGet packages (including Progress.Baseline.*), builds with dotnet publish

# Stage 2: Runtime with ASP.NET Core 10
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
# Copies published output, sets ENTRYPOINT ["dotnet", "www.dll"]

Key build arguments:

Argument Default Production
CONFIGURATION Debug Release
ASPNETCORE_ENVIRONMENT Debug Production

Appendix: Quick Reference Commands

# Build and run locally
docker compose -f docker-compose.yml up -d

# Build image manually
docker build -t progresscu:local \
  --build-arg CONFIGURATION=Release \
  --build-arg ASPNETCORE_ENVIRONMENT=Production \
  -f Dockerfile .

# Push to ACR
az acr login --name progressacr
docker tag progresscu:local progressacr.azurecr.io/progresscu:latest
docker push progressacr.azurecr.io/progresscu:latest

# Check deployed image
az webapp config container show \
  --name progresscu-v17 \
  --resource-group rg-progress-progresscu
Migration documentation by Double for Progress Credit Union