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_IMAGEenvironment 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.
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.
If your certificate is issued through your own process, no profile or flag is involved — drop the files into the ssl/ directory, which is bind-mounted into NGINX, and restart it:
cp your-chain.pem ssl/fullchain.pem cp your-key.pem ssl/privkey.pem docker compose restart nginx
NGINX checks the file contents at start-up, not just the filenames: the chain must contain BEGIN CERTIFICATE and the key must contain BEGIN PRIVATE KEY. The placeholder ssl/*.pem files shipped in the repository fail that check, which is why a fresh clone serves HTTP until you replace them. Watch for the decision in the log:
docker compose logs nginx | grep nginx-entrypoint
A second, independently issued certificate can be served at the same time from ssl/secondary-fullchain.pem and ssl/secondary-privkey.pem, selected by TLS SNI. That one requires SECONDARY_DOMAIN to be set in .env, and NGINX logs a warning and skips it if the certificate is present without the variable. All three certificates — your own, Let's Encrypt, and the secondary — can be active together, each with its own access policy.
For local-only HTTPS evaluation, start the stack with the tls-selfsigned profile and restart NGINX once to load the freshly generated certificate:
docker compose --profile tls-selfsigned up -d docker compose restart nginx
Browsers will display a security warning because the certificate is not signed by a trusted authority. Use this profile only for local evaluation.
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:
-
Confirm every service is healthy:
docker compose ps
-
Tail the logs of any service to confirm startup completed cleanly:
docker compose logs -f countly-api
-
Check that the ClickHouse sink connector is running:
curl -s http://127.0.0.1:8083/connectors/clickhouse-sink/status | jq .
- 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
unhealthy
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
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.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
.env are not taking effect
.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.