Two Servers, One Script, Zero Surprises: Updating Power BI Report Server Safely
Updating a Power BI Report Server should be a routine maintenance task, yet anyone who has done it in production knows it can turn into an adventure you didn’t sign up for. Over the years I’ve collected a few scars, a few lessons, and a workflow that finally feels reliable enough to share. This post walks through the pitfalls, the safer alternatives, and the practical steps to get both paginated reports and PBIX files onto a test system before touching production.
Why Direct Production Updates Are a Bad Idea
Updating a live Report Server sounds simple until it isn’t. I’ve had situations where, after an update, no Power BI report would open anymore. Rolling back didn’t help much either, because a rollback isn’t really a rollback. You have to uninstall the new version, reinstall the old one, and hope the database still cooperates.
That last part is the real trap. If the version gap is too large—say January 2025 to January 2026—or if the new release introduces structural changes, the older server version may refuse to work with the already-upgraded ReportServer database. At that point, the only escape is restoring database backups of ReportServer and ReportServerTempDB,losing everything that happened since the backup.
This is not the kind of excitement anyone needs.
A Better Approach: A Second Server
The safer and far more predictable method is to introduce a second server dedicated to testing updates. This can even be the free Developer Edition as long as it’s not used for production workloads.
The key is to configure this test server as close to production as possible. That means comparing configuration files and aligning everything except environment‑specific settings like URLs or database connection strings. The closer the match, the more meaningful your test results.
What you should not do is restore the production ReportServer database directly onto the test server. While it may appear to work, it brings along all user permissions and can cause issues when editions differ—for example, production running Enterprise while the test server runs Standard.
And don’t forget the access path. If your users reach the Report Server through a reverse proxy or an identity gateway like Okta Access Gateway, then your test must go through the same route. Testing from inside the datacenter alone won’t reveal the full picture.
The Real Challenge: Migrating Reports and Data Sources
Once you have two aligned environments, the next question is how to keep the test server up to date with the ever‑changing set of PBIX files, paginated reports, and shared data sources. Manually uploading over a hundred files is not an option. Ideally you would have something like a Git repository where every change is pushed to and then “automagically” rolled out to different environments. However in my case this was not possible. Surprisingly I have not found a simple community option for migrating reports and data sources (especially for PBIX) and went out to build my own.
I tried several approaches.
Powershell ReportingServicesTools
This was my first stop, but it quickly became clear that the module wasn’t going to cooperate. Parameter handling was inconsistent, versions didn’t match, and Copilot at the time struggled to generate working scripts for it.
RS.exe
RS.exe was my next experiment. Getting it to run wasn’t exactly plug‑and‑play: it required a bit of setup effort with configuration files and sample scripts from GitHub. Some of the provided VB code was simply broken and had to be fixed before anything useful happened.
Once that hurdle was cleared, RS.exe actually worked very well—for paginated reports. It handled RDL migration reliably, but completely ignored PBIX files. So in the end it turned out to be a solid tool for moving paginated reports between servers, just not a complete solution for a full Report Server migration.
Here’s my call to RS.exe in PowerShell for anyone who would try to follow along:
$RsExe = "C:\Program Files\Microsoft Power BI Report Server\Shared Tools\RS.exe" $Script = "C:\Temp\ssrs_migration.rss" & $RsExe ` -i $Script ` -s "https://production-server/ReportServer" ` -t "http://localhost/ReportServer" ` -v SrcFolder="/" ` -v DestFolder="/" ` -v Overwrite=true ` -v IncludeDataSources=true ` -v IncludeDatasets=true ` -l "C:\Temp\rs_migration.log"
My solution: A Custom Script That Finally Works
Eventually I had Copilot generate a script that actually did what I needed. It downloads PBIX files from the source server into a temporary directory, recreates the folder structure on the target server, and uploads the files there. It handles both PBIX and RDL files, which covers the majority of real‑world scenarios. Surprisingly this works best just with basic API calls instead of leveraging any PowerShell package specialized for Reportserver.
Data sources still need manual attention. Passwords cannot be migrated automatically because they are encrypted in the ReportServer database and there is no supported way to modify those entries. But at least the structural migration is automated. If you use Direct Query on SSAS Tabular with Kerberos (log in as the current user) then congratulations this is a no-brainer and works out of a box. If however you use a PBIX with Import Mode you would need to fill in credentials for each report.
One upside: PBIX files contain the data snapshot from the last upload. It may be outdated, but it’s usually enough for functional testing.
So without further ado: Here’s my AI-generated final sync-script. Give it a try and let me know how it works for you. You will have to adjust lines 14 to 18 in the Configuration block with your urls. Optionally you can give a start path to the folder structure if you would like to sync only a part of the server.
I executed it directly on the testserver (calling with localhost) because of certificate issues.
<#
.SYNOPSIS
Migrates Power BI Reports (PBIX) and Paginated Reports (RDL)
between Power BI Report Server instances using REST API.
#>
Import-Module ReportingServicesTools
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# ==========================================================
# CONFIGURATION
# ==========================================================
$SourcePortal = "https://prodserver/Reports"
$TargetPortal = "http://localhost/Reports"
$ExportRoot = "C:\Temp\PBIX-Migration"
$StartPath = "" # e.g. "BI-internal\Finance"
# ==========================================================
# INITIAL SETUP
# ==========================================================
New-Item -ItemType Directory -Path $ExportRoot -Force | Out-Null
$StartPathRs = if ([string]::IsNullOrWhiteSpace($StartPath)) {
""
}
else {
"/" + ($StartPath.TrimStart("\","/") -replace '\\','/')
}
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "PBIRS MIGRATION STARTED (PBIX + RDL)" -ForegroundColor Cyan
Write-Host "Source Portal : $SourcePortal"
Write-Host "Target Portal : $TargetPortal"
Write-Host "Export Root : $ExportRoot"
Write-Host "Start Path : $(if ($StartPathRs) { $StartPathRs } else { "<ROOT>" })"
Write-Host "==================================================" -ForegroundColor Cyan
# ==========================================================
# CREATE REST SESSIONS
# ==========================================================
$srcSession = New-RsRestSession -ReportPortalUri $SourcePortal
$dstSession = New-RsRestSession -ReportPortalUri $TargetPortal
# ==========================================================
# GET & FILTER MIGRATABLE ITEMS
# ==========================================================
$items = Get-RsRestFolderContent `
-WebSession $srcSession `
-ReportPortalUri $SourcePortal `
-RsFolder "/" `
-Recurse
$migratableItems = $items | Where-Object {
($_.Type -eq "PowerBIReport" -or $_.Type -eq "Report") -and
$_.Path -notlike "/Users Folders*" -and
(
[string]::IsNullOrWhiteSpace($StartPathRs) -or
$_.Path.StartsWith($StartPathRs)
)
}
Write-Host "Found reports to migrate:`n PBIX : $($migratableItems | Where Type -eq PowerBIReport | Measure | Select -ExpandProperty Count)`n RDL : $($migratableItems | Where Type -eq Report | Measure | Select -ExpandProperty Count)" -ForegroundColor Yellow
# ==========================================================
# STEP 1 – CREATE LOCAL FOLDER STRUCTURE
# ==========================================================
foreach ($item in $migratableItems) {
$parent = Split-Path $item.Path -Parent
$relative = if ($StartPathRs) {
$parent.Substring($StartPathRs.Length).TrimStart("/")
}
else {
$parent.TrimStart("/")
}
if ($relative) {
New-Item -ItemType Directory `
-Path (Join-Path $ExportRoot ($relative -replace "/","\")) `
-Force | Out-Null
}
}
# ==========================================================
# STEP 2 – DOWNLOAD REPORTS (PBIX + RDL)
# ==========================================================
foreach ($item in $migratableItems) {
$relative = if ($StartPathRs) {
$item.Path.Substring($StartPathRs.Length).TrimStart("/")
}
else {
$item.Path.TrimStart("/")
}
$localFolder = Join-Path $ExportRoot (Split-Path $relative -Parent)
$extension = if ($item.Type -eq "Report") { ".rdl" } else { ".pbix" }
$localFile = Join-Path $localFolder ($item.Name + $extension)
New-Item -ItemType Directory -Path $localFolder -Force | Out-Null
Write-Host "Downloading [$($item.Type)]: $($item.Path)"
Out-RsRestCatalogItem `
-WebSession $srcSession `
-ReportPortalUri $SourcePortal `
-RsItem $item.Path `
-Destination $localFolder `
-Overwrite | Out-Null
if (-not (Test-Path $localFile)) {
throw "Download failed: $localFile"
}
}
Write-Host "✔ All reports downloaded successfully" -ForegroundColor Green
# ==========================================================
# STEP 3 – CREATE TARGET FOLDER STRUCTURE
# ==========================================================
$reportDirs = Get-ChildItem -Path $ExportRoot -Recurse -Include *.pbix, *.rdl |
Select-Object -ExpandProperty DirectoryName -Unique
foreach ($dir in $reportDirs) {
$relative = $dir.Substring($ExportRoot.Length).TrimStart("\")
if (-not $relative) { continue }
$rsPath = "/" + ($relative -replace "\\","/")
$parts = $rsPath.Trim("/").Split("/")
$current = "/"
foreach ($part in $parts) {
try {
New-RsRestFolder `
-WebSession $dstSession `
-ReportPortalUri $TargetPortal `
-RsFolder $current `
-FolderName $part `
-ErrorAction Stop | Out-Null
}
catch { }
$current = if ($current -eq "/") { "/$part" } else { "$current/$part" }
}
}
Write-Host "✔ Target folder structure created" -ForegroundColor Green
# ==========================================================
# STEP 4 – UPLOAD REPORTS
# ==========================================================
$reportFiles = Get-ChildItem -Path $ExportRoot -Recurse -Include *.pbix, *.rdl
foreach ($file in $reportFiles) {
$relativeDir = $file.DirectoryName.Substring($ExportRoot.Length).TrimStart("\")
$rsFolder = if ($relativeDir) {
"/" + ($relativeDir -replace "\\","/")
}
else {
"/"
}
Write-Host "Uploading:"
Write-Host " File : $($file.FullName)"
Write-Host " Folder: $rsFolder"
Write-RsRestCatalogItem `
-WebSession $dstSession `
-ReportPortalUri $TargetPortal `
-Path $file.FullName `
-RsFolder $rsFolder `
-Overwrite
Write-Host "✔ Upload successful: $($file.BaseName)" -ForegroundColor Green
}
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "PBIRS MIGRATION COMPLETED SUCCESSFULLY" -ForegroundColor Cyan
Write-Host "PBIX and Paginated Reports migrated" -ForegroundColor Cyan
Write-Host "Shared Data Sources must be handled manually"
Write-Host "==================================================" -ForegroundColor Cyan
Disclaimer
This article was created based on my personal notes with support from Microsoft Copilot. While Copilot assisted in structuring and refining the content, all technical details have been carefully reviewed and developed by me.