ASP.NET Core with IIS on Nano Server

By Sourabh Shirhatti

In this tutorial, you'll take an existing ASP.NET Core app and deploy it to a Nano Server instance running IIS.


Nano Server is an installation option in Windows Server 2016, offering a tiny footprint, better security, and better servicing than Server Core or full Server. Please consult the official Nano Server documentation for more details and download links for 180 Days evaluation versions.

There are three easy ways for you to try out Nano Server. When you sign in with your MS account:

  1. You can download the Windows Server 2016 ISO file and build a Nano Server image.

  2. Download the Nano Server VHD.

  3. Create a VM in Azure using the Nano Server image in the Azure Gallery. If you don’t have an Azure account, you can get a free 30-day trial.

In this tutorial, we will be using the 2nd option, the pre-built Nano Server VHD from Windows Server 2016.

Before proceeding with this tutorial, you will need the published output of an existing ASP.NET Core application. Ensure your application is built to run in a 64-bit process.

Setting up the Nano Server instance

Create a new Virtual Machine using Hyper-V on your development machine using the previously downloaded VHD. The machine will require you to set an administrator password before logging on. At the VM console, press F11 to set the password before the first log in. You also need to check your new VM's IP address either my checking your DHCP server's fixed IP supplied while provisioning your VM or in Nano Server recovery console's networking settings.


Let's assume your new VM runs with the local V4 IP address

Now you're able to manage it using PowerShell remoting, which is the only way to fully administer your Nano Server.

Connecting to your Nano Server instance using PowerShell Remoting

Open an elevated PowerShell window to add your remote Nano Server instance to your TrustedHosts list.

$nanoServerIpAddress = ""
Set-Item WSMan:\localhost\Client\TrustedHosts "$nanoServerIpAddress" -Concatenate -Force

Replace the variable $nanoServerIpAddress with the correct IP address.

Once you have added your Nano Server instance to your TrustedHosts, you can connect to it using PowerShell remoting.

$nanoServerSession = New-PSSession -ComputerName $nanoServerIpAddress -Credential ~\Administrator
Enter-PSSession $nanoServerSession

A successful connection results in a prompt with a format looking like: []: PS C:\Users\Administrator\Documents>

Creating a file share

Create a file share on the Nano server so that the published application can be copied to it. Run the following commands in the remote session:

New-Item C:\PublishedApps\AspNetCoreSampleForNano -type directory
netsh advfirewall firewall set rule group="File and Printer Sharing" new enable=yes
net share AspNetCoreSampleForNano=c:\PublishedApps\AspNetCoreSampleForNano /GRANT:EVERYONE`,FULL

After running the above commands, you should be able to access this share by visiting \\\AspNetCoreSampleForNano in the host machine's Windows Explorer.

Open port in the firewall

Run the following commands in the remote session to open up a port in the firewall to let IIS listen for TCP traffic on port TCP/8000.

New-NetFirewallRule -Name "AspNet5 IIS" -DisplayName "Allow HTTP on TCP/8000" -Protocol TCP -LocalPort 8000 -Action Allow -Enabled True

Installing IIS

Add the NanoServerPackage provider from the PowerShell Gallery. Once the provider is installed and imported, you can install Windows packages.

Run the following commands in the PowerShell session that was created earlier:

Install-PackageProvider NanoServerPackage
Import-PackageProvider NanoServerPackage
Install-NanoServerPackage -Name Microsoft-NanoServer-Storage-Package
Install-NanoServerPackage -Name Microsoft-NanoServer-IIS-Package

To quickly verify if IIS is setup correctly, you can visit the URL and should see a welcome page. When IIS is installed, a website called Default Web Site listening on port 80 is created by default.

Installing the ASP.NET Core Module (ANCM)

The ASP.NET Core Module is an IIS 7.5+ module which is responsible for process management of ASP.NET Core HTTP listeners and to proxy requests to processes that it manages. At the moment, the process to install the ASP.NET Core Module for IIS is manual. You will need to install the .NET Core Windows Server Hosting bundle on a regular (not Nano) machine. After installing the bundle on a regular machine, you will need to copy the following files to the file share that we created earlier.

On a regular (not Nano) server with IIS, run the following copy commands:

Copy-Item -Path  C:\windows\system32\inetsrv\aspnetcore.dll -Destination `\\<nanoserver-ip-address>\AspNetCoreSampleForNano`
Copy-Item -Path  C:\windows\system32\inetsrv\config\schema\aspnetcore_schema.xml -Destination `\\<nanoserver-ip-address>\AspNetCoreSampleForNano`

Replace C:\windows\system32\inetsrv with C:\Program Files\IIS Express on a Windows 10 machine.

On the Nano side, you will need to copy the following files from the file share that we created earlier to the valid locations. So, run the following copy commands:

Copy-Item -Path C:\PublishedApps\AspNetCoreSampleForNano\aspnetcore.dll -Destination C:\windows\system32\inetsrv\
Copy-Item -Path C:\PublishedApps\AspNetCoreSampleForNano\aspnetcore_schema.xml -Destination C:\windows\system32\inetsrv\config\schema\

Run the following script in the remote session:

# Backup existing applicationHost.config
Copy-Item -Path C:\Windows\System32\inetsrv\config\applicationHost.config -Destination  C:\Windows\System32\inetsrv\config\applicationHost_BeforeInstallingANCM.config

Import-Module IISAdministration

# Initialize variables
Reset-IISServerManager -confirm:$false   $sm = Get-IISServerManager

# Add AppSettings section 

# Set Allow for handlers section
$appHostconfig = $sm.GetApplicationHostConfiguration()
$section = $appHostconfig.GetSection("system.webServer/handlers")

# Add aspNetCore section to system.webServer
$sectionaspNetCore = $appHostConfig.RootSectionGroup.SectionGroups["system.webServer"].Sections.Add("aspNetCore")
$sectionaspNetCore.OverrideModeDefault = "Allow"

# Configure globalModule
Reset-IISServerManager -confirm:$false
$globalModules = Get-IISConfigSection "system.webServer/globalModules" | Get-IISConfigCollection
New-IISConfigCollectionElement $globalModules -ConfigAttribute @{"name"="AspNetCoreModule";"image"=$aspNetCoreHandlerFilePath}

# Configure module
$modules = Get-IISConfigSection "system.webServer/modules" | Get-IISConfigCollection
New-IISConfigCollectionElement $modules -ConfigAttribute @{"name"="AspNetCoreModule"}

Delete the files aspnetcore.dll and aspnetcore_schema.xml from the share after the above step.

Installing .NET Core Framework

If you published a Framework-dependent (portable) app, .NET Core must be installed on the target machine. Execute the following PowerShell script in a remote PowerShell session to install the .NET Framework on your Nano Server.


To understand the differences between Framework-dependent deployments (FDD) and Self-contained deployments (SCD), see deployment options.


# Copyright (c) .NET Foundation and contributors. All rights reserved.

# Licensed under the MIT license. See LICENSE file in the project root for full license information.




    Installs dotnet cli


    Installs dotnet cli. If dotnet installation already exists in the given directory

    it will update it only if the requested version differs from the one already installed.


    Default: preview

    Channel is the way of reasoning about stability and quality of dotnet. This parameter takes one of the values:

    - future - Possibly unstable, frequently changing, may contain new finished and unfinished features

    - preview - Pre-release stable with known issues and feature gaps

    - production - Most stable releases


    Default: latest

    Represents a build version on specific channel. Possible values:

    - 4-part version in a format A.B.C.D - represents specific version of build

    - latest - most latest build on specific channel

    - lkg - last known good version on specific channel

    Note: LKG work is in progress. Once the work is finished, this will become new default


    Default: %LocalAppData%\Microsoft\dotnet

    Path to where to install dotnet. Note that binaries will be placed directly in a given directory.

.PARAMETER Architecture

    Default: <auto> - this value represents currently running OS architecture

    Architecture of dotnet binaries to be installed.

    Possible values are: <auto>, x64 and x86

.PARAMETER SharedRuntime

    Default: false

    Installs just the shared runtime bits, not the entire SDK

.PARAMETER DebugSymbols

    If set the installer will include symbols in the installation.


    If set it will not perform installation but instead display what command line to use to consistently install

    currently requested version of dotnet cli. In example if you specify version 'latest' it will display a link

    with specific version so that this command can be used deterministicly in a build script.

    It also displays binaries location if you prefer to install or download it yourself.


    By default this script will set environment variable PATH for the current process to the binaries folder inside installation folder.

    If set it will display binaries location but not set any environment variable.


    Displays diagnostics information.



    This parameter should not be usually changed by user. It allows to change URL for the Azure feed used by this installer.

.PARAMETER ProxyAddress

    If set, the installer will use the proxy when making web requests









   [switch]$DebugSymbols, # TODO: Switch does not work yet. Symbols zip is not being uploaded yet.







Set-StrictMode -Version Latest




# example path with regex: shared/1.0.0-beta-12345/somepath



function Say($str) {

    Write-Host "dotnet-install: $str"


function Say-Verbose($str) {

    Write-Verbose "dotnet-install: $str"


function Say-Invocation($Invocation) {

    $command = $Invocation.MyCommand;

    $args = (($Invocation.BoundParameters.Keys | foreach { "-$_ `"$($Invocation.BoundParameters[$_])`"" }) -join " ")

    Say-Verbose "$command $args"


function Get-Machine-Architecture() {

    Say-Invocation $MyInvocation

    # possible values: AMD64, IA64, x86



# TODO: Architecture and CLIArchitecture should be unified

function Get-CLIArchitecture-From-Architecture([string]$Architecture) {

    Say-Invocation $MyInvocation

    switch ($Architecture.ToLower()) {

        { $_ -eq "<auto>" } { return Get-CLIArchitecture-From-Architecture $(Get-Machine-Architecture) }

        { ($_ -eq "amd64") -or ($_ -eq "x64") } { return "x64" }

        { $_ -eq "x86" } { return "x86" }

        default { throw "Architecture not supported. If you think this is a bug, please report it at" }



function Get-Version-Info-From-Version-Text([string]$VersionText) {

    Say-Invocation $MyInvocation

    $Data = @($VersionText.Split([char[]]@(), [StringSplitOptions]::RemoveEmptyEntries));

    $VersionInfo = @{}

    $VersionInfo.CommitHash = $Data[0].Trim()

    $VersionInfo.Version = $Data[1].Trim()

    return $VersionInfo


function Load-Assembly([string] $Assembly) {

    try {

        Add-Type -Assembly $Assembly | Out-Null


    catch {

        # On Nano Server, Powershell Core Edition is used.  Add-Type is unable to resolve base class assemblies because they are not GAC'd.

        # Loading the base class assemblies is not unnecessary as the types will automatically get resolved.



function GetHTTPResponse([Uri] $Uri)


    $HttpClient = $null

    try {

        # HttpClient is used vs Invoke-WebRequest in order to support Nano Server which doesn't support the Invoke-WebRequest cmdlet.

        Load-Assembly -Assembly System.Net.Http


            $HttpClientHandler = New-Object System.Net.Http.HttpClientHandler

            $HttpClientHandler.Proxy =  New-Object System.Net.WebProxy -Property @{Address=$ProxyAddress}

            $HttpClient = New-Object System.Net.Http.HttpClient -ArgumentList $HttpClientHandler


        else {

            $HttpClient = New-Object System.Net.Http.HttpClient


        $Response = $HttpClient.GetAsync($Uri).Result

        if (($Response -eq $null) -or (-not ($Response.IsSuccessStatusCode)))


            $ErrorMsg = "Failed to download $Uri."

            if ($Response -ne $null)


                $ErrorMsg += "  $Response"


            throw $ErrorMsg


        return $Response


    finally {

        if ($HttpClient -ne $null) {





function Get-Latest-Version-Info([string]$AzureFeed, [string]$AzureChannel, [string]$CLIArchitecture) {

    Say-Invocation $MyInvocation

    $VersionFileUrl = $null

    if ($SharedRuntime) {

        $VersionFileUrl = "$UncachedFeed/$AzureChannel/dnvm/$CLIArchitecture.version"


    else {

        $VersionFileUrl = "$UncachedFeed/Sdk/$AzureChannel/latest.version"



    $Response = GetHTTPResponse -Uri $VersionFileUrl

    $StringContent = $Response.Content.ReadAsStringAsync().Result

    switch ($Response.Content.Headers.ContentType) {

        { ($_ -eq "application/octet-stream") } { $VersionText = [Text.Encoding]::UTF8.GetString($StringContent) }

        { ($_ -eq "text/plain") } { $VersionText = $StringContent }

        default { throw "``$Response.Content.Headers.ContentType`` is an unknown .version file content type." }


    $VersionInfo = Get-Version-Info-From-Version-Text $VersionText

    return $VersionInfo


# TODO: AzureChannel and Channel should be unified

function Get-Azure-Channel-From-Channel([string]$Channel) {

    Say-Invocation $MyInvocation

    # For compatibility with build scripts accept also directly Azure channels names

    switch ($Channel.ToLower()) {

        { ($_ -eq "future") -or ($_ -eq "dev") } { return "dev" }

        { $_ -eq "production" } { throw "Production channel does not exist yet" }

        default { return $_ }



function Get-Specific-Version-From-Version([string]$AzureFeed, [string]$AzureChannel, [string]$CLIArchitecture, [string]$Version) {

    Say-Invocation $MyInvocation

    switch ($Version.ToLower()) {

        { $_ -eq "latest" } {

            $LatestVersionInfo = Get-Latest-Version-Info -AzureFeed $AzureFeed -AzureChannel $AzureChannel -CLIArchitecture $CLIArchitecture

            return $LatestVersionInfo.Version


        { $_ -eq "lkg" } { throw "``-Version LKG`` not supported yet." }

        default { return $Version }



function Get-Download-Links([string]$AzureFeed, [string]$AzureChannel, [string]$SpecificVersion, [string]$CLIArchitecture) {

    Say-Invocation $MyInvocation


    $ret = @()


    if ($SharedRuntime) {

        $PayloadURL = "$AzureFeed/$AzureChannel/Binaries/$SpecificVersion/dotnet-win-$CLIArchitecture.$"


    else {

        $PayloadURL = "$AzureFeed/Sdk/$SpecificVersion/dotnet-dev-win-$CLIArchitecture.$"


    Say-Verbose "Constructed payload URL: $PayloadURL"

    $ret += $PayloadURL

    return $ret


function Get-User-Share-Path() {

    Say-Invocation $MyInvocation

    $InstallRoot = $env:DOTNET_INSTALL_DIR

    if (!$InstallRoot) {

        $InstallRoot = "$env:LocalAppData\Microsoft\dotnet"


    return $InstallRoot


function Resolve-Installation-Path([string]$InstallDir) {

    Say-Invocation $MyInvocation

    if ($InstallDir -eq "<auto>") {

        return Get-User-Share-Path


    return $InstallDir


function Get-Version-Info-From-Version-File([string]$InstallRoot, [string]$RelativePathToVersionFile) {

    Say-Invocation $MyInvocation

    $VersionFile = Join-Path -Path $InstallRoot -ChildPath $RelativePathToVersionFile

    Say-Verbose "Local version file: $VersionFile"


    if (Test-Path $VersionFile) {

        $VersionText = cat $VersionFile

        Say-Verbose "Local version file text: $VersionText"

        return Get-Version-Info-From-Version-Text $VersionText


    Say-Verbose "Local version file not found."

    return $null


function Is-Dotnet-Package-Installed([string]$InstallRoot, [string]$RelativePathToPackage, [string]$SpecificVersion) {

    Say-Invocation $MyInvocation


    $DotnetPackagePath = Join-Path -Path $InstallRoot -ChildPath $RelativePathToPackage | Join-Path -ChildPath $SpecificVersion

    Say-Verbose "Is-Dotnet-Package-Installed: Path to a package: $DotnetPackagePath"

    return Test-Path $DotnetPackagePath -PathType Container


function Get-Absolute-Path([string]$RelativeOrAbsolutePath) {

    # Too much spam

    # Say-Invocation $MyInvocation

    return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RelativeOrAbsolutePath)


function Get-Path-Prefix-With-Version($path) {

    $match = [regex]::match($path, $VersionRegEx)

    if ($match.Success) {

        return $entry.FullName.Substring(0, $match.Index + $match.Length)



    return $null


function Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package([System.IO.Compression.ZipArchive]$Zip, [string]$OutPath) {

    Say-Invocation $MyInvocation


    $ret = @()

    foreach ($entry in $Zip.Entries) {

        $dir = Get-Path-Prefix-With-Version $entry.FullName

        if ($dir -ne $null) {

            $path = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $dir)

            if (-Not (Test-Path $path -PathType Container)) {

                $ret += $dir





    $ret = $ret | Sort-Object | Get-Unique


    $values = ($ret | foreach { "$_" }) -join ";"

    Say-Verbose "Directories to unpack: $values"


    return $ret


# Example zip content and extraction algorithm:

# Rule: files if extracted are always being extracted to the same relative path locally

# .\

#       a.exe   # file does not exist locally, extract

#       b.dll   # file exists locally, override only if $OverrideFiles set

#       aaa\    # same rules as for files

#           ...

#       abc\1.0.0\  # directory contains version and exists locally

#           ...     # do not extract content under versioned part

#       abc\asd\    # same rules as for files

#            ...

#       def\ghi\1.0.1\  # directory contains version and does not exist locally

#           ...         # extract content

function Extract-Dotnet-Package([string]$ZipPath, [string]$OutPath) {

    Say-Invocation $MyInvocation

    Load-Assembly -Assembly System.IO.Compression.FileSystem

    Set-Variable -Name Zip

    try {

        $Zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)


        $DirectoriesToUnpack = Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package -Zip $Zip -OutPath $OutPath


        foreach ($entry in $Zip.Entries) {

            $PathWithVersion = Get-Path-Prefix-With-Version $entry.FullName

            if (($PathWithVersion -eq $null) -Or ($DirectoriesToUnpack -contains $PathWithVersion)) {

                $DestinationPath = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $entry.FullName)

                $DestinationDir = Split-Path -Parent $DestinationPath

                $OverrideFiles=$OverrideNonVersionedFiles -Or (-Not (Test-Path $DestinationPath))

                if ((-Not $DestinationPath.EndsWith("\")) -And $OverrideFiles) {

                    New-Item -ItemType Directory -Force -Path $DestinationDir | Out-Null

                    [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $DestinationPath, $OverrideNonVersionedFiles)





    finally {

        if ($Zip -ne $null) {





function DownloadFile([Uri]$Uri, [string]$OutPath) {

    $Stream = $null

    try {

        $Response = GetHTTPResponse -Uri $Uri

        $Stream = $Response.Content.ReadAsStreamAsync().Result

        $File = [System.IO.File]::Create($OutPath)




    finally {

        if ($Stream -ne $null) {





$AzureChannel = Get-Azure-Channel-From-Channel -Channel $Channel

$CLIArchitecture = Get-CLIArchitecture-From-Architecture $Architecture

$SpecificVersion = Get-Specific-Version-From-Version -AzureFeed $AzureFeed -AzureChannel $AzureChannel -CLIArchitecture $CLIArchitecture -Version $Version

$DownloadLinks = Get-Download-Links -AzureFeed $AzureFeed -AzureChannel $AzureChannel -SpecificVersion $SpecificVersion -CLIArchitecture $CLIArchitecture

if ($DryRun) {

    Say "Payload URLs:"

    foreach ($DownloadLink in $DownloadLinks) {

        Say "- $DownloadLink"


    Say "Repeatable invocation: .\$($MyInvocation.MyCommand) -Version $SpecificVersion -Channel $Channel -Architecture $CLIArchitecture -InstallDir $InstallDir"

    exit 0


$InstallRoot = Resolve-Installation-Path $InstallDir

Say-Verbose "InstallRoot: $InstallRoot"

$IsSdkInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage "sdk" -SpecificVersion $SpecificVersion

Say-Verbose ".NET SDK installed? $IsSdkInstalled"

$free = Get-CimInstance -Class win32_logicaldisk | where deviceid -eq c:| select Freespace

if ($free.Freespace / 1MB -le 200 ) {

    Say "there is not enough disk space on drive c:"

    exit 0


if ($IsSdkInstalled) {

    Say ".NET SDK version $SpecificVersion is already installed."

    exit 0


New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null

foreach ($DownloadLink in $DownloadLinks) {

    $ZipPath = [System.IO.Path]::GetTempFileName()

    Say "Downloading $DownloadLink"

    DownloadFile -Uri $DownloadLink -OutPath $ZipPath

    Say "Extracting zip from $DownloadLink"

    Extract-Dotnet-Package -ZipPath $ZipPath -OutPath $InstallRoot

    Remove-Item $ZipPath


$BinPath = Get-Absolute-Path $(Join-Path -Path $InstallRoot -ChildPath $BinFolderRelativePath)

if (-Not $NoPath) {

    Say "Adding to current process PATH: `"$BinPath`". Note: This change will not be visible if PowerShell was run as a child process."

    $env:path = "$BinPath;" + $env:path


else {

    Say "Binaries of dotnet can be found in $BinPath"


Say "Installation finished"

exit 0

Publishing the application

Copy the published output of your existing application to the file share's root.

You may need to make changes to your web.config to point to where you extracted dotnet.exe. Alternatively, you can add dotnet.exe to your PATH.

Example of how a web.config might look if dotnet.exe is not on the PATH:

<?xml version="1.0" encoding="utf-8"?>
      <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModule" resourceType="Unspecified" />
    <aspNetCore processPath="C:\dotnet\dotnet.exe" arguments=".\AspNetCoreSampleForNano.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\aspnetcore-stdout" forwardWindowsAuthToken="true" />

Run the following commands in the remote session to create a new site in IIS for the published app on a different port than the default website. You also need to open that port to access the web. This script uses the DefaultAppPool for simplicity. For more considerations on running under an application pool, see Application Pools.

Import-module IISAdministration
New-IISSite -Name "AspNetCore" -PhysicalPath c:\PublishedApps\AspNetCoreSampleForNano -BindingInformation "*:8000:"

Known issue running .NET Core CLI on Nano Server and workaround

New-NetFirewallRule -Name "AspNetCore Port 81 IIS" -DisplayName "Allow HTTP on TCP/81" -Protocol TCP -LocalPort 81 -Action Allow -Enabled True

Running the application

The published web app is accessible in a browser at If you've set up logging as described in Log creation and redirection, you can view your logs at C:\PublishedApps\AspNetCoreSampleForNano\logs.