A migration without a reliable deployment pipeline is a migration waiting to break in production.

The deployment infrastructure is also frequently underestimated during project scoping. Teams focus on the migration mechanics — the uSync exports, the code rewrites, the Block List conversions — and then try to bolt on deployment workflows at the end. This creates fragile pipelines, environment inconsistency, and last-minute firefighting on go-live day.

This post covers building CI/CD for Umbraco 17 properly: GitHub Actions for the main pipeline, Azure App Service with staging slots for zero-downtime deployments, Docker Compose for local development that matches production, and the specific Azure configuration Umbraco requires.

Umbraco CI/CD Pipeline — Azure + Self-Host

Azure App Service Configuration for Umbraco

Before writing a single YAML file, configure your Azure App Service correctly. Umbraco has specific requirements that are different from a standard ASP.NET Core application.

Required Application Settings

In your Azure App Service → Configuration → Application settings:

UMBRACO__CMS__GLOBAL__MAINDOMLOCK = FileSystemMainDomLock

(For single-instance deployments. For multi-instance: use SqlMainDomLock)

UMBRACO__CMS__HOSTING__LOCALTEMPSTORAGELOACTION = EnvironmentTemp
UMBRACO__CMS__EXAMINE__LUCENEDIRECTORYFACTORY = SyncedTempFileSystemDirectoryFactory
ASPNETCORE_ENVIRONMENT = Production
WEBSITE_RUN_FROM_PACKAGE = 0

(Umbraco does NOT support Run From Package — disable it explicitly)

Connection Strings

Set these in Azure App Service → Configuration → Connection strings or via environment variables:

ConnectionStrings__umbracoDbDSN = Server=myserver.database.windows.net;Database=mydb;...

Use Azure Key Vault references for secrets in production:

@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/DbConnectionString/)

Media Storage: Azure Blob Storage

For any production Umbraco site on Azure, move media to Azure Blob Storage. Azure App Services don’t have persistent file storage across deployments.

dotnet add package Umbraco.StorageProviders.AzureBlob
// Program.cs
builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddAzureBlobMediaFileSystem()  // Azure Blob for /media
    .Build();
// appsettings.json — Blob Storage configuration
{
  "Umbraco": {
    "Storage": {
      "AzureBlob": {
        "Media": {
          "ConnectionString": "DefaultEndpointsProtocol=https;...",
          "ContainerName": "umbraco-media",
          "ContainerPublicAccess": "Blob"
        }
      }
    }
  }
}

GitHub Actions: Full CI/CD Pipeline

This workflow runs on:

  • PR to develop: Build + Test only
  • Merge to main: Build + Test + Deploy to Staging slot
  • Manual dispatch or tag: Deploy Staging → Production (slot swap)
# .github/workflows/umbraco-cicd.yml
name: Umbraco 17 CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
  workflow_dispatch:
    inputs:
      deploy_to_production:
        description: 'Swap staging slot to production?'
        required: false
        default: 'false'

env:
  DOTNET_VERSION: '10.x'
  PROJECT_PATH: './src/MySite.Web/MySite.Web.csproj'
  PUBLISH_PATH: './publish'
  AZURE_WEBAPP_NAME: 'my-umbraco-site'

jobs:
  build-and-test:
    name: Build & Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup .NET ${{ env.DOTNET_VERSION }}
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Restore dependencies
        run: dotnet restore ${{ env.PROJECT_PATH }}

      - name: Build
        run: |
          dotnet build ${{ env.PROJECT_PATH }} \
            --configuration Release \
            --no-restore \
            /WarnAsError  # Treat warnings as errors for clean builds

      - name: Run tests
        run: |
          dotnet test ./tests/ \
            --configuration Release \
            --no-build \
            --logger "trx;LogFileName=test-results.trx" \
            --collect:"XPlat Code Coverage"

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: '**/*.trx'

  deploy-staging:
    name: Deploy to Staging
    needs: build-and-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup .NET ${{ env.DOTNET_VERSION }}
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Publish
        run: |
          dotnet publish ${{ env.PROJECT_PATH }} \
            --configuration Release \
            --output ${{ env.PUBLISH_PATH }} \
            --runtime linux-x64 \
            --self-contained false

      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to Staging Slot
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          slot-name: staging
          package: ${{ env.PUBLISH_PATH }}

      - name: Trigger uSync import on staging
        run: |
          curl -X POST \
            "https://${{ env.AZURE_WEBAPP_NAME }}-staging.azurewebsites.net/umbraco/api/usync/import" \
            -H "Authorization: Bearer ${{ secrets.USYNC_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{"force": false}'

  promote-to-production:
    name: Promote Staging to Production
    needs: deploy-staging
    if: github.event.inputs.deploy_to_production == 'true'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Swap Staging → Production
        uses: azure/CLI@v2
        with:
          inlineScript: |
            az webapp deployment slot swap \
              --name ${{ env.AZURE_WEBAPP_NAME }} \
              --resource-group my-resource-group \
              --slot staging \
              --target-slot production

GitHub Secrets Required

AZURE_CLIENT_ID          — Service principal client ID
AZURE_TENANT_ID          — Azure tenant ID
AZURE_SUBSCRIPTION_ID    — Azure subscription ID
USYNC_API_KEY            — uSync API key from appsettings

For the Azure service principal, use OIDC (OpenID Connect) which doesn’t require storing a secret:

# Create service principal with OIDC federation
az ad app create --display-name "umbraco-cicd"
az ad sp create --id <app-id>
az role assignment create \
  --role "Contributor" \
  --assignee <service-principal-id> \
  --scope /subscriptions/<subscription-id>/resourceGroups/<resource-group>

uSync in the CI/CD Pipeline

uSync keeps your document types, data types, and settings in sync across environments. Integrate it into CI/CD so that schema changes deploy automatically.

Configure uSync Export on Commit

Add a git pre-commit hook or CI step that ensures uSync files are up-to-date:

# In your build job — validate uSync files are committed
- name: Check uSync sync status
  run: |
    # If uSync files have been modified but not committed, fail the build
    if git diff --name-only HEAD | grep -q "^uSync/"; then
      echo "Error: uSync files have been modified but not committed."
      echo "Run uSync export from backoffice and commit the changes."
      exit 1
    fi

Configure uSync Auto-Import on Startup

// appsettings.json
{
  "uSync": {
    "Settings": {
      "ImportOnStartup": true,
      "ExportOnSave": true,
      "BackOfficeHandlerNames": ["DocumentTypes", "DataTypes", "Templates"]
    }
  }
}

With ImportOnStartup: true, every time the Umbraco app restarts (i.e., after a deployment), uSync will automatically import any schema changes from the committed uSync files. This means your document types stay in sync with your codebase without backoffice intervention.


Docker Compose for Local Development

Every developer on the team should have a local environment that matches the production stack as closely as possible. Docker Compose provides this.

docker-compose.yml

version: '3.9'

services:
  sql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: umbraco-sql
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "YourStrong!Password2024"
      MSSQL_PID: "Developer"
    ports:
      - "1433:1433"
    volumes:
      - sql-data:/var/opt/mssql
    healthcheck:
      test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-S", "localhost", 
             "-U", "sa", "-P", "YourStrong!Password2024", "-Q", "SELECT 1"]
      interval: 10s
      timeout: 5s
      retries: 5

  umbraco:
    build:
      context: .
      dockerfile: Dockerfile.dev
    container_name: umbraco-app
    ports:
      - "8080:8080"
    environment:
      ASPNETCORE_ENVIRONMENT: "Development"
      ConnectionStrings__umbracoDbDSN: >
        Server=sql;Database=UmbracoDev;User Id=sa;
        Password=YourStrong!Password2024;TrustServerCertificate=True;
      UMBRACO__CMS__GLOBAL__MAINDOMLOCK: "FileSystemMainDomLock"
    volumes:
      - ./uSync:/app/uSync          # uSync files from repo
      - media-data:/app/wwwroot/media  # Persistent media for local dev
    depends_on:
      sql:
        condition: service_healthy

volumes:
  sql-data:
  media-data:

Dockerfile.dev

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS dev
WORKDIR /app
COPY . .
RUN dotnet restore src/MySite.Web/MySite.Web.csproj
EXPOSE 8080
CMD ["dotnet", "watch", "--project", "src/MySite.Web/MySite.Web.csproj", "run"]
# Start local dev environment
docker-compose up -d

# Watch logs
docker-compose logs -f umbraco

# Stop
docker-compose down

.env.local for Developer Overrides

# .env.local (gitignored)
ADMIN_EMAIL=dev@local.com
ADMIN_PASSWORD=MyDevPassword123!
USYNC_AUTO_IMPORT=true

Environment Configuration Strategy

Umbraco 17 uses standard ASP.NET Core environment-based configuration. Use this layered approach:

appsettings.json                    — Shared, committed defaults
appsettings.Development.json        — Local dev overrides, committed (no secrets)
appsettings.Staging.json            — Staging overrides (committed, no secrets)
appsettings.Production.json         — Production structural config (committed, no secrets)
Azure App Settings                  — All secrets + environment-specific values (NOT in git)
// appsettings.Development.json
{
  "Serilog": {
    "MinimumLevel": { "Default": "Debug" }
  },
  "Umbraco": {
    "CMS": {
      "ModelsBuilder": {
        "ModelsMode": "SourceCodeManual"
      }
    }
  },
  "uSync": {
    "Settings": {
      "ImportOnStartup": true,
      "ExportOnSave": true
    }
  }
}
// appsettings.Production.json
{
  "Serilog": {
    "MinimumLevel": { "Default": "Warning" }
  },
  "Umbraco": {
    "CMS": {
      "ModelsBuilder": {
        "ModelsMode": "Nothing"  // Don't regenerate models in production
      }
    }
  },
  "uSync": {
    "Settings": {
      "ImportOnStartup": true,
      "ExportOnSave": false    // Don't write back to disk in production
    }
  }
}

ResourceSKUPurpose
App Service PlanP1v3 (min)Hosting — P1v3 supports staging slots
Azure SQLS2 (min per Umbraco docs)Umbraco database
Azure Blob StorageLRS StandardMedia files
Azure Key VaultStandardAll secrets
Azure CDNStandardStatic assets + media delivery
Application InsightsStandardMonitoring + performance

Cost optimization: For low-traffic sites, P1v3 can be scaled to B2/B3 during initial migration testing, then scaled up for production. Use auto-scale rules for traffic spikes.


This is Part 6 of 8 in the Umbraco AI-Powered Migration Playbook.

Series outline:

  1. Why Migrate Now (Part 1)
  2. AI-Assisted Assessment & Estimation (Part 2)
  3. Migration Paths: v7/v8/v13 → v17 (Part 3)
  4. Code, Content & Templates (Part 4)
  5. AI Agents, ADR & Workflow (Part 5)
  6. CI/CD: Azure + Self-Host (this post)
  7. Marketing OS Framework (Part 7)
  8. Testing, QA & Go-Live (Part 8)
Export for reading

Comments