
Automating Business Central On-Prem App Deployment with Azure DevOps PipelineA practical, end-to-end guide to publishing an AL extension automatically whenever code is committed to the main branch Introduction Manual deployment of a Microsoft Dynamics 365 Business Central extension is repetitive and error-prone. A deployment consultant must locate the correct package, connect to the target server, publish the extension, synchronize its schema, and either install or upgrade it. Azure DevOps Pipeline can automate this sequence so that every approved commit to the main branch produces a repeatable deployment to a Business Central on-premises environment.
My article uses a self-hosted Windows agents. The agent has network access to the Business Central server and runs the required AL build tools and Business Central administration cmdlets. No container platform is required here.
Solution Architecture 1.//app.json/Pipeline/Version.ps1/Pipeline/Build.ps1/Pipeline/Deploy.ps1/azure-pipelines.yml Do not commit credentials, personal access tokens or machine-specific settings.
Step 2: Configure the Self-Hosted Windows Agent 1.Open Azure DevOps > Organization settings > Agent pools and create or select a self-hosted pool. 2.Click on New Agent button and follow the instructions to download the Windows agent package to the VM. 3.Extract it into a dedicated directory as shown below.

4.Run its configuration command.

5.Register the agent against the required organization and pool using a PAT. 6.Run the agent as a Windows service under a dedicated windows account. 7.Install the AL compiler, Business Central administration modules, and project dependencies on the VM. if already exist, please skip this point. 8.Verify that the service account can read the project workspace, execute PowerShell, load the administration module, and reach the Business Central service instance.
More details on the Self-Hosted Windows Agent can be found at below link.
Step 3: Create Pipeline Variables Create some variables as mentioned below for deployment and testing purpose.
Variables: AppName: Name of the App as mentioned in app.json ArtifactName: BCAPP BCServerTEST: On-Prem instance name BuildConfiguration: Release
Step 4: Add the PowerShell Script The following script is version-aware: it installs the app if it is not present and upgrades it when an older version is installed. Adjust parameters and module paths to match your Business Central & PowerShell environment.
Version.ps1 $ErrorActionPreference = "Stop" $projectFolder = Split-Path $PSScriptRoot -Parent $appJsonPath = Join-Path $projectFolder "app.json" Write-Host "Reading app.json..." $appJson = Get-Content $appJsonPath -Raw | ConvertFrom-Json Write-Host "Version BEFORE : $($appJson.version)" $parts = $appJson.version.Split('.') if ($parts.Count -ne 4) { throw "Invalid version format: $($appJson.version)" } $major = [int]$parts[0] $minor = [int]$parts[1] $build = [int]$parts[2] $revision = [int]$parts[3] # Increment build number $build++ $newVersion = "$major.$minor.$build.$revision" Write-Host "Version AFTER : $($newVersion)" $appJson.version = $newVersion $appJson | ConvertTo-Json -Depth 20 | Set-Content $appJsonPath -Encoding UTF8 Write-Host "" Write-Host "========================================" Write-Host "App Version : $newVersion" Write-Host "========================================" Write-Host "##vso[task.setvariable variable=AppVersion]$newVersion"
Build.ps1 $ErrorActionPreference = "Stop" $projectFolder = Split-Path $PSScriptRoot -Parent $outputFolder = Join-Path $projectFolder "output" $packageCache = Join-Path $projectFolder ".alpackages" $appJson = Get-Content "$projectFolder\app.json" -Raw | ConvertFrom-Json Write-Host "==================================" Write-Host "Building Version: $($appJson.version)" Write-Host "==================================" # Validate and update the AL compiler as per your setup path in the below line $alc = "C:\Rakesh\Compiler\alc.exe" if (!(Test-Path $alc)) { throw "AL Compiler not found: $alc" } if (!(Test-Path $packageCache)) { throw ".alpackages folder not found." } New-Item ` -ItemType Directory ` -Path $outputFolder ` -Force | Out-Null $appFile = Join-Path $outputFolder "BCApp.app" Write-Host "" Write-Host "Compiling AL Extension..." Write-Host "" & $alc ` /project:"$projectFolder" ` /packagecachepath:"$packageCache" ` /out:"$appFile" if ($LASTEXITCODE -ne 0) { throw "Compilation failed." } if (!(Test-Path $appFile)) { throw ".app file was not generated." } # Validate & update the below path as per your setup Import-Module "C:\Program Files\Microsoft Dynamics 365 Business Central\280\Service\NavAdminTool.ps1" $appInfo = Get-NAVAppInfo -Path $appFile Write-Host "Compiled App Version: $($appInfo.Version)" Write-Host "" Write-Host "Compilation Successful" Write-Host $appFile
Deploy.ps1 param( [Parameter(Mandatory)] [string]$ServerInstance, [Parameter(Mandatory)] [string]$AppFile, [string]$Tenant = "default" ) $ErrorActionPreference = "Stop" # Validate and update the below path as per your setup Import-Module "C:\Program Files\Microsoft Dynamics 365 Business Central\280\Service\NavAdminTool.ps1" if (!(Test-Path $AppFile)) { throw "App file not found: $AppFile" } $app = Get-Item $AppFile $appInfo = Get-NAVAppInfo -Path $app.FullName Write-Host "" Write-Host "Deploying:" Write-Host "Name : $($appInfo.Name)" Write-Host "Version : $($appInfo.Version)" Write-Host "" # Publish if not already published $published = Get-NAVAppInfo ` -ServerInstance $ServerInstance | Where-Object { $_.Name -eq $appInfo.Name ` -and $_.Publisher -eq $appInfo.Publisher ` -and $_.Version -eq $appInfo.Version } if (!$published) { Publish-NAVApp ` -ServerInstance $ServerInstance ` -Path $app.FullName ` -SkipVerification } Sync-NAVApp ` -ServerInstance $ServerInstance ` -Tenant $Tenant ` -Name $appInfo.Name ` -Publisher $appInfo.Publisher ` -Version $appInfo.Version ` -Mode Add $installed = Get-NAVAppInfo ` -ServerInstance $ServerInstance ` -Tenant $Tenant ` -TenantSpecificProperties | Where-Object { $_.Name -eq $appInfo.Name ` -and $_.Publisher -eq $appInfo.Publisher ` -and $_.IsInstalled } | Select-Object -First 1 if ($installed) { Write-Host "Upgrading from $($installed.Version) to $($appInfo.Version)" Start-NAVAppDataUpgrade ` -ServerInstance $ServerInstance ` -Tenant $Tenant ` -Name $appInfo.Name ` -Publisher $appInfo.Publisher ` -Version $appInfo.Version } else { Write-Host "Installing extension" Install-NAVApp ` -ServerInstance $ServerInstance ` -Tenant $Tenant ` -Name $appInfo.Name ` -Publisher $appInfo.Publisher ` -Version $appInfo.Version } # Verify installation $verify = Get-NAVAppInfo ` -ServerInstance $ServerInstance ` -Tenant $Tenant ` -TenantSpecificProperties | Where-Object { $_.Name -eq $appInfo.Name ` -and $_.Publisher -eq $appInfo.Publisher ` -and $_.Version -eq $appInfo.Version ` -and $_.IsInstalled } if (!$verify) { throw "Deployment verification failed." } Write-Host "" Write-Host "Deployment completed successfully." # Cleanup of older published versions Get-NAVAppInfo -ServerInstance $ServerInstance | Where-Object { $_.Name -eq $appInfo.Name ` -and $_.Publisher -eq $appInfo.Publisher ` -and $_.Version -ne $appInfo.Version } | ForEach-Object { try { Unpublish-NAVApp ` -ServerInstance $ServerInstance ` -Name $_.Name ` -Publisher $_.Publisher ` -Version $_.Version } catch { Write-Warning "Could not unpublish version $($_.Version): $($_.Exception.Message)" } }
Deployment note: Never automate destructive schema synchronization modes as the default. Review rollback procedures before deploying schema or data changes.
Step 5: Create the YAML Pipeline The YAML below triggers only for commits to main, uses the self-hosted pool as mentioned above, compiles the app, identifies exactly one generated package, and deploys it.
azure-pipelines.yml trigger: branches: include: - master pool: # name should be the pool name which was created in Step 2.1 name: stages: - stage: Build displayName: Build jobs: - job: Build displayName: Build AL Extension steps: - checkout: self clean: true persistCredentials: true fetchDepth: 0 - task: PowerShell@2 displayName: Update Version inputs: targetType: filePath filePath: Pipeline\Version.ps1 - task: PowerShell@2 displayName: Commit Updated Version inputs: targetType: inline script: | # Replace email & name with your value in the below lines. git config user.email "rakesh@samadhanindia.com" git config user.name "Rakesh Kumar" git add app.json git diff --cached --quiet if ($LASTEXITCODE -eq 1) { git commit -m "Auto increment version [skip ci]" git pull --rebase origin master git push origin HEAD:refs/heads/master } else { Write-Host "No version changes." } - task: PowerShell@2 displayName: Build AL App inputs: targetType: filePath filePath: Pipeline\Build.ps1 - task: PublishPipelineArtifact@1 displayName: Publish App inputs: targetPath: '$(Build.SourcesDirectory)\output' artifact: BCAPP - stage: DeployTEST displayName: Deploy to TEST dependsOn: Build jobs: - deployment: Deploy displayName: Deploy BC Extension environment: TEST strategy: runOnce: deploy: steps: - checkout: none - download: current artifact: BCAPP - task: PowerShell@2 displayName: Deploy Extension inputs: targetType: filePath filePath: Pipeline\Deploy.ps1 arguments: > -ServerInstance "$(BCServerTEST)" -AppFile "$(Pipeline.Workspace)\BCAPP\BCApp.app"
Step 6: Create and Authorize the Pipeline 1.In Azure DevOps, select Pipelines > New pipeline. 2.Select Azure Repos Git and choose the repository. 3.Select the existing YAML file and choose azure-pipelines.yml. 4.Authorize the pipeline to use the self-hosted agent pool and deployment environment. 5.Run the pipeline manually once from a non-production test setup to validate paths, permissions, symbols, and app compatibility. 6.After validation, merge the YAML and script into main so that subsequent commits trigger the pipeline automatically.
Step 7: Validate the Deployment •Confirm that the pipeline was triggered by the expected commit and branch. •Check the compilation log for warnings, errors, and the generated app version. •Confirm that only the intended .app package was downloaded by the deployment job. •Review the PowerShell output to verify that the package was published, synchronized, and installed or upgraded. •Open Business Central and verify the new version in Extension Management.
Things to note •Modify the above scripts to test the process in test environment. •Protect main with pull requests, reviewers, successful build validation, and blocked direct pushes. •Add environment approvals and checks before the production deployment job. •Back up the database and define a tested rollback plan before releases that change schema or data. •Retain build artifacts and logs so each deployed package can be traced to its source commit. •Patch the Windows VM, Azure DevOps agent, PowerShell, AL compiler, and Business Central modules regularly.
Conclusion With a self-hosted Windows agent, a version-controlled deployment script, and a YAML pipeline triggered by main, Business Central on-prem extension deployment becomes consistent, traceable, and significantly faster. Implement these first in a non-production environment, then promote the same process to production.
Happy Coding!
|