Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Architecting Canvas Apps for Enterprise ALM: Source Control with GitHub, Solution Layering, and Environment Pipeline Strategies

Architecting Canvas Apps for Enterprise ALM: Source Control with GitHub, Solution Layering, and Environment Pipeline Strategies

Power Apps🔥 Expert26 min readJul 6, 2026Updated Jul 6, 2026
Table of Contents
  • Introduction
  • Prerequisites
  • Understanding the Power Platform Solution File Format
  • The Canvas App Binary Problem
  • Designing a Multi-Layer Solution Architecture
  • The Three-Layer Model
Solution Layering in Practice: Dependency Declaration
  • Setting Up the Power Platform CLI and Service Principal
  • Exporting and Unpacking Solutions: The Source Control Workflow
  • Building the GitHub Actions Pipeline
  • Workflow 1: Export From Dev
  • Workflow 2: Deploy to Test
  • Workflow 3: Promote to Production
  • Handling the Hard Cases
  • Connection References: The Deployment Blocker
  • Canvas App Ownership
  • Managed Solution Upgrades vs. Updates
  • Solution Layer Conflicts
  • Environment Pipeline Strategy: More Than Just Dev-Test-Prod
  • Branch-Environment Mapping
  • Solution Version Strategy
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • "The solution import fails with 'Missing Dependency'"
  • "The canvas app unpack fails with 'Invalid msapp format'"
  • "My Git diff shows massive changes but I barely changed anything"
  • "The workflow triggers but deploys the wrong solution version"
  • "Connection references are broken after deployment"
  • "Managed solution upgrade fails with 'Component is customized in this environment'"
  • Summary & Next Steps
  • What to tackle next:
  • Architecting Canvas Apps for Enterprise ALM: Source Control with GitHub, Solution Layering, and Environment Pipeline Strategies

    Introduction

    Your team just shipped a Canvas App that 400 field technicians depend on daily to log equipment inspections. It works beautifully in your development environment. Then someone — maybe you, maybe a well-meaning colleague — makes an "emergency fix" directly in production. Two weeks later, nobody can remember what changed, why the approval flow broke, or why the new version of the app overwrites the shared Business Units table that the Finance team's app also depends on. You now have a franken-environment that nobody fully understands, and you're one bad deployment away from a weekend incident.

    This is the reality of Canvas Apps without Application Lifecycle Management (ALM). And it's shockingly common even in organizations that run disciplined DevOps practices everywhere else — because Power Platform looks low-code, so people assume it doesn't need grown-up engineering practices. It absolutely does. Solutions that span multiple Dataverse environments, shared component libraries, and cross-team dependencies require the same rigor you'd bring to a production microservice.

    By the end of this lesson, you will be able to design and operate a full enterprise ALM pipeline for Canvas Apps: unpacking solutions into source-controllable files, building GitHub Actions workflows that automate deployment across environments, architecting solution layers that protect shared components from per-app changes, and making defensible architectural decisions about where to put the seams in your system. This is not a beginner lesson. We're going to get into the internals of the Power Platform solution file format, discuss where the tooling lies to you, and build something you could actually use at work Monday morning.

    What you'll learn:

    • How the Power Platform solution file format works at the XML/YAML level, and why this matters for source control diffing
    • How to unpack, version, and repack solutions using the Power Platform CLI and GitHub Actions
    • How to design a multi-layer solution architecture that separates shared infrastructure from app-specific logic
    • How to build environment pipeline strategies that enforce promotion gates between Dev, Test, UAT, and Production
    • How to handle the hard cases: connection references, environment variables, canvas app ownership, and managed solution conflicts

    Prerequisites

    You should be comfortable with:

    • Creating and publishing Canvas Apps in Power Apps Studio
    • Working with Dataverse solutions (adding/removing components)
    • Basic Git concepts: branches, commits, pull requests
    • Basic YAML syntax (for GitHub Actions)
    • Power Platform environments and the concept of managed vs. unmanaged solutions

    You'll need access to:

    • At least two Power Platform environments (Dev and one other)
    • A GitHub repository (free tier is fine)
    • Power Platform CLI (pac) installed locally
    • A service principal or application user configured in your Power Platform tenant

    If you don't have a service principal yet, pause here and set one up. Everything in this lesson assumes non-interactive authentication, because interactive authentication in a pipeline breaks at 3 AM during a scheduled deployment.


    Understanding the Power Platform Solution File Format

    Before you can version-control anything, you need to understand what you're actually versioning. When you export a solution from Power Platform as a .zip file, you get a specific directory structure. The pac solution unpack command explodes that zip into a tree of XML and JSON files that Git can actually diff.

    Let's look at what that tree looks like for a realistic solution called EquipmentInspections:

    EquipmentInspections/
    ├── src/
    │   ├── CanvasApps/
    │   │   └── equipment_inspectionapp_ab123/
    │   │       ├── equipment_inspectionapp_ab123.msapp   ← binary, more on this
    │   │       └── equipment_inspectionapp_ab123.meta.xml
    │   ├── Workflows/
    │   │   └── NotifyTechnicianOnOverdueInspection-{guid}.json
    │   ├── Entities/
    │   │   └── equipment_Inspection/
    │   │       ├── Entity.xml
    │   │       └── Attributes/
    │   │           ├── equipment_completeddate.xml
    │   │           └── equipment_technicianid.xml
    │   ├── ConnectionReferences/
    │   │   └── equipment_SharedOffice365Connection.json
    │   ├── EnvironmentVariableDefinitions/
    │   │   └── equipment_APIEndpointURL.json
    │   └── solution.xml
    └── .gitignore
    

    The solution.xml file is the manifest. It records the solution version, the publisher prefix, and every component's type code and unique name. This file is your ground truth for what's in the solution — and it's the first place merge conflicts will surface.

    <!-- solution.xml (abbreviated) -->
    <ImportExportXml version="9.2.0.207">
      <SolutionManifest>
        <UniqueName>EquipmentInspections</UniqueName>
        <LocalizedNames>
          <LocalizedName description="Equipment Inspections" languagecode="1033" />
        </LocalizedNames>
        <Version>1.4.0.0</Version>
        <Managed>0</Managed>
        <Publisher>
          <UniqueName>equipmentpublisher</UniqueName>
          <EMailAddress>devteam@contoso.com</EMailAddress>
        </Publisher>
        <RootComponents>
          <RootComponent type="1" id="{a3d...}" behavior="0" />  <!-- Canvas App -->
          <RootComponent type="29" id="{c8f...}" behavior="0" /> <!-- Workflow -->
        </RootComponents>
      </SolutionManifest>
    </ImportExportXml>
    

    The Canvas App Binary Problem

    Here's the hard truth that most tutorials skip: the .msapp file inside CanvasApps/ is a binary zip file. It contains compiled Power Apps YAML, assets, and internal state. When two developers both modify the same Canvas App and you try to merge, Git will tell you it can't merge binary files, and you'll have to pick a winner.

    This is not a solved problem. The pac canvas unpack command (separate from pac solution unpack) can go one level deeper and unpack the .msapp into YAML source files, which are diffable. Here's the command:

    pac canvas unpack \
      --msapp src/CanvasApps/equipment_inspectionapp_ab123/equipment_inspectionapp_ab123.msapp \
      --sources src/CanvasApps/equipment_inspectionapp_ab123/CanvasAppSrc
    

    This produces a structure like:

    CanvasAppSrc/
    ├── src/
    │   ├── App.fx.yaml
    │   ├── Screen1.fx.yaml
    │   └── Components/
    │       └── InspectionFormComponent.fx.yaml
    ├── pkgs/
    │   └── Microsoft.PowerFx.Core.dll  ← still binary, but rarely changes
    ├── DataSources/
    │   └── Inspections.json
    └── Entropy/
        └── Checksums.json               ← noise, add to .gitignore
    

    The Entropy/ folder and Checksums.json are regenerated on every unpack and create noisy diffs. Add them to .gitignore. The src/*.fx.yaml files, though — those are gold. Now you can actually do a pull request review and see that someone changed a Navigate() call or modified a gallery's Items property.

    Architecture decision: Decide upfront whether you'll unpack canvas apps to YAML or treat them as blobs. Unpacking gives you full diff visibility and the ability to do code review on formula changes, but it adds complexity to your pack/unpack workflow. For apps with multiple contributors or complex business logic, it's worth it. For simple display apps maintained by one person, binary-plus-good-commit-messages may be acceptable.


    Designing a Multi-Layer Solution Architecture

    This is where most Power Platform projects fail at the architecture level. Teams put everything — shared tables, reusable components, app-specific customizations, and environment configuration — into a single solution. When it's time to update the shared table schema that four different apps use, they have no clean way to do it without touching all four apps.

    The layered solution pattern solves this. The core principle: separate the rate of change. Infrastructure changes rarely. App logic changes often. Configuration changes per-environment. Give each layer its own solution.

    The Three-Layer Model

    Here's the architecture we'll build around:

    Layer 1: Core Platform (changes rarely — months)

    This solution is managed and locked in upper environments. It owns:

    • Shared Dataverse tables (e.g., equipment_Technician, equipment_Location, equipment_Equipment)
    • Global option sets used across multiple apps
    • Shared security roles and column-level security profiles
    • Shared connection references to enterprise connectors (SharePoint, Teams, SAP)

    Solution name: EquipmentCore Publisher prefix: eqcore

    Layer 2: App-Specific Logic (changes frequently — weeks)

    This solution depends on Layer 1 but owns its own components:

    • The Canvas App itself
    • App-specific cloud flows
    • App-specific tables that aren't shared
    • App-specific business rules and calculated columns

    Solution name: EquipmentInspections Publisher prefix: eqinsp

    Layer 3: Environment Configuration (changes per-environment)

    This solution holds only environment variables and connection reference values — the things that differ between Dev, Test, UAT, and Production. It has no components of its own, just overrides.

    Solution name: EquipmentInspections_Config Publisher prefix: eqconfig

    Why managed in upper environments? When you import a managed solution, its components become read-only in that environment. This is a feature, not a limitation. It prevents the "someone edited production directly" problem. The only way to change a managed component is to update the source solution and redeploy. This enforces your pipeline as the single path to production.

    Solution Layering in Practice: Dependency Declaration

    When EquipmentInspections (Layer 2) references the equipment_Inspection table owned by EquipmentCore (Layer 1), that reference creates a solution dependency. In solution.xml, this appears as:

    <Dependencies>
      <Dependency>
        <Required component.type="1" solution.uniquename="EquipmentCore"
                  component.id="{table-guid}" />
      </Dependency>
    </Dependencies>
    

    This dependency declaration means: "Do not let anyone import EquipmentInspections into an environment where EquipmentCore hasn't already been installed." Power Platform enforces this at import time. It's a contract.

    The practical implication for your pipeline: Layer 1 must always be deployed before Layer 2, and Layer 2 before Layer 3. Your GitHub Actions workflow needs to encode this order explicitly.


    Setting Up the Power Platform CLI and Service Principal

    Before writing any pipelines, get your tooling working locally. Install the Power Platform CLI:

    # On macOS/Linux via npm
    npm install -g @microsoft/powerplatform-actions
    
    # Or directly via dotnet tool
    dotnet tool install --global Microsoft.PowerApps.CLI.Tool
    

    Create an Application User in your tenant for non-interactive pipeline authentication. In the Azure portal, register an app, generate a client secret, and note the:

    • Application (client) ID
    • Client secret value
    • Tenant ID

    Then add this app as an Application User in each Power Platform environment via the Power Platform Admin Center (Settings → Users → Application Users → New app user). Give it the System Administrator security role.

    Authenticate the CLI:

    pac auth create \
      --environment "https://contoso-dev.crm.dynamics.com" \
      --name "ContosoDev" \
      --applicationId "your-app-id" \
      --clientSecret "your-secret" \
      --tenant "your-tenant-id"
    

    Test it:

    pac solution list --environment "https://contoso-dev.crm.dynamics.com"
    

    If you see your solutions listed, you're ready to build pipelines.


    Exporting and Unpacking Solutions: The Source Control Workflow

    Here's the full export-unpack-commit workflow. We'll automate this later, but understanding the manual steps first prevents confusion about what the automation is doing.

    Step 1: Export the solution from Dev

    pac solution export \
      --path ./exports/EquipmentInspections.zip \
      --name EquipmentInspections \
      --environment "https://contoso-dev.crm.dynamics.com" \
      --managed false
    

    Always export as unmanaged from Dev. The managed version gets built during deployment.

    Step 2: Unpack the solution

    pac solution unpack \
      --zipfile ./exports/EquipmentInspections.zip \
      --folder ./solutions/EquipmentInspections/src \
      --processCanvasApps true \
      --allowDelete true
    

    The --processCanvasApps true flag tells the tool to also run pac canvas unpack on any .msapp files it finds. The --allowDelete true flag means that if a component was removed from the solution, its corresponding file gets deleted from the unpacked folder — keeping your source tree honest.

    Step 3: Inspect the diff and commit

    cd solutions/EquipmentInspections
    git diff --stat
    

    You should see changed XML and YAML files corresponding to whatever changed in your app. Review them. Write a meaningful commit message:

    git add .
    git commit -m "feat(inspections): add photo capture to inspection form
    
    - Added InspectionPhoto field to equipment_Inspection table
    - Updated InspectionFormComponent to include camera control
    - Added cloud flow to compress and store photo to SharePoint
    - Bumped solution version to 1.5.0.0"
    

    Step 4: Repack the solution

    pac solution pack \
      --zipfile ./exports/EquipmentInspections_packed.zip \
      --folder ./solutions/EquipmentInspections/src \
      --processCanvasApps true
    

    Step 5: Import to the target environment

    pac solution import \
      --path ./exports/EquipmentInspections_packed.zip \
      --environment "https://contoso-test.crm.dynamics.com" \
      --activate-plugins true \
      --force-overwrite true
    

    For upper environments (UAT, Production), use the managed flag instead:

    pac solution import \
      --path ./exports/EquipmentInspections_managed.zip \
      --environment "https://contoso-prod.crm.dynamics.com" \
      --managed true \
      --activate-plugins true
    

    Warning: The --force-overwrite flag will overwrite existing customizations. In production, you almost never want this for unmanaged solutions. For managed solutions, the import process is more predictable, but test your upgrade behavior in UAT before assuming production will behave the same way.


    Building the GitHub Actions Pipeline

    Now we automate. Here's the mental model for the pipeline architecture:

    • main branch represents what's in Production
    • release/* branches represent what's in UAT
    • develop branch represents what's in Test
    • Feature branches (feature/*) are for development work that gets exported from Dev, unpacked, and committed

    The pipeline has three workflows:

    1. export-from-dev.yml — Triggered manually or on a schedule; exports and unpacks the solution from Dev, opens a PR
    2. deploy-to-test.yml — Triggered on merge to develop; deploys to Test environment
    3. promote-to-prod.yml — Triggered on merge to main after UAT approval; deploys managed solution to Production

    Let's build these out.

    Workflow 1: Export From Dev

    # .github/workflows/export-from-dev.yml
    name: Export Solution from Dev
    
    on:
      workflow_dispatch:
        inputs:
          solution_name:
            description: 'Solution to export'
            required: true
            default: 'EquipmentInspections'
          solution_version:
            description: 'New version number (e.g. 1.5.0.0)'
            required: true
    
    env:
      DEV_ENVIRONMENT_URL: ${{ secrets.DEV_ENVIRONMENT_URL }}
      CLIENT_ID: ${{ secrets.POWER_PLATFORM_APP_ID }}
      CLIENT_SECRET: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
      TENANT_ID: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
    
    jobs:
      export-and-unpack:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout repository
            uses: actions/checkout@v4
            with:
              fetch-depth: 0
    
          - name: Install Power Platform CLI
            uses: microsoft/powerplatform-actions/actions-install@v1
    
          - name: Bump solution version in Dev
            uses: microsoft/powerplatform-actions/set-solution-version@v1
            with:
              environment-url: ${{ env.DEV_ENVIRONMENT_URL }}
              app-id: ${{ env.CLIENT_ID }}
              client-secret: ${{ env.CLIENT_SECRET }}
              tenant-id: ${{ env.TENANT_ID }}
              solution-name: ${{ github.event.inputs.solution_name }}
              solution-version: ${{ github.event.inputs.solution_version }}
    
          - name: Export unmanaged solution from Dev
            uses: microsoft/powerplatform-actions/export-solution@v1
            with:
              environment-url: ${{ env.DEV_ENVIRONMENT_URL }}
              app-id: ${{ env.CLIENT_ID }}
              client-secret: ${{ env.CLIENT_SECRET }}
              tenant-id: ${{ env.TENANT_ID }}
              solution-name: ${{ github.event.inputs.solution_name }}
              solution-output-file: exported/${{ github.event.inputs.solution_name }}_unmanaged.zip
              managed: false
    
          - name: Unpack solution to source
            uses: microsoft/powerplatform-actions/unpack-solution@v1
            with:
              solution-file: exported/${{ github.event.inputs.solution_name }}_unmanaged.zip
              solution-folder: solutions/${{ github.event.inputs.solution_name }}/src
              solution-type: Unmanaged
              process-canvas-apps: true
              overwrite-files: true
    
          - name: Create Pull Request with changes
            uses: peter-evans/create-pull-request@v6
            with:
              token: ${{ secrets.GITHUB_TOKEN }}
              commit-message: "export: ${{ github.event.inputs.solution_name }} v${{ github.event.inputs.solution_version }}"
              branch: export/${{ github.event.inputs.solution_name }}-v${{ github.event.inputs.solution_version }}
              base: develop
              title: "Export: ${{ github.event.inputs.solution_name }} v${{ github.event.inputs.solution_version }}"
              body: |
                Automated export from Dev environment.
                Solution: ${{ github.event.inputs.solution_name }}
                Version: ${{ github.event.inputs.solution_version }}
                
                **Review the diff carefully before merging.**
                Check for unintended changes to shared components.
    

    Workflow 2: Deploy to Test

    # .github/workflows/deploy-to-test.yml
    name: Deploy to Test
    
    on:
      push:
        branches:
          - develop
        paths:
          - 'solutions/**'
    
    env:
      TEST_ENVIRONMENT_URL: ${{ secrets.TEST_ENVIRONMENT_URL }}
      CLIENT_ID: ${{ secrets.POWER_PLATFORM_APP_ID }}
      CLIENT_SECRET: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
      TENANT_ID: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
    
    jobs:
      determine-changed-solutions:
        runs-on: ubuntu-latest
        outputs:
          changed_solutions: ${{ steps.detect.outputs.solutions }}
        steps:
          - uses: actions/checkout@v4
            with:
              fetch-depth: 2
          - name: Detect changed solutions
            id: detect
            run: |
              CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'solutions/' \
                | cut -d'/' -f2 | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))')
              echo "solutions=$CHANGED" >> $GITHUB_OUTPUT
    
      deploy-core:
        needs: determine-changed-solutions
        if: contains(fromJSON(needs.determine-changed-solutions.outputs.changed_solutions), 'EquipmentCore')
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
    
          - uses: microsoft/powerplatform-actions/actions-install@v1
    
          - name: Pack EquipmentCore solution
            uses: microsoft/powerplatform-actions/pack-solution@v1
            with:
              solution-folder: solutions/EquipmentCore/src
              solution-file: packed/EquipmentCore_unmanaged.zip
              solution-type: Unmanaged
              process-canvas-apps: true
    
          - name: Import EquipmentCore to Test
            uses: microsoft/powerplatform-actions/import-solution@v1
            with:
              environment-url: ${{ env.TEST_ENVIRONMENT_URL }}
              app-id: ${{ env.CLIENT_ID }}
              client-secret: ${{ env.CLIENT_SECRET }}
              tenant-id: ${{ env.TENANT_ID }}
              solution-file: packed/EquipmentCore_unmanaged.zip
              force-overwrite: true
              publish-changes: true
    
      deploy-app:
        needs: [determine-changed-solutions, deploy-core]
        if: |
          always() &&
          (needs.deploy-core.result == 'success' || needs.deploy-core.result == 'skipped') &&
          contains(fromJSON(needs.determine-changed-solutions.outputs.changed_solutions), 'EquipmentInspections')
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: microsoft/powerplatform-actions/actions-install@v1
    
          - name: Pack EquipmentInspections solution
            uses: microsoft/powerplatform-actions/pack-solution@v1
            with:
              solution-folder: solutions/EquipmentInspections/src
              solution-file: packed/EquipmentInspections_unmanaged.zip
              solution-type: Unmanaged
              process-canvas-apps: true
    
          - name: Import EquipmentInspections to Test
            uses: microsoft/powerplatform-actions/import-solution@v1
            with:
              environment-url: ${{ env.TEST_ENVIRONMENT_URL }}
              app-id: ${{ env.CLIENT_ID }}
              client-secret: ${{ env.CLIENT_SECRET }}
              tenant-id: ${{ env.TENANT_ID }}
              solution-file: packed/EquipmentInspections_unmanaged.zip
              force-overwrite: true
              publish-changes: true
    

    Workflow 3: Promote to Production

    # .github/workflows/promote-to-prod.yml
    name: Promote to Production
    
    on:
      push:
        branches:
          - main
        paths:
          - 'solutions/**'
    
    jobs:
      deploy-managed-to-prod:
        runs-on: ubuntu-latest
        environment: production   # GitHub environment with required reviewers configured
        steps:
          - uses: actions/checkout@v4
          - uses: microsoft/powerplatform-actions/actions-install@v1
    
          # Deploy Core first, as managed
          - name: Pack EquipmentCore as Managed
            uses: microsoft/powerplatform-actions/pack-solution@v1
            with:
              solution-folder: solutions/EquipmentCore/src
              solution-file: packed/EquipmentCore_managed.zip
              solution-type: Both   # generates both managed and unmanaged
              process-canvas-apps: true
    
          - name: Import EquipmentCore to Production (managed)
            uses: microsoft/powerplatform-actions/import-solution@v1
            with:
              environment-url: ${{ secrets.PROD_ENVIRONMENT_URL }}
              app-id: ${{ secrets.POWER_PLATFORM_APP_ID }}
              client-secret: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
              tenant-id: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
              solution-file: packed/EquipmentCore_managed.zip
              force-overwrite: false   # Never force-overwrite prod managed solutions
              publish-changes: true
              convert-to-managed: true
    
          - name: Pack EquipmentInspections as Managed
            uses: microsoft/powerplatform-actions/pack-solution@v1
            with:
              solution-folder: solutions/EquipmentInspections/src
              solution-file: packed/EquipmentInspections_managed.zip
              solution-type: Both
              process-canvas-apps: true
    
          - name: Import EquipmentInspections to Production (managed)
            uses: microsoft/powerplatform-actions/import-solution@v1
            with:
              environment-url: ${{ secrets.PROD_ENVIRONMENT_URL }}
              app-id: ${{ secrets.POWER_PLATFORM_APP_ID }}
              client-secret: ${{ secrets.POWER_PLATFORM_CLIENT_SECRET }}
              tenant-id: ${{ secrets.POWER_PLATFORM_TENANT_ID }}
              solution-file: packed/EquipmentInspections_managed.zip
              force-overwrite: false
              publish-changes: true
              convert-to-managed: true
    

    The environment: production key on the job is critical. Configure your GitHub production environment to require approval from named reviewers. This gives you a human gate before anything reaches production — the digital equivalent of a change advisory board, but one that doesn't require a Tuesday afternoon meeting.


    Handling the Hard Cases

    The pipeline above is the happy path. Real enterprise deployments run into four consistently painful scenarios. Here's how to handle each one.

    Connection References: The Deployment Blocker

    Connection references are how Canvas Apps and cloud flows connect to external services without hardcoding credentials. They're environment-specific — the SharePoint connection in Dev points to a dev tenant, while production points to the real SharePoint. When you import a solution that contains a connection reference, Power Platform will block the import if the reference isn't satisfied.

    The fix is the environment-specific configuration solution (your Layer 3). After deploying Layer 2 to a new environment, deploy Layer 3 with a deploymentSettings.json file:

    {
      "EnvironmentVariables": [
        {
          "SchemaName": "equipment_APIEndpointURL",
          "Value": "https://api.contoso-test.com/v2"
        }
      ],
      "ConnectionReferences": [
        {
          "LogicalName": "equipment_SharedOffice365Connection",
          "ConnectionId": "abc123def456",
          "ConnectorId": "/providers/Microsoft.PowerApps/apis/shared_office365"
        }
      ]
    }
    

    You get the ConnectionId by querying the Power Platform API or from the connection URL in the environment's connection list. Store one of these files per environment in your repo under config/test/deploymentSettings.json, config/uat/deploymentSettings.json, etc. Feed it to the import step:

    - name: Import solution with connection references
      uses: microsoft/powerplatform-actions/import-solution@v1
      with:
        solution-file: packed/EquipmentInspections_unmanaged.zip
        deployment-settings-file: config/test/deploymentSettings.json
        # ... other params
    

    Security note: deploymentSettings.json files reference connection IDs but not connection secrets. The actual credentials are stored in the Power Platform environment's connections store. However, ConnectionId values are still sensitive — they could be used to enumerate connections. Store your config files in private repos and never commit them to public repositories.

    Canvas App Ownership

    When a solution is imported, the Canvas App's "owner" defaults to the service principal doing the import. Users who had the app shared with them will still see it. But the app won't appear in the maker's "My Apps" list — which can cause support confusion.

    Worse: if the original owner leaves the organization, the app can become orphaned. Establish a policy that production Canvas Apps are owned by a service account or a shared mailbox, not a named individual. Set ownership post-import:

    pac canvas set-app-owner \
      --environment "https://contoso-prod.crm.dynamics.com" \
      --app-name "Equipment Inspection App" \
      --app-owner "svc-powerplatform@contoso.com"
    

    Managed Solution Upgrades vs. Updates

    This distinction bites people constantly. When you import a newer version of a managed solution:

    • Update: Preserves existing components and adds/modifies new ones. Safe for adding new features. Does not remove deleted components.
    • Upgrade: Applies deletions from the solution. If you deleted a column in your solution, the upgrade removes it from the environment.

    You control this via the solution-type behavior and the import parameters. By default, Power Platform imports managed solutions as updates (safer). To trigger an upgrade (and apply deletions), add the --stage-and-upgrade flag:

    pac solution import \
      --path EquipmentInspections_managed.zip \
      --environment "https://contoso-prod.crm.dynamics.com" \
      --stage-and-upgrade true
    

    Warning: Never run --stage-and-upgrade without having confirmed data migration. If you deleted a column that contains production data, a staged upgrade will attempt to delete that column and fail — or worse, succeed and destroy the data. Always test schema deletions in UAT with a production data clone first.

    Solution Layer Conflicts

    When two solutions both contain a customization for the same component (say, both EquipmentCore and EquipmentInspections try to modify the equipment_Inspection table's form), Power Platform resolves conflicts using the solution layer order — essentially, last-imported wins for unmanaged, and for managed solutions, the layer with the higher solution import order takes precedence.

    You can inspect solution layers for any component using:

    pac solution get-layers \
      --environment "https://contoso-prod.crm.dynamics.com" \
      --component-name "equipment_Inspection"
    

    The correct fix for layer conflicts is to make the ownership clear in your architecture: a component should be customized by exactly one solution. If EquipmentCore owns the table, it defines the columns and forms. EquipmentInspections should not add customizations to that table's forms — it should use the table through its API, not modify its shape. If you need app-specific form modifications, create a separate form within EquipmentInspections rather than modifying the shared form.


    Environment Pipeline Strategy: More Than Just Dev-Test-Prod

    A three-tier pipeline (Dev → Test → Production) is the minimum viable ALM setup. For enterprise deployments, you need to think more carefully about the purpose of each environment and what guarantees it provides.

    Here's a more complete environment model:

    Environment Type Solution State Purpose Who Has Access
    Personal Dev Sandbox Unmanaged Individual experimentation Developer only
    Team Dev Sandbox Unmanaged Integration of features Dev team
    Test Sandbox Unmanaged Automated functional testing QA, DevOps
    UAT Sandbox Managed Business acceptance testing Business users, PM
    Production Production Managed Live operations End users

    The column that most teams miss is Solution State. UAT and Production must run managed solutions. If UAT is running unmanaged solutions, your UAT test results don't validate what production will actually behave like, because managed solutions behave differently — they lock customizations, enforce dependency chains, and resolve layer conflicts differently.

    Branch-Environment Mapping

    Map your Git branches to environments explicitly and enforce it:

    feature/* → exports from Personal Dev, PR to develop
    develop   → auto-deploys to Team Dev
    release/* → auto-deploys to UAT (as managed)
    main      → deploys to Production (as managed, with approval gate)
    

    Use GitHub branch protection rules to enforce:

    • develop requires passing checks before merge
    • release/* requires at least one approver
    • main requires two approvers and passing checks

    Solution Version Strategy

    Use semantic versioning adapted for Power Platform's four-part version number: Major.Minor.Patch.Build

    • Major: Breaking schema changes (deleted columns, renamed tables)
    • Minor: New features (new columns, new app screens)
    • Patch: Bug fixes (formula corrections, flow logic fixes)
    • Build: Auto-incremented by your pipeline (set to the GitHub run number)

    In your GitHub Actions workflow, set the build number automatically:

    - name: Set solution version
      run: |
        # Extract base version from solution.xml
        BASE_VERSION=$(grep -oP '(?<=<Version>)[^<]+' solutions/EquipmentInspections/src/solution.xml | head -1)
        MAJOR_MINOR_PATCH=$(echo $BASE_VERSION | cut -d'.' -f1-3)
        NEW_VERSION="${MAJOR_MINOR_PATCH}.${{ github.run_number }}"
        echo "SOLUTION_VERSION=$NEW_VERSION" >> $GITHUB_ENV
        # Update solution.xml
        sed -i "s|<Version>.*</Version>|<Version>${NEW_VERSION}</Version>|" \
          solutions/EquipmentInspections/src/solution.xml
    

    This means your production solution always has a build number that directly maps to a GitHub Actions run, which in turn maps to an exact Git commit. When something breaks in production, you run pac solution get-component-version and immediately know which commit introduced the change.


    Hands-On Exercise

    In this exercise, you'll set up the full source control pipeline for a Canvas App from scratch.

    Scenario: You have a Canvas App called ProjectTracker in a Dev environment. It tracks project milestones against a Dataverse table. You need to get it under source control and build a pipeline to deploy it to a Test environment.

    Step 1: Create your repository structure

    Create a new GitHub repository called contoso-power-platform. Create the following directory structure:

    contoso-power-platform/
    ├── solutions/
    │   └── .gitkeep
    ├── exports/
    │   └── .gitignore       # git-ignore the zip files themselves
    ├── config/
    │   ├── test/
    │   │   └── deploymentSettings.json
    │   └── prod/
    │       └── deploymentSettings.json
    └── .github/
        └── workflows/
            ├── export-from-dev.yml
            └── deploy-to-test.yml
    

    Add this to exports/.gitignore:

    *.zip
    

    Step 2: Export and commit your first solution

    Create a solution in your Dev environment called ProjectTracker. Add your Canvas App to it. Then run:

    # Authenticate
    pac auth create \
      --name DevEnvironment \
      --environment "https://yourorg-dev.crm.dynamics.com" \
      --applicationId "your-app-id" \
      --clientSecret "your-secret" \
      --tenant "your-tenant-id"
    
    # Export
    pac solution export \
      --path exports/ProjectTracker.zip \
      --name ProjectTracker \
      --managed false
    
    # Unpack
    pac solution unpack \
      --zipfile exports/ProjectTracker.zip \
      --folder solutions/ProjectTracker/src \
      --processCanvasApps true \
      --allowDelete true
    
    # Commit
    git add solutions/ProjectTracker/
    git commit -m "initial: add ProjectTracker solution v1.0.0.0"
    git push origin develop
    

    Step 3: Add GitHub Secrets

    In your GitHub repository, go to Settings → Secrets and variables → Actions. Add:

    • DEV_ENVIRONMENT_URL
    • TEST_ENVIRONMENT_URL
    • POWER_PLATFORM_APP_ID
    • POWER_PLATFORM_CLIENT_SECRET
    • POWER_PLATFORM_TENANT_ID

    Step 4: Create the deploy-to-test workflow

    Using the workflow template from earlier in this lesson, create .github/workflows/deploy-to-test.yml adapted for ProjectTracker. Commit it to develop.

    Step 5: Make a change and trace it through the pipeline

    In your Dev environment, open the Canvas App and add a new screen with a simple label. Export the solution again with a bumped version number (1.1.0.0). Unpack, review the diff (you should see changes in the YAML source files under CanvasAppSrc/src/), commit, and push.

    Watch the GitHub Actions workflow trigger. Verify the deployment appears in your Test environment.

    Stretch goal: Configure a GitHub environment called test with yourself as a required reviewer. Update the workflow to use environment: test. Trigger a deployment and approve it through the GitHub Actions UI.


    Common Mistakes & Troubleshooting

    "The solution import fails with 'Missing Dependency'"

    This means your solution references a component that isn't in the target environment. The two most common causes:

    1. You're deploying Layer 2 before Layer 1. Check your pipeline ordering and make sure EquipmentCore deploys before EquipmentInspections.
    2. A publisher-specific component exists in Dev but was never added to any solution. Go to Dev, check the solution layers for the missing component, and add it to the correct solution.

    To diagnose, run:

    pac solution check --path exports/EquipmentInspections.zip
    

    This runs the Solution Checker and surfaces dependency issues before import.

    "The canvas app unpack fails with 'Invalid msapp format'"

    The .msapp was edited by Power Apps Studio in an incompatible way, or the file is corrupted. First try re-exporting the solution fresh. If that doesn't work, check the Power Platform CLI version — canvas unpack format changes between CLI versions. Pin your GitHub Actions workflow to a specific CLI version:

    - uses: microsoft/powerplatform-actions/actions-install@v1
      with:
        pac-version: '1.27.6'
    

    "My Git diff shows massive changes but I barely changed anything"

    This usually means the Entropy/Checksums.json file wasn't gitignored, or the canvas app YAML is being regenerated with different internal ordering. Add to your .gitignore:

    solutions/*/src/CanvasApps/*/CanvasAppSrc/Entropy/
    solutions/*/src/CanvasApps/*/*.msapp
    

    Wait — why gitignore the .msapp itself? Because you're storing the unpacked YAML source. The .msapp is a derived artifact. You rebuild it from YAML during pack. Storing both creates duplication and confusing diffs.

    "The workflow triggers but deploys the wrong solution version"

    Your solution.xml in source has a stale version number. The export-from-dev workflow should always bump the version before exporting, not after. If you bump in source without exporting, the packed solution will have the new version number in solution.xml but the old component versions inside it — Power Platform may accept the import but behave unpredictably.

    "Connection references are broken after deployment"

    The deploymentSettings.json wasn't applied, or the connection ID has changed. Connection IDs in Power Platform environments are not stable across environment resets or tenant migrations. After any environment recreation, re-query the connection IDs and update your config files:

    pac connection list --environment "https://yourorg-test.crm.dynamics.com"
    

    Match the logical connector name to the connector type and update deploymentSettings.json accordingly.

    "Managed solution upgrade fails with 'Component is customized in this environment'"

    Someone edited a managed component directly in the target environment (usually production). This is the exact problem you set up managed solutions to prevent, but someone found a way around it — perhaps by installing an unmanaged layer on top.

    To find the offending customization:

    pac solution get-layers \
      --environment "https://yourorg-prod.crm.dynamics.com" \
      --component-name "your_component_name"
    

    You'll see an unmanaged "Active" layer on top of your managed layer. To clear it, remove the customization from the Active solution in the target environment. This requires direct environment access and should go through a change management process.


    Summary & Next Steps

    You've now seen the full picture of enterprise ALM for Canvas Apps. Let's anchor the key principles before you go:

    Source control is non-negotiable. The Power Platform CLI's unpack capability gives you human-readable, diffable source files. Canvas app YAML unpacking gives you code review capability for formula changes. Use both.

    Solution layering is architectural design. Separate your infrastructure (Core), your application logic (App), and your environment configuration (Config) into distinct solutions with explicit dependency declarations. This is what makes it possible to update a shared table without touching every downstream app.

    Managed solutions in upper environments enforce your process. This is the technical enforcement of your change management policy. Without it, you're relying on culture and goodwill. With it, you're relying on the platform.

    Environment variables and connection references are first-class citizens. Handle them explicitly in your pipeline with deploymentSettings.json files per environment. Don't let them be an afterthought.

    The pipeline ordering encodes your architecture. Deploy Layer 1 before Layer 2 before Layer 3. Make this explicit and unambiguous in your GitHub Actions workflows, using job dependencies and conditional execution.

    What to tackle next:

    • Power Platform Pipelines (in-product ALM): Microsoft's native pipeline tooling in Power Platform Admin Center provides a simpler setup for teams not ready for full GitHub Actions infrastructure. Understand how it compares and where GitHub gives you capabilities that the in-product tool doesn't.
    • Automated testing with Power Apps Test Studio and Power Fx test files: Integrate automated UI tests into your pipeline so that the deploy-to-test workflow actually tests the app, not just deploys it.
    • Component library versioning: Shared component libraries in Canvas Apps have their own versioning and publication mechanism. These need to be treated as their own solutions — learn how to manage component library dependencies across multiple consuming apps.
    • Dataverse DevKit and ALM Accelerator: Microsoft's open-source ALM Accelerator for Power Platform provides a production-ready template for exactly the kind of pipeline we built here, with additional features like WSDL generation and automated solution checker gates. Study it as a reference architecture.
    • Multi-geo and sovereign cloud considerations: If your Power Platform tenant spans multiple geographies or operates in a sovereign cloud (GCC, GCC High), environment creation and pipeline authentication have additional constraints. Understand them before assuming this pipeline template applies unchanged.

    The investment you make in ALM infrastructure now pays compound interest. Every hour you spend setting up this pipeline is ten hours you don't spend debugging a mystery production break at midnight.

    Learning Path: Canvas Apps 101

    Previous

    Implementing Role-Based Screen Access and Dynamic UI in Canvas Apps Using Azure AD Group Membership

    Related Articles

    Power Apps⚡ Practitioner

    Implementing Role-Based Screen Access and Dynamic UI in Canvas Apps Using Azure AD Group Membership

    20 min
    Power Apps🌱 Foundation

    Publishing and Sharing Your Canvas App: Environments, Versions, and App Distribution for End Users

    17 min
    Power Apps🔥 Expert

    Power Apps Canvas App Automated Testing: Building Test Suites with Test Studio and Power Automate for CI/CD Pipelines

    28 min

    On this page

    • Introduction
    • Prerequisites
    • Understanding the Power Platform Solution File Format
    • The Canvas App Binary Problem
    • Designing a Multi-Layer Solution Architecture
    • The Three-Layer Model
    • Solution Layering in Practice: Dependency Declaration
    • Setting Up the Power Platform CLI and Service Principal
    • Exporting and Unpacking Solutions: The Source Control Workflow
    • Building the GitHub Actions Pipeline
    • Workflow 1: Export From Dev
    • Workflow 2: Deploy to Test
    • Workflow 3: Promote to Production
    • Handling the Hard Cases
    • Connection References: The Deployment Blocker
    • Canvas App Ownership
    • Managed Solution Upgrades vs. Updates
    • Solution Layer Conflicts
    • Environment Pipeline Strategy: More Than Just Dev-Test-Prod
    • Branch-Environment Mapping
    • Solution Version Strategy
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • "The solution import fails with 'Missing Dependency'"
    • "The canvas app unpack fails with 'Invalid msapp format'"
    • "My Git diff shows massive changes but I barely changed anything"
    • "The workflow triggers but deploys the wrong solution version"
    • "Connection references are broken after deployment"
    • "Managed solution upgrade fails with 'Component is customized in this environment'"
    • Summary & Next Steps
    • What to tackle next: