Running Quartz under Aspire
Aspire (".NET Aspire" before Aspire 13) orchestrates a local application. Its AppHost declares databases and projects and wires them together; ServiceDefaults, a project every service references, turns on OpenTelemetry, health checks, service discovery and HTTP resilience. Quartz.Aspire connects a scheduler to both in one call:
builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
Documented against Aspire 13.5. Settings, the provider table and the connection ladder are on the Aspire Integration reference page. There is no hosting integration (no Aspire.Hosting.Quartz package, no AddQuartz AppHost resource): Quartz.Aspire only needs a named connection string, from anywhere.
What the package subscribes for you
ServiceDefaults' OpenTelemetry pipeline subscribes to a fixed list (runtime, ASP.NET Core and HttpClient metrics and traces, and the application's own ActivitySource), which does not include Quartz. AddQuartzPersistentStore:
- calls
AddOpenTelemetry()and adds Quartz's activity source and meter (both"Quartz", constants inQuartz.Diagnostics); - registers the scheduler's health check on the
IHealthChecksBuilderthat holds ServiceDefaults'selfcheck; - works before or after
AddServiceDefaults(), because OpenTelemetry keeps oneTracerProviderand oneMeterProviderper container and a secondAddOpenTelemetry()composes with the first.
With DisableTracing and DisableMetrics set, or without Quartz.Aspire, subscribe by hand:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(QuartzInstrumentation.ActivitySourceName))
.WithMetrics(metrics => metrics.AddMeter(QuartzInstrumentation.MeterName));
Do not add an exporter here
ServiceDefaults calls UseOtlpExporter() whenever OTEL_EXPORTER_OTLP_ENDPOINT is set, and the AppHost sets it. Calling it twice, or combining it with a signal-specific AddOtlpExporter(), throws NotSupportedException. For an application without ServiceDefaults, see Observability.
The AppHost
dotnet new install Aspire.ProjectTemplates
dotnet new aspire-apphost -n AppHost
dotnet sln add AppHost/AppHost.csproj
aspire-service-defaults creates the ServiceDefaults project. You need a container runtime (Docker Desktop, Podman), but not the aspire CLI or a workload: Aspire.AppHost.Sdk brings the dashboard and the Developer Control Plane, so dotnet run on the AppHost is enough. See getting started.
A Postgres server, a database, and the worker:
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithDataVolume()
.WithLifetime(ContainerLifetime.Persistent);
var quartzDb = postgres.AddDatabase("quartz");
builder.AddProject<Projects.Orders_Worker>("orders")
.WithReference(quartzDb)
.WaitFor(quartzDb);
builder.Build().Run();
AddDatabase("quartz")names the resource and, with no second argument, the database.WithReferencepasses the connection string asConnectionStrings__quartz, read asConnectionStrings:quartz: the AppHost name is the name the worker asks for.WaitForholds the worker until Postgres is healthy.WithDataVolume()andWithLifetime(ContainerLifetime.Persistent)keep the database across restarts; without them the job store starts empty every run.
The worker
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.AddNpgsqlDataSource("quartz");
builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
AddServiceDefaultsis your own code, generated by the template; it is generic overIHostApplicationBuilder, so workers can use it.AddNpgsqlDataSourcecomes fromAspire.Npgsql, which brings the Npgsql driver. It registers the unkeyedDbDataSourcethatAddQuartzPersistentStorelooks for.- Register the data source first.
AddQuartzPersistentStoresees only services registered before it. The order of the three Quartz calls does not matter.
dotnet add package Aspire.Npgsql
A working copy of all of this
src/Quartz.Examples.Aspire.AppHost and src/Quartz.Examples.Aspire.Worker in the Quartz.NET repository are this page as two projects. They are in the solution, so a call on this page that stops compiling fails the build.
Without an AppHost
Quartz.Aspire does not need one. It references no Aspire.* package, and AddQuartzPersistentStore(name) reads ConnectionStrings:<name> from any IConfiguration source, such as appsettings.json:
{
"ConnectionStrings": {
"quartz": "Host=localhost;Database=quartz;Username=postgres;Password=postgres"
}
}
var builder = Host.CreateApplicationBuilder(args);
builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
You still get provider inference, Aspire:Quartz:* binding, per-scheduler sections, the health check, telemetry and schema provisioning under Development. Reference the driver yourself (dotnet add package Npgsql). An AppHost adds the container, connection string, startup order and dashboard; the rest of this page assumes one.
What the call chose, and how to choose it yourself
builder.AddQuartz(q =>
{
q.ConfigureScheduler(options =>
{
options.InstanceName = "orders";
options.GenerateInstanceId = true;
});
q.UsePersistentStore(store =>
{
store.UsePostgres(db => db.UseRegisteredDataSource = true);
store.UseClustering();
});
});
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
UseRegisteredDataSourceresolves the container's one unkeyedDbDataSource. It is aDataSourceOptionssetting, likeConnectionStringandConnectionStringName, and wins over both.UsePostgresis still needed: it selectsPostgreSQLDelegateand the Npgsql driver description (SQL and parameter shape); the data source supplies connections.- Commands come from the data source's connections, so its type mappers, logging and multiplexing apply to Quartz's statements. Prefer an existing
NpgsqlDataSourceto a connection string. GenerateInstanceIdandUseClusteringare for replicas; see Clustering.settings.Clustered = truesets both. Nodes that all keep the defaultNON_CLUSTEREDid are not a cluster.
More than one database
AddKeyedNpgsqlDataSource("quartz") registers the DbDataSource under a service key, and AddQuartzPersistentStore("quartz") looks for a keyed data source under "quartz" before an unkeyed one. By hand, set DataSourceServiceKey, which implies UseRegisteredDataSource:
builder.AddQuartz(q => q.UsePersistentStore(store =>
store.UsePostgres(db => db.DataSourceServiceKey = "quartz")));
One scheduler has one store, so two databases usually means two schedulers. Name each with QuartzAspireSettings.SchedulerName; see More than one scheduler.
SQL Server takes the connection-string path
Microsoft.Data.SqlClient has no DbDataSource, and Aspire's SQL Server integration registers a scoped SqlConnection, so UseRegisteredDataSource would fail at first use. Read the connection string by name (only WithReference is needed, no client integration):
builder.AddQuartz(q => q.UsePersistentStore(store =>
store.UseSqlServer(db => db.ConnectionStringName = "quartz")));
AddQuartzPersistentStore does this automatically for SQL Server and skips the data-source probe. The same shape works for Postgres when there is no data source to share.
Getting the tables there
Quartz never migrates its schema. Production runs the scripts in database/tables; see Database Schema. For development, store.ProvisionSchema() (4.0+) creates what is missing at start. AddQuartzPersistentStore chooses from builder.Environment:
| Environment | SchemaProvisioning |
|---|---|
Development | CreateIfMissing |
| any other | Validate (the default); the scheduler's account usually cannot, and should not, run DDL |
See Creating the schema. To choose explicitly, set QuartzAspireSettings.SchemaProvisioning (the same three-valued SchemaProvisioning):
builder.AddQuartzPersistentStore(
"quartz",
settings => settings.SchemaProvisioning = SchemaProvisioning.CreateIfMissing);
A value set on the store itself, through ConfigureStore or Quartz:JobStore:SchemaProvisioning, wins:
builder.AddQuartz(q => q.UsePersistentStore(store =>
store.ConfigureStore(options => options.SchemaProvisioning = SchemaProvisioning.None)));
Validate cannot be set that way, because an unconfigured store already holds it; set SchemaProvisioning = SchemaProvisioning.Validate on QuartzAspireSettings. SchemaProvisioning.None skips the startup check, which the old bool could not.
Aspire's hooks do not apply a production schema. AddDatabase creates the database (on the server's ResourceReadyEvent), but:
WithCreationScriptruns one command against the server's default database (Database=postgres), meant forCREATE DATABASE, not tables. A failure other than "database already exists" is logged, not thrown, so the AppHost stays green with no tables.WithInitFilescopies files into/docker-entrypoint-initdb.d, which Postgres runs only when it first initializes its data directory, insidePOSTGRES_DB, before Aspire'sCREATE DATABASE. It works ifPOSTGRES_DBequals theAddDatabasename, but withWithDataVolume()the schema stays at whatever the first run created.
Use the shape Aspire teaches for EF Core migrations: a small project that applies the schema and exits, awaited with WaitForCompletion. (AddEFMigrations shells out to dotnet ef database update, so it cannot run a hand-written tables_postgres.sql.)
var migrations = builder.AddProject<Projects.Orders_Migrations>("migrations")
.WithReference(quartzDb)
.WaitFor(quartzDb);
builder.AddProject<Projects.Orders_Worker>("orders")
.WithReference(quartzDb)
.WaitForCompletion(migrations);
WaitFor alone only proves the server is up, not that QRTZ_TRIGGERS exists. Provisioning does not replace this in production: it needs DDL rights, and does not move an existing schema forward. database/migrations/ does that, and the 3.x → 4.0 upgrade is still mandatory.
Health
MapDefaultEndpoints() serves the scheduler's check from /health with no further wiring. With DisableHealthChecks, or without Quartz.Aspire, register it from the core package (no web reference needed; see Hosted Services Integration):
builder.Services.AddHealthChecks().AddQuartz();
To show it in the dashboard, point the AppHost at the endpoint; the project must serve HTTP:
builder.AddProject<Projects.Orders_Api>("api")
.WithHttpHealthCheck("/health")
.WithReference(quartzDb)
.WaitFor(quartzDb);
WithHttpHealthCheck throws while the AppHost is built if the resource has no http or https endpoint (no launch profile, no WithHttpEndpoint()).
| Scheduler | Quartz reports | /health answers | Dashboard shows |
|---|---|---|---|
| Running, and its store answers | Healthy | 200 | Running |
| In standby | Degraded | 200 | Running |
Running, but the store threw SchedulerException | Unhealthy | 503 | Running (Unhealthy) |
| Created but never started, shutting down, or shut down | Unhealthy | 503 | Running (Unhealthy) |
Standby reports degraded: healthy would hide a scheduler that never started, unhealthy would remove a node doing what it was told. But ASP.NET Core maps Degraded to 200 by default, and WithHttpHealthCheck accepts exactly one status code (200 unless you pass another), so an HTTP probe can never show Running (Degraded). To make standby visible, map Degraded to 503, which shows it as unhealthy:
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResultStatusCodes =
{
[HealthStatus.Healthy] = StatusCodes.Status200OK,
[HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable,
[HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
},
});
Or read the /health body, where the default writer puts the aggregate status. Quartz.Aspire maps nothing itself: ResultStatusCodes is the application's decision.
The check carries no tags
So it is in /health but not /alive, which keeps only checks tagged live; standby should not fail liveness. To add a tag, configure the options rather than registering the check again:
builder.Services.Configure<QuartzHealthCheckOptions>(options => options.Tags.Add("live"));
The registration reads IOptionsMonitor<QuartzHealthCheckOptions> when the health-check service is built, so any Configure call reaches it, including for AddQuartzPersistentStore's registration. For a named scheduler, configure the options under its name.
A worker project has no health endpoint at all
MapDefaultEndpoints needs a WebApplication; a Microsoft.NET.Sdk.Worker project has no HTTP server, so WithHttpHealthCheck has nothing to poll. Aspire's own worker samples skip MapDefaultEndpoints() for this reason and document no fix. Quartz's fix: switch the SDK to Microsoft.NET.Sdk.Web, use WebApplication.CreateBuilder, and map the endpoint. The application still hosts only the scheduler.
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
WebApplication app = builder.Build();
app.MapHealthChecks("/health");
app.Run();
MapDefaultEndpoints() maps /health and /alive; the MapHealthChecks above is the first. Aspire gave the same answer on microsoft/aspire#4045.
Without Kestrel: register a custom IHealthCheck in the AppHost and attach it with .WithHealthCheck("quartz-ready"). It queries the database directly, and is the only route that can report a resource as Degraded.
ServiceDefaults maps both endpoints only in development
MapDefaultEndpoints maps its health endpoints only when IsDevelopment(), for security. Locally that is fine; deployed, WithHttpHealthCheck("/health") polls a 404 wherever that guard applies.
Logs
No setup is needed. Everything the container builds for a scheduler logs through its ILoggerFactory (the loop; the job store with its cluster manager, misfire handler, driver delegate and lock handler; the thread pool, job factory, type loader and instance id generator that Use*<T>() chose), and ServiceDefaults has put an OpenTelemetry provider on it.
Types no container builds read the ambient factory: a listener or trigger you constructed, CronTriggerImpl, the static helpers, and the jobs in Quartz.Jobs. Forward the host's factory to LogProvider.SetLogProvider once to include them; see the migration guide.
The loop opens one logging scope for its run with quartz.scheduler.name and quartz.scheduler.id. ServiceDefaults sets IncludeScopes = true, so they become attributes on every line, including job lines (the scope flows through the execution context captured at dispatch), and the dashboard's advanced log filter can select on them. That matters when a process runs several schedulers, because the logger category is only a type name. From 4.3, q.AddJobLogScope() adds the job, trigger and fire instance to every line a firing logs; see A log scope per firing.
What the dashboard shows
Traces.
- One
Quartz.Job.Executespan per firing (ActivityKind.Internal), and aQuartz.Job.Vetospan when a trigger listener refuses. - View details shows
quartz.job.name,quartz.job.group,quartz.trigger.nameandquartz.trigger.group.quartz.scheduler.name,quartz.scheduler.id,quartz.job.typeandquartz.fire.instance.idappear only when the span is sampled for full data. - A failed firing sets error status and tags
error.type. It adds an exception event unless turned off. EveryQuartz.Job.Executespan carriesquartz.job.result. - One
Quartz.JobStore.*span per store operation (Quartz.JobStore.AcquireNextTriggers,.TriggersFired,.ScheduleJoband the rest),ActivityKind.Client, from every store including in-memory.
Metrics (the Quartz meter):
quartz.job.execution.duration, a histogram in seconds (charted as P50, P90, P99), andquartz.job.execution.active, an up-down counter of running jobs. Both carryquartz.scheduler.name,quartz.scheduler.idand the four job and trigger identity attributes; the histogram addserror.typeon failure, so the failure rate is its count with that attribute.quartz.fire.instance.idis not a metric attribute (it is unique per firing).quartz.trigger.misfire,quartz.trigger.acquisition.duration,quartz.trigger.acquired,quartz.jobstore.operation.duration, and on a clustered persistent storequartz.cluster.checkin.durationandquartz.cluster.recovery.trigger. All carryquartz.scheduler.id, so you can filter to one node.
Full tables: Observability.
What Aspire does not do for you
- Clustering.
WithReplicas(2)starts two workers, not a cluster. AddUseClustering()and a distinctInstanceIdper node:GenerateInstanceId = trueuses the host name and a timestamp, distinct across replicas on one machine. A node finds its own check-in row and fired triggers by that id, so every replica keepingNON_CLUSTEREDbreaks clustering.QuartzAspireSettings.Clusteredsets both. - Store settings: table prefix, serializer, misfire threshold, lock strategy. See Configuration Reference.
- Startup order beyond the container.
WaitForwaits for the database's health check, not the Quartz tables; wait for your migration step instead. - History. The Aspire dashboard keeps telemetry in memory for the session. For last week's failed job, point the OTLP export at a collector; ServiceDefaults owns the exporter, so Quartz needs no change.
Tips
Nothing here is Aspire-specific. AddServiceDefaults() is a template you own, so this works in any generic-host application that configures OpenTelemetry; Observability is the version without Aspire. AddQuartzPersistentStore works anywhere a ConnectionStrings: entry does.
See also
- Aspire Integration — every setting, the provider-inference table, and the connection ladder in full
- Observability — the spans, instruments and attributes
- Operations — the health check outside Aspire, and what it does and does not assert
