How To Schedule Tasks With a Quartz.NET Windows Service (.NET 8 and 10)



Last updated: October 28th, 2026

Updated October 2026: the original 2013 version used a .NET Framework ServiceBase project, Quartz.NET 2 and CronTriggerImpl. This version uses a .NET 8+ Worker Service with Quartz.Extensions.Hosting, which is far simpler to write, debug and install. The code was compiled and run with Quartz.NET 4.6 on .NET 10, and the Quartz.NET 3.x variant (for .NET 8) was compiled on .NET 8.

Goals

Any website with a real user base needs automated work that runs on a schedule. In my case, I wanted to send birthday emails once a day: find the users, email each a customized message, and log a receipt of the message in a SQL Server database. I also wanted a heartbeat job that logs every few minutes, so I could tell the service was still alive on days when there were no birthdays.

Quartz.NET is a mature, open-source job scheduler for .NET with cron expressions, misfire handling, and optional database-backed job storage. Here is how to host it in a Windows Service today.

Step 1: Create a Worker Service

A Worker Service is a console app built on the .NET Generic Host. The same executable runs as a normal console app while you develop (so breakpoints just work, which was a real pain with old ServiceBase services) and as a Windows Service in production.

dotnet new worker -n Scheduler
cd Scheduler
dotnet add package Quartz.Extensions.Hosting
dotnet add package Microsoft.Extensions.Hosting.WindowsServices

Which Quartz.NET version? Quartz.NET 4.x targets .NET 10. If your project targets .NET 8, install 3.x instead: dotnet add package Quartz.Extensions.Hosting --version 3.*. The setup code is identical; only the job method signature differs (shown below).

The project file ends up like this (delete the template's Worker.cs; Quartz replaces it):

<Project Sdk="Microsoft.NET.Sdk.Worker">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.*" />
    <!-- Quartz.NET 4.x targets .NET 10. On .NET 8, use Version="3.*" instead. -->
    <PackageReference Include="Quartz.Extensions.Hosting" Version="4.*" />
  </ItemGroup>
</Project>

Step 2: Register Quartz, Jobs and Triggers

// Program.cs (Worker Service project: dotnet new worker -n Scheduler)
// dotnet add package Quartz.Extensions.Hosting
// dotnet add package Microsoft.Extensions.Hosting.WindowsServices
using Quartz;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

// Lets the same exe run as a console app (dev) or as a Windows Service (prod).
builder.Services.AddWindowsService(options => options.ServiceName = "MySite Scheduler");

builder.Services.AddQuartz(q =>
{
    // Health check every 10 minutes.
    var heartbeatKey = new JobKey("heartbeat");
    q.AddJob<HeartbeatJob>(opts => opts.WithIdentity(heartbeatKey));
    q.AddTrigger(opts => opts
        .ForJob(heartbeatKey)
        .WithIdentity("heartbeat-trigger")
        .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromMinutes(10)).RepeatForever()));

    // Daily email job at 02:00 UTC, regardless of the server's time zone.
    var birthdayKey = new JobKey("birthday-emails");
    q.AddJob<BirthdayEmailJob>(opts => opts.WithIdentity(birthdayKey));
    q.AddTrigger(opts => opts
        .ForJob(birthdayKey)
        .WithIdentity("birthday-emails-trigger")
        .WithCronSchedule("0 0 2 * * ?", cron => cron.InTimeZone(TimeZoneInfo.Utc)));
});

// Runs the scheduler for the lifetime of the host; finish running jobs on shutdown.
builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

builder.Build().Run();

Things worth noticing:

  • Time zones: cron triggers use the server's local time unless you say otherwise. InTimeZone(TimeZoneInfo.Utc) makes "2:00" mean 2:00 UTC on any server. (2:00 UTC is early evening in US time zones the day before; for birthdays, a little early beats a day late.)
  • Quartz cron has seconds: 0 0 2 * * ? is second minute hour day-of-month month day-of-week. Use ? in one of the two day fields.
  • AddWindowsService does nothing when you run from Visual Studio or dotnet run, so development needs no special code.

Step 3: Write the Jobs

Each job implements IJob. Jobs are resolved from dependency injection, so you can inject a DbContext factory, an email sender, or a logger:

using Quartz;

// Quartz.NET 4.x signature. (In 3.x it was: public Task Execute(IJobExecutionContext context))
// Jobs are created through dependency injection, so constructor injection just works.
[DisallowConcurrentExecution] // never run two copies of this job at once
public sealed class BirthdayEmailJob(ILogger<BirthdayEmailJob> logger) : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        logger.LogInformation("Birthday job started. Next run: {Next}", context.NextFireTimeUtc);

        // 1. Query users whose birthday is today (month + day).
        // 2. Send each one an email (pass cancellationToken along).
        // 3. Record that it was sent so a re-run can't send it twice.
        await Task.Delay(0, cancellationToken);
    }
}

public sealed class HeartbeatJob(ILogger<HeartbeatJob> logger) : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        logger.LogInformation("Scheduler alive at {Utc:o}", DateTimeOffset.UtcNow);
        return ValueTask.CompletedTask;
    }
}

Quartz.NET 4 changed Execute to return ValueTask and take a CancellationToken. On Quartz.NET 3.x (.NET 8), the same jobs look like this:

using Quartz;

// Quartz.NET 3.x (for .NET 8): Execute returns Task and takes only the context.
// The cancellation token is available as context.CancellationToken.
[DisallowConcurrentExecution]
public sealed class BirthdayEmailJob(ILogger<BirthdayEmailJob> logger) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        logger.LogInformation("Birthday job started. Next run: {Next}", context.NextFireTimeUtc);
        await Task.Delay(0, context.CancellationToken);
    }
}

public sealed class HeartbeatJob(ILogger<HeartbeatJob> logger) : IJob
{
    public Task Execute(IJobExecutionContext context)
    {
        logger.LogInformation("Scheduler alive at {Utc:o}", DateTimeOffset.UtcNow);
        return Task.CompletedTask;
    }
}

For a job that must not run twice for the same day (like emails), record what was sent and check it before sending. If the service restarts mid-run, the job can safely run again.

Step 4: Install It as a Windows Service

Publish the app and register it with sc.exe from an elevated prompt. In cmd.exe, the space after each = is required:

REM Publish a self-contained single folder, then register it as a Windows service
dotnet publish -c Release -r win-x64 --self-contained -o C:\services\Scheduler

sc.exe create "MySite Scheduler" binPath= "C:\services\Scheduler\Scheduler.exe" start= delayed-auto
sc.exe description "MySite Scheduler" "Runs scheduled jobs (Quartz.NET)"
sc.exe start "MySite Scheduler"

REM To update: stop, copy the new files, start.  To remove:
sc.exe stop "MySite Scheduler"
sc.exe delete "MySite Scheduler"

Or with PowerShell:

# The same with PowerShell (run as Administrator)
New-Service -Name "MySite Scheduler" -BinaryPathName "C:\services\Scheduler\Scheduler.exe" `
    -StartupType AutomaticDelayedStart -Description "Runs scheduled jobs (Quartz.NET)"
Start-Service "MySite Scheduler"

Delayed auto start gives SQL Server and the network time to come up after a reboot before your jobs try to connect. The service runs as LocalSystem by default; for database access with Windows authentication, run it as a dedicated account (set under the service's Log On tab) that has a SQL login.

Logging

When it runs as a service, the Generic Host sends ILogger warnings and errors to the Windows Event Log. For log files or a database table, add a provider such as Serilog or log4net. The heartbeat job above gives you a simple way to check the service is alive: if its log entries stop, something is wrong.

Persisting Jobs

By default Quartz keeps jobs in memory (RAMJobStore), which is fine when every job and trigger is defined in code, as here. If jobs are created at runtime, or you run several instances that must not all fire the same job, switch to the ADO.NET job store with q.UsePersistentStore(...) and enable clustering.

Frequently Asked Questions

How do I run Quartz.NET as a Windows Service?

Create a .NET Worker Service, add Quartz.Extensions.Hosting and Microsoft.Extensions.Hosting.WindowsServices, call AddWindowsService(), AddQuartz() and AddQuartzHostedService(), then install the published exe with sc.exe create.

Why does my Quartz cron trigger fire at the wrong time?

Cron triggers default to the machine's local time zone. Set it explicitly with .WithCronSchedule("0 0 2 * * ?", x => x.InTimeZone(TimeZoneInfo.Utc)).

Can I use Quartz.NET in an ASP.NET Core app instead?

Yes, the same AddQuartz and AddQuartzHostedService calls work in a web app. But IIS can shut down idle apps, which stops the scheduler, so a separate Windows Service is more dependable for important jobs (or see keeping IIS sites alive).

How do I debug a .NET Windows Service?

With a Worker Service you don't need tricks: press F5 and it runs as a console app with full debugging. AddWindowsService only switches to service mode when Windows starts it.

Summary

A Worker Service plus Quartz.NET gives you a scheduled-job host that is easy to develop, debug and install: define jobs as DI-friendly classes, pin cron triggers to UTC, and register the published exe with sc.exe using delayed auto start.


Comments

No Comments

CAPTCHA



Join my email list!



ryan
About Me

With over 15 years in tech, I've excelled as a senior software engineer, specializing in ASP.NET, C#, SQL, Azure, and front-end technologies. I've led diverse projects across various sectors, from startups to global corporations, particularly during my decade in the San Francisco Bay Area.


Sign Up Hostinger Email