Deploy Countly with Single-Host Docker Compose

This guide shows you how to deploy the full Countly stack on a single virtual machine using Docker Compose. It is suitable for evaluation, low-traffic production, and any case where one host is enough to absorb your ingestion and query load. For multi-host horizontal scaling, deploy the Countly Helm chart on Kubernetes instead.

What you will deploy

A single docker compose up command brings up the full Countly stack on one host: NGINX, the Countly API, Frontend, Ingestor, Aggregator, and JobServer, plus MongoDB, ClickHouse, Kafka, and Kafka Connect. Defaults are tuned to fit in four to eight CPU cores and 16 to 32 GB of RAM.

If you already run MongoDB, ClickHouse, or Kafka and want Countly to use those instead of the bundled ones, see Deploy Countly with External MongoDB, ClickHouse, or Kafka. The rest of this guide assumes the bundled datastores.

Prerequisites

Before you begin, make sure you have:

  • A Linux VM with at least four CPU cores and 16 GB of RAM. Production deployments should plan for eight cores and 32 GB.
  • Docker Engine and the Docker Compose plugin installed. 

  • Pull access to the Countly Unified container image. The image reference is provided to you by Countly and goes into the COUNTLY_IMAGE environment variable.
  • For TLS via Let's Encrypt: a public DNS record pointing at the VM and ports 80 and 443 reachable from the internet.

Configuring the Environment

All configuration lives in a single .env file in the dockerized_deploy/ directory. Start by copying the template and generating the required secrets:

cp .env.example .env
./scripts/generate-secrets.sh >> .env
$EDITOR .env

The generate-secrets.sh helper appends fresh values for the password hashing secret, session signing key, reports encryption key, and the ClickHouse password. Open .env in your editor and set COUNTLY_IMAGE to the image reference Countly provided.

Required Variables

The docker compose up command will fail loudly if any of the following are missing:

Variable What It Is How to Get It
COUNTLY_IMAGE Unified Countly image reference Provided by Countly (private registry)
COUNTLY_CONFIG__PASSWORDSECRET Password hashing secret ./scripts/generate-secrets.sh
COUNTLY_CONFIG__WEB_SESSION_SECRET Express session signing key Same script
COUNTLY_CONFIG__ENCRYPTION_REPORTS_KEY Reports encryption key Same script
CLICKHOUSE_PASSWORD Password for the countly SQL user Same script

Starting the Stack

With .env populated, bring everything up using the bundled Makefile wrapper:

make up

The first boot takes about three to five minutes while MongoDB initializes its replica set, ClickHouse bootstraps the countly_drill schema, and Kafka Connect registers the ClickHouse sink connector. Watch progress with:

make ps

Each service should report healthy before you proceed. Once they do, open http://localhost (or your VM's public IP) in a browser to reach the Countly first-time setup screen.

Prefer raw docker compose?

The Makefile is a thin wrapper around docker compose that adds tier awareness (covered in Sizing the Stack, below). Raw docker compose up -d does work, and it picks up .env on its own — no --env-file flag needed, since Compose reads that file from the project directory automatically.

It does not load a tier file, though. make up defaults to TIER=T4-16 and layers tiers/T4-16.env on top, while raw docker compose up -d falls back to the smaller defaults written inline in docker-compose.yml. ClickHouse gets 6 GB instead of 12 GB, the JobServer a 1 GB heap instead of 2 GB, and every other service is similarly smaller. Prefer make up unless you have a specific reason not to.

Configuring TLS

By default the stack serves HTTP on port 80, which is suitable when an L7 load balancer terminates TLS upstream or for local evaluation. To terminate TLS directly on the VM, pick one of the three options below. Let's Encrypt and self-signed use a bundled Compose profile; supplying your own certificate needs neither a profile nor a flag.

Let's Encrypt (Production) Your Own Certificate Self-Signed (Local)

Set the domain and contact e-mail in .env, then start the stack with the tls-letsencrypt profile:

echo 'LE_DOMAIN=analytics.example.com' >> .env
echo 'LE_EMAIL=ops@example.com'         >> .env
docker compose --profile tls-letsencrypt up -d

DNS for LE_DOMAIN must already resolve to the host before the certbot service can complete the HTTP-01 challenge. After the first issuance, restart NGINX once so it picks up the freshly minted certificate:

docker compose restart nginx

The certbot service is one-shot. The companion certbot-renew service runs in the background and reloads NGINX after each successful renewal.

Docker socket exposure with auto-renewal

The certbot-renew service mounts /var/run/docker.sock read-only so it can reload NGINX after each renewal. If your security policy forbids exposing the Docker socket, swap to a manual cron job that runs docker compose exec nginx nginx -s reload after renewal instead.

Sizing the Stack

make up on its own runs the smallest single-instance defaults, which fit comfortably on a 16 GB host. To match a larger host, pick one of the bundled tiers. A tier is a preset of resource caps (RAM, CPU, JVM heap) and replica counts for the busy services (API, ingestor, aggregator, jobserver), sized to a host machine.

make up TIER=T32-128

The full tier table runs from T4-16 (4 vCPU / 16 GB) up to T80-320 (80 vCPU / 320 GB). Three tiers—T16-64, T32-128, and T64-256—are validated against benchmark data; the rest are interpolated. After make up TIER=…, sibling commands (make ps, make logs, make restart) inherit the active tier from .active-tier automatically—you only pass TIER= again when switching tiers.

Switching tiers later

To resize the running stack (for example, after upgrading the host), re-run make up TIER=<new-tier>. Compose recreates only the services whose effective config changed, and Kafka rebalances aggregator partitions across the new replicas automatically. To go back to single-instance defaults, run make down && make up with no TIER.

For the per-service memory and CPU values that each tier applies, see tiers/README.md in the dockerized_deploy/ directory. For fine-grained tweaks beyond a tier preset (for example, raising one service's heap on a custom host size), the per-service env vars in .env.example remain available as overrides. For multi-host horizontal scaling beyond what a single tier delivers, switch to the Countly Helm chart on Kubernetes.

Network and Port Exposure

Out of the box, only NGINX (ports 80 and 443) is reachable externally. The internal datastore ports are bound to 127.0.0.1 for diagnostics from the host:

  • MongoDB: 127.0.0.1:27017
  • ClickHouse HTTP: 127.0.0.1:8123
  • Kafka: 127.0.0.1:9092
  • Kafka Connect: 127.0.0.1:8083

Datastores run unauthenticated on the internal network

The Docker bridge network is the trust boundary. MongoDB and ClickHouse run without authentication on it, which is safe only because nothing outside the host can reach them. Keep it that way: NGINX is the only service that should be reachable from outside the VM.

Data and Persistence

Each datastore keeps its files in its own subdirectory, and all three sit under ./data/ next to the Compose file by default. This is the only state that matters: it survives make down, and losing it loses your analytics.

data/
├── mongodb/
├── clickhouse/
└── kafka/

Set COUNTLY_DATA_DIR in .env to move all three somewhere else in one go.

Use a Separate Disk per Datastore

For production, give MongoDB, ClickHouse, and Kafka a dedicated disk each and point them at it individually. The three have very different access patterns, so separating them keeps one from starving the others on IOPS, lets you size and grow each independently, and means a full or failing disk takes down one datastore instead of all of them. It also keeps every one of them off the root volume, where filling up takes down the host.

MONGODB_DATA_DIR=/mongodb-data
CLICKHOUSE_DATA_DIR=/clickhouse-data
KAFKA_DATA_DIR=/kafka-data

Each setting overrides COUNTLY_DATA_DIR for that datastore, so you can move one to a dedicated disk and leave the others where they are.

Remove lost+found before Kafka's first start

A freshly formatted ext4 or XFS volume contains a lost+found directory. Kafka in KRaft mode treats any unrecognised entry in its data directory as a fatal error and refuses to start, so delete it from the disk you mount at KAFKA_DATA_DIR before the first boot.

When a datastore is on a dedicated disk, a boot-time guard checks the disk is actually mounted before Docker starts, so a disk that fails to mount stops the stack rather than letting a datastore silently write to the root volume instead. scripts/provision-docker-host.sh installs it.

Verifying the Deployment

After docker compose up returns, run through these checks:

  1. Confirm every service is healthy:

    docker compose ps
  2. Tail the logs of any service to confirm startup completed cleanly:

    docker compose logs -f countly-api
  3. Check that the ClickHouse sink connector is running:

    curl -s http://127.0.0.1:8083/connectors/clickhouse-sink/status | jq .
  4. Open http://localhost (or your TLS hostname) in a browser. Countly should serve the first-time setup screen.

You have a running Countly stack

Once the setup screen loads, complete admin user creation, then start sending events from your SDKs to the /i endpoint on your hostname.

Common Issues and Gotchas

A service is stuck in unhealthy
Run docker compose ps to identify which service, then docker compose logs --tail=200 <service> to read the error. Common culprits: the countly-* services need mongodb and clickhouse to reach healthy first, and connector-init must succeed before the ingestor accepts traffic. Verify COUNTLY_IMAGE is pullable with docker pull <image> if any pod cannot start at all.
connector-init fails with HTTP 401
A 401 in the response from kafka-connect means the ClickHouse credentials do not match. Confirm CLICKHOUSE_PASSWORD in .env matches the password the countly SQL user was created with, then re-run docker compose up -d connector-init.
The connector keeps restarting

Inspect the connector status:

curl -s http://127.0.0.1:8083/connectors/clickhouse-sink/status | jq .

If state is FAILED, the JSON response includes the trace. The most common cause is a schema mismatch between the JSON Kafka events and the drill_events table. Restart the connector task:

curl -X POST http://127.0.0.1:8083/connectors/clickhouse-sink/restart
Changes to .env are not taking effect
Most environment variables are baked into containers at start time. After editing .env, run make up (or docker compose up -d) again. Compose recreates any container whose effective environment changed and leaves the rest alone.

Lifecycle Commands

Useful day-two commands for managing the stack. The Makefile targets are tier-aware (they re-use the tier from .active-tier); the docker compose equivalents work identically when you're on the single-instance defaults.

make ps                              # status (alias: docker compose ps)
make logs                            # follow last 200 lines from all services
make restart                         # restart all running services in place
make pull                            # pull updated images for all services
make tier-list                       # list all available tiers
make down                            # stop, keep data (alias: docker compose down)
docker compose logs -f countly-api   # follow logs of one specific service
docker compose restart countly-api   # restart one specific service
docker compose down -v               # stop and drop named volumes

Wiping bind-mount data is destructive

docker compose down -v removes named volumes but does not touch bind-mount data under ./data/. To wipe everything, including persisted MongoDB, ClickHouse, and Kafka state, run docker compose down -v && rm -rf ./data. This action is irreversible.

Was this page helpful?
Reach out to us for any other questions.
Helpful?

Looking For More Help?