~/2026/04/22/azure-what-we-got-wrong-moving-file-servers-to-azure-files.md

Azure: What We Got Wrong Moving File Servers to Azure Files

---
author: 
date: 
read: 8 min
in:   [engineering]
tags: [azure, active-directory, dfs]
---

$ grep -n '^#' post.md

Azure Files gets pitched as a drop-in replacement for an on-prem file server: same SMB protocol, same NTFS ACLs, one less physical box to patch and back up. Some of that is true. What the pitch leaves out is a set of assumptions baked into every file server that quietly stop holding once the storage is a WAN hop away instead of on the same LAN segment, and we found most of them the hard way across a handful of migrations. This is what went wrong, and the scripts we run now so it does not go wrong again.

The Latency We Didn't Plan For

A local file server answers an SMB request over a LAN. An Azure file share answers it after a round trip to an Azure region, and Microsoft is blunt about what that means: the farther the client is from the service, the slower the latency experience, and even ExpressRoute doesn't match an application whose compute and storage run in the same region. For a user opening a Word document, that's invisible. For a line-of-business app that does hundreds of small, chatty file operations per transaction, the kind that does a stat, a lock, a tiny read and a tiny write in a loop instead of one clean read, that latency multiplies into a genuinely slower application. The first anyone hears about it is a help desk ticket saying the app "feels sluggish" with no obvious cause.

Microsoft's own performance guide shows the arithmetic. Its worked example of a single-threaded app creating a 16 KiB file in six operations comes to about 14 ms per file, so 10,000 files take 140 seconds sequentially, and eight threads bring that down to 17.5 seconds. The per-operation cost is set by distance, so a serial app pays it on every call.

We now profile the I/O pattern of anything latency-sensitive before migrating it, not just how much data it stores. The cheapest way is to put a pilot copy of the share in Azure, point the app at it, and compare two storage metrics. SuccessE2ELatency is the full round trip from the client; SuccessServerLatency is time spent inside Azure Files. Microsoft's guidance is that the gap between them is network and client time, so a big gap means distance is your problem and a faster tier won't fix it.

bash
#!/usr/bin/env bash
# share-latency.sh: compare end-to-end and service latency for a pilot share.
set -euo pipefail

SUB="<subscription-id>"
RG="<resource-group>"
ACCOUNT="<storage-account>"
FILE_SERVICE="/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.Storage/storageAccounts/$ACCOUNT/fileServices/default"

# Hourly averages over the last 7 days, split by SMB/REST operation name.
az monitor metrics list \
  --resource "$FILE_SERVICE" \
  --metrics SuccessE2ELatency SuccessServerLatency \
  --dimension ApiName \
  --aggregation Average \
  --interval 1h \
  --offset 7d \
  --output table

If the app is chatty and the gap is large, the options are to move the app's compute into the same region as the share, keep a local cache with Azure File Sync, or leave that workload on a file server. Picking a bigger share doesn't help a serial app that's waiting on round trips.

It also pays to pick the billing model deliberately. Microsoft now recommends provisioned v2 for new deployments: you provision storage, IOPS and throughput separately and pay for what you provision, with credit-based bursting on a best-effort basis. You can decrease a provisioned value only after 24 hours have passed since the last increase. The per-share limits differ a lot by media tier:

Classic file share limitHDD provisioned v2SSD provisioned v2HDD pay-as-you-go
Maximum share size256 TiB256 TiB100 TiB
Maximum data IOPS50,000 (as provisioned)102,400 (as provisioned)20,000
Maximum IOPS per file1,00012,0001,000
Maximum handles per file or directory2,0002,0002,000
Maximum handles on the share root10,00010,00010,000

The handle limits are worth checking against any app that keeps thousands of files open. Here is the storage account, share and private endpoint we deploy for a pilot, using the commands from Microsoft's provisioned v2 and private endpoint guides:

bash
#!/usr/bin/env bash
# new-files-pilot.sh: HDD provisioned v2 share behind a private endpoint, public endpoint
# denied except for trusted Azure services.
set -euo pipefail

RG="<resource-group>"
ACCOUNT="<storage-account>"          # 3-24 lowercase letters and numbers
REGION="eastus2"
SHARE="finance"
VNET_RG="<vnet-resource-group>"
VNET="<vnet-name>"
SUBNET="<private-endpoint-subnet>"

az storage account create \
  --resource-group "$RG" --name "$ACCOUNT" --location "$REGION" \
  --kind FileStorage --sku StandardV2_LRS --output none

# Omitting --provisioned-iops and --provisioned-bandwidth-mibps uses the recommended values.
az storage share-rm create \
  --resource-group "$RG" --storage-account "$ACCOUNT" --name "$SHARE" \
  --quota 1024 --enabled-protocols SMB --output none

ACCOUNT_ID=$(az storage account show --resource-group "$RG" --name "$ACCOUNT" --query id --output tsv)
VNET_ID=$(az network vnet show --resource-group "$VNET_RG" --name "$VNET" --query id --output tsv)
SUBNET_ID=$(az network vnet subnet show --resource-group "$VNET_RG" --vnet-name "$VNET" --name "$SUBNET" --query id --output tsv)

az network vnet subnet update --ids "$SUBNET_ID" --disable-private-endpoint-network-policies --output none

PE_ID=$(az network private-endpoint create \
  --resource-group "$RG" --name "$ACCOUNT-pe" --location "$REGION" \
  --subnet "$SUBNET_ID" --private-connection-resource-id "$ACCOUNT_ID" \
  --group-id file --connection-name "$ACCOUNT-conn" --query id --output tsv)

# Private DNS so <account>.file.core.windows.net resolves to the private IP.
ZONE="privatelink.file.core.windows.net"
az network private-dns zone create --resource-group "$VNET_RG" --name "$ZONE" --output none
az network private-dns link vnet create --resource-group "$VNET_RG" --zone-name "$ZONE" \
  --name "$VNET-files-link" --virtual-network "$VNET_ID" --registration-enabled false --output none

NIC_ID=$(az network private-endpoint show --ids "$PE_ID" --query "networkInterfaces[0].id" --output tsv)
PE_IP=$(az network nic show --ids "$NIC_ID" --query "ipConfigurations[0].privateIPAddress" --output tsv)
az network private-dns record-set a create --resource-group "$VNET_RG" --zone-name "$ZONE" \
  --name "$ACCOUNT" --output none
az network private-dns record-set a add-record --resource-group "$VNET_RG" --zone-name "$ZONE" \
  --record-set-name "$ACCOUNT" --ipv4-address "$PE_IP" --output none

# Deny the public endpoint; AzureServices bypass keeps Azure File Sync working.
az storage account update --resource-group "$RG" --name "$ACCOUNT" \
  --default-action Deny --bypass AzureServices --output none

On-premises clients need their DNS servers to forward file.core.windows.net lookups into Azure, or they will keep resolving the public IP. Test from a client with Resolve-DnsName <account>.file.core.windows.net and confirm the answer is a CNAME to the privatelink name with your private IP, and always mount by the original name, not the privatelink one.

Getting Authentication Backwards

Azure Files supports Kerberos against on-prem Active Directory so NTFS ACLs carry over exactly as they were, which sounds like the safe default. The part we underestimated was how much has to line up for it to work. The storage account has to be registered in AD DS, which creates a computer (or service logon) account that represents it, a different setup from Microsoft Entra Domain Services. Clients need unimpeded network connectivity to a domain controller from wherever they sit. And there are two permission layers: a share-level Azure RBAC role assigned to the user's synced Entra identity, then the NTFS ACLs enforced against AD DS. Only hybrid identities that exist in both directories work with per-user share permissions.

Get the RBAC layer wrong and users get access denied even though their NTFS permissions are fine, which looks exactly like a Kerberos failure and sends you down the wrong troubleshooting path first. Two things made it worse for us. Share-level permission changes usually take effect within 30 minutes but can take longer, so a fix doesn't look like a fix right away. And the default share-level permission is None, so nothing works until you assign something. If clients can't reach a domain controller, for example Entra-joined laptops off the VPN, Microsoft Entra Kerberos is the identity source designed for that case, but a storage account has exactly one identity source, so decide before you set ACLs.

This is the pilot setup we run now, from a domain-joined machine in Windows PowerShell 5.1 with the AzFilesHybrid module (which only supports AES-256 Kerberos):

powershell
<#
.SYNOPSIS
    Registers a storage account in AD DS and grants share-level access to one group.
.DESCRIPTION
    Uses AzFilesHybrid Join-AzStorageAccount to create the computer account that
    represents the storage account, assigns a share-level RBAC role to a synced
    group on one share, and runs the AzFilesHybrid diagnostics.
.PARAMETER SubscriptionId
    Subscription that holds the storage account.
.PARAMETER ResourceGroupName
    Resource group of the storage account.
.PARAMETER StorageAccountName
    Storage account to join.
.PARAMETER SamAccountName
    Name of the AD computer object, 15 characters or less, no trailing $.
.PARAMETER OuDistinguishedName
    OU for the computer object. Use an OU without computer password expiration.
.PARAMETER ShareName
    Share to grant access on.
.PARAMETER GroupObjectId
    Object ID of the synced Entra group that gets Storage File Data SMB Share Contributor.
.EXAMPLE
    .\Join-FilesPilot.ps1 -SubscriptionId "<subscription-id>" -ResourceGroupName "<rg>" -StorageAccountName "<account>" -SamAccountName "<account>" -OuDistinguishedName "OU=AzureFiles,DC=contoso,DC=com" -ShareName "finance" -GroupObjectId "<group-object-id>"
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2026-04-22)
    Requires: Windows PowerShell 5.1, Az.Accounts, Az.Storage 8.1.0+, Az.Resources, AzFilesHybrid, ActiveDirectory module
#>
param (
    [Parameter(Mandatory = $true)] [string] $SubscriptionId,
    [Parameter(Mandatory = $true)] [string] $ResourceGroupName,
    [Parameter(Mandatory = $true)] [string] $StorageAccountName,
    [Parameter(Mandatory = $true)] [string] $SamAccountName,
    [Parameter(Mandatory = $true)] [string] $OuDistinguishedName,
    [Parameter(Mandatory = $true)] [string] $ShareName,
    [Parameter(Mandatory = $true)] [string] $GroupObjectId
)

Import-Module -Name AzFilesHybrid
Connect-AzAccount | Out-Null
Select-AzSubscription -SubscriptionId $SubscriptionId | Out-Null

# Create the AD computer account and enable AD DS authentication on the account.
Join-AzStorageAccount `
    -ResourceGroupName $ResourceGroupName `
    -StorageAccountName $StorageAccountName `
    -SamAccountName $SamAccountName `
    -DomainAccountType "ComputerAccount" `
    -OrganizationalUnitDistinguishedName $OuDistinguishedName

# Share-level permission for one synced group, scoped to one share.
$scope = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Storage/storageAccounts/$StorageAccountName/fileServices/default/fileshares/$ShareName"
New-AzRoleAssignment -ObjectId $GroupObjectId -RoleDefinitionName "Storage File Data SMB Share Contributor" -Scope $scope | Out-Null

# Confirm the identity source, then run the built-in checks as the signed-in AD user.
$account = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName
$account.AzureFilesIdentityBasedAuth.DirectoryServiceOptions
Debug-AzStorageAccountAuth -StorageAccountName $StorageAccountName -ResourceGroupName $ResourceGroupName -Verbose

One more trap: the AD object that represents the storage account has a password, and Microsoft's guidance is to put it in an OU where it won't hit a maximum password age, or rotate it before it does. If it expires, every mount fails at once.

Moving the Data Without Losing the ACLs

Microsoft's migration guide says to use robocopy, not AzCopy, when an Azure file share is the target: robocopy /MIR mirrors deletions, while azcopy sync doesn't remove files deleted at the source. Mount the target share with admin-level access (an admin-level RBAC role, or the storage account key), because the /B backup-mode switch needs it. The guide's command is /MT:20 /R:2 /W:1 /B /MIR /IT /COPY:DATSO /DCOPY:DAT /NP /NFL /NDL /XD "System Volume Information". /COPY:DATSO carries data, attributes, timestamps, NTFS ACLs and owner (auditing info can't be stored in Azure Files, so no U), and /IT catches ACL changes that /MIR would otherwise miss.

The pattern is repeated passes: a first bulk pass while users keep working, catch-up passes that only move changes, then a final pass after you block writes to the source. The final pass takes about as long as the previous catch-up pass, which is your downtime estimate. This wrapper runs one pass for every share in a CSV and treats robocopy's exit codes correctly: 0 to 7 are success variants, 8 or higher means at least one failure.

powershell
<#
.SYNOPSIS
    Runs one robocopy migration pass for every share listed in a CSV.
.DESCRIPTION
    Reads a CSV with Source and Target columns (UNC paths), runs robocopy with the
    switches from Microsoft's Azure Files migration guide, writes one Unicode log per
    share and pass, and reports any share whose exit code is 8 or higher.
.PARAMETER MappingFile
    CSV with Source and Target columns.
.PARAMETER LogFolder
    Folder for robocopy logs.
.PARAMETER Threads
    Value for /MT. Microsoft suggests 8-20 for the first pass and roughly the core count after.
.PARAMETER ListOnly
    Adds /L so robocopy only lists what it would copy or purge.
.EXAMPLE
    .\Invoke-ShareMigration.ps1 -MappingFile .\shares.csv -LogFolder D:\MigrationLogs -Threads 16
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2026-04-22)
    Requires: elevated session, Windows Server 2022 or newer recommended, target shares mounted with admin-level access
#>
param (
    [Parameter(Mandatory = $true)] [string] $MappingFile,
    [Parameter(Mandatory = $true)] [string] $LogFolder,
    [int] $Threads = 16,
    [switch] $ListOnly
)

New-Item -Path $LogFolder -ItemType Directory -Force | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmm"
$results = @()

foreach ($row in (Import-Csv -Path $MappingFile)) {
    $name = ($row.Source.TrimEnd("\") -split "\\")[-1]
    $log = Join-Path -Path $LogFolder -ChildPath "$name-$stamp.log"

    $arguments = @(
        $row.Source, $row.Target,
        "/MT:$Threads", "/R:2", "/W:1", "/B", "/MIR", "/IT",
        "/COPY:DATSO", "/DCOPY:DAT", "/NP", "/NFL", "/NDL",
        "/XD", "System Volume Information",
        "/UNILOG:$log"
    )
    if ($ListOnly) {
        $arguments += "/L"
    }

    $started = Get-Date
    & robocopy.exe @arguments | Out-Null
    $code = $LASTEXITCODE

    $results += [pscustomobject]@{
        Share    = $name
        ExitCode = $code
        Result   = if ($code -ge 8) { "FAILED" } else { "OK" }
        Minutes  = [math]::Round(((Get-Date) - $started).TotalMinutes, 1)
        Log      = $log
    }
}

$results | Format-Table -AutoSize
if ($results | Where-Object { $_.ExitCode -ge 8 }) {
    Write-Warning "One or more shares had copy failures. Check the logs, then run another pass."
}

The shares.csv is two columns under a Source,Target header row, for example \\oldfs01\finance,\\<account>.file.core.windows.net\finance. Keep the source and target at exactly the same folder level: Microsoft warns that /MIR against mismatched levels produces large-scale deletions and recopies. And freeze big namespace changes (mass moves, ACL rewrites) during the migration window, because every catch-up pass has to replay them.

To keep users' paths working after cutover, a DFS Namespace in front of the shares is the cleanest option. Microsoft documents root consolidation for exactly this: a stand-alone namespace named #OldServerName on a member server, folders named after the old shares with the Azure file share as the target, the old name transferred with netdom computername <dfs-server> /add:<old-name>, and a CNAME from the old name to the namespace server. The source server has to be offline for that step, so schedule it with the final robocopy pass.

Cloud Tiering's Hidden Cost

For some sites we used Azure File Sync to keep an on-prem cache in front of the cloud share, with cloud tiering enabled to reclaim local disk. That worked well for weeks, then stalled badly the first time someone opened a folder of large files that had tiered out and hadn't been touched recently. Every one of those files had to be recalled over the WAN on first read, all at once, and what should have been an instant folder open turned into a multi-minute stall.

The mechanics explain it. Tiering is driven by a volume free space policy and an optional date policy, ranked by a heatmap of last access and modify times. A tiered file keeps its namespace entry locally with a reparse point and the offline and recall-on-data-access attributes, and its content comes down on first read. Microsoft also warns that the agent reserves 10% of memory for persisting recalls, and a burst of recalls against many tiered files can hit that threshold and cause extra egress, slow I/O and hangs.

The fix wasn't complicated once we knew the knobs existed. Folders with bursty, all-at-once access go on the GhostingExclusionList registry value so they're never tiered, anything already tiered gets recalled ahead of a known event (a fiscal year archive review, say), and we warn users before those events.

powershell
<#
.SYNOPSIS
    Excludes a folder from cloud tiering and recalls anything in it that is already tiered.
.DESCRIPTION
    Sets the Azure File Sync GhostingExclusionList registry value, restarts FileSyncSvc
    so the exclusion takes effect, then runs Invoke-StorageSyncFileRecall on the folder.
.PARAMETER FolderPath
    Folder under a server endpoint to keep local, for example D:\Shares\Projects\Active.
.EXAMPLE
    .\Set-TieringExclusion.ps1 -FolderPath "D:\Shares\Projects\Active"
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2026-04-22)
    Requires: Azure File Sync agent, elevated session. On a failover cluster use the Cluster\StorageSync registry path instead.
#>
param (
    [Parameter(Mandatory = $true)] [string] $FolderPath
)

$key = "HKLM:\SOFTWARE\Microsoft\Azure\StorageSync"
# The value is one pipe-separated string; append instead of overwriting existing exclusions.
$current = (Get-ItemProperty -Path $key -Name "GhostingExclusionList" -ErrorAction SilentlyContinue).GhostingExclusionList
# Paths are stored with doubled backslashes, and ^ $ ( ) [ ] { } + need a backslash escape.
$escaped = $FolderPath.Replace("\", "\\") -replace '([\^\$\(\)\[\]\{\}\+])', '\$1'
$entries = @($current -split "\|" | Where-Object { $_ }) + $escaped | Select-Object -Unique
Set-ItemProperty -Path $key -Name "GhostingExclusionList" -Value ($entries -join "|") -Type String

Restart-Service -Name "FileSyncSvc"

# Exclusions don't apply to files that are already tiered, so recall them now.
Import-Module "C:\Program Files\Azure\StorageSyncAgent\StorageSync.Management.ServerCmdlets.dll"
Invoke-StorageSyncFileRecall -Path $FolderPath -ThreadCount 8 -PerFileRetryCount 3 -PerFileRetryDelaySeconds 10

Microsoft's documentation says folder paths in this value use doubled backslashes (D:\\Shares\\Projects), each exclusion is separated by a pipe, and the characters ^ $ ( ) [ ] \{ \} + in a path need a backslash escape, which is what the two replace operations do. File name and extension exclusions apply to every server endpoint on the server. You can confirm what the agent picked up in Event ID 9001 in the Telemetry log under Applications and Services\Microsoft\FileSync\Agent. fsutil reparsepoint query <file> showing tag 0x8000001e tells you a given file is tiered.

What We'd Do Differently

Test the actual applications against the actual latency before committing, not just the file copy throughput, and read the E2E-versus-server latency gap before blaming the tier. Get the AD DS Kerberos path and the share-level RBAC layer working in a pilot before touching production ACLs. Migrate with robocopy in repeated passes and put DFS-N in front so paths survive. And treat cloud tiering as something that needs explicit exclusions for any folder with bursty access patterns, not a set-and-forget setting. None of this makes Azure Files a bad target: for the steady, low-chatter file shares most departments actually use, it has worked well since. But it isn't the transparent swap the marketing implies, and the gap shows up exactly where you didn't look for it.

References