Automating Business Central On-Prem App Deployment with Azure DevOps Pipeline

09-08-2026

Automating Business Central On-Prem App Deployment with Azure DevOps Pipeline

A 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.

https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/windows-agent?view=azure-devops


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!