A repatriation‑oriented approach for synchronizing Azure Storage Accounts across isolated private endpoint networks without public access, shared DNS, or credential‑based authentication.
Introduction
In Azure repatriation programs, enterprises often need to migrate large volumes of blob data from a source Azure environment to a target Azure environment under strict security and network isolation constraints.
A typical repatriation setup includes fully isolated source and target environments, each protected by private endpoints and independent DNS configurations.
Typical Repatriation Architecture
Source Environment
- Azure Storage Account in Region A
- Source subscription in Region A
- Public network access disabled
- Private Endpoint configured in a source hub-spoke network
- Private DNS zone scoped only to the source network
Target Environment
- Azure Storage Account in Region B
- Target subscription in Region B
- Public network access disabled
- Private Endpoint configured in a target hub-spoke network
- Independent Private DNS zone (no DNS sharing with source)
✅ There is no shared VNet, no shared Private DNS zone, and no direct private connectivity between the two environments.
Problem Statement
Azure Storage supports server‑side copy operations under many conditions. However, when both the source and destination storage accounts are protected by private endpoints and deployed in isolated virtual networks without shared DNS resolution or network connectivity, server‑side copy operations are not supported.
In such cases, copy attempts commonly fail with the following error:
403 – CannotVerifyCopySource
This presents a challenge for organizations that need to migrate data securely without:
- Enabling public network access
-
Using Shared Access Signatures (SAS) or storage account keys
- Re-architecting or modifying the source environment
- Relaxing established enterprise network isolation boundaries
Repatriation Optimized Solution Pattern (Recommended)
Core Design Principle: Anchor the data movement in the target environment.
.Rather than attempting direct storage‑to‑storage copy across isolated networks, this pattern executes the data transfer from a controlled Azure Virtual Machine (VM) deployed in the target environment, which acts as the authorized client for both the source and target storage accounts.
Secure Azure Blob data synchronization across isolated regions and private endpoint networks
Execution Flow
- An Azure VM is deployed in the target subscription
- VM resides in:
-
- The same virtual network as the target storage private endpoint, or
- A peered virtual network with access to the target private endpoint
3. Private DNS A records are created in the target private DNS zone for:
-
- Source storage account blob endpoint
- Target storage account blob endpoint
4. AzCopy runs on the VM using Microsoft Entra ID authentication via Managed Identity
5. VM reads data from the source storage account
6. VM writes data to the target storage account
7. All data transfer occurs over private networking, without traversing public endpoints
DNS Configuration (Critical for Success)
Because source and target environments use separate private DNS zones, DNS resolution must be explicitly aligned.
Required Configuration, with-in the target private DNS zone:
- Create A records for:
- sourceaccount.blob.core.windows.net
- targetaccount.blob.core.windows.net
-
Map each record to the private IP address of its corresponding private endpoint
This configuration ensures that:
- AzCopy resolves both storage endpoints to private IP addresses
- Public endpoint resolution is avoided
- Data transfer remains compliant with network isolation and security policies
⚠️ Without this DNS alignment, AzCopy authentication and transfer will fail, even when network connectivity and role assignments are correctly configured.
Identity & Access Configuration
The Azure VM uses a Managed Identity for authentication.
Required Role Assignments
On Source Storage Account
- Storage Blob Data Reader
On Target Storage Account
- Storage Blob Data Contributor
These assignments provide:
- Read-only access to source data
- Write access to the destination
- Authentication without embedding credentials, keys, or secrets in scripts
AzCopy performs data plane operations using Microsoft Entra ID–based RBAC, without accessing storage account keys.
Storage Sync Script for Repatriation
Script Overview
The PowerShell script performs the following actions:
- Authenticates using VM's Managed Identity
- Iterates through defined source–destination storage account pairs
- Enumerates all containers in the source storage account
- Creates missing containers in the destination storage account
- Executes azcopy sync for each container
- Logs execution results and handles errors without terminating the entire process
Prerequisites
Ensure the following prerequisites are met before execution:
- Azure VM with either system-assigned or user-assigned Managed Identity
- AzCopy installed on the VM
- Azure PowerShell module installed on the VM
- VM connected to the same VNet, or a peered VNet, as the storage private endpoints
- DNS resolution set up for private blob endpoints
PowerShell Script s
Note: The following code snippets are provided as examples only and may need to be adapted to match your environment, subscriptions, and naming standards.
Script 1 - Package Installations - Az and AzCopy Modules
# Ensure script runs with admin privileges
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator"))
{
Write-Error "Please run PowerShell as Administrator."
exit
}
# Enforce TLS 1.2 (recommended for PSGallery)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# -------------------------------
# Check and Install Az module
# -------------------------------
if (-not (Get-Module -ListAvailable -Name Az)) {
Write-Output "Az module not found. Installing Az PowerShell module..."
Install-Module -Name Az -Repository PSGallery -Force -AllowClobber
}
else {
Write-Output "Az module already installed. Skipping installation."
}
# Verify Az module
Write-Output "Verifying Az module installation..."
Import-Module Az
Get-Module Az -ListAvailable | Select-Object Name, Version
# -------------------------------
# Check & Install AzCopy module
# -------------------------------
Write-Host "Checking AzCopy installation..." -ForegroundColor Cyan
# 1. Check if azcopy is already available in PATH
if (Get-Command azcopy -ErrorAction SilentlyContinue) {
Write-Host "AzCopy already installed and available in PATH." -ForegroundColor Green
azcopy --version
return
}
# 2. Check standard install location
$installPath = Join-Path $env:ProgramFiles "AzCopy"
$targetExe = Join-Path $installPath "azcopy.exe"
if (Test-Path $targetExe) {
Write-Host "AzCopy found at $targetExe" -ForegroundColor Green
}
else {
Write-Host "AzCopy not found. Downloading and installing..." -ForegroundColor Yellow
# Download
$azCopyUrl = "https://aka.ms/downloadazcopy-v10-windows"
$zipPath = Join-Path $env:TEMP "azcopy.zip"
$extractPath = Join-Path $env:TEMP "azcopy_extract"
Invoke-WebRequest -Uri $azCopyUrl -OutFile $zipPath
if (Test-Path $extractPath) {
Remove-Item $extractPath -Recurse -Force
}
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
# Find azcopy.exe inside extracted folder
$foundExe = Get-ChildItem -Path $extractPath -Recurse -Filter "azcopy.exe" |
Select-Object -First 1 -ExpandProperty FullName
if (-not $foundExe) {
throw "azcopy.exe not found after extraction."
}
# Copy to Program Files\AzCopy
New-Item -ItemType Directory -Path $installPath -Force | Out-Null
Copy-Item -Path $foundExe -Destination $targetExe -Force
}
# 3. Add AzCopy to Machine PATH (only if missing)
Write-Host "Ensuring AzCopy is added to PATH..." -ForegroundColor Cyan
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
if ($machinePath -notlike "*$installPath*") {
[Environment]::SetEnvironmentVariable(
"Path",
"$machinePath;$installPath",
[EnvironmentVariableTarget]::Machine
)
}
# Update current session PATH so azcopy works immediately
$env:Path = "$env:Path;$installPath"
# 4. Verify installation
Write-Host "Verifying AzCopy installation..." -ForegroundColor Cyan
& $targetExe --version
azcopy --version
Script 2 - Azure Storage Account Blob Synchronization
# Login using the VM's System Assigned Identity
Connect-AzAccount -Identity
# Use below command if managed identity is associated with VM
# $clientId = "VMManagedIdentity"
# Connect-AzAccount -Identity -AccountId $clientId
# Define source and destination account pairs with subscription IDs
$storagePairs = @(
@{ SourceAccount = "SourceStorageAccountName"; SourceRG = "SourceResourceGroup"; SourceSub = "SourceSubscriptionId";
DestAccount = "DestinationStorageAccountName"; DestRG = "DestinationResourceGroup"; DestSub = "DestinationSubscriptionId" }
# Add more pairs as needed
)
foreach ($pair in $storagePairs) {
$sourceAccount = $pair.SourceAccount
$sourceRG = $pair.SourceRG
$sourceSub = $pair.SourceSub
$destAccount = $pair.DestAccount
$destRG = $pair.DestRG
$destSub = $pair.DestSub
Write-Host "`n?? Processing pair: $sourceAccount ($sourceSub) ? $destAccount ($destSub)"
try {
# Set context to source subscription and get key
Set-AzContext -SubscriptionId $sourceSub
$sourceContext = New-AzStorageContext -StorageAccountName $sourceAccount
# Set context to destination subscription and get key
Set-AzContext -SubscriptionId $destSub
$destContext = New-AzStorageContext -StorageAccountName $destAccount
# Get all containers from the source account
$containers = Get-AzStorageContainer -Context $sourceContext
foreach ($container in $containers) {
$containerName = $container.Name
Write-Host "`n?? Syncing container: $containerName"
try {
# Check if destination container exists
$destContainer = Get-AzStorageContainer -Name $containerName -Context $destContext -ErrorAction SilentlyContinue
if (-not $destContainer) {
New-AzStorageContainer -Name $containerName -Context $destContext | Out-Null
Write-Host "? Created destination container: $containerName"
}
# Build source and destination URLs
$sourceUrl = "https://$sourceAccount.blob.core.windows.net/$containerName"
$destUrl = "https://$destAccount.blob.core.windows.net/$containerName"
# Run AzCopy sync using identity
azcopy login --identity
Write-Host "Login Successful"
Write-Host "Sync Started"
azcopy sync $sourceUrl $destUrl --recursive=true --compare-hash=MD5 --include-directory-stub=true
#Write-Host "? Sync completed for container: $containerName"
} catch {
Write-Error "? Error syncing container '$containerName': $_"
}
}
} catch {
Write-Error "? Error processing storage pair $sourceAccount ? $destAccount : $_"
}
}
Script 3 - Post Validation of Azure Storage Account Blob Synchronization
<#
Compares container inventory between two Azure Storage accounts:
- container presence
- blob count & total bytes per container
- blob-level diffs (missing files + size mismatch) across all subfolders
.REQUIREMENTS
- Az.Accounts + Az.Storage modules
- Entra auth (Connect-AzAccount) OR Managed Identity (Connect-AzAccount -Identity)
- Data-plane RBAC role: Storage Blob Data Reader on both storage accounts
#>
# ------------------ CONFIG ------------------
$SrcStorageAccount = "SourceStorageAccountName"
$SrcSubscriptionId = "SourceSubscriptionId"
$DstStorageAccount = "DestinationStorageAccountName"
$DstSubscriptionId = "DestinationSubscriptionId"
$UseManagedIdentity = $false # Set $true to use VM Managed Identity
$BlobLevelDiff = $true # Set $true for file-level comparison
# Output folder
$OutDir = ".\PostAzSync-Outputfile"
New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
$SummaryCsv = Join-Path $OutDir "container-compare-summary.csv"
$BlobDiffCsv = Join-Path $OutDir "blob-diff.csv"
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ------------------ FUNCTIONS ------------------
function Get-StorageContext {
param(
[Parameter(Mandatory=$true)] [string] $SubscriptionId,
[Parameter(Mandatory=$true)] [string] $StorageAccountName
)
Set-AzContext -SubscriptionId $SubscriptionId | Out-Null
return New-AzStorageContext -StorageAccountName $StorageAccountName -Protocol Https -UseConnectedAccount
}
function Get-ContainerStats {
param(
[Parameter(Mandatory=$true)] $Context,
[Parameter(Mandatory=$true)] [string] $ContainerName,
[Parameter(Mandatory=$true)] [string] $SubscriptionId
)
Set-AzContext -SubscriptionId $SubscriptionId | Out-Null
$token = $null
$count = 0L
$bytes = 0L
do {
$page = Get-AzStorageBlob -Container $ContainerName -Context $Context -MaxCount 5000 -ContinuationToken $token
if ($page) {
foreach ($b in $page) {
$count++
if ($null -ne $b.Length) { $bytes += [int64]$b.Length }
}
$token = if ($page -is [array] -and $page.Count -gt 0) { $page[$page.Count - 1].ContinuationToken } else { $null }
} else { $token = $null }
} while ($null -ne $token)
[pscustomobject]@{
Container = $ContainerName
BlobCount = $count
TotalBytes = $bytes
}
}
function Get-BlobIndex {
param(
[Parameter(Mandatory=$true)] $Context,
[Parameter(Mandatory=$true)] [string] $ContainerName,
[Parameter(Mandatory=$true)] [string] $SubscriptionId
)
Set-AzContext -SubscriptionId $SubscriptionId | Out-Null
$token = $null
$dict = New-Object "System.Collections.Generic.Dictionary[string,Int64]" ([System.StringComparer]::OrdinalIgnoreCase)
do {
$page = Get-AzStorageBlob -Container $ContainerName -Context $Context -MaxCount 5000 -ContinuationToken $token
if ($page) {
foreach ($b in $page) {
$len = 0L
if ($null -ne $b.Length) { $len = [int64]$b.Length }
$dict[$b.Name] = $len
}
$token = if ($page -is [array] -and $page.Count -gt 0) { $page[$page.Count - 1].ContinuationToken } else { $null }
} else { $token = $null }
} while ($null -ne $token)
return $dict
}
function Export-CsvSafe {
param(
[Parameter(Mandatory=$true)][AllowNull()][AllowEmptyCollection()] $Data,
[Parameter(Mandatory=$true)][string] $Path
)
if ($null -eq $Data -or $Data.Count -eq 0) { return }
$Data | Export-Csv -Path $Path -NoTypeInformation
}
# ------------------ AUTH ------------------
if ($UseManagedIdentity) {
Connect-AzAccount -Identity | Out-Null
} else {
Connect-AzAccount | Out-Null
}
# ------------------ VALIDATE CONNECTIVITY ------------------
try {
$ctxA = Get-StorageContext -SubscriptionId $SrcSubscriptionId -StorageAccountName $SrcStorageAccount
Get-AzStorageContainer -Context $ctxA -MaxCount 1 -ErrorAction Stop | Out-Null
Write-Host "Connected to source: $SrcStorageAccount" -ForegroundColor Green
} catch {
Write-Error "Cannot connect to source '$SrcStorageAccount' in subscription '$SrcSubscriptionId': $($_.Exception.Message)"
exit 1
}
try {
$ctxB = Get-StorageContext -SubscriptionId $DstSubscriptionId -StorageAccountName $DstStorageAccount
Get-AzStorageContainer -Context $ctxB -MaxCount 1 -ErrorAction Stop | Out-Null
Write-Host "Connected to destination: $DstStorageAccount" -ForegroundColor Green
} catch {
Write-Error "Cannot connect to destination '$DstStorageAccount' in subscription '$DstSubscriptionId': $($_.Exception.Message)"
exit 1
}
# ------------------ LIST CONTAINERS ------------------
Set-AzContext -SubscriptionId $SrcSubscriptionId | Out-Null
$containersA = @(Get-AzStorageContainer -Context $ctxA | Select-Object -ExpandProperty Name)
Set-AzContext -SubscriptionId $DstSubscriptionId | Out-Null
$containersB = @(Get-AzStorageContainer -Context $ctxB | Select-Object -ExpandProperty Name)
$allContainers = ($containersA + $containersB | Sort-Object -Unique)
Write-Host "`nContainers in Source: $($containersA.Count) | Destination: $($containersB.Count) | Union: $($allContainers.Count)"
# ============================================================
# CONTAINER-LEVEL COMPARISON (count + bytes)
# ============================================================
$summary = foreach ($c in $allContainers) {
$inA = $containersA -contains $c
$inB = $containersB -contains $c
$statsA = $null
$statsB = $null
if ($inA) {
Write-Host " Counting blobs in source/$c ..."
$statsA = Get-ContainerStats -Context $ctxA -ContainerName $c -SubscriptionId $SrcSubscriptionId
}
if ($inB) {
Write-Host " Counting blobs in destination/$c ..."
$statsB = Get-ContainerStats -Context $ctxB -ContainerName $c -SubscriptionId $DstSubscriptionId
}
$countA = if ($statsA) { $statsA.BlobCount } else { $null }
$countB = if ($statsB) { $statsB.BlobCount } else { $null }
$bytesA = if ($statsA) { $statsA.TotalBytes } else { $null }
$bytesB = if ($statsB) { $statsB.TotalBytes } else { $null }
$match = if ($statsA -and $statsB) { ($countA -eq $countB) -and ($bytesA -eq $bytesB) } else { $false }
if (-not $inA) { $status = "MISSING_IN_SOURCE" }
elseif (-not $inB) { $status = "MISSING_IN_DESTINATION" }
elseif ($match) { $status = "MATCH" }
else { $status = "MISMATCH" }
Write-Host " $c : $status (Blobs: $countA / $countB, Bytes: $bytesA / $bytesB)" -ForegroundColor $(if ($status -eq 'MATCH') { 'Green' } else { 'Yellow' })
[pscustomobject]@{
Container = $c
PresentInSource = $inA
PresentInDest = $inB
BlobCount_Src = $countA
BlobCount_Dst = $countB
TotalBytes_Src = $bytesA
TotalBytes_Dst = $bytesB
BlobCount_Delta = if ($statsA -and $statsB) { $countA - $countB } else { $null }
TotalBytes_Delta = if ($statsA -and $statsB) { $bytesA - $bytesB } else { $null }
Status = $status
}
}
$summary | Sort-Object Container | Export-Csv -NoTypeInformation -Path $SummaryCsv
Write-Host "`nContainer summary -> $SummaryCsv"
# ============================================================
# BLOB-LEVEL DIFF (file-by-file across all subfolders)
# ============================================================
if ($BlobLevelDiff) {
$diffRows = New-Object System.Collections.Generic.List[object]
$allFileRows = New-Object System.Collections.Generic.List[object]
$allSrcFiles = New-Object System.Collections.Generic.List[object]
$allDstFiles = New-Object System.Collections.Generic.List[object]
foreach ($row in ($summary | Where-Object { $_.PresentInSource -and $_.PresentInDest })) {
$c = $row.Container
Write-Host "`n Blob-diff: $c"
$ctxA = Get-StorageContext -SubscriptionId $SrcSubscriptionId -StorageAccountName $SrcStorageAccount
$idxA = Get-BlobIndex -Context $ctxA -ContainerName $c -SubscriptionId $SrcSubscriptionId
$ctxB = Get-StorageContext -SubscriptionId $DstSubscriptionId -StorageAccountName $DstStorageAccount
$idxB = Get-BlobIndex -Context $ctxB -ContainerName $c -SubscriptionId $DstSubscriptionId
# ---- Source inventory file ----
foreach ($n in ($idxA.Keys | Sort-Object)) {
$folder = if ($n -match '/') { $n.Substring(0, $n.LastIndexOf('/')) } else { '(root)' }
$allSrcFiles.Add([pscustomobject]@{
StorageAccount = $SrcStorageAccount
SubscriptionId = $SrcSubscriptionId
Container = $c
Folder = $folder
BlobName = $n
SizeBytes = $idxA[$n]
})
}
# ---- Destination inventory file ----
foreach ($n in ($idxB.Keys | Sort-Object)) {
$folder = if ($n -match '/') { $n.Substring(0, $n.LastIndexOf('/')) } else { '(root)' }
$allDstFiles.Add([pscustomobject]@{
StorageAccount = $DstStorageAccount
SubscriptionId = $DstSubscriptionId
Container = $c
Folder = $folder
BlobName = $n
SizeBytes = $idxB[$n]
})
}
$names = @($idxA.Keys) + @($idxB.Keys) | Sort-Object -Unique
foreach ($n in $names) {
$hasA = $idxA.ContainsKey($n)
$hasB = $idxB.ContainsKey($n)
$lenA = if ($hasA) { $idxA[$n] } else { $null }
$lenB = if ($hasB) { $idxB[$n] } else { $null }
$folder = if ($n -match '/') { $n.Substring(0, $n.LastIndexOf('/')) } else { '(root)' }
if (-not $hasA) { $diffType = "MissingInSource" }
elseif (-not $hasB) { $diffType = "MissingInDest" }
elseif ($lenA -ne $lenB) { $diffType = "SizeMismatch" }
else { $diffType = "Match" }
$fileRow = [pscustomobject]@{
Container = $c
Folder = $folder
BlobName = $n
Bytes_Src = $lenA
Bytes_Dst = $lenB
Bytes_Delta = if ($null -ne $lenA -and $null -ne $lenB) { $lenA - $lenB } else { $null }
InSource = $hasA
InDest = $hasB
Status = $diffType
}
$allFileRows.Add($fileRow)
if ($diffType -ne "Match") {
$diffRows.Add($fileRow)
}
}
$matchCnt = @($allFileRows | Where-Object { $_.Container -eq $c -and $_.Status -eq 'Match' }).Count
$missDst = @($diffRows | Where-Object { $_.Container -eq $c -and $_.Status -eq 'MissingInDest' }).Count
$missSrc = @($diffRows | Where-Object { $_.Container -eq $c -and $_.Status -eq 'MissingInSource' }).Count
$sizeDiff = @($diffRows | Where-Object { $_.Container -eq $c -and $_.Status -eq 'SizeMismatch' }).Count
Write-Host " Files: Match=$matchCnt | MissingInDst=$missDst | MissingInSrc=$missSrc | SizeDiff=$sizeDiff"
# Per-container CSV with all files
$safe = $c -replace '[^a-zA-Z0-9\-]', '_'
$containerFiles = @($allFileRows | Where-Object { $_.Container -eq $c })
if ($containerFiles.Count -gt 0) {
Export-CsvSafe -Data $containerFiles -Path (Join-Path $OutDir "FILES_${safe}_All.csv")
}
# Per-container mismatch CSV
$containerDiffs = @($diffRows | Where-Object { $_.Container -eq $c })
if ($containerDiffs.Count -gt 0) {
Export-CsvSafe -Data $containerDiffs -Path (Join-Path $OutDir "MISMATCH_${safe}_All.csv")
$missing = @($containerDiffs | Where-Object { $_.Status -eq 'MissingInDest' -or $_.Status -eq 'MissingInSource' })
if ($missing.Count -gt 0) {
Export-CsvSafe -Data $missing -Path (Join-Path $OutDir "MISMATCH_${safe}_MissingFiles.csv")
}
$sizeMm = @($containerDiffs | Where-Object { $_.Status -eq 'SizeMismatch' })
if ($sizeMm.Count -gt 0) {
Export-CsvSafe -Data $sizeMm -Path (Join-Path $OutDir "MISMATCH_${safe}_ByteSizeDiff.csv")
}
}
}
# Master files (all containers)
if ($allFileRows.Count -gt 0) {
Export-CsvSafe -Data $allFileRows -Path (Join-Path $OutDir "FILES_ALL_Comparison.csv")
Write-Host "`n[ALL FILES] $($allFileRows.Count) file(s) -> FILES_ALL_Comparison.csv" -ForegroundColor Cyan
}
# ---- SOURCE inventory: all files with bytes from all containers ----
$srcInventoryPath = Join-Path $OutDir "SOURCE_ALL_Files.csv"
if ($allSrcFiles.Count -gt 0) {
Export-CsvSafe -Data $allSrcFiles -Path $srcInventoryPath
Write-Host "[SOURCE INVENTORY] $($allSrcFiles.Count) file(s) from $SrcStorageAccount -> SOURCE_ALL_Files.csv" -ForegroundColor Cyan
}
# ---- DESTINATION inventory: all files with bytes from all containers ----
$dstInventoryPath = Join-Path $OutDir "DESTINATION_ALL_Files.csv"
if ($allDstFiles.Count -gt 0) {
Export-CsvSafe -Data $allDstFiles -Path $dstInventoryPath
Write-Host "[DEST INVENTORY] $($allDstFiles.Count) file(s) from $DstStorageAccount -> DESTINATION_ALL_Files.csv" -ForegroundColor Cyan
}
if ($diffRows.Count -gt 0) {
$diffRows | Export-Csv -NoTypeInformation -Path $BlobDiffCsv
Write-Host "[BLOB DIFF] $($diffRows.Count) difference(s) -> $BlobDiffCsv" -ForegroundColor Red
Export-CsvSafe -Data $diffRows -Path (Join-Path $OutDir "MISMATCH_ALL_Files.csv")
$allMissing = @($diffRows | Where-Object { $_.Status -eq 'MissingInDest' -or $_.Status -eq 'MissingInSource' })
if ($allMissing.Count -gt 0) {
Export-CsvSafe -Data $allMissing -Path (Join-Path $OutDir "MISMATCH_ALL_MissingFiles.csv")
Write-Host "[MISSING FILES] $($allMissing.Count) file(s) -> MISMATCH_ALL_MissingFiles.csv" -ForegroundColor Red
}
$allSzDiff = @($diffRows | Where-Object { $_.Status -eq 'SizeMismatch' })
if ($allSzDiff.Count -gt 0) {
Export-CsvSafe -Data $allSzDiff -Path (Join-Path $OutDir "MISMATCH_ALL_ByteSizeDiff.csv")
Write-Host "[BYTE MISMATCH] $($allSzDiff.Count) file(s) -> MISMATCH_ALL_ByteSizeDiff.csv" -ForegroundColor Red
}
} else {
Write-Host "[ALL MATCH] Every file matches by name and size." -ForegroundColor Green
}
}
# ============================================================
# FINAL SUMMARY
# ============================================================
Write-Host "`n===================================="
Write-Host " VALIDATION SUMMARY"
Write-Host "===================================="
$matchCount = @($summary | Where-Object { $_.Status -eq "MATCH" }).Count
$mismatchCount = @($summary | Where-Object { $_.Status -ne "MATCH" }).Count
Write-Host "Source: $SrcStorageAccount ($SrcSubscriptionId)"
Write-Host "Destination: $DstStorageAccount ($DstSubscriptionId)"
Write-Host ""
Write-Host "Containers: $($allContainers.Count) total"
Write-Host " MATCH: $matchCount" -ForegroundColor Green
Write-Host " MISMATCH: $mismatchCount" -ForegroundColor $(if ($mismatchCount -gt 0) { 'Red' } else { 'Green' })
if ($mismatchCount -gt 0) {
Write-Host "`nMismatched containers:" -ForegroundColor Red
$summary | Where-Object { $_.Status -ne "MATCH" } |
Format-Table Container, PresentInSource, PresentInDest, BlobCount_Src, BlobCount_Dst, TotalBytes_Src, TotalBytes_Dst, Status -AutoSize
Write-Host "RESULT: FAIL" -ForegroundColor Red
} else {
Write-Host "`nRESULT: PASS" -ForegroundColor Green
}
Write-Host "`nAll output files: $OutDir"
Output of Script - Validation of Azure Storage Account Blob Synchronization
Container PresentInSource PresentInDest BlobCount_Src BlobCount_Dst TotalBytes_Src TotalBytes_Dst BlobCount_Delta TotalBytes_Delta Status sdcacx TRUE FALSE 3 1188 MISSING_IN_DESTINATION test TRUE FALSE 1 25927 MISSING_IN_DESTINATION tesy TRUE FALSE 7 55575 MISSING_IN_DESTINATION
Container Folder BlobName Bytes_Src Bytes_Dst Bytes_Delta InSource InDest Status sdcacx (root) kk 396 25927 -25531 TRUE TRUE SizeMismatch sdcacx (root) Post-AzSyncValidation.ps1 396 25927 -25531 TRUE TRUE SizeMismatch sdcacx rr rr/kk 396 25927 -25531 TRUE TRUE SizeMismatch test (root) kk 25927 25927 0 TRUE TRUE Match
Key Features
✅ No SAS tokens, no storage account keys, and no environment‑specific secrets
✅ Executes from the target VM using Managed Identity
✅Uses azcopy sync to support resumability and large datasets
✅ Enable Logging for audit and troubleshooting
✅ Prevents full script termination due to partial failures. Skips failed containers and continues processing remaining pairs.
When to Use This Pattern
✅ Recommended Scenarios
- Azure repatriation or region-to-region migration
- Source environment is locked down and cannot be modified
- Separate hub-spoke network architectures per region
- One-time or phased migration efforts.
- Environments with strong compliance and security requirements
After successful repatriation and source environment decommissioning, any temporary private endpoint or DNS records created in the target environment can be safely removed.
❌ Less Suitable Scenarios
- Continuous or near real-time replication requirements
- Architectures with shared networking between environments
- Disaster recovery scenarios requiring bi‑directional synchronization
Decision Matrix: Choosing the Right Pattern
|
Requirement |
Target-Anchored Repatriation VM |
|
Use Case |
Repatriation / migration |
|
Network Model |
Fully isolated |
|
DNS Complexity |
Explicit A record management |
|
Source Changes |
None |
|
Automation Scope |
Scoped per migration |
|
Recommended For |
One-time or phased moves |
Key Takeaway
For Azure repatriation scenarios involving different regions, subscriptions, and isolated private endpoint networks, a reliable and secure approach is to: Execute AzCopy from a VM in the target environment, explicitly align Private DNS resolution, and authenticate using Managed Identity.
This pattern maintains:
- Network isolation
- Zero credential exposure
- Alignment with enterprise security and compliance controls
while still enabling large‑scale, high‑throughput data migration.
References
- AzCopy Overview : Copy or move data to Azure Storage by using AzCopy v10 | Microsoft Learn
- Azure Storage PowerShell Module : Az.Storage Module | Microsoft Learn
- Use AzCopy to copy blobs between storage accounts with access restriction - Azure | Microsoft Learn