1. .NET Generic Host

The .NET Generic Host, available in the Microsoft.Extensions.Hosting NuGet package, is responsible for app startup and lifetime management. A host is an object that encapsulates an app’s resources and lifetime functionality, such as: [1] [2]

When a host starts, it calls IHostedService.StartAsync on each implementation of IHostedService registered in the service container’s collection of hosted services. In a worker service app, all IHostedService implementations that contain BackgroundService instances have their BackgroundService.ExecuteAsync methods called.

The main reason for including all of the app’s interdependent resources in one object is lifetime management: control over app startup and graceful shutdown.

1.1. Set up a host

<!-- Example.WorkerService.csproj -->

<!-- <Project Sdk="Microsoft.NET.Sdk.Worker"> -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Microsoft.Extensions.DependencyInjection" />
    <Using Include="Microsoft.Extensions.Hosting" />
    <Using Include="Microsoft.Extensions.Logging" />
  </ItemGroup>

</Project>
// Program.cs

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
using IHost host = builder.Build();
host.Run();

public sealed class Worker(ILogger<Worker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            logger.LogInformation("{}: I'm busy at work.", DateTime.Now.TimeOfDay);
        }
    }
}
$ dotnet run
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
. . .
info: Worker[0]
      11:17:39.1114826: I'm busy at work.
info: Worker[0]
      11:17:44.1725394: I'm busy at work.
info: Worker[0]
      11:17:49.1739304: I'm busy at work.
^Cinfo: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...

For an HTTP workload:

<!-- Example.WebApp.csproj -->

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>
// Program.cs

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();
$ ASPNETCORE_URLS=http://+:5000 dotnet run --no-launch-profile
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
. . .
^Cinfo: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
$ curl -i localhost:5000
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Tue, 02 Apr 2024 03:44:23 GMT
Server: Kestrel
Transfer-Encoding: chunked

Hello World!

1.2. Kestrel server

Kestrel server is the default, cross-platform HTTP server implementation. Kestrel provides the best performance and memory utilization, but it doesn’t have some of the advanced features in HTTP.sys. [3]

Use Kestrel:

  • By itself as an edge server processing requests directly from a network, including the Internet.

    Kestrel communicates directly with the Internet without a reverse proxy server
  • With a reverse proxy server, such as Internet Information Services (IIS), Nginx, or Apache. A reverse proxy server receives HTTP requests from the Internet and forwards them to Kestrel.

    Kestrel communicates indirectly with the Internet through a reverse proxy server

Either hosting configuration—with or without a reverse proxy server—is supported.

For Kestrel configuration guidance and information on when to use Kestrel in a reverse proxy configuration, see Kestrel web server in ASP.NET Core.

1.3. Use HTTP/3 with the ASP.NET Core Kestrel web server

HTTP/3 is an approved standard and the third major version of HTTP. [4]

HTTP/3 has different requirements depending on the operating system. If the platform that Kestrel is running on doesn’t have all the requirements for HTTP/3, then it’s disabled, and Kestrel will fall back to other HTTP protocols.

  • Windows

    • Windows 11 Build 22000 or later OR Windows Server 2022.

    • TLS 1.3 or later connection.

  • Linux

  • libmsquic package installed.

    libmsquic is published via Microsoft’s official Linux package repository at packages.microsoft.com.

    .NET 6 is only compatible with the 1.9.x versions of libmsquic. Libmsquic 2.x is not compatible due to breaking changes. Libmsquic receives updates to 1.9.x when needed to incorporate security fixes.
  • macOS

    HTTP/3 isn’t currently supported on macOS and may be available in a future release.

$ apt-cache madison libmsquic
 libmsquic |      2.3.5 | https://packages.microsoft.com/debian/12/prod bookworm/main amd64 Packages
 libmsquic |      2.3.4 | https://packages.microsoft.com/debian/12/prod bookworm/main amd64 Packages
. . .
$ sudo apt-get install libmsquic -y
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following additional packages will be installed:
  libnuma1
The following NEW packages will be installed:
  libmsquic libnuma1
0 upgraded, 2 newly installed, 0 to remove and 3 not upgraded.
. . .
$ dpkg -S libmsquic
libmsquic: /usr/share/doc/libmsquic
libmsquic: /usr/lib/x86_64-linux-gnu/libmsquic.so.2.3.5
libmsquic: /usr/lib/x86_64-linux-gnu/libmsquic.lttng.so.2.3.5
libmsquic: /usr/lib/x86_64-linux-gnu/libmsquic.so.2
libmsquic: /usr/share/doc/libmsquic/changelog.gz
$ ASPNETCORE_URLS=https://+:5001 dotnet run \
    --no-launch-profile \
    --Kestrel:EndpointDefaults:Protocols=Http1AndHttp2AndHttp3
warn: Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServer[8]
      The ASP.NET Core developer certificate is not trusted. For information about trusting the ASP.NET Core devel
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://[::]:5001
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
$ docker run -it --rm --network host ymuski/curl-http3 curl -ik --http3 https://localhost:5001
HTTP/3 200
content-type: text/plain; charset=utf-8
date: Tue, 02 Apr 2024 06:19:53 GMT
server: Kestrel
alt-svc: h3=":5001"; ma=86400

Hello World!

2. Create Windows Service using BackgroundService

To interop with native Windows Services from .NET IHostedService implementations, it’s needed to install the Microsoft.Extensions.Hosting.WindowsServices NuGet package. [5]

The EventLog provider sends log output to the Windows Event Log. Unlike the other providers, the EventLog provider does NOT inherit the default non-provider settings. If EventLog log settings aren’t specified, they default to LogLevel.Warning.

To log events lower than LogLevel.Warning, explicitly set the log level. The following example sets the Event Log default log level to LogLevel.Information:

"Logging": {
  "EventLog": {
    "LogLevel": {
      "Default": "Information"
    }
  }
}

AddEventLog overloads can pass in EventLogSettings. If null or not specified, the following default settings are used:

  • LogName: "Application"

  • SourceName: ".NET Runtime"

  • MachineName: The local machine name is used.

The following code changes the SourceName from the default value of ".NET Runtime" to CustomLogs:

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Logging.AddEventLog(
    config => config.SourceName = "CustomLogs");

using IHost host = builder.Build();

host.Run();

2.1. Update project file

<!-- Example.WorkerService.csproj -->

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TargetFramework>net8.0-windows</TargetFramework>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <PlatformTarget>x64</PlatformTarget>
    <PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
    <DebugType>embedded</DebugType>
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Microsoft.Extensions.DependencyInjection" />
    <Using Include="Microsoft.Extensions.Hosting" />
    <Using Include="Microsoft.Extensions.Logging" />
    <Using Include="Microsoft.Extensions.Logging.Configuration" />
    <Using Include="Microsoft.Extensions.Logging.EventLog" />
  </ItemGroup>

</Project>

2.2. Rewrite the Program class

// Program.cs

using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Logging.EventLog;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = ".NET Example WorkerService";
});

builder.Logging.AddEventLog(options =>
{
    options.SourceName = ".NET Example WorkerService";
});

LoggerProviderOptions.RegisterProviderOptions<EventLogSettings, EventLogLoggerProvider>(builder.Services);

builder.Services.AddHostedService<Worker>();
using IHost host = builder.Build();
host.Run();

public sealed class Worker(ILogger<Worker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
                logger.LogInformation("{}: I'm busy at work.", DateTime.Now.TimeOfDay);
            }
        }
        catch (OperationCanceledException)
        {
            // When the stopping token is canceled, for example, a call made from services.msc,
            // we shouldn't exit with a non-zero exit code. In other words, this is expected...
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "{Message}", ex.Message);

            // Terminates this process and returns an exit code to the operating system.
            // This is required to avoid the 'BackgroundServiceExceptionBehavior', which
            // performs one of two scenarios:
            // 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
            // 2. When set to "StopHost": will cleanly stop the host, and log errors.
            //
            // In order for the Windows Service Management system to leverage configured
            // recovery options, we need to terminate the process with a non-zero exit code.
            Environment.Exit(1);
        }
    }
}

2.3. Publish the app

To create the .NET Worker Service app as a Windows Service, it’s recommended that you publish the app as a single file executable. It’s less error-prone to have a self-contained executable, as there aren’t any dependent files lying around the file system. But you may choose a different publishing modality, which is perfectly acceptable, so long as you create an *.exe file that can be targeted by the Windows Service Control Manager.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TargetFramework>net8.0-windows</TargetFramework>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <PlatformTarget>x64</PlatformTarget>
    <PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
    <DebugType>embedded</DebugType>
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
  </PropertyGroup>
. . .
$ dotnet publish (1)
MSBuild version 17.10.0-preview-24101-01+07fd5d51f for .NET
Restore complete (0.3s)
You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
  Example.WorkerService succeeded (2.1s) → bin\Release\net8.0-windows\win-x64\publish\

Build succeeded in 2.6s

$ ls bin/Release/net8.0-windows/win-x64/publish/
Example.WorkerService.exe
1 The dotnet publish command now uses the Release configuration instead of the Debug configuration by default if the target framework is .NET 8 or a later version.

You can also publish .NET apps using the .NET CLI with the following switches.

dotnet publish \
    -f net8.0-windows \
    -r win-x64 \
    --sc \
    -p:PublishSingleFile=true \
    -p:IncludeNativeLibrariesForSelfExtract=true \
    -p:DebugType=embedded

2.4. Create the Windows Service

To create a Windows Service, run PowerShell as an Administrator.

New-Service -Name "TestService" -BinaryPathName 'C:\Path\To\App.WindowsService.exe'

Let’s create a directory, and copy the executable file to it.

> mkdir D:\Example.WorkerService\
> cp .\bin\Release\net8.0-windows\win-x64\publish\Example.WorkerService.exe D:\Example.WorkerService\
  • Create the .NET Example WorkerService service

    New-Service -Name ".NET Example WorkerService" -BinaryPathName D:\Example.WorkerService\Example.WorkerService.exe
  • Start the .NET Example WorkerService service

    Start-Service -Name ".NET Example WorkerService"
  • Get the status of the .NET Example WorkerService service

    Get-Service -Name ".NET Example WorkerService" | Format-List
  • Get events from the .NET Example WorkerService service

    Get-EventLog -LogName Application -Source ".NET Example WorkerService" | Format-List
    Index              : 3884
    EntryType          : Warning
    InstanceId         : 0
    Message            : Category: Worker
                         EventId: 0
    
                         16:15:27.1390426: I'm busy at work.
    
    Category           : (0)
    CategoryNumber     : 0
    ReplacementStrings : {Category: Worker
                         EventId: 0
    
                         16:15:27.1390426: I'm busy at work.
                         }
    Source             : .NET Example WorkerService
    TimeGenerated      : 04/02/2024 16:15:27
    TimeWritten        : 04/02/2024 16:15:27
    UserName           :
    
    Index              : 3883
    EntryType          : Information
    InstanceId         : 0
    Message            : Service started successfully.
    Category           : (0)
    CategoryNumber     : 0
    ReplacementStrings : {Service started successfully.}
    Source             : .NET Example WorkerService
    TimeGenerated      : 04/02/2024 16:15:22
    TimeWritten        : 04/02/2024 16:15:22
    UserName           :
  • Stop the .NET Example WorkerService service

    Stop-Service -Name ".NET Example WorkerService"
  • Remove the .NET Example WorkerService service

    Remove-Service -Name ".NET Example WorkerService"
    The Remove-Service cmdlet was introduced in PowerShell 6.0.

    Use the native Windows Service Control Manager’s (sc.exe) delete command.

    sc.exe delete ".NET Example WorkerService"

3. Host ASP.NET Core in a Windows Service

An ASP.NET Core app can be hosted on Windows as a Windows Service without using IIS. When hosted as a Windows Service, the app automatically starts after server reboots. [6]

3.1. Current directory and content root

The current working directory returned by calling GetCurrentDirectory for a Windows Service is the C:\WINDOWS\system32 folder. The system32 folder isn’t a suitable location to store a service’s files (for example, settings files). Use one of the following approaches to maintain and access a service’s assets and settings files.

  • Use IHostEnvironment.ContentRootPath or ContentRootFileProvider to locate an app’s resources.

  • When the app runs as a service, sets the ContentRootPath to AppContext.BaseDirectory.

  • Don’t attempt to use GetCurrentDirectory to obtain a resource path because a Windows Service app returns the C:\WINDOWS\system32 folder as its current directory.

3.2. Configure endpoints

New ASP.NET Core projects are configured to bind to a random HTTP port between 5000-5300 and a random HTTPS port between 7000-7300. The selected ports are stored in the generated Properties/launchSettings.json file and can be modified by the developer. The launchSetting.json file is only used in local development.

If there’s no endpoint configuration, then Kestrel binds to http://localhost:5000.

For additional URL and port configuration approaches, see Configure endpoints for the ASP.NET Core Kestrel web server.

3.3. Update project file

<!-- Example.WebApp.csproj -->

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TargetFramework>net8.0-windows</TargetFramework>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <PlatformTarget>x64</PlatformTarget>
    <PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
    <DebugType>embedded</DebugType>
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
    <IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
  </ItemGroup>

</Project>

3.4. Rewrite the Program class

// Program.cs

using Microsoft.Extensions.Hosting.WindowsServices;
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Logging.EventLog;

// See https://github.com/dotnet/AspNetCore.Docs/issues/23387#issuecomment-927317675
WebApplicationOptions options = new()
{
    Args = args,
    // Sets the content root to AppContext.BaseDirectory.
    ContentRootPath = WindowsServiceHelpers.IsWindowsService() ? AppContext.BaseDirectory : default
};

WebApplicationBuilder builder = WebApplication.CreateBuilder(options);

// Sets the host lifetime to WindowsServiceLifetime.
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = ".NET Example WebApp";
});

builder.Logging.AddEventLog(options =>
{
    options.SourceName = ".NET Example WebApp";
});

LoggerProviderOptions.RegisterProviderOptions<EventLogSettings, EventLogLoggerProvider>(builder.Services);

WebApplication app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

3.5. Publish the app

$ dotnet.exe publish
MSBuild version 17.10.0-preview-24101-01+07fd5d51f for .NET
Restore complete (0.4s)
You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
  Example.WebApp succeeded (0.4s) → bin\Release\net8.0-windows\win-x64\publish\

Build succeeded in 0.8s

$ ls bin/Release/net8.0-windows/win-x64/publish/
Example.WebApp.exe  appsettings.Development.json  appsettings.json

3.6. Create the Windows Service

  • Create work directory and copy files

    > mkdir D:\Example.WebApp\
    > cp .\bin\Release\net8.0-windows\win-x64\publish\Example.WebApp.exe D:\Example.WebApp\
  • Create the .NET Example WebApp service

    New-Service -Name ".NET Example WebApp" -BinaryPathName D:\Example.WebApp\Example.WebApp.exe
    Status   Name               DisplayName
    ------   ----               -----------
    Stopped  .NET Example We... .NET Example WebApp
  • Start the .NET Example WebApp service

    Start-Service -Name ".NET Example WebApp"
  • Get the status of the .NET Example WebApp service

    Get-Service -Name ".NET Example WebApp" | Format-List
    Name                : .NET Example WebApp
    DisplayName         : .NET Example WebApp
    Status              : Running
    DependentServices   : {}
    ServicesDependedOn  : {}
    CanPauseAndContinue : False
    CanShutdown         : True
    CanStop             : True
    ServiceType         : Win32OwnProcess
  • Get the events of the .NET Example WebApp service

    Get-EventLog -LogName Application -Source ".NET Example WebApp"
       Index Time          EntryType   Source                 InstanceID Message
       ----- ----          ---------   ------                 ---------- -------
        4677 Apr 02 17:39  Information .NET Example WebApp             0 Service started successfully.
  • Test the endpoint of the .NET Example WebApp service

    Invoke-WebRequest -Uri http://localhost:5000
    StatusCode        : 200
    StatusDescription : OK
    Content           : Hello World!
    RawContent        : HTTP/1.1 200 OK
                        Transfer-Encoding: chunked
                        Content-Type: text/plain; charset=utf-8
                        Date: Tue, 02 Apr 2024 09:33:34 GMT
                        Server: Kestrel
    
                        Hello World!
    . . .
  • Stop and delete the .NET Example WebApp service

    Stop-Service -Name ".NET Example WebApp"
    sc.exe delete ".NET Example WebApp"

3.7. Static files in ASP.NET Core

Static files, such as HTML, CSS, images, and JavaScript, are assets an ASP.NET Core app serves directly to clients by default, which are stored within the project’s web root directory. For more information, see Static files in ASP.NET Core. [7]

3.7.1. Create the wwwroot directory, and sample files

powershell
mkdir wwwroot
Write-Output "Hello Default Files!" | Out-File -Encoding ascii .\wwwroot\index.html
Write-Output "Hello Windows Service!" | Out-File -Encoding ascii .\wwwroot\service.html

3.7.2. Rewrite the Program class

// Program.cs

using Microsoft.Extensions.Hosting.WindowsServices;
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Logging.EventLog;

// See https://github.com/dotnet/AspNetCore.Docs/issues/23387#issuecomment-927317675
WebApplicationOptions options = new()
{
    Args = args,
    // Sets the content root to AppContext.BaseDirectory.
    ContentRootPath = WindowsServiceHelpers.IsWindowsService() ? AppContext.BaseDirectory : default
};

WebApplicationBuilder builder = WebApplication.CreateBuilder(options);

// Sets the host lifetime to WindowsServiceLifetime.
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = ".NET Example WebApp";
});

builder.Logging.AddEventLog(options =>
{
    options.SourceName = ".NET Example WebApp";
});

LoggerProviderOptions.RegisterProviderOptions<EventLogSettings, EventLogLoggerProvider>(builder.Services);

WebApplication app = builder.Build();

app.UseFileServer();

// Set the path to `/hello`, instead of the root `/`.
app.MapGet("/hello", () => "Hello World!");

app.Run();

3.7.3. Publish the app

$ dotnet publish
MSBuild version 17.10.0-preview-24101-01+07fd5d51f for .NET
Restore complete (0.7s)
You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
  Example.WebApp succeeded (4.7s) → bin\Release\net8.0-windows\win-x64\publish\

Build succeeded in 5.5s

$ ls bin/Release/net8.0-windows/win-x64/publish/
Example.WebApp.exe  appsettings.Development.json  appsettings.json  wwwroot

3.7.4. Update the Windows Service

  • Stop the .NET Example WebApp service

    Stop-Service -Name ".NET Example WebApp"
  • Copy files

    Copy-Item `
        -Path .\bin\Release\net8.0-windows\win-x64\publish\* `
        -Destination D:\Example.WebApp\ `
        -Recurse `
        -Force
  • Start the .NET Example WebApp service

    Start-Service -Name ".NET Example WebApp"
  • Test the endpoint of the .NET Example WebApp service

    > Invoke-WebRequest -Uri http://localhost:5000 `
        | Select-Object -Property StatusCode,StatusDescription,Content `
        | Format-List
    
    
    StatusCode        : 200
    StatusDescription : OK
    Content           : Hello Default Files!
    
    > Invoke-WebRequest -Uri http://localhost:5000/index.html `
        | Select-Object -Property StatusCode,StatusDescription,Content `
        | Format-List
    
    
    StatusCode        : 200
    StatusDescription : OK
    Content           : Hello Default Files!
    
    > Invoke-WebRequest -Uri http://localhost:5000/service.html `
        | Select-Object -Property StatusCode,StatusDescription,Content `
        | Format-List
    
    
    StatusCode        : 200
    StatusDescription : OK
    Content           : Hello Windows Service!
    
    > Invoke-WebRequest -Uri http://localhost:5000/hello `
        | Select-Object -Property StatusCode,StatusDescription,Content `
        | Format-List
    
    
    StatusCode        : 200
    StatusDescription : OK
    Content           : Hello World!

4. Create a Windows Service installer

An Windows Installer, an installation and configuration service provided with Windows, bundles the app’s executables and exposes a customizable installation user experience. [9]

The Wix Toolset is a set of tools that build Windows installation packages from XML source code.

The WiX Toolset only supports Windows.

The following steps will use the Example.WebApp project as an example to package a Windows Installer.

4.1. WiX Command-line .NET tool

WiX, available as a .NET tool, supports commands to perform particular operations. For example, the build command can be used to build MSI packages, bundles, and other package types.

  • Install the Wix Toolset

  • Create the local tool manifest file

    > dotnet new tool-manifest
    The template "Dotnet local tool manifest file" was created successfully.
  • Install the Wix Toolset

    > dotnet tool install wix --version 5.0.0
    You can invoke the tool from this directory using the following commands: 'dotnet tool run wix' or 'dotnet wix'.
    Tool 'wix' (version '5.0.0') was successfully installed. . . .
  • Create the WiX package source file Package.wxs

    <?xml version="1.0" encoding="UTF-8"?>
    
    <?define Name = ".NET Example WebApp" ?>
    <?define Manufacturer = ".NET Example Corporation" ?>
    <?define Version = "1.0.0.0" ?>
    <?define UpgradeCode = "288C8793-D5D7-427F-A82F-B647ECDBDCC1" ?>
    <?define ServiceName = ".NET Example WebApp" ?>
    
    <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
      <Package Name="$(Name)"
               Manufacturer="$(Manufacturer)"
               Version="$(Version)" (1)
               UpgradeCode="$(var.UpgradeCode)"
               Compressed="true">
        <MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." /> (2)
    
        <MediaTemplate EmbedCab="yes" />
    
        <StandardDirectory Id="ProgramFiles64Folder">
          <Directory Id="ROOTDIRECTORY" Name="!(bind.Property.Manufacturer)">
            <Directory Id="INSTALLFOLDER" Name="!(bind.Property.ProductName)">
              <Directory Id="WEBROOTDIRECTORY" Name="wwwroot" />
            </Directory>
          </Directory>
        </StandardDirectory>
    
        <ComponentGroup Id="WebAppServiceComponents" Directory="INSTALLFOLDER">
          <Component Id="ServiceExecutable" Bitness="always64">
            <File Source="$(var.Example.WebApp.TargetDir)publish\Example.WebApp.exe" />
    
            <ServiceInstall Id="ServiceInstaller"
                            Type="ownProcess"
                            Name="$(ServiceName)"
                            DisplayName="$(ServiceName)"
                            Description="A joke service that periodically logs nerdy humor."
                            Start="auto"
                            ErrorControl="normal" />
    
            <ServiceControl Id="StartService"
                            Start="install"
                            Stop="both"
                            Remove="uninstall"
                            Name="$(ServiceName)"
                            Wait="true" />
          </Component>
        </ComponentGroup>
    
        <ComponentGroup Id="AppSettingsComponents" Directory="INSTALLFOLDER">
          <File Source="$(var.Example.WebApp.TargetDir)publish\appsettings.json" />
          <Files Include="$(var.Example.WebApp.TargetDir)publish\appsettings.*.json" />
        </ComponentGroup>
    
        <ComponentGroup Id="WebRootComponents" Directory="INSTALLFOLDER">
          <Files Directory="WEBROOTDIRECTORY" Include="$(var.Example.WebApp.TargetDir)publish\wwwroot\**" />
        </ComponentGroup>
    
        <Feature Id="WebApp">
          <ComponentGroupRef Id="WebAppServiceComponents"/>
          <ComponentGroupRef Id="AppSettingsComponents" />
          <ComponentGroupRef Id="WebRootComponents" />
        </Feature>
      </Package>
    </Wix>
    1 Windows Installer uses only the first three fields of the product version. See ProductVersion Property for descriptions of these fields. If you include a fourth field in your product version, the installer ignores the fourth field.

    To create an upgrade installer package, you can update the version and repackage it.

    2 During a major upgrade using Windows Installer, the installer searches the user’s computer for applications that are related to the pending upgrade, and when it detects one, it retrieves the version of the installed application from the system registry. The installer then uses information in the upgrade database to determine whether to upgrade the installed application.
  • Publish the Example.WebApp project.

    > dotnet publish
    MSBuild version 17.10.0-preview-24101-01+07fd5d51f for .NET
    Restore complete (0.2s)
    You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
      Example.WebApp succeeded (1.2s) → bin\Release\net8.0-windows\win-x64\publish\
    
    Build succeeded in 1.6s
  • Build the MSI installer

    > $outdir = dotnet msbuild -getProperty:OutDir -p:Configuration=Release
    > dotnet wix build -arch x64 Package.wxs -d var.Example.WebApp.TargetDir=$outdir -out Example.WebApp.msi
    > dotnet msbuild -getProperty:OutDir -p:Configuration=Release
    bin\Release\net8.0-windows\win-x64\
  • Test in Windows Sandbox

    Enabling Windows Sandbox

    Windows Sandbox comes with Windows but isn’t installed by default. The documentation tells you to search from the taskbar for Turn Windows Features on or off to bring up the feature list. Another way to get to that list is to visit our old friend ARP (Programs and Features) and click on the Turn Windows Features on or off link on the left.

    That brings up a list of features. Scroll to almost the bottom of the list and you’ll see Windows Sandbox. If you do NOT see it, that means your machine isn’t modern enough to satisfy Windows’s cravings for the newest CPUs. If it’s there and checked, you’re all done. If it’s there and unchecked, check it and click OK. You’ll have to go through a reboot and spinner churn as Sandbox is installed.

    Once it’s installed, search for sandbox and choose the Windows Sandbox shortcut. The first time you run it, it might take a little while to come up (newbie jitters) but soon enough, you’ll have a window with a familiar-looking Windows desktop.

    • Select Example.WebApp.msi and press Ctrl+C (or choose Copy from the context menu). Go to your running Windows Sandbox and press Ctrl+V (or Paste) on the desktop.

    • Double click the Example.WebApp.msi to install the .NET Example WebApp service. You need to run the installation as an administrator.

      Once the service is installed, you can open Services to see the service running. To uninstall the service, use the Windows Add or Remove Programs feature to call the installer.

    • Run the following command on PowerShell to test the service.

      > Invoke-WebRequest -Uri http://localhost:5000 `
           | Select-Object -Property StatusCode,StatusDescription,Content `
           | Format-List
      
      
      StatusCode        : 200
      StatusDescription : OK
      Content           : Hello Default Files!

4.2. WiX MSBuild on the command line and CI/CD build systems

WiX, available as an MSBuild SDK for building from the command line using dotnet build from the .NET SDK or the .NET Framework-based MSBuild from Visual Studio, have smart defaults that make for simple .wixproj project authoring. For example, here’s a minimal .wixproj that builds an MSI from the .wxs source files in the project directory:

<Project Sdk="WixToolset.Sdk/5.0.0">
</Project>
FireGiant has released HeatWave Community Edition, available free of charge, to support WiX SDK-style MSBuild projects in Visual Studio.
  • Create the WiX SDK-style project directory

    > mkdir .\src\Example.WebApp.Installer
  • Create the WiX SDK-style project file

    > ni .\src\Example.WebApp.Installer\Example.WebApp.Installer.wixproj
    <!-- Example.WebApp.Installer.wixproj -->
    
    <Project Sdk="WixToolset.Sdk/5.0.0">
      <ItemGroup>
        <ProjectReference Include="..\Example.WebApp\Example.WebApp.csproj" />
      </ItemGroup>
    </Project>
  • Add the WiX SDK-style project to the solution

    > dotnet sln add .\src\Example.WebApp.Installer\
  • Copy the Package.wxs from the project Example.WebApp to Example.WebApp.Installer

    > cp .\src\Example.WebApp\Package.wxs .\src\Example.WebApp.Installer\
  • Publish the Example.WebApp and build the MSI package

    > dotnet publish .\src\Example.WebApp\
    MSBuild version 17.10.0-preview-24101-01+07fd5d51f for .NET
    Restore complete (0.6s)
    You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
      Example.WebApp succeeded (7.5s) → src\Example.WebApp\bin\Release\net8.0-windows\win-x64\publish\
    
    Build succeeded in 8.3s
    > dotnet build -r win-x64 -c Release -p:InstallerPlatform=x64 .\src\Example.WebApp.Installer\
    MSBuild version 17.10.0-preview-24101-01+07fd5d51f for .NET
    Restore complete (0.4s)
    You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
      Example.WebApp succeeded (0.3s) → src\Example.WebApp\bin\Release\net8.0-windows\win-x64\Example.WebApp.dll
      Example.WebApp.Installer succeeded (0.0s) → src\Example.WebApp.Installer\bin\Release\Example.WebApp.Installer.msi
    
    Build succeeded in 0.9s

4.3. Signing Windows Installer MSI packages

Windows Installer packages can be signed directly by signing tools like Signtool.exe. [10]

SignTool is available as part of the Windows SDK, which you can download from Windows SDK. The tool is installed in the \bin folder of the SDK installation path, for example: C:\Program Files (x86)\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe. [11] [12]

  • Use New-SelfSignedCertificate to create a self-signed certificate for testing

    New-SelfSignedCertificate `
      -Type Custom `
      -Subject "CN=Contoso Software, O=Contoso Corporation, C=US" `
      -KeyUsage DigitalSignature ` (1)
      -FriendlyName "Your friendly name goes here" `
      -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") ` (2)
      -CertStoreLocation "Cert:\CurrentUser\My"
    1 KeyUsage: This parameter defines what the certificate may be used for. For a self-signing certificate, this parameter should be set to DigitalSignature.
    2 TextExtension: This parameter includes settings for the following extensions:

    Extended Key Usage (EKU): This extension indicates additional purposes for which the certified public key may be used. For a self-signing certificate, this parameter should include the extension string "2.5.29.37={text}1.3.6.1.5.5.7.3.3", which indicates that the certificate is to be used for code signing.

    Basic Constraints: This extension indicates whether or not the certificate is a Certificate Authority (CA). For a self-signing certificate, this parameter should include the extension string "2.5.29.19={text}", which indicates that the certificate is an end entity (not a CA).

  • You can view your certificate in a PowerShell window by using the following commands:

    Get-ChildItem Cert:\CurrentUser\My | Format-Table Subject, FriendlyName, Thumbprint
  • Export a certificate

    To export the certificate in the local store to a Personal Information Exchange (PFX) file, use the Export-PfxCertificate cmdlet.

    When using Export-PfxCertificate, you must either create and use a password or use the "-ProtectTo" parameter to specify which users or groups can access the file without a password. Note that an error will be displayed if you don’t use either the "-Password" or "-ProtectTo" parameter.

    • Password usage

      $password = ConvertTo-SecureString -String <Your Password> -Force -AsPlainText
      Export-PfxCertificate `
        -cert "Cert:\CurrentUser\My\<Certificate Thumbprint>" `
        -FilePath <FilePath>.pfx `
        -Password $password
    • ProtectTo usage

      Export-PfxCertificate `
        -cert Cert:\CurrentUser\My\<Certificate Thumbprint> `
        -FilePath <FilePath>.pfx `
        -ProtectTo <Username or group name>
      > Get-ChildItem Cert:\CurrentUser\My | Format-Table Subject, FriendlyName, Thumbprint
      
      Subject                                          FriendlyName                               Thumbprint
      -------                                          ------------                               ----------
      CN=localhost                                     ASP.NET Core HTTPS development certificate 621654A0E5B9683E1129FE5D0871FAB5995F30E6
      CN=Contoso Software, O=Contoso Corporation, C=US Your friendly name goes here               532554756AC84D83A274B094A76A8C38FE821227
      > $password = ConvertTo-SecureString -String "<Your Password>" -Force -AsPlainText
      > Export-PfxCertificate -Cert Cert:\CurrentUser\My\532554756AC84D83A274B094A76A8C38FE821227 -FilePath cert.pfx -Password $password
      
      . . .
      
      Mode                 LastWriteTime         Length Name
      ----                 -------------         ------ ----
      -a----        04/08/2024     17:01           2782 cert.pfx
  • Sign MSI package using SignTool

    > signtool sign /v /fd SHA256 /f cert.pfx /p "<Your Password>" Example.WebApp.Installer.msi
    The following certificate was selected:
        Issued to: Contoso Software
        Issued by: Contoso Software
        Expires:   Tue Apr 08 16:54:26 2025
        SHA1 hash: 532554756AC84D83A274B094A76A8C38FE821227
    
    Done Adding Additional Store
    Successfully signed: .\Example.WebApp.Installer.msi
    
    Number of files successfully Signed: 1
    Number of warnings: 0
    Number of errors: 0