---
title: Production hosting
editUrl: https://github.com/Cratis/Chronicle/edit/main/Documentation/hosting/production.md
description: Run the Chronicle production image with durable storage, TLS and encryption certificates, health probes, backups, and clustering.
---


The production image differs from the development images in ways that stop a first deployment cold: it has no bundled database, generates no certificates, and creates no default administrator password. This page lists what the production image requires, and how to run, probe, back up, secure, and scale it. MongoDB is the storage used in the examples; the image also supports PostgreSQL, SQL Server, and SQLite through the [storage configuration](/chronicle/hosting/configuration/storage/).

## Docker Image

Chronicle is distributed as a Docker image available on Docker Hub:

```bash
# Latest stable version
docker pull cratis/chronicle:latest

# A specific release (recommended for production) - pin the version you tested
docker pull cratis/chronicle:19.6.1
```

[![Docker](https://img.shields.io/docker/v/cratis/chronicle?label=Chronicle&logo=docker&sort=semver)](https://hub.docker.com/r/cratis/chronicle)

## Configuration

Chronicle requires configuration to define its runtime behavior. For complete configuration details, see the [Configuration](/chronicle/hosting/configuration/) guide.

The configuration file must be mounted into the container at `/app/chronicle.json`, or you can use environment variables with the `Cratis__Chronicle__` prefix.

### What makes a build a production build

The production code paths are selected by the **`DEVELOPMENT` compile-time symbol**, not by `ASPNETCORE_ENVIRONMENT` or `DOTNET_ENVIRONMENT`. The symbol is defined for `Debug` builds only, so `cratis/chronicle:latest` — published from a Release build — always takes the production path, and no environment variable can switch it back. Only the `latest-development` and `latest-development-slim` images are compiled with the symbol.

That matters because the production path needs all three of these before it will run — and two of them stop startup dead:

| Setting | Environment variable | Why it is required |
| --- | --- | --- |
| Storage connection | `Cratis__Chronicle__Storage__ConnectionDetails` | Defaults to an empty string — there is no built-in fallback to connect to. Pair it with `Cratis__Chronicle__Storage__Type` when the backend is not MongoDB, which is what an unset type resolves to |
| TLS certificate | `Cratis__Chronicle__Tls__CertificatePath` (and `__CertificatePassword`) | Required by default for port 35000 to multiplex gRPC and HTTP/1.1. Without it the server throws "No TLS certificate is configured" at startup. Explicit `Tls__Enabled=false` instead requires a private h2c backend behind an HTTPS proxy |
| Encryption certificate | `Cratis__Chronicle__EncryptionCertificate__CertificatePath` (and `__CertificatePassword`) | Protects the internal OAuth authority's signing and encryption keys. Without a certificate the server throws "An encryption certificate is required in production" at startup — unless the internal authority is not in play, see below |

The encryption certificate is required because the **internal OAuth authority** needs it. It is therefore not required when that authority is out of the picture: turning the `oAuthAuthority` feature off, or pointing `Cratis__Chronicle__Authentication__Authority` at an external authority (which disables the internal one automatically), removes the requirement. See [Features](/chronicle/hosting/configuration/features/). The value-encryption subsystem is separate — it still throws `EncryptionCertificateNotConfigured` on first use if you encrypt values without a certificate configured.

"Not configured" and "configured wrongly" are different states. A `CertificatePath` pointing at a file that is not there stops startup with `EncryptionCertificateFileNotFound`, naming the path — it does not quietly fall back to running without a certificate. See [What a broken ring does](/chronicle/hosting/encryption-certificate/#what-a-broken-ring-does).

The development images generate a self-signed TLS certificate and fall back to ephemeral OAuth keys instead of throwing, which is exactly why a Compose file that works against `latest-development` fails against `latest`. See [Data Protection Key Encryption](/chronicle/hosting/encryption-certificate/) and [TLS Configuration](/chronicle/hosting/configuration/tls/) for generating and mounting the certificates.

## Storage requirements

Chronicle opens MongoDB transactions when it appends events, and transactions are only served by a replica set (or a sharded cluster) — a standalone `mongod` rejects them. **Every MongoDB deployment behind Chronicle must be a replica set**, including a single-node one. The Compose recipes below initialize a single-node replica set named `rs0`; a real production deployment uses a multi-member replica set or a managed service such as MongoDB Atlas, which is already one.

Chronicle's connection string carries `?directConnection=true` so the driver talks to the member it was given instead of following the replica set's advertised host name. This does not disable transactions — they keep working because MongoDB is running as a replica set.

## Port Configuration

Chronicle exposes the following ports:

| Port | Service | Description |
| --- | --- | --- |
| 11111 | Orleans Silo | Internal Orleans clustering |
| 30000 | Orleans Gateway | Client connections to Orleans cluster |
| 35000 | Main Service | gRPC (HTTP/2) plus REST API, Workbench, OAuth, and health checks (HTTP/1.1), multiplexed over one TLS port |

> **Note**: By default port 35000 serves HTTP/2 (gRPC) and HTTP/1.1 over TLS and requires a certificate in production. Alternatively set `Tls:Enabled=false` explicitly for a **private** HTTP/2-only h2c backend behind an HTTPS-terminating reverse proxy. The proxy must forward all traffic using h2c, and clients and browsers must use the public HTTPS endpoint. With authentication enabled, configure an external HTTPS `Authentication:Authority` and forward the public HTTPS scheme. See [TLS Configuration](/chronicle/hosting/configuration/tls/).

## Docker Deployment

### Basic Docker Run

```bash
docker run -d \
  --name chronicle \
  -p 35000:35000 \
  -v /path/to/chronicle.json:/app/chronicle.json:ro \
  -v /path/to/certs:/certs:ro \
  -e Cratis__Chronicle__Storage__ConnectionDetails='mongodb://mongodb:27017/?directConnection=true' \
  -e Cratis__Chronicle__Tls__CertificatePath=/certs/chronicle.pfx \
  -e Cratis__Chronicle__Tls__CertificatePassword="$CHRONICLE_CERTIFICATE_PASSWORD" \
  -e Cratis__Chronicle__EncryptionCertificate__CertificatePath=/certs/encryption-cert.pfx \
  -e Cratis__Chronicle__EncryptionCertificate__CertificatePassword="$ENCRYPTION_CERTIFICATE_PASSWORD" \
  cratis/chronicle:latest
```

Both certificates and the storage connection are mandatory — see [What makes a build a production build](#what-makes-a-build-a-production-build). The MongoDB instance this points at must be a replica set.

### Docker Compose

```yaml
services:
  chronicle:
    image: cratis/chronicle:latest
    ports:
      - "35000:35000"
    environment:
      Cratis__Chronicle__Storage__Type: "MongoDB"
      Cratis__Chronicle__Storage__ConnectionDetails: "mongodb://mongodb:27017/?directConnection=true"
      Cratis__Chronicle__Tls__CertificatePath: "/certs/chronicle.pfx"
      Cratis__Chronicle__Tls__CertificatePassword: "${CHRONICLE_CERTIFICATE_PASSWORD}"
      Cratis__Chronicle__EncryptionCertificate__CertificatePath: "/certs/encryption-cert.pfx"
      Cratis__Chronicle__EncryptionCertificate__CertificatePassword: "${ENCRYPTION_CERTIFICATE_PASSWORD}"
    volumes:
      - ./chronicle.json:/app/chronicle.json:ro
      - ./certs:/certs:ro
    depends_on:
      - mongodb
      - mongodb-init
    restart: unless-stopped

  mongodb:
    image: mongo:8.2
    command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
    # This MongoDB has no authentication. Publish it on loopback at most, for host tools.
    ports:
      - "127.0.0.1:27017:27017"
    volumes:
      - mongodb_data:/data/db
    restart: unless-stopped

  mongodb-init:
    image: mongo:8.2
    depends_on:
      - mongodb
    restart: "no"
    command:
      - /bin/bash
      - -lc
      - |
        until mongosh --host mongodb --quiet --eval "db.adminCommand('ping')" >/dev/null 2>&1; do
          sleep 1
        done
        mongosh --host mongodb --quiet --eval "
        try {
          rs.status();
        } catch (e) {
          rs.initiate({
            _id: 'rs0',
            members: [{ _id: 0, host: 'localhost:27017' }]
          });
        }"

volumes:
  mongodb_data:
```

The `mongodb-init` service is a one-shot container: it waits for `mongod` to answer, initiates the `rs0` replica set if it is not already initiated, and exits. This single-node MongoDB is a starting point, not a hardened database: it runs without authentication, so it is published on loopback only. A real deployment enables MongoDB authentication and TLS, or uses a managed replica set, and puts the credentials in the connection string from your secret store. Advertising the member as `localhost:27017` keeps the replica set reachable from host tools such as `mongosh` and Compass, while Chronicle reaches MongoDB over the Compose network with `directConnection=true` so it does not follow that advertised host back into its own container.

If an existing MongoDB data volume was initialized with a different replica-set host, wipe it (`docker compose down -v`) before starting again so `rs.initiate()` can apply the new host.

The remaining settings stay in the mounted `chronicle.json`; the environment variables above override any values it holds for the same keys. Keep the certificate passwords out of the file — supply them from your secret store, as the `${...}` references do here.

## Best Practices

1. **Use specific version tags** instead of `latest` for production deployments
2. **Mount configuration as read-only** (`-v /path/to/chronicle.json:/app/chronicle.json:ro`)
3. **Use environment-specific connection strings** for MongoDB
4. **Configure appropriate timeouts** based on your infrastructure (see [Configuration](/chronicle/hosting/configuration/))
5. **Enable health checks** for container orchestration
6. **Set up monitoring** for all exposed ports
7. **Use secrets management** for sensitive configuration values
8. **Enable TLS** with proper certificates (see [TLS Configuration](/chronicle/hosting/configuration/tls/))

## Health Checks

Chronicle exposes a health check endpoint at `/health` by default.

:::note
**Probe Chronicle from outside the container.** The production image is built on `mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled`, which ships no shell and no `curl` or `wget`. A Docker `HEALTHCHECK` — or a Compose `healthcheck:` — runs *inside* the container, so any command you give it fails immediately and the container is reported unhealthy no matter how healthy Chronicle is.
:::

Use your orchestrator's own HTTP probe instead. The default TLS main port may use a self-signed certificate that some probers reject; a private h2c main port requires an HTTP/2-capable prober. Publish a dedicated plaintext HTTP/1.1 health port when either presents a problem:

```bash
Cratis__Chronicle__Health__Port=8080
Cratis__Chronicle__Health__Tls=false
```

Then point the probe at it:

```yaml
livenessProbe:
  httpGet:
    path: /health
    port: 8080
    scheme: HTTP
readinessProbe:
  httpGet:
    path: /health
    port: 8080
    scheme: HTTP
```

Treat that port as internal — it serves the other HTTP/1.1 endpoints too, so do not expose it publicly.

> **Note**: The health check endpoint path is configurable. See [Root Properties](/chronicle/hosting/configuration/root-properties/#health-check-endpoint) for details, and [Health Endpoint](/chronicle/hosting/configuration/health-endpoint/) for the dedicated port.

## Backup and restore

A Chronicle deployment is not restorable from a database backup alone. Three things have to be backed up, and restored **in this order** — restoring them in the wrong order produces data nothing can read, and nothing about the restore reports it.

| Order | What | Where it lives |
| --- | --- | --- |
| 1 | The encryption-certificate ring — every certificate and its password, in the shape the ring had **when the backup was taken** | Your secret store |
| 2 | The storage backend (MongoDB or SQL) — event store data, the Data Protection key ring, webhook definitions and their encrypted credentials, OAuth applications and tokens | Your database backups |
| 3 | The compliance key store, when one is configured, at the same point in time as (2) | Vault, Azure Key Vault, or the general storage |

The Data Protection key ring lives *inside* the storage backend, so a database backup already carries it. What a database backup cannot carry is the certificate that unlocks it — that is why the certificates come first, and why they have to be the ones that were live when the backup was taken rather than the ones live today.

:::danger
**A certificate has to stay in the backup set for longer than it stays in the ring** — for at least as long as the oldest backup you would still restore. Retiring a certificate from the ring and deleting the file makes every backup taken before that rotation permanently unreadable.
:::

Start one node after a restore and read `GET /diagnostics/encryption-certificates` before starting the rest: keys reported with the role `Retired` mean the ring is missing a certificate the restored data needs. The full procedure, the retention rule and the failure modes are in [Backup and restore ordering](/chronicle/hosting/encryption-certificate/#backup-and-restore-ordering); the PII key store has its own ordering in [Compliance Storage](/chronicle/hosting/configuration/compliance-storage/).

## Security Considerations

- **Network Isolation**: Run Chronicle in a private network with MongoDB
- **Connection Encryption**: Use TLS for all connections in production
- **Client certificate validation**: The clients connect over TLS but accept *any* server certificate unless the connection string sets `skipTlsValidation=false`. Set it for every client that reaches a production kernel. See [Client TLS](/chronicle/configuration/tls/) and [Server connection strings](/chronicle/connection-strings/server/)
- **Workbench behind a path-routed proxy**: When one host serves several instances by path, set `Workbench:BasePath` so the Workbench works under its prefix. See [Workbench](/chronicle/hosting/configuration/workbench/)
- **Initial administrator**: The production image creates the administrator without a password and lets the first visitor to the Workbench set one. Supply the initial password from your secret store through `Authentication:AdminUser`, or keep the port unreachable until setup is done. See [Authentication](/chronicle/hosting/configuration/authentication/)
- **Access Control**: Implement proper firewall rules for exposed ports
- **Secrets Management**: Use external secret management for sensitive configuration
- **Regular Updates**: Keep Chronicle and MongoDB images updated
- **TLS Certificates**: Configure valid TLS certificates for production (see [TLS Configuration](/chronicle/hosting/configuration/tls/))

## Scaling

Chronicle supports horizontal scaling through Orleans clustering:

1. **Multiple Instances**: Deploy multiple Chronicle containers
2. **Load Balancing**: Use a load balancer for API traffic (port 35000)
3. **Orleans Clustering**: Ensure Orleans ports (11111, 30000) are accessible between instances
4. **Shared Storage**: All instances must connect to the same MongoDB cluster. Only MongoDB storage supports more than one node; with PostgreSQL, SQL Server or SQLite, run a single instance (see [Clustering](/chronicle/hosting/configuration/clustering/))

:::caution
Every multi-node deployment **must** set the clustering type to `MongoDB`. The default is `Localhost`,
which is single-node membership. Two containers left on the default that share one MongoDB do **not**
join as one cluster - each forms its own isolated single-node cluster over the same data, a split-brain
topology reported by no error at startup. The server logs a warning when it detects localhost clustering
against non-local storage, but it does not refuse to start.
:::

Set the clustering type explicitly on every node, and give every node the same cluster and service id:

```bash
Cratis__Chronicle__Clustering__Type=MongoDB
Cratis__Chronicle__Clustering__ClusterId=chronicle
Cratis__Chronicle__Clustering__ServiceId=chronicle
```

See [Clustering](/chronicle/hosting/configuration/clustering/) for the full set of clustering properties.

### Two-node Docker Compose

Both nodes run the same image, connect to the same MongoDB, and set `Cratis__Chronicle__Clustering__Type=MongoDB`
so they form a single cluster over the shared storage:

```yaml
services:
  chronicle-1:
    image: cratis/chronicle:latest
    ports:
      - "35001:35000"
    environment:
      Cratis__Chronicle__Storage__ConnectionDetails: "mongodb://mongodb:27017/?directConnection=true"
      Cratis__Chronicle__Clustering__Type: "MongoDB"
      Cratis__Chronicle__Clustering__ClusterId: "chronicle"
      Cratis__Chronicle__Clustering__ServiceId: "chronicle"
      # Port 35000 requires a TLS certificate - see the TLS Configuration guide.
      Cratis__Chronicle__Tls__CertificatePath: "/certs/chronicle.pfx"
      Cratis__Chronicle__Tls__CertificatePassword: "${CHRONICLE_CERTIFICATE_PASSWORD}"
      # Every node must share the same encryption certificate.
      Cratis__Chronicle__EncryptionCertificate__CertificatePath: "/certs/encryption-cert.pfx"
      Cratis__Chronicle__EncryptionCertificate__CertificatePassword: "${ENCRYPTION_CERTIFICATE_PASSWORD}"
    volumes:
      - ./certs:/certs:ro
    depends_on:
      - mongodb
      - mongodb-init
    restart: unless-stopped

  chronicle-2:
    image: cratis/chronicle:latest
    ports:
      - "35002:35000"
    environment:
      Cratis__Chronicle__Storage__ConnectionDetails: "mongodb://mongodb:27017/?directConnection=true"
      Cratis__Chronicle__Clustering__Type: "MongoDB"
      Cratis__Chronicle__Clustering__ClusterId: "chronicle"
      Cratis__Chronicle__Clustering__ServiceId: "chronicle"
      Cratis__Chronicle__Tls__CertificatePath: "/certs/chronicle.pfx"
      Cratis__Chronicle__Tls__CertificatePassword: "${CHRONICLE_CERTIFICATE_PASSWORD}"
      Cratis__Chronicle__EncryptionCertificate__CertificatePath: "/certs/encryption-cert.pfx"
      Cratis__Chronicle__EncryptionCertificate__CertificatePassword: "${ENCRYPTION_CERTIFICATE_PASSWORD}"
    volumes:
      - ./certs:/certs:ro
    depends_on:
      - mongodb
      - mongodb-init
    restart: unless-stopped

  mongodb:
    image: mongo:8.2
    command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
    # This MongoDB has no authentication. Publish it on loopback at most, for host tools.
    ports:
      - "127.0.0.1:27017:27017"
    volumes:
      - mongodb_data:/data/db
    restart: unless-stopped

  mongodb-init:
    image: mongo:8.2
    depends_on:
      - mongodb
    restart: "no"
    command:
      - /bin/bash
      - -lc
      - |
        until mongosh --host mongodb --quiet --eval "db.adminCommand('ping')" >/dev/null 2>&1; do
          sleep 1
        done
        mongosh --host mongodb --quiet --eval "
        try {
          rs.status();
        } catch (e) {
          rs.initiate({
            _id: 'rs0',
            members: [{ _id: 0, host: 'localhost:27017' }]
          });
        }"

volumes:
  mongodb_data:
```

Both nodes share one encryption certificate on purpose: Data Protection keys live in the shared storage, so
every instance must be able to decrypt what any other instance wrote. The same holds for the whole
certificate ring during a rotation — give every node the same `Previous` list, or a node will fail to read
what another one wrote. See [Data Protection Key Encryption](/chronicle/hosting/encryption-certificate/) and
[Rotating the certificate](/chronicle/hosting/encryption-certificate/#rotating-the-certificate).

Each container reaches the others over the Compose network by service name, so the default advertised
address works. When you instead run multiple nodes directly on one host, set
`Cratis__Chronicle__Clustering__AdvertisedIP` and distinct `SiloPort`/`GatewayPort` values per node - see
[Clustering](/chronicle/hosting/configuration/clustering/).
