Manual Power Platform deployments are a liability. This deep-dive lesson shows you how to build a complete Azure DevOps CI/CD pipeline that exports Canvas App solutions as source artifacts, substitutes environment variables per target environment, and deploys through approval-gated stages to production — with rollback built in from day one.

Picture this: your team has been developing a critical inventory management Canvas App for three months. It's connected to Dataverse, integrates with an Azure SQL backend via a custom connector, uses environment-specific SharePoint sites for document storage, and handles role-based access for five distinct user groups. The app works flawlessly in your development environment. Then Friday afternoon arrives, someone manually exports the solution, imports it into production, forgets to update the SharePoint URL environment variable, and three hundred warehouse workers spend Monday morning staring at a broken app.
This scenario plays out constantly in Power Platform shops that haven't invested in proper CI/CD infrastructure. Manual solution promotion is fragile, undocumented, and impossible to audit. The good news is that Microsoft has built a mature set of tooling — Power Platform Build Tools for Azure DevOps, the pac CLI, and solution-aware source control — that lets you treat Canvas Apps with the same engineering rigor you'd apply to any enterprise software system.
By the end of this lesson you'll be able to build a complete, production-grade CI/CD pipeline that automatically exports your Canvas App solution from development, unpacks it into source-controlled YAML and JSON artifacts, substitutes environment variables per target environment, runs automated tests, and deploys to staging and production with full approvals and rollback capability. We're going to go deep on the mechanics — not just the happy path, but the edge cases that trip up experienced teams.
What you'll learn:
Before diving in, you should be comfortable with the following:
pac CLI extensivelyYou do not need prior CI/CD experience with Power Platform specifically. We'll build everything from scratch.
Before touching a single pipeline, you need to understand what you're actually deploying. A Power Platform solution is a ZIP file containing XML metadata, Canvas App source files, Power Automate flow definitions, Dataverse schema, connection references, and environment variable definitions. When you export a solution, you get one of two variants:
Managed solutions are sealed packages intended for target environments. Components cannot be edited directly after import. This is what you deploy to staging and production.
Unmanaged solutions are open packages used during development. Components can be modified after import. You never promote an unmanaged solution to production — this is one of the most common mistakes teams make when moving from ad-hoc to systematic deployment.
When you unpack a solution using the pac solution unpack command (or the equivalent Build Tools task), the ZIP expands into a folder structure that Azure DevOps can diff and version like any other codebase. For a Canvas App specifically, the unpacked structure looks like this:
/InventoryApp/
/CanvasApps/
/src/
InventoryManagement.msapp ← raw .msapp binary (before further unpacking)
/pkgs/
/CanvasManifest.json ← app metadata
/Src/
/AppInfo.json
/Screens/
HomeScreen.json
InventoryScreen.json
ReceivingScreen.json
/Controls/
...
/References/
DataSources.json
/Entities/
/InventoryItem/
InventoryItem.xml
/EnvironmentVariableDefinitions/
/sql_connection_string/
environmentvariabledefinition.xml
/ConnectionReferences/
/shared_sql_1/
connectionreference.xml
solution.xml
customizations.xml
Key insight: The
.msappfile inside a solution is itself a ZIP archive containing the Canvas App's internal representation. Thepac canvas unpackcommand unpacks that too, turning binary blobs into individual JSON files per screen and control. Without this second unpack, your Git diffs will show binary changes — useless for code review. Always unpack to source format before committing.
The reason this architecture matters for your pipeline design is that every layer introduces a potential point of failure during promotion. The connection references need to exist in the target environment. Environment variable values need to be set or overridden. The managed solution import needs to handle existing components gracefully. Your pipeline needs to handle all of this systematically.
Your pipeline needs to authenticate to Power Platform environments without human credentials. Create a dedicated service principal in Azure AD, not a user account. User accounts create single points of failure when people leave the organization.
In Azure Portal, navigate to Azure Active Directory, then App registrations, and create a new registration called something like powerplatform-devops-sp. After creation, generate a client secret and note both the Application (client) ID and the Directory (tenant) ID. You'll also need the secret value — store this immediately because Azure won't show it again.
Next, go to your Power Platform Admin Center and grant this service principal the System Administrator role in each environment it needs to access (Development, Staging, Production). Navigate to Environments, select each environment, go to Settings, then Users plus permissions, then Application users. Create a new application user, paste the client ID, and assign System Administrator. This is a broad role — for production environments, some organizations create a custom role with just the necessary permissions, though the official Build Tools documentation uses System Administrator as the baseline.
Warning: Do not use a regular user account as your pipeline service identity. User accounts are tied to individual licenses, can have MFA policies applied unexpectedly, and leave your pipeline vulnerable when that user changes their password or leaves the organization. Service principals are the right answer.
In your Azure DevOps organization, go to the Marketplace (the shopping bag icon) and search for "Power Platform Build Tools." Install the Microsoft-published extension to your organization. This gives you a set of pipeline tasks: PowerPlatformExportSolution, PowerPlatformImportSolution, PowerPlatformPublishCustomizations, PowerPlatformSetSolutionVersion, and several others.
In your Azure DevOps project, navigate to Project Settings, then Service connections. Create a new service connection of type "Power Platform." You'll provide:
https://yourorg-dev.crm.dynamics.com)Create one service connection per environment. Name them clearly: PowerPlatform-Dev, PowerPlatform-Staging, PowerPlatform-Prod. You'll reference these by name in your YAML pipeline, so consistent naming pays off.
Store the client secret as a variable group secret in Azure DevOps Pipelines, Library. Create a variable group called powerplatform-credentials with a secret variable SP_CLIENT_SECRET. Link this variable group to your pipelines — never hardcode secrets in YAML.
Before writing pipeline YAML, establish your repository structure. A clean layout makes pipelines simpler and onboarding new team members easier.
/repo-root/
/solutions/
/InventoryApp/ ← unpacked solution source
/CanvasApps/
/Entities/
/EnvironmentVariableDefinitions/
/ConnectionReferences/
solution.xml
/pipelines/
/export-solution.yml ← export + unpack + commit
/build-solution.yml ← pack + publish artifact
/deploy-staging.yml ← deploy to staging
/deploy-production.yml ← deploy to production
/ci-pipeline.yml ← orchestrating pipeline
/config/
/dev.json ← env variable values for dev
/staging.json ← env variable values for staging
/prod.json ← env variable values for production
/tests/
/InventoryApp.Tests/ ← Test Studio test plans
The /config/ directory is critical. It externalizes environment-specific values — database connection strings, SharePoint site URLs, API endpoints — from the solution itself. We'll come back to this when we discuss environment variable substitution in detail.
Note: The
/config/JSON files should contain non-sensitive configuration only. Connection strings with embedded credentials should be stored as Azure DevOps secret variables or in Azure Key Vault, referenced dynamically during pipeline execution — not committed to source control.
The export pipeline runs on a schedule or on demand from the development environment. Its job is to export the solution, unpack it to source format, and commit the changes back to the feature branch. This is the "check in your work" workflow for Power Platform developers who aren't writing traditional code.
# pipelines/export-solution.yml
trigger: none # Manual trigger only - developers run this on demand
parameters:
- name: solutionName
displayName: Solution Name
type: string
default: InventoryApp
- name: sourceBranch
displayName: Target Branch for Commit
type: string
default: feature/current-work
variables:
- group: powerplatform-credentials
- name: buildAgentPool
value: 'ubuntu-latest'
- name: pacCliVersion
value: '1.30.6'
pool:
vmImage: $(buildAgentPool)
stages:
- stage: ExportAndUnpack
displayName: 'Export Solution from Dev and Unpack to Source'
jobs:
- job: Export
steps:
- checkout: self
persistCredentials: true
clean: true
# Install the pac CLI
- task: PowerShell@2
displayName: 'Install pac CLI'
inputs:
targetType: 'inline'
script: |
dotnet tool install --global Microsoft.PowerApps.CLI.Tool --version $(pacCliVersion)
echo "##vso[task.prependpath]$env:USERPROFILE/.dotnet/tools"
# Export the solution from dev (unmanaged)
- task: PowerPlatformExportSolution@2
displayName: 'Export Unmanaged Solution from Dev'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'PowerPlatform-Dev'
SolutionName: '${{ parameters.solutionName }}'
SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/${{ parameters.solutionName }}.zip'
Managed: false
AsyncOperation: true
MaxAsyncWaitTime: '60'
# Unpack solution ZIP to folder structure
- task: PowerPlatformUnpackSolution@2
displayName: 'Unpack Solution to Source Format'
inputs:
SolutionInputFile: '$(Build.ArtifactStagingDirectory)/${{ parameters.solutionName }}.zip'
SolutionTargetFolder: '$(Build.SourcesDirectory)/solutions/${{ parameters.solutionName }}'
SolutionType: 'Unmanaged'
ProcessCanvasApps: true # This triggers the .msapp secondary unpack
# Commit changes back to branch
- task: PowerShell@2
displayName: 'Commit Unpacked Solution to Source Control'
inputs:
targetType: 'inline'
script: |
git config user.email "devops-bot@yourorg.com"
git config user.name "DevOps Pipeline Bot"
git checkout ${{ parameters.sourceBranch }}
git add solutions/${{ parameters.solutionName }}
# Only commit if there are actual changes
$changes = git status --porcelain
if ($changes) {
git commit -m "chore: export solution ${{ parameters.solutionName }} from dev [skip ci]"
git push origin ${{ parameters.sourceBranch }}
Write-Host "Changes committed and pushed."
} else {
Write-Host "No changes detected. Nothing to commit."
}
Notice the ProcessCanvasApps: true flag on the unpack task. This is what triggers the secondary unpack of the .msapp binary into individual JSON files. Without this, Canvas App changes produce unreadable binary diffs in your pull requests. With it, reviewers can see exactly which screens and controls changed.
The [skip ci] tag in the commit message is important. It prevents the commit from triggering another pipeline run, which would create an infinite loop.
Tip: Run the export pipeline immediately after every development session, not at the end of a sprint. Teams that batch exports end up with massive, unreviable commits that defeat the purpose of source control. Treat "export and commit" the same way you'd treat a
git commitin traditional development.
The build pipeline takes the source-controlled solution files, packs them into a managed solution ZIP, and publishes that ZIP as a pipeline artifact. This artifact is what gets deployed to staging and production — never a direct export from the development environment.
# pipelines/build-solution.yml
trigger:
branches:
include:
- main
paths:
include:
- solutions/**
variables:
- group: powerplatform-credentials
- name: solutionName
value: 'InventoryApp'
- name: buildVersion
value: '$(Build.BuildId)'
pool:
vmImage: 'ubuntu-latest'
stages:
- stage: Build
displayName: 'Pack Managed Solution and Publish Artifact'
jobs:
- job: PackSolution
steps:
- checkout: self
# Increment the solution version using the build ID
- task: PowerPlatformSetSolutionVersion@2
displayName: 'Set Solution Version'
inputs:
SolutionSourceFolder: '$(Build.SourcesDirectory)/solutions/$(solutionName)'
SolutionVersionNumber: '1.0.$(buildVersion).0'
# Pack source files back into a managed solution ZIP
- task: PowerPlatformPackSolution@2
displayName: 'Pack as Managed Solution'
inputs:
SolutionSourceFolder: '$(Build.SourcesDirectory)/solutions/$(solutionName)'
SolutionOutputFile: '$(Build.ArtifactStagingDirectory)/$(solutionName)_managed.zip'
SolutionType: 'Managed'
ProcessCanvasApps: true
# Publish the artifact for downstream deployment stages
- task: PublishBuildArtifacts@1
displayName: 'Publish Solution Artifact'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'solution-drop'
publishLocation: 'Container'
# Also publish the config directory for env var substitution
- task: PublishBuildArtifacts@1
displayName: 'Publish Config Artifact'
inputs:
PathtoPublish: '$(Build.SourcesDirectory)/config'
ArtifactName: 'config-drop'
publishLocation: 'Container'
Setting the solution version to 1.0.$(buildVersion).0 means each build produces a uniquely versioned artifact. When you're debugging a production issue and need to know exactly which build is running, this version number is your audit trail. Import history in Power Platform Admin Center shows solution versions, so you can trace exactly what's deployed.
Environment variables in Power Platform solutions are two-part constructs: a definition (the schema and default value, stored in the solution) and a value (the actual data, stored outside the solution in the target environment). This design is intentional — it means you can deploy the same solution to multiple environments without modifying the solution itself.
The problem most teams hit is that environment variable values are not automatically promoted with the solution. You must set them explicitly in each target environment, either manually (fragile) or programmatically during deployment (correct).
Your /config/staging.json might look like this:
{
"environmentVariables": [
{
"schemaName": "wsd_SQLConnectionString",
"value": "Server=staging-sql.database.windows.net;Database=InventoryDB_Staging;Authentication=Active Directory Managed Identity"
},
{
"schemaName": "wsd_SharePointSiteURL",
"value": "https://yourorg.sharepoint.com/sites/InventoryApp-Staging"
},
{
"schemaName": "wsd_APIGatewayBaseURL",
"value": "https://api-staging.yourorg.com/inventory/v2"
},
{
"schemaName": "wsd_MaxBatchSize",
"value": "500"
}
],
"connectionReferences": [
{
"logicalName": "wsd_sharedSQL",
"connectionId": "/providers/Microsoft.PowerApps/apis/shared_sql/connections/abc123staging",
"connectorId": "/providers/Microsoft.PowerApps/apis/shared_sql"
}
]
}
And /config/prod.json has the same keys with production values. The schema names must exactly match the environment variable schema names in your solution — a mismatch here will silently fail, leaving the old value in place.
Warning: Connection references are separate from environment variables and require separate handling. The connection ID format (
/providers/Microsoft.PowerApps/...) must reference connections that already exist in the target environment, created under the service principal or a designated service account. Connections cannot be created by pipelines — they must be pre-created and their IDs captured for the config file.
Here's a PowerShell script that reads the config file and applies environment variable values via the pac CLI:
# scripts/Apply-EnvironmentConfig.ps1
param(
[string]$ConfigFile,
[string]$EnvironmentUrl,
[string]$TenantId,
[string]$ClientId,
[string]$ClientSecret
)
# Authenticate pac CLI
pac auth create `
--environment $EnvironmentUrl `
--tenant $TenantId `
--applicationId $ClientId `
--clientSecret $ClientSecret `
--kind ServicePrincipal
$config = Get-Content $ConfigFile | ConvertFrom-Json
# Apply environment variable values
foreach ($envVar in $config.environmentVariables) {
Write-Host "Setting environment variable: $($envVar.schemaName)"
# Use pac env set-variable (pac CLI 1.28+)
pac env update-settings `
--environment $EnvironmentUrl `
--settings "$($envVar.schemaName)=$($envVar.value)"
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to set environment variable: $($envVar.schemaName)"
exit 1
}
}
Write-Host "Environment configuration applied successfully."
Note: The
pac env update-settingscommand syntax evolved across pac CLI versions. The example above targets version 1.28+. If you're on an older version, you may need to use the Dataverse Web API directly to updateenvironmentvariablevaluerecords. Pin your pac CLI version in the pipeline to avoid surprises when Microsoft releases updates.
Now we assemble everything into a single orchestrating pipeline that moves artifacts through staging and production with appropriate gates.
# pipelines/ci-pipeline.yml
trigger:
branches:
include:
- main
paths:
include:
- solutions/**
variables:
- group: powerplatform-credentials
- name: solutionName
value: 'InventoryApp'
stages:
# ─── STAGE 1: Build ────────────────────────────────────────────────────────
- stage: Build
displayName: 'Build Managed Solution'
jobs:
- job: PackAndPublish
pool:
vmImage: 'ubuntu-latest'
steps:
- template: build-solution.yml
# ─── STAGE 2: Deploy to Staging ────────────────────────────────────────────
- stage: DeployStaging
displayName: 'Deploy to Staging'
dependsOn: Build
condition: succeeded()
jobs:
- deployment: DeployStagingJob
pool:
vmImage: 'ubuntu-latest'
environment: 'PowerPlatform-Staging' # Maps to DevOps Environment with approval
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: solution-drop
- download: current
artifact: config-drop
# Import the managed solution to staging
- task: PowerPlatformImportSolution@2
displayName: 'Import Solution to Staging'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'PowerPlatform-Staging'
SolutionInputFile: '$(Pipeline.Workspace)/solution-drop/$(solutionName)_managed.zip'
AsyncOperation: true
MaxAsyncWaitTime: '120'
PublishWorkflows: true
OverwriteUnmanagedCustomizations: true
SkipProductUpdateDependencies: true
# Apply staging-specific environment variables
- task: PowerShell@2
displayName: 'Apply Staging Environment Config'
inputs:
targetType: 'filePath'
filePath: '$(Build.SourcesDirectory)/scripts/Apply-EnvironmentConfig.ps1'
arguments: >
-ConfigFile "$(Pipeline.Workspace)/config-drop/staging.json"
-EnvironmentUrl "https://yourorg-staging.crm.dynamics.com"
-TenantId "$(TENANT_ID)"
-ClientId "$(SP_CLIENT_ID)"
-ClientSecret "$(SP_CLIENT_SECRET)"
# Publish customizations to make the app available
- task: PowerPlatformPublishCustomizations@2
displayName: 'Publish Customizations'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'PowerPlatform-Staging'
# Run automated tests against staging
- task: PowerShell@2
displayName: 'Run Canvas App Tests'
inputs:
targetType: 'inline'
script: |
pac test run `
--provider canvas `
--environment-url "https://yourorg-staging.crm.dynamics.com" `
--tenant $(TENANT_ID) `
--application-id $(SP_CLIENT_ID) `
--client-secret $(SP_CLIENT_SECRET) `
--test-plan-file "$(Build.SourcesDirectory)/tests/InventoryApp.Tests/TestPlan.fx.yaml" `
--output-directory "$(Build.ArtifactStagingDirectory)/test-results"
- task: PublishTestResults@2
displayName: 'Publish Test Results'
condition: always()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '$(Build.ArtifactStagingDirectory)/test-results/*.xml'
# ─── STAGE 3: Deploy to Production ─────────────────────────────────────────
- stage: DeployProduction
displayName: 'Deploy to Production'
dependsOn: DeployStaging
condition: succeeded()
jobs:
- deployment: DeployProductionJob
pool:
vmImage: 'ubuntu-latest'
environment: 'PowerPlatform-Production' # Has manual approval gate configured
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: solution-drop
- download: current
artifact: config-drop
- task: PowerPlatformImportSolution@2
displayName: 'Import Solution to Production'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'PowerPlatform-Prod'
SolutionInputFile: '$(Pipeline.Workspace)/solution-drop/$(solutionName)_managed.zip'
AsyncOperation: true
MaxAsyncWaitTime: '180'
PublishWorkflows: true
OverwriteUnmanagedCustomizations: false # More conservative in prod
SkipProductUpdateDependencies: false
- task: PowerShell@2
displayName: 'Apply Production Environment Config'
inputs:
targetType: 'filePath'
filePath: '$(Build.SourcesDirectory)/scripts/Apply-EnvironmentConfig.ps1'
arguments: >
-ConfigFile "$(Pipeline.Workspace)/config-drop/prod.json"
-EnvironmentUrl "https://yourorg.crm.dynamics.com"
-TenantId "$(TENANT_ID)"
-ClientId "$(SP_CLIENT_ID)"
-ClientSecret "$(SP_CLIENT_SECRET)"
- task: PowerPlatformPublishCustomizations@2
displayName: 'Publish Customizations'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'PowerPlatform-Prod'
The environment key in the deployment jobs maps to Azure DevOps Environments (found under Pipelines, then Environments). Configure your PowerPlatform-Production environment with a manual approval check requiring sign-off from your release manager before the stage executes. This is your production gate — the pipeline pauses, sends a notification, and waits for a human to approve.
For canvas app testing integrated into the pipeline, the pac test run command requires that test plans be committed to source control as .fx.yaml files, which Test Studio exports. The tests run against the staging environment after deployment, validating that the app actually functions correctly with the staging data and configuration before anyone approves the production push.
Connection references deserve dedicated treatment because they're the most common source of post-deployment failures. When you import a solution into a new environment, connection references in the solution need to be mapped to actual connections in that environment. If they're not mapped, flows don't run and connectors don't connect.
The correct pattern is:
Here's a PowerShell function that handles the connection reference update:
function Set-ConnectionReference {
param(
[string]$EnvironmentUrl,
[string]$AccessToken,
[string]$ConnectionReferenceLogicalName,
[string]$ConnectionId,
[string]$ConnectorId
)
$headers = @{
'Authorization' = "Bearer $AccessToken"
'Content-Type' = 'application/json'
'OData-MaxVersion' = '4.0'
'OData-Version' = '4.0'
}
# Find the connection reference record
$query = "$EnvironmentUrl/api/data/v9.2/connectionreferences?`$filter=connectionreferencelogicalname eq '$ConnectionReferenceLogicalName'"
$result = Invoke-RestMethod -Uri $query -Headers $headers -Method Get
if ($result.value.Count -eq 0) {
throw "Connection reference '$ConnectionReferenceLogicalName' not found in environment."
}
$recordId = $result.value[0].connectionreferenceid
# Update the connection reference
$body = @{
connectionid = $ConnectionId
connectorid = $ConnectorId
} | ConvertTo-Json
$updateUrl = "$EnvironmentUrl/api/data/v9.2/connectionreferences($recordId)"
Invoke-RestMethod -Uri $updateUrl -Headers $headers -Method Patch -Body $body
Write-Host "Connection reference '$ConnectionReferenceLogicalName' updated successfully."
}
You'll call this function for each entry in the connectionReferences array of your config JSON, passing the OAuth access token obtained from your service principal.
Production deployments fail. Your pipeline needs a rollback mechanism that doesn't require a developer to manually re-import a previous solution version at 11 PM.
The cleanest rollback approach for Power Platform is maintaining a "last known good" artifact. After a successful production deployment, tag the artifact:
# After successful production deployment
- task: PowerShell@2
displayName: 'Tag Successful Release'
inputs:
targetType: 'inline'
script: |
$tag = "prod-release-$(Build.BuildId)"
git tag $tag
git push origin $tag
Write-Host "Tagged release: $tag"
Create a separate rollback pipeline that accepts a build ID parameter, downloads the artifact from that build, and re-imports it to production. The rollback pipeline should have its own approval gate — rolling back is a production change that needs sign-off, just like a forward deployment.
# pipelines/rollback-production.yml
trigger: none # Manual only
parameters:
- name: rollbackBuildId
displayName: 'Build ID to Rollback To'
type: number
jobs:
- deployment: RollbackProd
environment: 'PowerPlatform-Production' # Same approval gate applies
strategy:
runOnce:
deploy:
steps:
- task: DownloadBuildArtifacts@1
inputs:
buildType: 'specific'
project: '$(System.TeamProjectId)'
pipeline: '$(System.DefinitionId)'
buildId: '${{ parameters.rollbackBuildId }}'
artifactName: 'solution-drop'
downloadPath: '$(Pipeline.Workspace)'
- task: PowerPlatformImportSolution@2
displayName: 'Rollback: Import Previous Solution Version'
inputs:
authenticationType: 'PowerPlatformSPN'
PowerPlatformSPN: 'PowerPlatform-Prod'
SolutionInputFile: '$(Pipeline.Workspace)/solution-drop/InventoryApp_managed.zip'
AsyncOperation: true
MaxAsyncWaitTime: '180'
Warning: Dataverse schema changes (new columns, new tables, modified option sets) cannot be rolled back by re-importing an older solution version if data exists against the new schema. Plan schema changes carefully. If a column was added in the version you're rolling back from, that column and its data remain in Dataverse even after rollback. Your rollback plan must account for this — sometimes the right answer is a forward fix, not a rollback.
Your pipeline touches production systems and handles sensitive credentials. Several security practices are non-negotiable.
Least-privilege for pipeline variables: Use Azure DevOps variable group permissions to restrict which pipelines can access the production credentials. Only the deployment pipeline should have access to PowerPlatform-Prod credentials.
Secrets in Key Vault, not DevOps: For sensitive environment variable values (database passwords, API keys), store them in Azure Key Vault and reference them in your pipeline using the Azure Key Vault task rather than committing them to the config JSON files. Your config JSON holds only the Key Vault secret name, and the pipeline resolves the actual value at runtime.
- task: AzureKeyVault@2
displayName: 'Fetch Secrets from Key Vault'
inputs:
azureSubscription: 'Azure-Service-Connection'
KeyVaultName: 'your-keyvault-name'
SecretsFilter: 'InventoryDB-ConnStr-Prod,APIKey-Prod'
RunAsPreJob: false
After this task, the secrets are available as pipeline variables with the same names as the Key Vault secrets, and you pass them to your Apply-EnvironmentConfig.ps1 script as parameters.
Branch protection: Require pull request reviews before merging to main. Since the build pipeline triggers on main, this means no changes reach staging without a code review. This directly connects to security and data permissions at the platform level — your pipeline enforces the people-process layer of security.
Audit your DLP policies: Ensure that your automated deployment doesn't inadvertently enable connectors that violate your organization's data loss prevention policies. DLP violations after import can silently break connector functionality without clear error messages.
One architectural decision that bites teams late is the publisher prefix. Every component you create in a solution gets prefixed with your publisher prefix (e.g., wsd_ for Wicked Smart Data). This prefix is baked into schema names, environment variable names, and connection reference logical names.
If you started development with publisher prefix new_ (the default) and now need to standardize to wsd_, you cannot simply rename it. You'd need to recreate all components. This is why you should establish your publisher and prefix before writing a single line of app logic.
Your solution.xml file contains the publisher information:
<Publisher>
<UniqueName>WickedSmartData</UniqueName>
<LocalizedNames>
<LocalizedName description="Wicked Smart Data" languagecode="1033" />
</LocalizedNames>
<Descriptions />
<EMailAddress />
<SupportingWebsiteUrl />
<CustomizationPrefix>wsd</CustomizationPrefix>
<CustomizationOptionValuePrefix>10000</CustomizationOptionValuePrefix>
</Publisher>
This is source-controlled alongside everything else. Any change to the prefix in your solution.xml without corresponding changes to all component schema names will break the pack step.
Your Canvas App CI/CD pipeline doesn't exist in isolation. If your app uses custom connectors with Azure API Management, those connectors also live in your solution and need to have their base URLs updated per environment — exactly the pattern we covered in the environment variable section, but applied to the connector definition itself.
If your app uses role-based screen access via Azure AD group membership, the Azure AD group object IDs will differ between your dev and production tenants if you're deploying across tenant boundaries. Your config JSON should include the group ID mappings, and your pipeline should update any environment variables or app settings that reference those IDs.
If your app has significant state management complexity with named formulas or global variables initialized from environment variables, test that the post-deployment state initialization works correctly in staging before promoting to production. Environment variable changes don't take effect until users refresh the app, so your test suite should include a fresh-load test.
For apps with performance telemetry integrated with Application Insights, your Application Insights instrumentation key is an environment variable that needs per-environment substitution — staging should point to a staging App Insights instance so production telemetry stays clean.
Let's put this into practice with a complete workflow. Use an existing Canvas App you've built, or create a simple one with a basic data entry form for the purpose of this exercise.
Exercise: Build a Full Export-Build-Deploy Pipeline
Step 1: Prepare your solution
TrainingCI with publisher prefix tci_tci_DataSourceURL (text) with a default value of https://dev-sharepoint.yourorg.com and tci_MaxRecords (whole number) with a default of 100Step 2: Set up the Azure DevOps project
PowerPlatform-Dev and PowerPlatform-Test using the service principal credentialspowerplatform-credentials with TENANT_ID, SP_CLIENT_ID, and SP_CLIENT_SECRET (marked as secret)Step 3: Create your config files
/config/test.json with the tci_DataSourceURL value pointing to a test SharePoint site and tci_MaxRecords set to 50Step 4: Run the export pipeline
TrainingCI, and trigger it manuallyStep 5: Run the build and deploy pipeline
tci_DataSourceURL environment variable has the test value, not the dev defaultStep 6: Break it intentionally
test.json and re-run the pipelineMistake 1: Exporting a managed solution from dev and deploying it to production
Managed solutions from dev contain the developer's personal connection references and can't be edited in production if something breaks. Always build your managed artifact from source using PowerPlatformPackSolution, not by toggling the "Export as managed" checkbox in the maker portal.
Mistake 2: Not pinning the pac CLI version Microsoft releases pac CLI updates frequently, and syntax changes can silently alter behavior. Pin to a specific version in your pipeline and test upgrades deliberately, not accidentally when Azure DevOps agents update.
Mistake 3: Forgetting to publish customizations after import
Solution import doesn't automatically publish. Without PowerPlatformPublishCustomizations, the app may appear imported in the solution list but serve users the previous version. Always include the publish step.
Mistake 4: Environment variable values not showing in the app after deployment
Canvas Apps cache environment variable values at app load time. Users need to close and reopen the app for new values to take effect. In a Canvas App, environment variables are read via the Environment function or via connection to Dataverse — if you're reading them via a startup Set() call, that call only runs when the app loads.
Mistake 5: Branch protection not configured on main
If anyone can push directly to main, they bypass the pipeline entirely and can manually import ad-hoc solutions to production. Your pipeline security is only as strong as your branch protection rules.
Troubleshooting: Import fails with "Solution import failed" and no useful message
Async imports log details in the msdyn_solutionhistory Dataverse table. Query it with:
GET https://yourenv.crm.dynamics.com/api/data/v9.2/msdyn_solutionhistories
?$filter=msdyn_name eq 'InventoryApp'
&$orderby=msdyn_starttime desc
&$top=5
&$select=msdyn_name,msdyn_result,msdyn_exceptionmessage,msdyn_suboperations
The msdyn_exceptionmessage and msdyn_suboperations fields usually contain the actual error that the pipeline surface didn't surface. Build a post-import diagnostic step into your pipeline that queries this table and writes the exception details to the pipeline log.
Troubleshooting: Canvas App screens appear blank after import
This usually indicates a failed PublishCustomizations step or a dependency on an environment variable that has no value set. Check the environment variable definitions in the target environment and verify they have current values, not just definitions.
You've built a production-grade CI/CD pipeline for Power Platform Canvas Apps. Let's recap the architectural decisions and why they matter:
Export as unmanaged from dev, deploy as managed to staging/production. This is the single most important rule. Managed solutions enforce that production components are only changed through the pipeline, not through ad-hoc maker portal edits.
Source-controlled, unpacked solutions enable genuine code review. When Canvas App screens are individual JSON files, pull requests show meaningful diffs. Teams can enforce code review for Power Platform changes exactly as they do for TypeScript or Python.
Environment variable substitution via config files externalizes environment coupling. The same artifact deploys to any environment. The pipeline, not the app developer, is responsible for applying environment-specific values.
Approval gates and rollback pipelines make deployments safe. Production is always behind a human approval gate. Rollback is a one-click operation that runs the same pipeline mechanics in reverse.
From here, consider deepening the pipeline with:
microsoft/powerplatform-actions repository offers equivalent tasks if your organization uses GitHubBuilding this infrastructure is a one-time investment that pays dividends on every subsequent release. The teams that skip it pay in emergency Friday deployments, untraceable production incidents, and developers who are afraid to ship because the process is so fragile. The teams that build it right deploy confidently, daily, and with full audit trails.