This guide shows you how to point a single-host Docker Compose deployment at a MongoDB, ClickHouse, or Kafka cluster you already run, instead of the ones the stack starts for itself. Use it when you have a managed database, a shared cluster, or a datastore another team owns. If you want Countly to run everything on one host, follow Deploy Countly with Single-Host Docker Compose instead.
What you will configure
One connection string per datastore in .env. Setting MONGODB_URI, CLICKHOUSE_URL, or KAFKA_BROKERS stops Compose from creating that container and points every part of Countly that uses it at the address you supply. The three choices are independent of each other.
Prerequisites
Before you begin, make sure you have:
- A working single-host deployment, or a host prepared for one. This guide only changes where the data lives.
- Network reachability from the Countly host to each external endpoint. For ClickHouse that means the native TCP port as well as the HTTP port, not just HTTP.
- Credentials with the privileges listed for each datastore below. Countly creates schemas and topics on first boot, so read-only credentials are not enough.
Choosing What Runs Externally
Each datastore is controlled by its own variable in .env. Leave a variable empty and the bundled container runs as usual; set it and that container is never created.
| Datastore | Variable | Example |
|---|---|---|
| MongoDB | MONGODB_URI |
mongodb://user:pw@host:27017/countly?replicaSet=rs0 |
| ClickHouse | CLICKHOUSE_URL |
https://clickhouse.internal:8443 |
| Kafka | KAFKA_BROKERS |
broker-1:9093,broker-2:9093 |
The three are fully independent. A managed MongoDB alongside a local ClickHouse and Kafka is as valid a combination as moving all three, and every mix in between works the same way.
How the switch works
docker-compose.yml interpolates each variable into an include: path, for example ./datastores/mongodb${MONGODB_URI:+.external}.yml. Empty, it resolves to the file that defines the container; set, it resolves to the .external.yml twin, which defines nothing. Each file's header documents what that datastore expects of you.
Connecting to an External MongoDB
Set the full connection string in .env:
MONGODB_URI=mongodb://countly:secret@mongo-a:27017,mongo-b:27017/countly?replicaSet=rs0&authSource=admin
mongodb+srv:// works too. Credentials, extra hosts, TLS, and maxPoolSize all travel inside the URI.
The cluster must meet two requirements:
-
It must be a replica set. Countly uses change streams and transactions, neither of which a standalone
mongodprovides. A standalone will connect and then fail at runtime. -
The user needs
readWriteon four databases:countly,countly_drill,countly_out, andcountly_fs.
Once MONGODB_URI is set, MONGODB_REPLICA_SET, MONGODB_DATABASE, and MONGODB_MAX_POOL_SIZE no longer apply. Put replicaSet=, the database name, and maxPoolSize= in the URI instead.
Replica set members must be reachable by name
A client that connects with replicaSet= in the URI discovers the rest of the cluster from the replica set configuration and connects to those addresses, not the one you typed. If a member is registered as localhost:27017, or under a hostname the Countly host cannot resolve, the connection succeeds and every subsequent operation fails. Check with rs.conf() and make sure every member host is reachable from the Countly host.
Connecting to an External ClickHouse
Set the HTTP endpoint, scheme included:
CLICKHOUSE_URL=https://clickhouse.internal:8443 CLICKHOUSE_USER=countly CLICKHOUSE_PASSWORD=your-password CLICKHOUSE_DATABASE=countly_drill
The scheme is required. Startup fails without it, because the ClickHouse sink connector derives its host, port, and TLS setting by splitting this URL.
Required Grants
| Grant | Why |
|---|---|
CREATE DATABASE ON *.* |
Countly creates the database if it is missing |
ALL ON countly_drill.* |
Creating and writing the drill schema |
ALL ON identity.* |
User identity resolution |
SELECT ON system.* |
Health and diagnostics |
SYSTEM RELOAD DICTIONARY ON *.* |
Dictionary refresh |
INTROSPECTION ON *.* |
Query introspection |
The bundled user definition at clickhouse/users.d/countly.xml is a working template for the external one.
Native Port and TLS
ClickHouse dictionaries connect over the native TCP protocol rather than HTTP, so they need their own port and TLS flag. Managed ClickHouse usually wants:
CLICKHOUSE_NATIVE_PORT=9440 CLICKHOUSE_SECURE=true
The defaults, 9000 and false, match a plain self-hosted server.
CLICKHOUSE_PASSWORD may be left empty for an external server that uses no password. The bundled server still requires one, because it templates the password into its own configuration at boot.
An empty password means genuinely empty
A ClickHouse user declared with an empty <password></password> accepts only an empty password and rejects any placeholder string. A user declared <no_password/> accepts anything. If your server uses the first form, leave CLICKHOUSE_PASSWORD blank rather than filling in a dummy value.
Match the bundled ClickHouse version
Run the same ClickHouse version as the bundled deployment, set by CLICKHOUSE_VERSION in .env.example. Other versions are untested and can store data incorrectly without reporting any error.
Connecting to an External Kafka
Set the bootstrap servers, comma-separated:
KAFKA_BROKERS=broker-1.example.com:9093,broker-2.example.com:9093
Raise the broker's message ceiling before the first event arrives. Countly publishes batches up to 50 MB, and the Kafka default of roughly 1 MB rejects them:
message.max.bytes=52428800 replica.fetch.max.bytes=52428800
Countly creates its own topics at boot, so the account needs permission to create them — or you pre-create them yourself. Every create the stack issues uses --if-not-exists, so on a pre-provisioned cluster the topic step reduces to a reachability check. See the kafka-init service in docker-compose.yml for the exact names, partition counts, and retention settings.
Authentication
For a cluster that requires credentials:
KAFKA_SECURITY_PROTOCOL=SASL_SSL # PLAINTEXT | SSL | SASL_PLAINTEXT | SASL_SSL KAFKA_SASL_MECHANISM=SCRAM-SHA-512 # PLAIN | SCRAM-SHA-256 | SCRAM-SHA-512 KAFKA_SASL_USERNAME=countly KAFKA_SASL_PASSWORD=your-password
These reach the Countly services, Kafka Connect, the topic bootstrapper, and the push scheduler alike. Leave all four empty for a plaintext cluster.
More than one broker needs a second variable
The Countly services take their broker list as a JSON array. Compose can wrap a single address into an array but cannot split a comma-separated one, so a multi-broker cluster also needs KAFKA_BROKERS_JSON set to the same list in JSON form:
KAFKA_BROKERS=broker-1:9093,broker-2:9093 KAFKA_BROKERS_JSON=["broker-1:9093","broker-2:9093"]
Startup stops if you set one and not the other. This matters because the Kafka client falls back to localhost:9092 when its broker list is not an array, and does so without logging a warning.
TLS uses each image's own CA bundle
A broker or ClickHouse server presenting a certificate signed by a private CA will fail the TLS handshake. The stack does not install additional root certificates. Use a publicly trusted certificate, or add your CA to the images before deploying.
Starting the Stack
Nothing about the command changes. Compose works out which containers to create from the variables themselves:
make up
There is no extra flag, profile, or Compose file to remember.
Three short-lived containers run before anything else. Each proves its datastore answers and the credentials work, creating the database or topics if they are missing. Nothing else starts until all three pass, so a wrong connection string produces one clear error instead of a service that starts healthy and fails quietly later.
| Container | What It Checks |
|---|---|
countly-mongodb-init |
The URI answers and reports a writable primary |
countly-clickhouse-init |
The server answers, credentials work, the database exists |
countly-kafka-init |
The brokers answer and the topics exist |
Verifying the Connection
After make up returns, work through these checks:
-
Read the three checks. Each exits
0when it is satisfied:docker logs countly-mongodb-init docker logs countly-clickhouse-init docker logs countly-kafka-init
You are looking for
MongoDB ready (writable primary),ClickHouse ready., andKafka topics ready.With an external MongoDB you will also seeExternal MongoDB — leaving replica set configuration alone., which confirms the URI was recognised. -
Confirm no bundled datastore container exists for anything you moved off:
make ps
There should be no
countly-mongodb,countly-clickhouse, orcountly-kafka. -
Run the health checks. This prints a line for each external datastore and checks only what still runs locally:
make verify
-
Send one event and confirm it lands in ClickHouse. Replace the app key with your own:
curl -s "http://localhost/i?app_key=YOUR_APP_KEY&device_id=smoke&events=%5B%7B%22key%22%3A%22smoke_test%22%2C%22count%22%3A1%7D%5D"
Then query the drill table. Note that
nholds the custom event name andeholds the event type:SELECT n, ts FROM countly_drill.drill_events WHERE n = 'smoke_test' ORDER BY ts DESC;
Your external datastores are live
Rows appearing with a current timestamp means the whole path is working: SDK to ingestor, ingestor to Kafka, Kafka Connect to ClickHouse, and the API and aggregator to MongoDB.
Hosts with Dedicated Data Disks
Production deployments usually keep MongoDB, ClickHouse, and Kafka on dedicated disks, and provision-docker-host.sh installs a boot-time guard that stops Docker from starting if one of those disks is missing. The guard reads the same .env file, so it has to be regenerated before it knows that a datastore now lives somewhere else:
sudo bash scripts/provision-docker-host.sh fine-tune
The fine-tune mode reapplies the host tuning and rewrites the guard without reinstalling any packages.
Do this before detaching a disk
The guard runs as ExecStartPre on docker.service, so a failure stops the Docker daemon itself, not just Countly. If you move a datastore off the host and then unmount or detach the disk it no longer uses without regenerating the guard, the next reboot leaves the host unable to start Docker at all — possibly weeks after the change that caused it.
To recover, run sudo touch /etc/countly/skip-mount-check followed by sudo systemctl start docker, then run the command above and delete the file.
Hosts that keep their data under the default ./data path are not affected, because there is no dedicated disk to check.
Common Issues and Gotchas
mongosh before changing anything else.mongod, or the URI resolves to a secondary without replicaSet= set. See the replica set warning above.CLICKHOUSE_URL must start with http:// or https://
generate-secrets.sh appended to .env, which is correct for a bundled server and wrong for an external one. Put your real password after the generated block — the last assignment in a .env file wins.CREATE DATABASE. Grant it, or create the database yourself and re-run make up.All sinks failed
docker logs countly-ingestor for the broker address it actually tried. If it reads localhost:9092, the broker list did not reach it as a JSON array — see KAFKA_BROKERS_JSON above.CLICKHOUSE_VERSION in .env.example.systemctl status docker for a check-data-mounts.sh line reporting FATAL. The boot-time mount guard is still expecting a disk for a datastore you moved off the host. Recover with sudo touch /etc/countly/skip-mount-check and sudo systemctl start docker, then run sudo bash scripts/provision-docker-host.sh fine-tune and delete the file. See Hosts with Dedicated Data Disks above..env are ignored
.env and override it. Put per-service sizing in a tier file under tiers/, not in .env.What Countly No Longer Manages
Moving a datastore off the host means this deployment stops managing it. It does not size it, back it up, or monitor it. make verify skips containers that no longer exist, and the MongoDB and Kafka exporters under make up OBS=1 only cover bundled datastores. The mount guard skips the matching data directory, since nothing is written locally for it.
Point your own monitoring and backups at the external servers. The one thing this host still tracks is the boot-time mount guard described above, which needs regenerating whenever the set of local datastores changes.