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

For local development, call AddCratisChronicle() without arguments. This uses the Chronicle development image (cratis/chronicle:latest-development), which bundles MongoDB and generates its own self-signed TLS certificate at startup — so neither an external database nor a certificate is required.

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();
}
}

The development image is ideal for:

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

For production or staging environments, use the configure callback. This switches to the production image (cratis/chronicle:latest), which drops both the embedded MongoDB and the development conveniences — so it needs three things wired up before it will start:

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"));
}
}

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, but supply it: Chronicle reads the file as PKCS#12 only when a password is present, and a TLS listener needs the private key inside that PKCS#12 container. 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");
}

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));
}
}

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 against the production image, AddCratisChronicleMongoDB() provisions this 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));
}
}

That solves the database half only. Running the production image locally still needs both certificates from Certificates — if all you want is a Chronicle to develop against, AddCratisChronicle() with no configure callback is the shorter road, because the development image brings its own replica set and its own certificates.

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.0")
.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));
}
}

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 embedded development image (AddCratisChronicle() with no configure callback) bundles a ready replica set, which is why the development path just works; the requirement only surfaces once you move to the production image with an external database.

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));
}
}

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));
}
}

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"));
}

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);
}
}

The connection string exposed by ChronicleResource uses the chronicle:// scheme and points to the gRPC port (35000 by default), matching the format expected by ChronicleOptions.FromConnectionString().

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();
}
}

For development, drop the whole callback. 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();
}
}
Image tagDescriptionSelected by
cratis/chronicle:latestProduction image — no embedded MongoDB, no self-generated certificatesAddCratisChronicle(configure: ...)
cratis/chronicle:latest-developmentDevelopment image — includes embedded MongoDB, generates a TLS certificate and ephemeral OAuth keysAddCratisChronicle()
cratis/chronicle:latest-development-slimDevelopment slim image — no embedded MongoDBNot selected by the Aspire integration — 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");
}

WithImageTag overrides the tag the integration selected while leaving the image and registry alone, and it does not change which image class is used — a configure callback still selects the production image. Pin to a released Chronicle version, and treat the upgrade as a deliberate change.

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