How to Create a Windows Service in .NET

Transitioning from Legacy to Modern

The old .NET Framework approach to Windows services required inheriting from ServiceBase, manually implementing installers, and dealing with complex project configurations. You'd spend hours debugging service startup issues because the code behaved differently when running as a service versus running in Visual Studio.

Modern .NET simplified this dramatically. The Worker Service template in .NET 8 uses the same hosting model as ASP.NET Core. Your service can run as a console app during development and as a Windows service in production without changing code. Dependency injection, configuration, and logging work exactly like web applications.

You'll learn how to create a Windows service using the Worker Service template, implement background tasks with BackgroundService, handle lifecycle events properly, and deploy your service to production. We'll cover both simple scheduled tasks and more complex scenarios that need graceful shutdown handling.

Creating Your First Worker Service

The Worker Service template provides everything you need for a Windows service. It includes hosting infrastructure, dependency injection, configuration support, and logging. You focus on your business logic in the ExecuteAsync method while the framework handles service lifecycle.

Start by creating a new Worker Service project. The template generates a Program.cs with hosting setup and a Worker class that implements BackgroundService. This structure mirrors ASP.NET Core applications, making it familiar if you've built web apps.

Terminal - Create project
dotnet new worker -n MyWindowsService
cd MyWindowsService
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
Program.cs - Service host setup
using MyWindowsService;

var builder = Host.CreateApplicationBuilder(args);

// Add Windows service support
builder.Services.AddWindowsService(options =>
{
    options.ServiceName = "My Background Service";
});

// Register your worker
builder.Services.AddHostedService();

// Configure logging
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddEventLog();

var host = builder.Build();
await host.RunAsync();

The AddWindowsService call enables Windows service hosting. When running as a console app, the host behaves normally. When installed as a service, it integrates with Windows Service Control Manager. This dual behavior simplifies development and debugging significantly.

Implementing Background Tasks

BackgroundService provides a base class for long-running tasks. You override ExecuteAsync and implement your work loop. The method receives a CancellationToken that signals when the service should stop. Monitor this token and exit gracefully when cancellation is requested.

A typical pattern involves a while loop that checks the cancellation token and performs work at intervals. Use Task.Delay with the cancellation token rather than Thread.Sleep to enable responsive shutdown.

Worker.cs - Basic background task
public class Worker : BackgroundService
{
    private readonly ILogger _logger;

    public Worker(ILogger logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Service starting at: {time}", DateTimeOffset.Now);

        try
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogInformation("Service running at: {time}", DateTimeOffset.Now);

                // Your work here
                await DoWorkAsync(stoppingToken);

                // Wait before next iteration
                await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
            }
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("Service stopping gracefully");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception in service");
            throw;
        }

        _logger.LogInformation("Service stopped at: {time}", DateTimeOffset.Now);
    }

    private async Task DoWorkAsync(CancellationToken cancellationToken)
    {
        // Example: Process files, check database, call APIs
        _logger.LogInformation("Processing data...");

        await Task.Delay(1000, cancellationToken);

        _logger.LogInformation("Processing complete");
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Service stop requested");

        await base.StopAsync(cancellationToken);
    }
}

The while loop continues until the cancellation token signals. Task.Delay with the token allows quick shutdown when the service stops. The try-catch around OperationCanceledException handles normal shutdown gracefully without logging errors.

Running Scheduled Tasks

Many services need to run tasks on a schedule rather than continuously. You can implement scheduling logic directly or use libraries like Quartz.NET. For simple scenarios, a timer-based approach with periodic execution works well.

This pattern calculates the delay until the next scheduled time and waits. When the time arrives, it executes the task and calculates the next delay. This approach handles schedule changes gracefully and avoids drift from using fixed intervals.

ScheduledWorker.cs - Time-based execution
public class ScheduledWorker : BackgroundService
{
    private readonly ILogger _logger;
    private readonly TimeSpan _scheduleTime = new(3, 0, 0); // 3:00 AM

    public ScheduledWorker(ILogger logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Scheduled service started");

        while (!stoppingToken.IsCancellationRequested)
        {
            var now = DateTime.Now;
            var nextRun = CalculateNextRunTime(now);
            var delay = nextRun - now;

            _logger.LogInformation(
                "Next execution scheduled for: {nextRun} (in {delay})",
                nextRun,
                delay);

            try
            {
                await Task.Delay(delay, stoppingToken);

                _logger.LogInformation("Starting scheduled task execution");
                await ExecuteScheduledTaskAsync(stoppingToken);
                _logger.LogInformation("Scheduled task completed");
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error executing scheduled task");
            }
        }

        _logger.LogInformation("Scheduled service stopped");
    }

    private DateTime CalculateNextRunTime(DateTime currentTime)
    {
        var scheduledTime = currentTime.Date.Add(_scheduleTime);

        // If scheduled time already passed today, schedule for tomorrow
        if (currentTime > scheduledTime)
        {
            scheduledTime = scheduledTime.AddDays(1);
        }

        return scheduledTime;
    }

    private async Task ExecuteScheduledTaskAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Running daily maintenance...");

        // Your scheduled work here
        await Task.Delay(2000, cancellationToken);

        _logger.LogInformation("Maintenance completed");
    }
}

The CalculateNextRunTime method determines when to run next. If the scheduled time already passed today, it schedules for tomorrow. This approach works for daily tasks and can be extended to support weekly or monthly schedules.

Installing and Managing the Service

After building your service, you need to install it on Windows. The sc command creates and manages services. You provide the service name, binary path, and optionally set startup type and credentials.

For easier deployment, publish your application as a single-file executable. This packages all dependencies into one file, simplifying installation and avoiding runtime version conflicts.

Terminal - Publish and install
# Publish as single-file executable
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true

# Create the service (run as Administrator)
sc create MyBackgroundService binPath="C:\Services\MyWindowsService.exe" start=auto

# Start the service
sc start MyBackgroundService

# Query service status
sc query MyBackgroundService

# Stop the service
sc stop MyBackgroundService

# Delete the service
sc delete MyBackgroundService

The binPath must be an absolute path to your executable. The start=auto option makes the service start automatically when Windows boots. After creating the service, use sc start to run it immediately.

Try It Yourself

Here's a complete working example you can test. This service logs messages every 10 seconds and demonstrates proper shutdown handling.

MyWindowsService.csproj
<Project Sdk="Microsoft.NET.Sdk.Worker">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>dotnet-MyWindowsService-12345</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
  </ItemGroup>
</Project>
Complete service implementation
// Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddWindowsService(options =>
{
    options.ServiceName = "Demo Background Service";
});

builder.Services.AddHostedService();

var host = builder.Build();
await host.RunAsync();

// DemoWorker.cs
public class DemoWorker : BackgroundService
{
    private readonly ILogger _logger;
    private int _executionCount = 0;

    public DemoWorker(ILogger logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Demo service started");

        while (!stoppingToken.IsCancellationRequested)
        {
            _executionCount++;
            _logger.LogInformation(
                "Service tick #{count} at: {time}",
                _executionCount,
                DateTime.Now);

            await Task.Delay(10000, stoppingToken);
        }

        _logger.LogInformation("Demo service stopped after {count} executions",
            _executionCount);
    }
}

// Output when running:
// info: DemoWorker[0]
//       Demo service started
// info: DemoWorker[0]
//       Service tick #1 at: 11/04/2025 2:30:15 PM
// info: DemoWorker[0]
//       Service tick #2 at: 11/04/2025 2:30:25 PM

Run this locally with dotnet run to test as a console application. When you press Ctrl+C, the cancellation token triggers and the service stops gracefully. After testing, publish and install it as a Windows service using the commands from the previous section.

Production Deployment Tips

Always configure file-based logging for production services. Since services run without a console, Console.WriteLine output disappears. Use logging frameworks like Serilog or NLog that write to files in directories the service account can access. Include timestamps, log levels, and structured data for troubleshooting.

Handle exceptions carefully in your ExecuteAsync method. Unhandled exceptions crash the service. Wrap your work in try-catch blocks, log errors with full stack traces, and decide whether to continue or exit based on the error severity. For transient failures like network issues, implement retry logic with exponential backoff.

Set appropriate service recovery options using sc failure commands. Configure the service to restart automatically after failures. Specify delays between restart attempts to avoid rapid failure loops. Consider sending alerts when the service crashes repeatedly.

Test service shutdown thoroughly. Your code should respond to cancellation tokens within a reasonable time. The Service Control Manager waits for StopAsync to complete, but it will eventually force-kill the process. Clean up resources, close database connections, and flush logs before exiting.

Frequently Asked Questions (FAQ)

What's the difference between IHostedService and BackgroundService?

BackgroundService is an abstract class that implements IHostedService with a simple ExecuteAsync method for long-running tasks. IHostedService requires implementing StartAsync and StopAsync directly. Use BackgroundService for most scenarios as it handles lifecycle management automatically.

How do I install a .NET Windows service?

Use the sc create command with your executable path, or use Microsoft.Extensions.Hosting.WindowsServices with AddWindowsService. Modern .NET Worker Services can be published as single-file executables and installed directly. The service runs with SYSTEM privileges by default.

Can I debug a Windows service locally?

Yes, run it as a console application during development. The Worker Service template supports both console and service modes. In debug mode, it runs as a console app. When published and installed, it runs as a Windows service without code changes.

How do I handle service shutdown gracefully?

Monitor the CancellationToken passed to ExecuteAsync. When the service stops, this token gets cancelled. Check it regularly and clean up resources when cancellation is requested. The host waits for StopAsync to complete before terminating.

Where should Windows services log errors?

Use ILogger with file-based providers like Serilog or NLog. Avoid using Console.WriteLine since services run without a console. Configure logging to write to files in a directory the service account can access. The Windows Event Log is also an option for critical errors.

Back to Articles