Skip to content

Aspire Integration

Chronicle provides first-class support for Microsoft Aspire, making it straightforward to run Chronicle as part of an Aspire distributed application. The Cratis.Chronicle.Aspire package adds a Chronicle resource to your Aspire AppHost, handling container lifecycle, endpoint wiring, and MongoDB configuration automatically.

Add the Cratis.Chronicle.Aspire package to your AppHost project:

Terminal window
dotnet add package Cratis.Chronicle.Aspire

When you run an Aspire AppHost, AddCratisChronicle() selects the Chronicle development image (cratis/chronicle:latest-development). It bundles MongoDB and generates its own self-signed TLS certificate at startup — so neither an external database nor a certificate is required. A configure callback can also set storage or certificates without changing that image selection.

using Aspire.Hosting;
public static class HostingAspireDevMode
{
public static void ConfigureAppHost(string[] args)
{
var builder = DistributedApplication.CreateBuilder(args);
var chronicle = builder.AddCratisChronicle();
builder.AddContainer("api", "my-org/my-api")
.WithReference(chronicle);
builder.Build().Run();
}
}

View C# snippet source on GitHub

The development image is ideal for:

  • Getting started quickly without additional infrastructure
  • Running in CI pipelines where a full stack is needed

When you publish an Aspire AppHost, AddCratisChronicle() selects the production image (cratis/chronicle:latest) whether or not you pass a configure callback. The image drops both the embedded MongoDB and the development conveniences — so configure these dependencies before deploying it:

WhatMethodWithout it
A databaseWithMongoDB / WithPostgreSql / WithMsSql / WithSqliteThere is no storage to connect to
A TLS certificateWithTlsCertificateThe server throws No TLS certificate is configured and exits
An encryption certificateWithEncryptionCertificateThe server throws An encryption certificate is required in production and exits — unless you turn the OAuthAuthority feature off or point Authentication:Authority at an external authority, which skips the internal authority that needs it

WithTlsCertificate and WithEncryptionCertificate each take a path to a PKCS#12 (.pfx) file on your machine and the password protecting it:

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireCertificates
{
public static IResourceBuilder<ChronicleResource> ConfigureAppHost(IDistributedApplicationBuilder builder)
{
var mongo = builder.AddConnectionString("chronicle-mongo");
return builder.AddCratisChronicle("chronicle", chronicle => chronicle
.WithMongoDB(mongo)
.WithTlsCertificate("certs/chronicle.pfx", "YourPassword")
.WithEncryptionCertificate("certs/encryption.pfx", "YourPassword"));
}
}

View C# snippet source on GitHub

Each method does two things: it bind-mounts the file read-only into the container (at /certs/tls.pfx and /certs/encryption.pfx respectively) and points the matching Chronicle setting at that in-container path. You do not add a WithBindMount of your own — a host path handed straight to the container names a file the container cannot see, so the mount is part of configuring the certificate rather than a separate step you have to remember. A relative path resolves against the AppHost directory, exactly like every other Aspire bind mount.

Point either method at a file that is not there and the AppHost fails to start with CertificateFileDoesNotExist, naming both the path you gave and the absolute path it resolved to. That is deliberate: Docker happily creates a directory at a missing bind-mount source, and the Chronicle container would then tell you no certificate is configured — the opposite of the truth — while staying up with a dead process inside. Calling either method twice for the same certificate is last-call-wins, like every other With… on the builder, so a base helper’s certificate can be overridden per environment.

The two calls are independent, so you can point both at the same .pfx if one certificate serves both purposes — they are mounted at different container paths, so nothing collides. They map to configuration like this:

MethodContainer environment variableChronicle setting
WithTlsCertificateCratis__Chronicle__Tls__CertificatePath / Cratis__Chronicle__Tls__CertificatePasswordCratis:Chronicle:Tls:CertificatePath / Cratis:Chronicle:Tls:CertificatePassword
WithEncryptionCertificateCratis__Chronicle__EncryptionCertificate__CertificatePath / Cratis__Chronicle__EncryptionCertificate__CertificatePasswordCratis:Chronicle:EncryptionCertificate:CertificatePath / Cratis:Chronicle:EncryptionCertificate:CertificatePassword

The password argument is optional: Chronicle always reads the file as PKCS#12, which carries the private key the TLS listener needs, and an unprotected .pfx loads without one. Protect the file with a password for any real deployment. Local Certificate Setup shows how to generate a suitable .pfx with dotnet dev-certs or OpenSSL; Production Hosting covers what a real deployment should use instead of a self-signed one.

WithEncryptionCertificate sets the active certificate. Chronicle also accepts previously active certificates, kept for decryption only, so a rotation needs no downtime — see Rotating the certificate. There is no builder method for that list yet; mount the previous certificate and point the configuration at it directly:

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireRotationPreviousCertificate
{
public static IResourceBuilder<ChronicleResource> Configure(
IDistributedApplicationBuilder builder) =>
builder
.AddCratisChronicle(configure: chronicle => chronicle
.WithTlsCertificate("certs/chronicle.pfx", "YourPassword")
.WithEncryptionCertificate(
"certs/encryption-2026.pfx",
"YourNewPassword"))
.WithBindMount(
"certs/encryption-2025.pfx",
"/certs/encryption-previous.pfx",
isReadOnly: true)
.WithEnvironment(
"Cratis__Chronicle__EncryptionCertificate__Previous__0__CertificatePath",
"/certs/encryption-previous.pfx")
.WithEnvironment(
"Cratis__Chronicle__EncryptionCertificate__Previous__0__CertificatePassword",
"YourOldPassword");
}

View C# snippet source on GitHub

AddCratisChronicle returns the resource builder, so WithBindMount and WithEnvironment chain straight onto it. The bind mount is what WithEncryptionCertificate would have done for you: the path in the environment variable is the path inside the container, so a host path handed straight to Chronicle names a file it cannot see.

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireMongo
{
public static IResourceBuilder<ChronicleResource> ConfigureAppHost(IDistributedApplicationBuilder builder)
{
var mongo = builder.AddConnectionString("chronicle-mongo");
return builder.AddCratisChronicle("chronicle", c =>
c.WithMongoDB(mongo));
}
}

View C# snippet source on GitHub

The WithMongoDB method sets the Cratis__Chronicle__Storage__Type and Cratis__Chronicle__Storage__ConnectionDetails environment variables on the Chronicle container using the connection string from the provided MongoDB resource.

mongo can be any IResourceBuilder<IResourceWithConnectionString> that resolves to a replica set, including:

  • A MongoDB Atlas connection string (builder.AddConnectionString("...")) — Atlas clusters are always replica sets.
  • Any self-hosted or cloud MongoDB cluster reached through a connection string.

For local development with an external MongoDB replica set, AddCratisChronicleMongoDB() provisions one for you — a MongoDB container that initializes itself as a single-node replica set, handed back as a connection string carrying ?directConnection=true:

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireMongoReplicaSetHelper
{
public static IResourceBuilder<ChronicleResource> ConfigureAppHost(IDistributedApplicationBuilder builder)
{
var mongo = builder.AddCratisChronicleMongoDB();
return builder.AddCratisChronicle(configure: chronicle => chronicle.WithMongoDB(mongo));
}
}

View C# snippet source on GitHub

That solves the database half only. If you override the image tag to run the production image locally, you still need both certificates from Certificates. Without an override, Aspire run mode uses the development image even when you configure external storage.

If you would rather define the container yourself, the same recipe written out in full is:

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireMongoReplicaSet
{
public static IResourceBuilder<ChronicleResource> ConfigureAppHost(IDistributedApplicationBuilder builder)
{
// Chronicle uses MongoDB transactions and change streams, so the database must run as a
// replica set. This command starts a single-node replica set that initializes itself on
// first run, keeping mongod as PID 1 for correct signal handling and privilege drop.
const string replicaSetCommand =
"( until mongosh --quiet --eval 'db.adminCommand({ ping: 1 })' >/dev/null 2>&1; do sleep 0.3; done; " +
"mongosh --quiet --eval 'try { rs.status() } catch (error) { rs.initiate({ _id: \"rs0\", members: [{ _id: 0, host: \"localhost:27017\" }] }) }' ) & " +
"exec docker-entrypoint.sh mongod --replSet rs0 --bind_ip_all";
var mongo = builder.AddContainer("mongo", "mongo", "8.2")
.WithEndpoint(targetPort: 27017, name: "tcp")
.WithEntrypoint("/bin/sh")
.WithArgs("-c", replicaSetCommand);
var mongoEndpoint = mongo.GetEndpoint("tcp");
// directConnection=true stops the driver from following the advertised replica-set member
// host (localhost:27017, only reachable inside the container) back out and hanging.
var mongoConnection = builder.AddConnectionString(
"chronicle-mongo",
ReferenceExpression.Create(
$"mongodb://{mongoEndpoint.Property(EndpointProperty.Host)}:{mongoEndpoint.Property(EndpointProperty.Port)}/?directConnection=true"));
return builder.AddCratisChronicle("chronicle", c => c.WithMongoDB(mongoConnection));
}
}

View C# snippet source on GitHub

directConnection=true keeps the driver from following the advertised replica-set member host — localhost:27017, only reachable inside the container — back out and hanging. This is the same single-node-replica-set recipe Chronicle’s own integration tests use. The development image bundles a ready replica set, which is why the default storage path works; an external MongoDB connection still needs a replica set in either mode.

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspirePostgres
{
public static IResourceBuilder<ChronicleResource> ConfigureAppHost(IDistributedApplicationBuilder builder)
{
var postgres = builder.AddConnectionString("chronicle-postgres");
return builder.AddCratisChronicle("chronicle", c =>
c.WithPostgreSql(postgres));
}
}

View C# snippet source on GitHub

postgres can be any IResourceBuilder<IResourceWithConnectionString>, including:

  • A connection string (builder.AddConnectionString("..."))
  • A PostgreSQL container: builder.AddPostgres("postgres").AddDatabase("chronicle")
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireSqlServer
{
public static IResourceBuilder<ChronicleResource> ConfigureAppHost(IDistributedApplicationBuilder builder)
{
var sql = builder.AddConnectionString("chronicle-sql");
return builder.AddCratisChronicle("chronicle", c =>
c.WithMsSql(sql));
}
}

View C# snippet source on GitHub

sql can be any IResourceBuilder<IResourceWithConnectionString>, including:

  • A connection string (builder.AddConnectionString("..."))
  • A SQL Server container: builder.AddSqlServer("sql").AddDatabase("chronicle")

For SQLite, provide the connection string directly (SQLite is file-based and does not require a network resource):

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireSqlite
{
public static IResourceBuilder<ChronicleResource> ConfigureAppHost(IDistributedApplicationBuilder builder) =>
builder.AddCratisChronicle("chronicle", c =>
c.WithSqlite("Data Source=/data/chronicle.db"));
}

View C# snippet source on GitHub

The database file lives inside the Chronicle container at the path you give. Unless you mount a writable volume there — ChronicleResource is an Aspire container resource, so the standard WithVolume applies — the directory must exist in the image and the data goes away with the container.

Pass the Chronicle resource as a connection string reference to your application projects so they receive the correct endpoint at runtime:

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspireConnectingClient
{
public static void ConfigureAppHost(IDistributedApplicationBuilder builder, IResourceBuilder<ChronicleResource> chronicle)
{
// Reference any of your application's projects the same way -
// builder.AddProject<Projects.MyApi>("api") when Projects.MyApi is generated from your solution
builder.AddContainer("api", "my-org/my-api")
.WithReference(chronicle);
}
}

View C# snippet source on GitHub

WithReference(chronicle) supplies ConnectionStrings__<resource name> to the application, exposed by .NET configuration as ConnectionStrings:<resource name>. In the application, call builder.AddCratisChronicle() and configure your event store as usual. Both the generic-host and ASP.NET Core entry points read ConnectionStrings:chronicle by default, so Aspire’s assigned host and port replace the localhost endpoint.

If you give the Chronicle resource another name, pass that same name in the application’s call: builder.AddCratisChronicle(configureOptions: null, configSection: null, configure: null, connectionStringName: "events") reads ConnectionStrings:events. Supply the preceding arguments explicitly in this overload; use null for defaults. This name selects the connection string, not the event store.

Connection strings take precedence in this order, highest first:

  1. ConnectionString set by the configureOptions callback to AddCratisChronicle.
  2. Cratis:Chronicle:ConnectionString, or ConnectionString under your custom configSection.
  3. ConnectionStrings:<connectionStringName> (chronicle by default).
  4. The existing development default: localhost on port 35000 with development client credentials.

Other Chronicle settings continue to bind from Cratis:Chronicle (or your custom section). The named fallback is parsed only after the section and callback have configured the options, and only if neither supplied a connection string. An explicit choice of the development connection string also takes precedence. A missing or blank named connection string leaves the development default unchanged.

The connection string exposed by ChronicleResource uses the chronicle://host:port format and points to the gRPC endpoint (container port 35000, with the host port assigned by Aspire). A URL without credentials still uses Chronicle’s existing client-credentials flow with the development client credentials; it does not disable authentication. For a production kernel, configure the required credentials explicitly rather than relying on the development defaults.

Chronicle exposes the following ports:

PortEndpoint NameDescription
35000grpcPrimary Chronicle service — gRPC (HTTP/2) and Workbench/API/OAuth/health (HTTP/1.1)

The grpc endpoint is registered automatically when you call AddCratisChronicle(). ChronicleResource no longer exposes a separate management endpoint — everything is served on the single grpc endpoint.

A typical Aspire AppHost Program.cs for a production setup with MongoDB — database plus both certificates, which is the smallest configuration the production image actually starts on:

using Aspire.Hosting;
public static class HostingAspireCompleteExample
{
public static void ConfigureAppHost(string[] args)
{
var builder = DistributedApplication.CreateBuilder(args);
var mongo = builder.AddConnectionString("chronicle-mongo");
var chronicle = builder.AddCratisChronicle("chronicle", c => c
.WithMongoDB(mongo)
.WithTlsCertificate("certs/chronicle.pfx", "YourPassword")
.WithEncryptionCertificate("certs/encryption.pfx", "YourPassword"));
builder.AddContainer("api", "my-org/my-api")
.WithReference(chronicle);
builder.Build().Run();
}
}

View C# snippet source on GitHub

For development with the bundled storage, drop the whole callback. In Aspire run mode the development image brings its own MongoDB replica set, generates its own TLS certificate, and uses ephemeral OAuth keys, so there is nothing left to wire:

using Aspire.Hosting;
public static class HostingAspireCompleteExampleDev
{
public static void ConfigureAppHost(string[] args)
{
var builder = DistributedApplication.CreateBuilder(args);
var chronicle = builder.AddCratisChronicle();
builder.AddContainer("api", "my-org/my-api")
.WithReference(chronicle);
builder.Build().Run();
}
}

View C# snippet source on GitHub

Image tagDescriptionSelected by
cratis/chronicle:latestProduction image — no embedded MongoDB, no self-generated certificatesAspire publish mode
cratis/chronicle:latest-developmentDevelopment image — includes embedded MongoDB, generates a TLS certificate and ephemeral OAuth keysAspire run mode
cratis/chronicle:latest-development-slimDevelopment slim image — no embedded MongoDBExplicit WithImageTag("latest-development-slim") or run it directly

Both tags above float. AddCratisChronicle picks the tag for you, so a production deployment tracks whatever cratis/chronicle:latest happens to be at pull time — two deployments of the same AppHost can land on different Chronicle versions. Pin an exact version by chaining WithImageTag after the call:

using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Cratis.Chronicle.Aspire;
public static class HostingAspirePinImage
{
public static IResourceBuilder<ChronicleResource> Configure(
IDistributedApplicationBuilder builder) =>
builder
.AddCratisChronicle(configure: chronicle => chronicle
.WithTlsCertificate("certs/chronicle.pfx", "YourPassword")
.WithEncryptionCertificate(
"certs/encryption.pfx",
"YourPassword"))
.WithImageTag("16.26.0");
}

View C# snippet source on GitHub

WithImageTag overrides the tag selected for the Aspire execution mode while leaving the image and registry alone. You can use it to run the production image locally or pin a released Chronicle version. Choose a tag for the image class you intend to run: a production tag needs external storage and certificates even in Aspire run mode. Treat the upgrade as a deliberate change.

See Production Hosting for guidance on running Chronicle in production environments.