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.
Installation
Section titled “Installation”Add the Cratis.Chronicle.Aspire package to your AppHost project:
dotnet add package Cratis.Chronicle.AspireDevelopment Mode
Section titled “Development Mode”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(); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.The development image is ideal for:
- Getting started quickly without additional infrastructure
- Running in CI pipelines where a full stack is needed
Production Mode
Section titled “Production Mode”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:
| What | Method | Without it |
|---|---|---|
| A database | WithMongoDB / WithPostgreSql / WithMsSql / WithSqlite | There is no storage to connect to |
| A TLS certificate | WithTlsCertificate | The server throws No TLS certificate is configured and exits |
| An encryption certificate | WithEncryptionCertificate | The 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 |
Certificates
Section titled “Certificates”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")); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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:
| Method | Container environment variable | Chronicle setting |
|---|---|---|
WithTlsCertificate | Cratis__Chronicle__Tls__CertificatePath / Cratis__Chronicle__Tls__CertificatePassword | Cratis:Chronicle:Tls:CertificatePath / Cratis:Chronicle:Tls:CertificatePassword |
WithEncryptionCertificate | Cratis__Chronicle__EncryptionCertificate__CertificatePath / Cratis__Chronicle__EncryptionCertificate__CertificatePassword | Cratis: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.
Rotating the encryption certificate
Section titled “Rotating the encryption certificate”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");}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
MongoDB
Section titled “MongoDB”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)); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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)); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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)); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
PostgreSQL
Section titled “PostgreSQL”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)); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.postgres can be any IResourceBuilder<IResourceWithConnectionString>, including:
- A connection string (
builder.AddConnectionString("...")) - A PostgreSQL container:
builder.AddPostgres("postgres").AddDatabase("chronicle")
Microsoft SQL Server
Section titled “Microsoft SQL Server”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)); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.sql can be any IResourceBuilder<IResourceWithConnectionString>, including:
- A connection string (
builder.AddConnectionString("...")) - A SQL Server container:
builder.AddSqlServer("sql").AddDatabase("chronicle")
SQLite
Section titled “SQLite”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"));}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.
Connecting a .NET Client
Section titled “Connecting a .NET Client”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); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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:
ConnectionStringset by theconfigureOptionscallback toAddCratisChronicle.Cratis:Chronicle:ConnectionString, orConnectionStringunder your customconfigSection.ConnectionStrings:<connectionStringName>(chronicleby default).- 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:
| Port | Endpoint Name | Description |
|---|---|---|
| 35000 | grpc | Primary 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.
Complete Example
Section titled “Complete Example”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(); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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(); }}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.Docker Images
Section titled “Docker Images”| Image tag | Description | Selected by |
|---|---|---|
cratis/chronicle:latest | Production image — no embedded MongoDB, no self-generated certificates | Aspire publish mode |
cratis/chronicle:latest-development | Development image — includes embedded MongoDB, generates a TLS certificate and ephemeral OAuth keys | Aspire run mode |
cratis/chronicle:latest-development-slim | Development slim image — no embedded MongoDB | Explicit WithImageTag("latest-development-slim") or run it directly |
Pin the image for production
Section titled “Pin the image for production”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");}Kotlin does not support this workflow yet.Java does not support this workflow yet.Elixir does not support this workflow yet.TypeScript does not support this workflow yet.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.