By the end of this build sheet you have moved two databases onto one managed instance by the two paths that actually exist: a SQL Server 2012 database restored natively from a backup in blob storage, arriving intact at compatibility level 110, and a SQL Server 2019 database moved with the Managed Instance link and cut over with a few seconds of downtime. You also have the certificate work done in the right order, which is where these projects lose their afternoons.
Prerequisites: a managed instance already provisioned in a delegated subnet with its required network security group and route table rules intact, its collation matching the source estate, and its update policy decided (all three are irreversible or expensive to change later, see the doctrine article); network connectivity from the source instances to the managed instance subnet and outbound to blob storage; a storage account for backups; SSMS 22.5.0 or later on the operator workstation if you want the Migrate SQL Server experience, because that capability shipped in 22.5.0 and earlier builds of SSMS 22 do not have it, noting that the feature’s own prerequisites page still says only “SSMS 22 and later”, which is the looser of the two claims. It also requires the Hybrid and Migration workload, installed through the Visual Studio Installer under Modify, Individual Components. The Managed Instance link wizard has no such floor and only asks for the latest SSMS; sysadmin on the sources; and, for the link path only, sources that meet the version floors in step 4 and the Azure Connect feature pack installed where required.
Documented, and observed
Everything in this sheet is verified against current Microsoft documentation, and the version floors, cmdlets and behaviours are cited from it rather than remembered. What is not here is a lab trace of every step in one sitting, and I would rather say so than imply otherwise. Where the documentation is precise, I state it as fact. Where practitioners consistently report friction the documentation does not describe, particularly around the link’s certificate exchange and the time seeding actually takes, I say that explicitly and mark it as observed. Designing around a documented guarantee and designing around an observed tendency are different disciplines, and a migration cutover is a bad place to confuse them.
Naming convention
Backup blobs as <source-instance>/<database>-<yyyyMMdd-HHmm>.bak, so a container listing reads as a history rather than a puzzle. Credentials on the source named for the container URL they authorise, which is the convention SQL Server itself expects. Link objects as link-<database>-<source-instance>, and the distributed availability group behind a link as dag-<database>. The worked examples use SQL2012-FIN01 and SQL2019-OPS01 as sources and mi-vcio-prod-eus as the target.
Step 1: Assess before you choose a path
Run an assessment first, because it decides the path rather than merely documenting it. If the source servers are Arc-enabled, the migration assessment is already running weekly and free at every license type, and it reports readiness per database plus a sizing recommendation from up to thirty days of performance history at the ninety-fifth percentile. That is the better input. If they are not Arc-enabled, use the Migrate SQL Server experience in SSMS, right-clicked from the instance in Object Explorer, and note one honest limitation: against a non-Arc instance its sizing recommendation is built from metadata rather than from observed performance, so treat it as a starting point for a conversation and not as a capacity decision.
What you are looking for at this stage is not the readiness percentage. It is the list of blocking findings, and specifically anything involving instance collation, unsupported features, or a database that is already encrypted.
Step 2: Choose the path
| Setting | Value | Why |
|---|---|---|
| Source at SQL Server 2016 SP3 or later, near-zero downtime required | Managed Instance link | Near-real-time data movement with a cutover measured in seconds. The only genuinely online path into the Business Critical tier. One database per link. |
| Source older than 2016 SP3 | Native RESTORE FROM URL | The link needs a distributed availability group, which those versions cannot form. Native restore reaches back to SQL Server 2005 and preserves the compatibility level. |
| Outage window too short for a single full restore, source too old to link | Log Replay Service | Free, log-shipping based, keeps the database in RESTORING while you feed it, then completes on command. The only method that restores differential backups on a managed instance. Job window capped at 30 days. |
| Database uses transparent data encryption | Migrate the certificate first, whichever path | The restore fails without it, and it fails at the end of a long operation rather than at the start. |
| Failback to SQL Server may ever be needed | Source must be 2022 or 2025, and the instance must not be on Always-up-to-date | Two-way operation is limited to those source versions with a matching update policy. An Always-up-to-date instance cannot fail back at all, and the policy cannot be reversed. |
Step 3: The native restore path, for the 2012 estate
First, the version floor that catches people: backing up to a URL requires SQL Server 2012 with Service Pack 1 Cumulative Update 2 as an absolute minimum. Below that, the feature does not exist and the path is a file copy into blob storage by other means. Also note that anything before 2016 writes page blobs with a one terabyte ceiling, while 2016 and later write block blobs with a shared access signature and can be striped across multiple URLs to a much larger total. If the source is genuinely 2012, you are on the page blob path and its ceiling is real.
If the database is encrypted, do the certificate before anything else. Export the certificate and its private key from the source, then upload it to the managed instance:
-- On the source instance
BACKUP CERTIFICATE TDE_Cert_FIN
TO FILE = 'D:\migration\TDE_Cert_FIN.cer'
WITH PRIVATE KEY (
FILE = 'D:\migration\TDE_Cert_FIN.pvk',
ENCRYPTION BY PASSWORD = '<strong-password>'
);
# Upload to the managed instance before attempting the restore
# Both -PrivateBlob and -Password are SecureString. Pass the base64
# private-key blob, not a byte array.
$blob = [Convert]::ToBase64String(
[System.IO.File]::ReadAllBytes("D:\migration\TDE_Cert_FIN.pvk"))
$securePrivateBlob = ConvertTo-SecureString -String $blob -AsPlainText -Force
$securePassword = ConvertTo-SecureString -String "<strong-password>" -AsPlainText -Force
Add-AzSqlManagedInstanceTransparentDataEncryptionCertificate `
-ResourceGroupName "rg-vcio-data-prod" `
-ManagedInstanceName "mi-vcio-prod-eus" `
-PrivateBlob $securePrivateBlob `
-Password $securePassword
Expected result: the cmdlet completes without output. Failure mode: skip this and the restore in a moment runs to completion of its copy phase and then fails on the encryption key, which on a large database means discovering the mistake an hour in. Do the certificate first, always, even when you are not sure the database is encrypted.
Now the backup and the restore. On the source, create a credential for the container and write the backup:
-- Source: SQL2012-FIN01
CREATE CREDENTIAL [https://stvciomig.blob.core.windows.net/backups]
WITH IDENTITY = 'SHARED ACCESS SIGNATURE',
SECRET = '<sas-token-without-leading-question-mark>';
BACKUP DATABASE [FIN]
TO URL = 'https://stvciomig.blob.core.windows.net/backups/SQL2012-FIN01/FIN-20260721-2100.bak'
WITH COPY_ONLY, COMPRESSION, CHECKSUM, STATS = 5;
Expected result: the backup completes and the blob appears in the container. Failure modes, in the order they usually happen: a shared access signature stored with its leading question mark fails authentication with an error that does not mention the question mark; a signature lacking write and list permissions fails partway; and on a pre-2016 source the credential form differs because the older versions authenticate with the storage account key against page blobs rather than with a signature. If you are on a genuine 2012 source, follow the page blob form rather than this one.
Then, connected to the managed instance:
-- Target: mi-vcio-prod-eus
CREATE CREDENTIAL [https://stvciomig.blob.core.windows.net/backups]
WITH IDENTITY = 'SHARED ACCESS SIGNATURE',
SECRET = '<sas-token>';
RESTORE FILELISTONLY
FROM URL = 'https://stvciomig.blob.core.windows.net/backups/SQL2012-FIN01/FIN-20260721-2100.bak';
RESTORE DATABASE [FIN]
FROM URL = 'https://stvciomig.blob.core.windows.net/backups/SQL2012-FIN01/FIN-20260721-2100.bak';
Note what you do not write: no MOVE clauses, no file paths, no WITH RECOVERY. The managed instance places the files itself and that is not something you can override. Expected result: the restore runs asynchronously, with the platform retrying in the background, and the database appears online. Run RESTORE FILELISTONLY first every time; it is the cheapest possible proof that the credential and the blob are both good before you commit to a long operation.
Then confirm the thing that makes this path safe:
SELECT name, compatibility_level, state_desc
FROM sys.databases WHERE name = 'FIN';
Expected result: compatibility level 110, state ONLINE. The internal database version has been upgraded irreversibly, but the compatibility level came across untouched, which means the optimiser is still behaving as it did on the 2012 box. That is the whole point. Leave it there, capture a Query Store baseline, and raise it later as its own change. Be aware of the ceiling while you plan that: on the default update policy the instance supports compatibility levels up to 160, so 170 is not available unless the instance is on the 2025 policy or Always-up-to-date. The default level for new databases on that policy is worth checking rather than assuming: the ALTER DATABASE reference states 150 in its table and 160 in its remarks, on the same page, at the time of writing.
Step 4: The link path, for anything from 2016 SP3 upward
The Managed Instance link builds a distributed availability group between your SQL Server and the managed instance, replicates continuously, and lets you cut over on command. Check the floor for your source version before anything else, because being one cumulative update short is the most common reason this stalls.
| Source version | Minimum build | Why it matters |
|---|---|---|
| SQL Server 2016 | SP3 plus the Azure Connect feature pack | The feature pack is a separate install and is Windows only. Without it the link cannot be created at all. |
| SQL Server 2017 | CU31 plus the Azure Connect feature pack | Same separate install as 2016, but unlike 2016 the 2017 source can run on Windows Server or Linux. Both 2016 and 2017 are one-way only: SQL Server to managed instance, with no failback. |
| SQL Server 2019 | CU20 | No feature pack needed from here on. |
| SQL Server 2022 | RTM for SQL Server to managed instance; CU10 to create a link in the other direction; CU13 for T-SQL failover | Three different floors for three different capabilities. Assuming RTM covers everything is the classic mistake here. |
| SQL Server 2025 | RTM | Supported. If you are reading a page that says the link tops out at 2022, that page is stale; the link feature overview is the current one. |
The guided path is SSMS, right-click the database, and the Azure SQL Managed Instance link wizard, which handles endpoint creation, certificate exchange and seeding. The scripted equivalent, which is what you want for anything repeatable:
New-AzSqlInstanceLink `
-ResourceGroupName "rg-vcio-data-prod" `
-InstanceName "mi-vcio-prod-eus" `
-Name "link-OPS-SQL2019-OPS01" `
-PartnerAvailabilityGroupName "dag-OPS" `
-InstanceAvailabilityGroupName "dag-OPS-mi" `
-Database "OPS" `
-PartnerEndpoint "TCP://sql2019-ops01.corp.example.com:5022" `
-InstanceLinkRole "Secondary" `
-FailoverMode "Manual" `
-SeedingMode "Automatic"
Observed, not documented: the certificate exchange between the source instance and the managed instance is the step that consistently costs people time. The documentation presents it as a step the wizard performs; practitioners report that when it fails it fails opaquely, usually because the database mirroring endpoint on the source is unreachable from the managed instance subnet on its port, or because an existing endpoint is using a different authentication or encryption configuration than the link expects. Before you start, confirm the source’s mirroring endpoint exists, is started, and is reachable on its port from inside the managed instance’s subnet. That single check removes most of the pain.
Monitor seeding from the source rather than guessing:
SELECT ag.name AS ag_name, drs.synchronization_state_desc,
drs.synchronization_health_desc, drs.log_send_queue_size,
drs.redo_queue_size
FROM sys.dm_hadr_database_replica_states AS drs
JOIN sys.availability_groups AS ag
ON drs.group_id = ag.group_id;
Expected result: synchronization state reaches SYNCHRONIZING and health becomes HEALTHY, with the send and redo queues trending toward zero. Observed: seeding runs at a rate that makes a several-hundred-gigabyte database an overnight proposition rather than a lunchtime one, and it competes with your production workload for the source’s network. Start it days before the cutover, not hours. That is the entire operational advantage of this path and people routinely squander it by starting late.
Cut over when the queues are drained and the business is ready. On a 2022 source at CU13 or later this is a T-SQL failover from the source; otherwise it is driven from the managed instance side. Either way the sequence is the same: stop the application, confirm the queues are at zero, fail over, repoint the connection string, start the application.
Worked example: two databases, two paths, one weekend
The estate: a finance database on SQL2012-FIN01, 180 gigabytes, compatibility level 110, transparent data encryption enabled because somebody switched it on in 2015 and nobody has thought about it since. And an operations database on SQL2019-OPS01, 400 gigabytes, busy, with an owner who will not agree to more than a few minutes of downtime.
The finance database takes the native restore path, because 2012 cannot form a distributed availability group and no amount of wanting changes that. The sequence on the Saturday: certificate exported and uploaded first, backup written to blob with COPY_ONLY and CHECKSUM, RESTORE FILELISTONLY run as a cheap sanity check, then the restore itself. It lands at compatibility level 110 and stays there. Total outage: the length of the backup plus the restore, agreed in advance at six hours and used in four.
The operations database takes the link. Its 2019 source is already past CU20, so no feature pack is needed. The link is created on the Tuesday, seeding runs across three nights, and the queues are watched each morning. The cutover on Saturday takes under a minute of application downtime: stop, confirm queues at zero, fail over, repoint, start. The owner who would not agree to more than a few minutes gets a few seconds, which is the entire reason this path exists.
One decision worth calling out because it was made early and quietly. The instance was left on the default SQL Server 2022 update policy rather than moved to Always-up-to-date, for one reason: the operations database has a vendor who has not yet certified anything newer, and the client wanted the option to move it back to a SQL Server instance if that conversation went badly. Always-up-to-date would have foreclosed that permanently. The compatibility ceiling of 160 was an acceptable price for keeping the door open, and it was a decision rather than a default.
Step 5: The Log Replay Service alternative
Worth knowing for the case that fits neither path cleanly: a source too old to link, with a database too large to restore inside the available window. The Log Replay Service takes a full backup, then differentials and logs, and keeps the target database in a restoring state while you feed it, completing on command when you are ready to cut over. It is free, it runs in autocomplete or continuous modes, and its job window is capped at thirty days, which is a real deadline rather than a guideline, because the job is cancelled automatically on expiry. One constraint catches everyone: the shared access signature must be scoped to the whole container and carry Read and List permissions and nothing else. Granting Write as well makes the Log Replay Service fail to start, which is the opposite of the rule that applies to the backup credential on the source. Documented source support runs from SQL Server 2008 to 2022; SQL Server 2025 is not listed.
Its distinguishing property is worth quoting exactly, because it decides the choice on its own: it is the only method that restores differential backups on a managed instance. If your source estate’s backup regime is built on weekly fulls and daily differentials, and re-engineering that for the migration is not on the table, this is your path.
Step 6: Validate end to end
Run all of these before anyone declares the migration finished.
-- Compatibility levels came across, and nothing silently moved
SELECT name, compatibility_level, state_desc, is_encrypted
FROM sys.databases WHERE database_id > 4;
-- The instance is on the update policy you think it is
SELECT SERVERPROPERTY('ProductUpdateType') AS update_type,
SERVERPROPERTY('Collation') AS instance_collation;
-- Agent jobs arrived and are enabled
SELECT name, enabled FROM msdb.dbo.sysjobs ORDER BY name;
-- Logins exist and nothing is orphaned
SELECT dp.name, dp.type_desc, sp.name AS server_login
FROM sys.database_principals AS dp
LEFT JOIN sys.server_principals AS sp ON dp.sid = sp.sid
WHERE dp.type IN ('S','U','G') AND dp.principal_id > 4;
Expected results: compatibility levels matching the sources rather than the instance default; update type returning CU on the default policy or Continuous on Always-up-to-date, and matching what you intended; Agent jobs present and enabled, because they do not migrate themselves and their absence here means somebody has not done that work yet; and no database principal with a null server login, because those are the orphaned users whose applications will fail on Monday.
The end-to-end test: from an application host, connect to the managed instance over its private endpoint using the application’s own credentials, run a transaction that touches both migrated databases in a single query using three-part naming, and then confirm the overnight Agent job runs on schedule against the new instance. That sequence proves the network path, authentication, the cross-database capability that justified choosing this destination over Azure SQL Database in the first place, and the Agent. If cross-database naming fails here, you have proven the migration but not the requirement, and that is worth finding on a Sunday rather than a Monday.
What comes next
Hold both databases at their original compatibility levels until you have a Query Store baseline across a full business cycle, then raise them one at a time. If part of the estate needs the whole product rather than the instance, Reporting Services or an exotic feature or a vendor pin, the next build sheet takes that path onto a SQL Server virtual machine.
Azure SQL
‹ Previous: [SQL 3] Managed Instance: The Instance That Survived the Move
Next: [SQL 4] SQL Server on an Azure VM: Full Control, Full Bill ›




