Skip to main content

Migrate an ASP.NET app to Managed Instance on App Service

Some Windows apps do not fit neatly into a standard Azure App Service app. A legacy ASP.NET Framework site might register a COM component, run an MSI installer, read a value from the Windows registry, tweak an IIS setting, or depend on a custom font or framework that has to be present on the machine before the app starts. On standard App Service you cannot make those OS-level changes, so these apps often stay on virtual machines long after everything else moved to PaaS.

Managed Instance on Azure App Service closes that gap. It is a plan-scoped hosting option for Windows web apps that keeps the managed platform - patching, scaling, diagnostics, identity, and load balancing - while giving you a startup configuration (install) script that runs with administrator rights so you can lay down OS-level and framework dependencies. It is built for the "lift and improve" migration: move a legacy .NET Framework / IIS app into App Service with minimal rewrites.

In this lab you migrate a prebuilt ASP.NET Framework 4.8 app to a Managed Instance plan. The app renders text into an image using the Aptos fonts - fonts that are not on the base image. A configuration script copies those fonts into C:\Windows\Fonts at startup, so when the app returns a rendered image you have proof that the OS-level customization worked. You will provision everything, deploy the app, and verify it end to end.

Managed Instance is in preview

Managed Instance on Azure App Service is in public preview. During preview it is Windows only (no Linux or containers), limited to Premium v4 (Pv4) and Premium Mv4 (Pmv4) pricing plans, and available in select regions: East Asia, East US, North Europe, and West Central US, with more to follow. It is expected to reach general availability soon. Preview features can change, so always confirm current limits in the official documentation.

App Service Labs complements Microsoft Learn

This lab is a hands-on, end-to-end walkthrough. For reference depth on any concept, follow the "Learn more" links to the official Microsoft Learn documentation.

Estimated time: 60 to 75 minutes

Objectives

By the end of this lab you will be able to:

  • Explain what Managed Instance on App Service is and when to choose it over standard App Service or an App Service Environment.
  • Package a configuration (install) script and its dependencies and stage them in Azure Blob Storage.
  • Create a Managed Instance plan on a P1V4 tier with a plan-level user-assigned managed identity and an install script.
  • Deploy an ASP.NET Framework app to the plan and confirm the OS-level customization (custom fonts) took effect.
  • Describe the plan-level adapters - storage mounts, registry keys, and just-in-time RDP - you would use to finish a real migration.

Prerequisites

Before you begin, you will need an Azure subscription with Owner permissions and a GitHub account.

In addition, you will need the following tools installed on your local machine:

Setup Azure CLI

Start by logging into Azure by run the following command and follow the prompts:

az login --use-device-code
tip

You can log into a different tenant by passing in the --tenant flag to specify your tenant domain or tenant ID.

You also need permission to create role assignments (Owner, or User Access Administrator) on the resource group, because the plan's managed identity is granted read access to the storage account that holds the install script.

Region and tier

This lab uses the East US region and a Premium v4 P1V4 plan (2 vCPU, about 6 GB RAM). Managed Instance requires a Pv4 or Pmv4 plan, which is a premium tier billed per hour - check the Azure pricing calculator for the rate in your region. Pick the smallest plan that shows the scenario, and delete the resources when you finish (see Clean up) to stop charges. Confirm the region supports Managed Instance for your SKU with az appservice list-locations --managed-instance-enabled --sku P1V4.

When to use Managed Instance

Managed Instance sits between standard App Service and a fully isolated App Service Environment. Use this table to place your workload.

ChooseWhen you need
Standard App ServiceModern, cloud-native apps: Linux or Windows, containers, multiple language stacks, and platform-managed infrastructure with no OS customization. This is the default and the cheapest.
Managed Instance on App ServiceLegacy Windows compatibility on PaaS: COM components, registry values, MSI installers, IIS configuration, GAC installs, Windows features, or custom fonts and frameworks - installed with a startup script. "Lift and improve" a .NET Framework / IIS app with minimal rewrites, with optional plan-level virtual network isolation and just-in-time RDP for diagnostics.
App Service Environment (ASE)Enterprise-scale, fully isolated, dedicated infrastructure - typically for large fleets (100+ apps) that require complete network boundary control.

Managed Instance runs Windows Server 2022 and preinstalls .NET Framework 3.5, 4.8, and .NET 8. Anything else - extra runtimes, native components, registry state - you install and maintain yourself through the configuration script.

How the migration works

A Managed Instance plan is an App Service plan in custom mode (isCustomMode = true). Two things make the customization work:

  • A plan-level user-assigned managed identity that the platform uses for infrastructure operations - reading the install script from Blob Storage, and (optionally) pulling secrets from Key Vault for storage mounts and registry adapters.
  • A configuration (install) script: a single zip whose root contains Install.ps1, staged in a Blob container. The platform runs it with administrator rights on every instance at startup, so your OS-level changes persist across restarts and scale-out.

In this lab the script copies the Aptos .ttf files into C:\Windows\Fonts and registers them. The app draws text into an image with those fonts, so a rendered image is the success signal: it can only work if the OS-level customization ran.

Adapters build on the same identity

Storage mounts and registry key adapters use the same plan-level identity to read secrets from Key Vault. You wire the identity and script here; you can add those adapters later without re-architecting. The Finish the migration section covers them.

Assess before you migrate (optional companion)

Before you move a real app, assess it. GitHub Copilot app modernization for .NET analyzes an ASP.NET solution in Visual Studio, flags framework and dependency issues, and proposes code changes to make the app cloud-ready - which is exactly the work that precedes a Managed Instance migration. The Microsoft Ignite lab LAB501: Modernizing ASP.NET applications with Azure Migrate and GitHub Copilot walks a "devShop" ASP.NET app through an assessment and then deploys it to Managed Instance. This lab focuses on the hosting migration and uses a prebuilt app, so you can skip the assessment - but for a production migration, run the assessment first. See Azure Migrate to discover and plan the move.

Get the sample

Both deployment paths start from the same sample repository, which contains the infrastructure template, the install script with the Aptos fonts, and a prebuilt ASP.NET 4.8 app (app.zip). Get it with azd init or a plain git clone.

Choose your path
Set this once - every matching step below follows your choice.
Provision with
mkdir managed-instance-lab && cd managed-instance-lab
azd init --template https://github.com/Azure-Samples/managed-instance-azure-app-service-quickstart.git

The template includes azure.yaml, an infra/ folder (the Bicep and the app-service-plan-managed-instance.json ARM template), a scripts/ folder (Install.ps1 plus the Aptos fonts), and app.zip.

Stage the install script

The platform runs Install.ps1 from a zip in Blob Storage. Package the script and fonts into a single zip whose root contains Install.ps1, then you upload it in the next section. This is the script the platform runs at startup:

# Install.ps1 - copy and register custom fonts on the Managed Instance
Write-Host "Installing custom fonts on Managed Instance..." -ForegroundColor Green

Get-ChildItem -Recurse -Include *.ttf, *.otf | ForEach-Object {
$FontName = $_.BaseName + " (TrueType)"
$Destination = "$env:windir\Fonts\$($_.Name)"
Copy-Item $_.FullName -Destination $Destination -Force
New-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" `
-Name $FontName -PropertyType String -Value $_.Name -Force | Out-Null
}

Write-Host "Font installation completed." -ForegroundColor Green

Build the zip from the repo root (the sample includes a helper, scripts/prepare-install.sh, that does the same thing):

bash scripts/prepare-install.sh
# Produces scripts.zip with Install.ps1 and the Aptos .ttf fonts at the root.

Provision and deploy

Choose your path. All three create a user-assigned managed identity, a storage account with a scripts container holding scripts.zip, a Managed Instance plan on P1V4 with the install script, and an ASP.NET V4.8 web app that uses the plan identity - then deploy app.zip.

The azd template provisions the base resources - the managed identity, the storage account and container, and it packages and uploads scripts.zip - in one flow. Set the location to a Managed Instance region and run azd up:

azd env set AZURE_LOCATION eastus
azd up

azd up creates the user-assigned identity, creates the storage account and scripts container, grants the identity Storage Blob Data Contributor on the account, and uploads scripts.zip. When it finishes, read the values you need:

azd env get-values
# AZURE_RESOURCE_GROUP, STORAGE_ACCOUNT_NAME, STORAGE_CONTAINER_NAME, MANAGED_IDENTITY_ID
The preview azd template stops at the base resources

During preview, the azd template provisions the identity, storage, and script upload but does not yet create the Managed Instance plan or the app. Create those with the CLI commands below (they read directly from your azd environment), or use the Portal tab. This is expected to be simplified as the product reaches general availability.

Create the plan and app from the same environment values. The plan reads the install script from the blob using the plan identity:

VALUES=$(azd env get-values --output json)
RG=$(echo "$VALUES" | jq -r .AZURE_RESOURCE_GROUP)
STORAGE=$(echo "$VALUES" | jq -r .STORAGE_ACCOUNT_NAME)
CONTAINER=$(echo "$VALUES" | jq -r .STORAGE_CONTAINER_NAME)
IDENTITY_ID=$(echo "$VALUES" | jq -r .MANAGED_IDENTITY_ID)
SCRIPT_URI="https://${STORAGE}.blob.core.windows.net/${CONTAINER}/scripts.zip"

PLAN="plan-asl-mi"
APP="app-asl-mi-$(openssl rand -hex 3)"

az deployment group create --resource-group "$RG" \
--template-file infra/app-service-plan-managed-instance.json \
--parameters location=eastus appServicePlanName="$PLAN" \
userAssignedIdentityResourceId="$IDENTITY_ID" installScriptSourceUri="$SCRIPT_URI" \
skuName=P1V4 skuCapacity=1

az webapp create --name "$APP" --resource-group "$RG" --plan "$PLAN" --runtime "ASPNET:V4.8"
az webapp identity assign --name "$APP" --resource-group "$RG" --identities "$IDENTITY_ID"
az webapp deploy --resource-group "$RG" --name "$APP" --src-path app.zip --type zip

Get the hostname and skip to Verify the app:

az webapp show --name "$APP" --resource-group "$RG" --query defaultHostName --output tsv

Verify the app

The migrated app renders text into an image using the Aptos fonts, and it serves that image straight from its home page. So a single request to / is the success signal: it returns a PNG that could only be produced if the configuration script installed the fonts. Set APP_URL to your app's hostname and request the home page, saving the response and its headers:

export APP_URL="https://app-asl-mi-xxxxxx.azurewebsites.net"

curl -s -o rendered.png -w "HTTP %{http_code} type=%{content_type} bytes=%{size_download}\n" "$APP_URL/"

Expected result (captured during authoring):

HTTP 200 type=image/png bytes=2412

Check the response headers confirm both an image and the ASP.NET Framework runtime:

curl -sI "$APP_URL/" | grep -iE "content-type|x-aspnet-version"
Content-Type: image/png
X-AspNet-Version: 4.0.30319

The image/png content type is the real proof: the app rendered text with the Aptos fonts, which only exist on the instance because the startup script copied them into C:\Windows\Fonts. The X-AspNet-Version header confirms the legacy ASP.NET Framework app is running on the Managed Instance plan. Open $APP_URL/ in a browser to see the rendered image, or open the saved rendered.png (a 400 x 100 PNG).

First request can be slow

The first request after deployment can take a while: the plan provisions, the install script runs on each instance, and the app cold-starts. If you get a 503 immediately after deploy, wait a minute and try again.

Finish the migration (plan-level adapters)

The lab above proves the core migration. A real legacy app usually needs a few more plan-level features. These are configured on the plan (Configuration blade), use the same plan identity, and back their secrets with Key Vault. They are documented here from the official guidance; the reproducible lab above does not configure them because they need external dependencies and, for RDP, a Windows GUI.

  • Storage mounts. Map an Azure Files share (or a custom UNC path) to a drive letter for apps that expect a shared filesystem. On the plan, go to Configuration > Mounts > New storage mount, choose the storage account and file share, and point Value/Secret at a Key Vault secret that holds the connection credential. Mounts persist across restarts. See Configure storage mounts.
  • Registry key adapters. For apps that read configuration from the Windows registry, add a registry key whose value comes from Key Vault. On the plan, go to Configuration > Registry Keys > Add, set the Path, the Vault and Secret, and the Type (String or DWORD). See Configure registry keys.
  • Just-in-time RDP via Azure Bastion. For transient diagnostics - inspecting logs, Event Viewer, IIS Manager, or confirming a script's effect - enable RDP. It requires the plan to be virtual network integrated and an Azure Bastion host on that network. On the plan, go to Configuration > Bastion/RDP and select Allow Remote Desktop (via Bastion). Changes made over RDP are not persistent - they are lost on restart or maintenance - so always persist real configuration through the install script. See Configure RDP (Bastion) access.
RDP is for diagnostics, not configuration

Managed Instance instances are recycled and scaled by the platform. Anything you change over an RDP session is lost on the next restart. Persistent customization must go in Install.ps1 so it reapplies on every instance. If you find yourself fixing something over RDP, move that fix into the script.

Cleanup

To clean up the resources created in this lab, run the following command to delete the resource group. If you want to use the resources again, you can skip this step.

az group delete \
--name ${RG_NAME} \
--yes \
--no-wait

This will delete the resource group and all its contents.

If you deployed with azd, tear down the base resources it created as well:

azd down --force --purge

Confirm the resource group is gone:

az group exists --name "$RG_NAME" # should print: false

Summary

You migrated a legacy ASP.NET Framework app to a Managed Instance plan on Azure App Service - a Windows hosting option that keeps the managed platform while letting you customize the OS. You staged a configuration (install) script in Blob Storage, created a P1V4 plan in custom mode with a plan-level user-assigned identity, deployed the app, and verified the customization: the app rendered an image using fonts that only exist on the instance because the startup script put them there. That is the "lift and improve" pattern - move an infrastructure-dependent Windows app to PaaS with minimal rewrites, then use plan-level adapters (storage mounts, registry keys) and just-in-time RDP to finish the job, with all persistent configuration captured in a script that reapplies on every instance.

Troubleshooting

  • The home page returns HTML or an error instead of a PNG image. The fonts did not install, so the app could not render the image. Confirm the plan identity has Storage Blob Data Reader (or Contributor) on the storage account, that scripts.zip has Install.ps1 at its root (the fonts can be in a subfolder - the script recurses), and that the plan's install script URI points at the blob. The script log is on the instance at C:\InstallScripts\Script\Install.log; ship App Service console logs to Azure Monitor to read it centrally.
  • Pv4 or Pmv4 is not offered when creating the plan. The region may not support Managed Instance for that SKU, or you may need quota. Verify with az appservice list-locations --managed-instance-enabled --sku P1V4 and pick a listed region (East Asia, East US, North Europe, or West Central US during preview).
  • isCustomMode is false after the deployment. The plan was created as a standard plan, so install scripts are ignored. Re-deploy with the infra/app-service-plan-managed-instance.json template (which sets isCustomMode: true) and confirm you are in a supported region.
  • The plan cannot read the install script (script never runs). The plan identity is missing the storage role, or the role has not propagated. Grant Storage Blob Data Reader to the identity's principal, wait a minute, and restart the plan instances.
  • az webapp deploy times out. Provisioning and the first cold start take several minutes while the script runs. The deploy usually completes even when the client poll times out - wait a minute and re-check the endpoint.

Learn more