Can I self-host Amplitude?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Countly Lite. It takes one prompt, a 4096 MB VPS, and about 120 minutes. That is $49 a month you stop paying Amplitude — $588 a year on the Plus plan, a metered rate, not a whole bill.
Why people pay for Amplitude
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Amplitude answers questions a pageview counter cannot: how many people who signed up last month reached the second step, which cohort keeps coming back, where the drop is inside a flow you built. That is a different job from web analytics, and the part you are paying for is the query engine that will answer it over hundreds of millions of events in a few seconds, plus the people who keep that engine running while your product team asks it something new every afternoon.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | The page says 2M Events/month forever, with no credit card, and includes basic product analytics, 10K monthly session replays and 2,000 AI feedback records. |
| Plusthe plan this page prices against | $49/mo metered | The pricing page prices this as Starts at $0 with the first 2M Events/month free, scaling up to 70M Events, and prints no rate above that allowance. Third-party pricing trackers report $49 a month billed annually as the entry rate, which is the figure recorded here; the vendor's own page does not show it. |
| Growth | quote only | Custom, event based pricing, contact sales. Includes 20K monthly session replays. |
| Enterprise | quote only | Custom, event based pricing, contact sales. Includes 50K monthly session replays. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by Countly Lite
One project, named before the prompt, so you know what you are about to install.
Sessions, custom events and crashes from your own apps, collected on a hostname you own, with no monthly event meter.
The only entry here built for the same shape of data: an SDK in your app posts sessions, custom events, user properties and crashes to a server you own, and there is no monthly event meter on the other end. Be clear about where the line falls, because it falls exactly where Amplitude earns its money. The open Lite server ships collection, the event and segment reports, crash reporting, push, ratings, remote config and the read and write APIs. Funnels, cohorts, user flows, retention and A/B testing are Enterprise plugins whose directories are not in the open repository at all. So this replaces the pipeline and the dashboard, and it does not replace the analysis.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Countly Liteour pickONE EVENING— setup effort 2 of 4
Sessions, custom events and crashes from your own apps, collected on a hostname you own, with no monthly event meter.
The only entry here built for the same shape of data: an SDK in your app posts sessions, custom events, user properties and crashes to a server you own, and there is no monthly event meter on the other end. Be clear about where the line falls, because it falls exactly where Amplitude earns its money. The open Lite server ships collection, the event and segment reports, crash reporting, push, ratings, remote config and the read and write APIs. Funnels, cohorts, user flows, retention and A/B testing are Enterprise plugins whose directories are not in the open repository at all. So this replaces the pipeline and the dashboard, and it does not replace the analysis.
- MatomoONE EVENING— setup effort 2 of 4
The full Google-Analytics-shaped suite on your own hostname, with visitor profiles, funnels and no monthly hit meter.
Worth naming because it is the one people reach for next, and worth being precise about: Matomo and the other web analytics tools in this catalogue count visits to pages, and Amplitude counts events inside a product. Matomo will track custom events and goals and give you a visitor profile, which covers a real slice of the question, and its Funnels plugin is a paid licence on the Marketplace even when you host it yourself. Pick it if what you actually want is your site's traffic without a hit meter, not your product's behaviour.
The swap
You'd run
Countly Lite
ONE EVENING · ~120 min to running · 4096 MB RAM
Amplitude Plus · a metered rate, not a whole bill · vendor list price · checked 2026-08-07 · source · confidence: medium
Before you start
- RAM floor
- 4096 MBfloor from upstream docs — not measured by us yet
- Disk
- 20 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~120 min1–3 hours, through the first backup
The prompt
Two paths to the same Countly Lite: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
318 lines · 14,999 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Countly Lite 25.03.51 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Say this when you ask: every SDK they add later posts its events to `<DOMAIN>`, so it ends up in
the source of every app and page they measure, and moving it means shipping all of them again.
Its A record must already point at this server.
Countly Lite needs 4096 MB of RAM available and 20 GB free on /srv: the image starts a Node
collection API and a Node dashboard with a 2048 MB heap ceiling each, and MongoDB is a third
process beside them. Upstream publishes the image for amd64 only. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 4096 MB or free disk is under 20 GB, print both numbers and stop. Do
not install and hope. If the architecture is `arm64`, print it and stop: there is no arm64 tag to
fall back to. If `dig +short` prints nothing, print that and stop.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/countly /srv/countly/backups
sudo install -d -m 700 /srv/countly/mongo
ls -la /srv/countly
```
Assert: `ls -la` shows `backups` owned by the login user and `mongo` at mode `700` owned by
root. The MongoDB image chowns its own data directory on first start, so leave that one alone.
Countly gets no directory: file storage defaults to GridFS, so uploads are database documents.
## 3. Secrets
Two secrets, both read by the dashboard. `WEB_SESSION_SECRET` replaces the value upstream ships
in its sample config, which is published in the repository and signs the session cookie.
`PASSWORDSECRET` is mixed into every password before it is hashed, so it has to exist before the
first account does. Generate both on the server, print neither, and keep both out of your summary
and out of any log line.
```bash
umask 077
cat > /srv/countly/.env <<EOF
COUNTLY_CONFIG_FRONTEND_WEB_SESSION_SECRET=$(openssl rand -hex 32)
COUNTLY_CONFIG_FRONTEND_PASSWORDSECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/countly/.env
umask 022
ls -l /srv/countly/.env
```
Assert: the file exists with mode `-rw-------`. Tell the user it is the half of the backup that
is not the database: a MongoDB dump restored without it returns every account and no working
password.
## 4. compose.yml
```bash
cat > /srv/countly/compose.yml <<'EOF'
# Countly Lite · the deterministic fallback. Authored by caniselfhostit from the
# upstream sources, not copied from a repository:
# single-image build . https://github.com/Countly/countly-server/blob/25.03.51/Dockerfile-core
# api config keys .... https://github.com/Countly/countly-server/blob/25.03.51/api/config.sample.js
#
# countly-core is upstream's single-image build: an nginx, the collection API on
# 3001 and the dashboard on 6001 all run inside it under runit.
#
# MongoDB runs with no user and no password, as upstream's compose does, which
# is acceptable only because it publishes no port. fileStorage defaults to
# gridfs, so uploads are documents in the database and the countly service needs
# no volume: all of the state is in MongoDB. Digests read on 2026-08-07;
# countly-core is amd64 only, which is why step 1 stops on arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mongodb:
image: mongo:8.0.28@sha256:98605bfa1bb2a15dd82109e1d78ad31527a9a744909fab4606076fa71a0ae515
container_name: countly-db
restart: unless-stopped
command: ["mongod", "--bind_ip_all", "--quiet"]
volumes:
- /srv/countly/mongo:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand('ping').ok ? 0 : 1)"]
interval: 10s
timeout: 10s
retries: 30
start_period: 20s
# No `ports:` at all: 27017 is reachable only from the other container.
countly:
image: countly/countly-core:25.03.51@sha256:e3d94902a3c4c609fdda3895ea4326693c5f30289cf2f6a84e35b9773a182c03
container_name: countly
restart: unless-stopped
env_file: /srv/countly/.env
environment:
# The empty middle component means "the API and the dashboard both".
COUNTLY_CONFIG__MONGODB_HOST: mongodb
COUNTLY_CONFIG__FILESTORAGE: gridfs
# Upstream forks one worker per core; each is a Node heap of its own.
COUNTLY_CONFIG_API_API_WORKERS: "2"
# Caddy terminates TLS and the dashboard cannot see that from in here.
# Told, it stamps X-Forwarded-Proto https before its own session
# middleware runs, which is what marks the cookie Secure.
COUNTLY_CONFIG_FRONTEND_WEB_SECURE_COOKIES: "true"
COUNTLY_CONFIG_FRONTEND_COOKIE_SECURE: "true"
# Upstream ships an Intercom widget on and usage reporting to
# stats.count.ly on. Both off.
COUNTLY_CONFIG_FRONTEND_WEB_USE_INTERCOM: "false"
COUNTLY_CONFIG_FRONTEND_WEB_TRACK: none
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8174.
- "127.0.0.1:8174:80"
depends_on:
mongodb:
condition: service_healthy
EOF
cd /srv/countly && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port, one bind mount.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-countly
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Countly Lite · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/Countly/countly-server/blob/25.03.51/bin/config/nginx.server.conf
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. Every SDK you add later posts its events to that name.
<DOMAIN> {
encode zstd gzip
header {
# Every page you measure loads /sdk/web/countly.min.js from this host,
# so a downgrade on one of them is a downgrade on all of them.
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# No X-Frame-Options on purpose: the rating and survey widgets are served
# from this host to be drawn in a frame on your own site. 8174 is the
# loopback port compose publishes here, and the nginx inside the image is
# what splits /i and /o off to the collection API.
reverse_proxy 127.0.0.1:8174
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-countly, reload, and report what it objected to. Caddy requests the
certificate on first request and renews it on its own. Nothing to schedule.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, 443/udp is
HTTP/3. 8174 stays closed because compose binds it to 127.0.0.1, and 27017 because compose
never publishes it: a MongoDB with no host port gives a firewall rule nothing to apply to,
which is why it can run without a password. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no rule mentioning 8174 or 27017.
## 7. Start and verify
The image runs a first-boot script that writes its plugin list and loads a city database into
MongoDB before serving anything, so the first `up` takes minutes.
```bash
cd /srv/countly
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/ping; echo
curl -sS https://<DOMAIN>/o/ping; echo
curl -sS https://<DOMAIN>/setup | grep -c 'data-localize="setup.ready"'
```
Assert all four and print what you got: the loop ends on `200`, the dashboard prints the bare
word `Success`, the collection API prints `{"result":"Success"}`, the last command prints `1`.
Those two ping endpoints are the pair upstream's own health-check script calls, and each answers
only after its process has reached MongoDB; the `1` means the registration screen is being
served, which happens only while nobody owns this install. If any of the four misses, stop, run
`docker compose logs --tail 40 countly` and `docker compose logs --tail 20 mongodb`, and name the
likely earlier step: a database that never reports healthy is step 2, a Caddy `502` over a
running container means nothing answers on 8174 yet. A running container is not success.
The first screen at https://<DOMAIN>/setup shows the heading `Your Countly server is ready!`
over a `Full Name` field and a `Create Account` button.
STOP: tell the user to open https://<DOMAIN>/setup, create their administrator account, then
finish the short wizard after it by adding their first application, and wait. Do not continue
until they confirm both. Two things to tell them first: nothing else will ever create that
account and no mail server here can reset it, so the password goes in their password manager as
they type it, and the wizard's question about enabling Countly's own analytics on this server
reports to stats.count.ly, which compose.yml already answers no to.
Once they confirm, prove the install is claimed and that it accepts events:
```bash
cd /srv/countly
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
key=$(docker compose exec -T mongodb mongosh --quiet countly --eval 'const a=db.apps.findOne({}); print(a ? (a.key || (a.keys && a.keys[0] && a.keys[0].key) || "") : "")' | tr -d '\r\n')
bogus=$(openssl rand -hex 16)
curl -sS "https://<DOMAIN>/i?app_key=${bogus}&device_id=selfhost-check&begin_session=1"; echo
curl -sS "https://<DOMAIN>/i?app_key=${key}&device_id=selfhost-check&begin_session=1"; echo
printf '<script src="https://<DOMAIN>/sdk/web/countly.min.js"></script>\n<script>\n Countly.init({ app_key: "%s", url: "https://<DOMAIN>" });\n Countly.track_sessions();\n Countly.track_pageview();\n</script>\n' "$key"
```
Assert all four. `/setup` prints `302`, upstream redirecting to the login page because the
members collection is no longer empty, and that is the security assert here: a `200` means the
install is still claimable by whoever finds it, so stop and do not report success. The invented
key prints `{"result":"App does not exist"}`; the real one prints `{"result":"Success"}`, the
product working end to end, an event carried over https through Caddy and the image's nginx into
MongoDB. The last command prints the snippet the user pastes into their site with their own app
key in it, so hand it to them as text: that key is readable in the source of every page it
measures, so it is not one of the secrets, and the values in /srv/countly/.env stay unprinted.
Tell the user the dashboard stays empty until the snippet is on a page or a mobile SDK is in
their app, and that this is not a fault.
## 8. First backup and restore
Two artifacts. MongoDB holds every account, application, session, event and uploaded file, and
`mongodump` with no database named takes all of it, which matters because file storage sits in a
second database beside the first. The config archive rebuilds the service around it.
```bash
cd /srv/countly
docker compose exec -T mongodb mongodump --quiet --archive --gzip > /srv/countly/backups/countly-db-$(date +%F).archive.gz
sudo tar -czf /srv/countly/backups/countly-config-$(date +%F).tar.gz -C /srv/countly compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/countly/backups/
```
Assert: both exist and both are non-empty. Print both sizes. Nothing stops: `mongodump` reads a
running database. A backup on the same disk is not a backup, so run this from the user's
machine:
```bash
mkdir -p ~/backups/countly
scp vps:/srv/countly/backups/* ~/backups/countly/
```
To restore: `docker compose down`, `sudo rm -rf /srv/countly/mongo`, recreate it as in step 2,
untar the config archive into /srv/countly so .env is back before anything starts,
`docker compose up -d mongodb`, wait about 30 seconds for healthy, pipe `gunzip -c` on the
archive into `docker compose exec -T mongodb mongorestore --archive --gzip --drop`, then
`docker compose up -d`. Order matters: the password secret in .env is mixed into every stored
password, so a database restored beside the wrong .env returns their accounts and no way into
any of them.
## 9. Updating later
New versions are listed at https://github.com/Countly/countly-server/releases. Take both backups
first, then edit the image line in /srv/countly/compose.yml to the new tag and digest:
```bash
cd /srv/countly
docker compose pull
docker compose up -d
docker compose logs --tail 30 countly
```
Countly migrates its collections on the way up. Watch that log until it settles, then re-run
step 7's ping checks before calling the update done.
## 10. What will probably go wrong
The first `docker compose up -d` returns in a second and then https://<DOMAIN> answers a Caddy
`502` for several minutes. I read the compose file twice looking for a mistake that was not there
before running `docker compose logs -f countly` and finding the container loading a city database
into MongoDB, which it does once, on first boot, before nginx answers anything. Give the loop in
step 7 its full forty attempts, and if you open the log, watch it: restarting the container
starts that load over.
## 11. Out of scope
- Do not add `drill`, `funnels`, `cohorts`, `flows`, `retention_segments`, `surveys` or
`ab-testing` to a `COUNTLY_PLUGINS` variable. Those directories are not in the repository this
image is built from, and naming one leaves the container restarting.
- Do not configure SMTP. Collection and the dashboard work without it, and what it switches on is
email reports on data that does not exist yet.
- Do not run upstream's one-line shell installer. It puts Countly and MongoDB on the host outside
Docker, and both are already in pinned containers here.
- Do not turn on MongoDB authentication afterwards. Adding a user without changing the connection
string leaves the container in a restart loop that reads like a broken image.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing Countly Lite 25.03.51 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.
Read this before step 1. `<DOMAIN>` is the address every SDK you install afterwards posts its
events to, so it ends up in the source of every app and page you measure. Moving it later means
shipping all of them again. Pick the hostname you intend to keep.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `4096` MB available, at least `20` G free, `amd64`, and your server's
IP on the last line.
If you do not: `arm64` on the third line is the end of this install, not a detour. Upstream
publishes the Countly image for amd64 only and there is no arm tag to fall back to. Under
4096 MB is also a stop: the image runs two Node processes with a 2048 MB heap ceiling each and
MongoDB is a third beside them, so a 2 GB box gets through the first boot and then dies during
the first real traffic. An empty last line means the A record does not exist yet. Add it, wait a
minute, and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name
that does not resolve and failed attempts count against a rate limit you cannot see.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/countly /srv/countly/backups
sudo install -d -m 700 /srv/countly/mongo
ls -la /srv/countly
```
You should see: `backups` owned by you, and `mongo` at mode `drwx------` owned by root.
If you do not: leave `mongo` owned by root on purpose. The MongoDB image chowns its own data
directory the first time it starts, and one you have already chowned to yourself makes it refuse
to initialise. There is no directory for Countly itself, which is correct: upstream defaults
file storage to GridFS, so uploads and app icons are documents in the database and the
application container writes nothing worth keeping to disk.
## 3. Secrets
Two secrets, both read by the dashboard process. `WEB_SESSION_SECRET` replaces the value
upstream ships in its sample config, which is published in the repository and signs your session
cookie. `PASSWORDSECRET` is mixed into every password before it is hashed, and it has to exist
before your first account does, because changing it later invalidates every password already
stored.
```bash
umask 077
cat > /srv/countly/.env <<EOF
COUNTLY_CONFIG_FRONTEND_WEB_SESSION_SECRET=$(openssl rand -hex 32)
COUNTLY_CONFIG_FRONTEND_PASSWORDSECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/countly/.env
umask 022
ls -l /srv/countly/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/countly/.env` and carry
on. If the file already existed from an earlier attempt, this block has now replaced both values,
which is harmless before you have an account and a problem afterwards: a changed
`PASSWORDSECRET` locks you out of every account already created, and the error you get is a
plain wrong-password message rather than anything about the file.
Do not paste that file, either value, or any output containing them into this chat window. This
file is the half of your backup that is not the database: a MongoDB dump restored without it
returns every account and no password that works on any of them.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/countly/compose.yml <<'EOF'
# Countly Lite · the deterministic fallback. Authored by caniselfhostit from the
# upstream sources, not copied from a repository:
# single-image build . https://github.com/Countly/countly-server/blob/25.03.51/Dockerfile-core
# api config keys .... https://github.com/Countly/countly-server/blob/25.03.51/api/config.sample.js
#
# countly-core is upstream's single-image build: an nginx, the collection API on
# 3001 and the dashboard on 6001 all run inside it under runit.
#
# MongoDB runs with no user and no password, as upstream's compose does, which
# is acceptable only because it publishes no port. fileStorage defaults to
# gridfs, so uploads are documents in the database and the countly service needs
# no volume: all of the state is in MongoDB. Digests read on 2026-08-07;
# countly-core is amd64 only, which is why step 1 stops on arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mongodb:
image: mongo:8.0.28@sha256:98605bfa1bb2a15dd82109e1d78ad31527a9a744909fab4606076fa71a0ae515
container_name: countly-db
restart: unless-stopped
command: ["mongod", "--bind_ip_all", "--quiet"]
volumes:
- /srv/countly/mongo:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand('ping').ok ? 0 : 1)"]
interval: 10s
timeout: 10s
retries: 30
start_period: 20s
# No `ports:` at all: 27017 is reachable only from the other container.
countly:
image: countly/countly-core:25.03.51@sha256:e3d94902a3c4c609fdda3895ea4326693c5f30289cf2f6a84e35b9773a182c03
container_name: countly
restart: unless-stopped
env_file: /srv/countly/.env
environment:
# The empty middle component means "the API and the dashboard both".
COUNTLY_CONFIG__MONGODB_HOST: mongodb
COUNTLY_CONFIG__FILESTORAGE: gridfs
# Upstream forks one worker per core; each is a Node heap of its own.
COUNTLY_CONFIG_API_API_WORKERS: "2"
# Caddy terminates TLS and the dashboard cannot see that from in here.
# Told, it stamps X-Forwarded-Proto https before its own session
# middleware runs, which is what marks the cookie Secure.
COUNTLY_CONFIG_FRONTEND_WEB_SECURE_COOKIES: "true"
COUNTLY_CONFIG_FRONTEND_COOKIE_SECURE: "true"
# Upstream ships an Intercom widget on and usage reporting to
# stats.count.ly on. Both off.
COUNTLY_CONFIG_FRONTEND_WEB_USE_INTERCOM: "false"
COUNTLY_CONFIG_FRONTEND_WEB_TRACK: none
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8174.
- "127.0.0.1:8174:80"
depends_on:
mongodb:
condition: service_healthy
EOF
cd /srv/countly && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/countly/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/countly/compose.yml` and paste again in one go.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-countly
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Countly Lite · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/Countly/countly-server/blob/25.03.51/bin/config/nginx.server.conf
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. Every SDK you add later posts its events to that name.
<DOMAIN> {
encode zstd gzip
header {
# Every page you measure loads /sdk/web/countly.min.js from this host,
# so a downgrade on one of them is a downgrade on all of them.
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# No X-Frame-Options on purpose: the rating and survey widgets are served
# from this host to be drawn in a frame on your own site. 8174 is the
# loopback port compose publishes here, and the nginx inside the image is
# what splits /i and /o off to the collection API.
reverse_proxy 127.0.0.1:8174
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-countly /etc/caddy/Caddyfile`, reload,
and paste again. Caddy requests the certificate on the first request and renews it on its own,
so there is nothing here to schedule.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8174` or `27017`.
If you do not: delete anything for `8174` or `27017` with `sudo ufw delete allow 8174`. 8174 is
bound to 127.0.0.1 by the compose file and 27017 is never published at all, so the database has
no host port a firewall rule could apply to, and that is exactly why it can run without a
password. 80/tcp is there to redirect to HTTPS and to answer the ACME challenge, 443/tcp is the
only way in, and 443/udp is HTTP/3, which Caddy offers by default. `Status: inactive` is a
different problem: Prompt Zero left this firewall enabled, so something has turned it off since,
and `sudo ufw enable` puts it back before you go any further.
## 7. Start and verify
The image runs a first-boot script that writes its plugin list and loads a city database into
MongoDB before it serves anything, so the first `up` takes minutes rather than seconds.
```bash
cd /srv/countly
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/ping; echo
curl -sS https://<DOMAIN>/o/ping; echo
curl -sS https://<DOMAIN>/setup | grep -c 'data-localize="setup.ready"'
```
You should see, in order: the loop reaching `200`, the bare word `Success` from the dashboard,
`{"result":"Success"}` from the collection API, and `1` from the last command. Those two ping
endpoints are the pair upstream's own health-check script calls, and each answers only after its
process has reached MongoDB.
If you do not: the loop is generous on purpose, so let it run out before you touch anything. If
it never reaches `200`, run `docker compose logs --tail 20 mongodb` first, because a database
that never reports healthy is step 2 done wrong, and `docker compose logs --tail 40 countly`
second. A Caddy `502` over a container that shows as running means nothing is answering on 8174
yet. A `0` from the last command instead of a `1` means the setup screen is not being served,
which on a fresh install means the dashboard process has not finished starting.
The first screen at https://<DOMAIN>/setup shows the heading `Your Countly server is ready!` over
a `Full Name` field and a `Create Account` button.
Now open https://<DOMAIN>/setup in a browser, create your administrator account, and finish the
short wizard after it by adding your first application. Two things before you start. Nothing else
will ever create that first account, and there is no mail server here to reset it with, so put
the password in your password manager as you type it. The wizard also asks whether to enable
Countly's own analytics on this server, which reports to stats.count.ly; the compose file above
already answers no, so answering no there is the consistent choice.
Then come back and run this:
```bash
cd /srv/countly
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
key=$(docker compose exec -T mongodb mongosh --quiet countly --eval 'const a=db.apps.findOne({}); print(a ? (a.key || (a.keys && a.keys[0] && a.keys[0].key) || "") : "")' | tr -d '\r\n')
bogus=$(openssl rand -hex 16)
curl -sS "https://<DOMAIN>/i?app_key=${bogus}&device_id=selfhost-check&begin_session=1"; echo
curl -sS "https://<DOMAIN>/i?app_key=${key}&device_id=selfhost-check&begin_session=1"; echo
printf '<script src="https://<DOMAIN>/sdk/web/countly.min.js"></script>\n<script>\n Countly.init({ app_key: "%s", url: "https://<DOMAIN>" });\n Countly.track_sessions();\n Countly.track_pageview();\n</script>\n' "$key"
```
You should see: `302`, then `{"result":"App does not exist"}`, then `{"result":"Success"}`, then
a snippet with your own app key already in it.
If you do not: the `302` is the one that decides whether this install is safe to leave running.
It means Countly redirected the setup page to the login page because the members collection is no
longer empty. A `200` there means nobody owns the install yet and anyone who finds the hostname
can claim it, so stop and finish the account. An empty `key` means the wizard did not create an
application, so go back and add one. That final `{"result":"Success"}` is the whole product
working end to end: an event went in over https, through Caddy, through the image's nginx, into
the collection API and into MongoDB. The app key it printed is not a secret, it is readable in
the source of every page it measures, which is the opposite of the two values in
/srv/countly/.env. Paste that snippet into your site. Until it is on a page, or one of the mobile
SDKs is in your app, the dashboard stays empty, and that is not a fault.
## 8. First backup and restore
Two artifacts. MongoDB holds every account, application, session, event and uploaded file, and
`mongodump` with no database named takes all of it, which matters because file storage sits in a
second database beside the first. The config archive rebuilds the service around it.
```bash
cd /srv/countly
docker compose exec -T mongodb mongodump --quiet --archive --gzip > /srv/countly/backups/countly-db-$(date +%F).archive.gz
sudo tar -czf /srv/countly/backups/countly-config-$(date +%F).tar.gz -C /srv/countly compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/countly/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`mongodump` reads a running database.
If you do not: an archive of about 20 bytes is an empty dump, which means `mongodump` failed and
the shell created the file anyway. Run the dump line without the redirect to read the error.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/countly
scp vps:/srv/countly/backups/* ~/backups/countly/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/countly/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is one test event:
```bash
cd /srv/countly
docker compose down
sudo rm -rf /srv/countly/mongo
sudo install -d -m 700 /srv/countly/mongo
docker compose up -d mongodb
sleep 30
gunzip -c /srv/countly/backups/countly-db-$(date +%F).archive.gz | docker compose exec -T mongodb mongorestore --archive --gzip --drop
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/ping; echo
```
You should see: restore lines naming the `countly` database, then `Success` from the last
command, which means the dashboard came back against a database that was deleted and rebuilt.
Sign in and check that your account still works.
If you do not: a login that fails after a restore is almost always .env. The password secret in
that file is mixed into every stored password, so a database restored beside the wrong .env
returns all your accounts and no way into any of them. Untar the config archive into
/srv/countly before you start the containers, not after.
## 9. Updating later
New versions are listed at https://github.com/Countly/countly-server/releases. Take both backup
artifacts first, then edit the `image:` line in /srv/countly/compose.yml to the new tag and its
digest.
```bash
cd /srv/countly
docker compose pull
docker compose up -d
docker compose logs --tail 30 countly
```
You should see: the services starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Then re-run the
two ping checks from step 7 before you call the update done, and send one more test event as
well, because a server that answers `Success` on health can still be refusing writes if a
collection migration stopped halfway.
## 10. What will probably go wrong
The first `docker compose up -d` returns in a second and then https://<DOMAIN> answers a Caddy
`502` for several minutes. I read the compose file twice looking for a mistake that was not there
before running `docker compose logs -f countly` and finding the container loading a city database
into MongoDB, which it does once, on first boot, before nginx answers anything. Give the loop in
step 7 its full forty attempts, and if you open the log, watch it: restarting the container
starts that load over.
## 11. Out of scope
- Do not add `drill`, `funnels`, `cohorts`, `flows`, `retention_segments`, `surveys` or
`ab-testing` to a `COUNTLY_PLUGINS` variable. Those directories are not in the open repository
this image is built from, and naming one leaves the container restarting.
- Do not configure SMTP. Collection and the dashboard work without it, and what it switches on is
email reports on data that does not exist yet.
- Do not run upstream's one-line shell installer. It puts Countly and MongoDB on the host outside
Docker, and both are already in pinned containers here.
- Do not turn on MongoDB authentication afterwards. The database publishes no port, and adding a
user without changing the connection string leaves the container in a restart loop that reads
like a broken image.316 lines · 14,984 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Countly Lite 25.03.51, with the MongoDB it stores every event in, under
~/selfhost/countly, answering at http://localhost:8174.
## 1. Preflight
Say this to the user before step 2 runs, because it decides whether they want this install at
all. Countly measures an app by having it post events to the address its SDK points at, and
here that is http://localhost:8174, which means "this computer" in any browser that reads it. It
measures software on this machine and nothing anyone else opens.
Detect the OS and measure the machine:
```bash
uname -s
uname -m
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2.
If `uname -m` printed `arm64` or `aarch64`, stop and tell the user why: upstream publishes the
Countly image for amd64 only, so an Apple Silicon Mac or an arm Linux box has nothing to pull.
This needs x86-64.
Countly plus MongoDB needs 4096 MB of RAM available and 20 GB free on the home disk, because the
image starts two Node processes with a 2048 MB heap ceiling each and MongoDB is a third beside
them. Every branch above prints free memory, and Docker Desktop's VM takes its share out of that.
Under either floor, print both numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/countly/backups
ls -la ~/selfhost/countly
```
Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: everything is a
document in MongoDB, which step 5 keeps in a Docker-managed volume, so no ownership fix is
needed.
## 4. Secrets
Two secrets, both read by the dashboard. `WEB_SESSION_SECRET` replaces the value upstream ships
in its sample config, published in the repository, and signs the session cookie.
`PASSWORDSECRET` is mixed into every password before hashing, so it has to exist before the first
account does. Generate both here, print neither, keep both out of your summary and any log.
```bash
umask 077
cat > ~/selfhost/countly/.env <<EOF
COUNTLY_CONFIG_FRONTEND_WEB_SESSION_SECRET=$(openssl rand -hex 32)
COUNTLY_CONFIG_FRONTEND_PASSWORDSECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/countly/.env
umask 022
ls -l ~/selfhost/countly/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same everywhere. On Windows the mode bits are advisory, and the real boundary is the user's own
Windows account. A dump restored without this file returns every account and no working
password.
## 5. compose.yml
```bash
cat > ~/selfhost/countly/compose.yml <<'EOF'
# Countly Lite · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream sources, not copied from a repository:
# single-image build . https://github.com/Countly/countly-server/blob/25.03.51/Dockerfile-core
# api config keys .... https://github.com/Countly/countly-server/blob/25.03.51/api/config.sample.js
#
# Every path is relative to ~/selfhost/countly/, so one file works on macOS,
# Linux and Windows. countly-core runs an nginx, the collection API on 3001 and
# the dashboard on 6001 inside one image under runit. The database is a named
# volume because MongoDB chowns /data/db to a uid Docker Desktop cannot grant on
# a Windows home-directory mount, and it is the only volume because fileStorage
# defaults to gridfs. Digests read on 2026-08-07; countly-core is amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mongodb:
image: mongo:8.0.28@sha256:98605bfa1bb2a15dd82109e1d78ad31527a9a744909fab4606076fa71a0ae515
container_name: countly-db
restart: unless-stopped
command: ["mongod", "--bind_ip_all", "--quiet"]
volumes:
- countly-mongo:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand('ping').ok ? 0 : 1)"]
interval: 10s
timeout: 10s
retries: 30
start_period: 20s
# No `ports:`, no user and no password, as upstream's own compose runs it.
countly:
image: countly/countly-core:25.03.51@sha256:e3d94902a3c4c609fdda3895ea4326693c5f30289cf2f6a84e35b9773a182c03
container_name: countly
restart: unless-stopped
env_file: ./.env
environment:
# The empty middle component means "the API and the dashboard both".
COUNTLY_CONFIG__MONGODB_HOST: mongodb
COUNTLY_CONFIG__FILESTORAGE: gridfs
# One Node heap per worker, so this is a ceiling, not a target.
COUNTLY_CONFIG_API_API_WORKERS: "2"
# Upstream ships an Intercom widget and reporting to stats.count.ly on.
COUNTLY_CONFIG_FRONTEND_WEB_USE_INTERCOM: "false"
COUNTLY_CONFIG_FRONTEND_WEB_TRACK: none
ports:
# Loopback only: no other device on the wifi can reach 8174.
- "127.0.0.1:8174:80"
depends_on:
mongodb:
condition: service_healthy
volumes:
countly-mongo:
EOF
cd ~/selfhost/countly && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Two services, one published port, one named volume.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. There is no hostname to resolve, no public
name for a certificate to attest, and nothing published beyond loopback for a rule to close.
Browsers treat http://localhost as a secure context anyway, so pages needing crypto still work.
8174 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. MongoDB publishes no host port at all, which is what makes running it without a
password acceptable. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/countly/compose.yml
```
Assert: that prints `1`, the single published-port line `- "127.0.0.1:8174:80"`. Anything larger
means a second service publishes a port and this step has not held.
## 7. Start and verify
The image writes its plugin list and loads a city database into MongoDB before serving anything,
so the first `up` takes minutes.
```bash
cd ~/selfhost/countly
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8174/ping); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8174/ping; echo
curl -sS http://localhost:8174/setup | grep -c 'data-localize="setup.ready"'
```
Assert all three and print what you got: the loop ends on `200`, the dashboard prints the bare
word `Success`, upstream's own health check, which answers only after it reaches MongoDB, and
the last prints `1`, meaning the registration screen is served and nobody owns this install yet. On a miss, stop, run `docker compose logs --tail 40 countly` and
`docker compose logs --tail 20 mongodb`, and name the cause: a database that never reports healthy
is step 5, a container still writing plugin files wants more time, and `port is already allocated`
means `lsof -nP -iTCP:8174 -sTCP:LISTEN` will name what holds 8174. A running container is not
success.
The first screen at http://localhost:8174/setup shows the heading `Your Countly server is ready!`
over a `Full Name` field and a `Create Account` button.
STOP: tell the user to open http://localhost:8174/setup, create their administrator account, then
finish the wizard after it by adding their first application, and wait. Do not continue until they
confirm both. No mail server here can reset that password, so it goes in their password manager as
they type it, and the wizard's analytics question reports to Countly, which compose.yml already
declines.
Once they confirm:
```bash
cd ~/selfhost/countly
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8174/setup
key=$(docker compose exec -T mongodb mongosh --quiet countly --eval 'const a=db.apps.findOne({}); print(a ? (a.key || (a.keys && a.keys[0] && a.keys[0].key) || "") : "")' | tr -d '\r\n')
curl -sS "http://localhost:8174/i?app_key=${key}&device_id=selfhost-check&begin_session=1"; echo
printf '<script src="http://localhost:8174/sdk/web/countly.min.js"></script>\n<script>Countly.init({ app_key: "%s", url: "http://localhost:8174" }); Countly.track_pageview();</script>\n' "$key"
```
Assert both. `/setup` prints `302`, upstream redirecting to the login page because the members
collection is no longer empty; a `200` means the install is still claimable, so stop. The event
call prints `{"result":"Success"}`, carried end to end into MongoDB. The last command prints the
snippet, with the user's own app key in it, for a page served from this machine. That key sits in
the source of every page it measures, so it is not a secret the way .env is. The dashboard is
empty until the snippet is on a page they open.
## 8. First backup and restore
Two artifacts. MongoDB holds everything, and `mongodump` with no database named takes all of it,
because file storage sits in a second database beside the first. The config archive rebuilds
the service.
```bash
cd ~/selfhost/countly
docker compose exec -T mongodb mongodump --quiet --archive --gzip > ~/selfhost/countly/backups/countly-db-$(date +%F).archive.gz
tar -C ~/selfhost/countly -czf ~/selfhost/countly/backups/countly-config-$(date +%F).tar.gz compose.yml .env
ls -lh ~/selfhost/countly/backups/
```
Assert: both exist and are non-empty. Print both sizes. Nothing stops: `mongodump` reads a
running database.
A backup on the same disk is not a backup, and on a laptop the disk and the machine die
together. Ask the user for a destination that leaves this computer, a folder a sync service
watches or a USB stick, and copy both there with `cp`. Assert: the user confirms both filenames
are listed there. If not, say plainly this install has no backup.
To restore, in this order. `cd ~/selfhost/countly`, untar the config archive there first so .env
is back before any container starts, because the password secret in it is mixed into every stored
password. Then `docker compose down -v`, the one place `-v` belongs because it drops the old
volume on purpose, `docker compose up -d mongodb`, wait 30 seconds for healthy, pipe `gunzip -c`
on the archive into `docker compose exec -T mongodb mongorestore --archive --gzip --drop`, then
`docker compose up -d` and re-run step 7's checks. That is the whole disaster plan.
## 9. Updating later
New versions are at https://github.com/Countly/countly-server/releases. Take both backups first,
then edit the image line in compose.yml to the new tag and digest:
```bash
cd ~/selfhost/countly
docker compose pull
docker compose up -d
docker compose logs --tail 30 countly
```
Countly migrates its collections on the way up. Watch that log until it settles, then re-run
step 7's checks.
## 10. What will probably go wrong
I rebooted this machine, opened http://localhost:8174, and got a connection error that reads like
a lost database. It was not: Docker Desktop had not started with the session, so nothing listened
on 8174 and every event a page sent went nowhere, silently. `restart: unless-stopped` acts only
once the Docker daemon is up. Turn on its start-at-login
setting, and after a reboot run `cd ~/selfhost/countly && docker compose up -d` before deciding
anything is broken.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8174 to 0.0.0.0 so a phone can reach it. That puts an unauthenticated
collection API on every network this computer joins.
- Do not add `drill`, `funnels`, `cohorts` or `flows` to a `COUNTLY_PLUGINS` variable. Those
directories are not in the repository this image is built from.compose.local.ymlthe services, pinned · local layout53 lines
# Countly Lite · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream sources, not copied from a repository:
# single-image build . https://github.com/Countly/countly-server/blob/25.03.51/Dockerfile-core
# api config keys .... https://github.com/Countly/countly-server/blob/25.03.51/api/config.sample.js
#
# Every path is relative to ~/selfhost/countly/, so one file works on macOS,
# Linux and Windows. countly-core runs an nginx, the collection API on 3001 and
# the dashboard on 6001 inside one image under runit. The database is a named
# volume because MongoDB chowns /data/db to a uid Docker Desktop cannot grant on
# a Windows home-directory mount, and it is the only volume because fileStorage
# defaults to gridfs. Digests read on 2026-08-07; countly-core is amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mongodb:
image: mongo:8.0.28@sha256:98605bfa1bb2a15dd82109e1d78ad31527a9a744909fab4606076fa71a0ae515
container_name: countly-db
restart: unless-stopped
command: ["mongod", "--bind_ip_all", "--quiet"]
volumes:
- countly-mongo:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand('ping').ok ? 0 : 1)"]
interval: 10s
timeout: 10s
retries: 30
start_period: 20s
# No `ports:`, no user and no password, as upstream's own compose runs it.
countly:
image: countly/countly-core:25.03.51@sha256:e3d94902a3c4c609fdda3895ea4326693c5f30289cf2f6a84e35b9773a182c03
container_name: countly
restart: unless-stopped
env_file: ./.env
environment:
# The empty middle component means "the API and the dashboard both".
COUNTLY_CONFIG__MONGODB_HOST: mongodb
COUNTLY_CONFIG__FILESTORAGE: gridfs
# One Node heap per worker, so this is a ceiling, not a target.
COUNTLY_CONFIG_API_API_WORKERS: "2"
# Upstream ships an Intercom widget and reporting to stats.count.ly on.
COUNTLY_CONFIG_FRONTEND_WEB_USE_INTERCOM: "false"
COUNTLY_CONFIG_FRONTEND_WEB_TRACK: none
ports:
# Loopback only: no other device on the wifi can reach 8174.
- "127.0.0.1:8174:80"
depends_on:
mongodb:
condition: service_healthy
volumes:
countly-mongo:agent-readable mirror: /self-host/amplitude.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned58 lines
# Countly Lite · the deterministic fallback. Authored by caniselfhostit from the
# upstream sources, not copied from a repository:
# single-image build . https://github.com/Countly/countly-server/blob/25.03.51/Dockerfile-core
# api config keys .... https://github.com/Countly/countly-server/blob/25.03.51/api/config.sample.js
#
# countly-core is upstream's single-image build: an nginx, the collection API on
# 3001 and the dashboard on 6001 all run inside it under runit.
#
# MongoDB runs with no user and no password, as upstream's compose does, which
# is acceptable only because it publishes no port. fileStorage defaults to
# gridfs, so uploads are documents in the database and the countly service needs
# no volume: all of the state is in MongoDB. Digests read on 2026-08-07;
# countly-core is amd64 only, which is why step 1 stops on arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
mongodb:
image: mongo:8.0.28@sha256:98605bfa1bb2a15dd82109e1d78ad31527a9a744909fab4606076fa71a0ae515
container_name: countly-db
restart: unless-stopped
command: ["mongod", "--bind_ip_all", "--quiet"]
volumes:
- /srv/countly/mongo:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "quit(db.adminCommand('ping').ok ? 0 : 1)"]
interval: 10s
timeout: 10s
retries: 30
start_period: 20s
# No `ports:` at all: 27017 is reachable only from the other container.
countly:
image: countly/countly-core:25.03.51@sha256:e3d94902a3c4c609fdda3895ea4326693c5f30289cf2f6a84e35b9773a182c03
container_name: countly
restart: unless-stopped
env_file: /srv/countly/.env
environment:
# The empty middle component means "the API and the dashboard both".
COUNTLY_CONFIG__MONGODB_HOST: mongodb
COUNTLY_CONFIG__FILESTORAGE: gridfs
# Upstream forks one worker per core; each is a Node heap of its own.
COUNTLY_CONFIG_API_API_WORKERS: "2"
# Caddy terminates TLS and the dashboard cannot see that from in here.
# Told, it stamps X-Forwarded-Proto https before its own session
# middleware runs, which is what marks the cookie Secure.
COUNTLY_CONFIG_FRONTEND_WEB_SECURE_COOKIES: "true"
COUNTLY_CONFIG_FRONTEND_COOKIE_SECURE: "true"
# Upstream ships an Intercom widget on and usage reporting to
# stats.count.ly on. Both off.
COUNTLY_CONFIG_FRONTEND_WEB_USE_INTERCOM: "false"
COUNTLY_CONFIG_FRONTEND_WEB_TRACK: none
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8174.
- "127.0.0.1:8174:80"
depends_on:
mongodb:
condition: service_healthyCaddyfilethe hostname and TLS27 lines
# Countly Lite · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/Countly/countly-server/blob/25.03.51/bin/config/nginx.server.conf
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile with <DOMAIN> replaced by the hostname
# pointed at this box. Every SDK you add later posts its events to that name.
<DOMAIN> {
encode zstd gzip
header {
# Every page you measure loads /sdk/web/countly.min.js from this host,
# so a downgrade on one of them is a downgrade on all of them.
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# No X-Frame-Options on purpose: the rating and survey widgets are served
# from this host to be drawn in a frame on your own site. 8174 is the
# loopback port compose publishes here, and the nginx inside the image is
# what splits /i and /o off to the collection API.
reverse_proxy 127.0.0.1:8174
}install.shthe same install, no agent176 lines
#!/usr/bin/env bash
# Countly Lite · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=analytics.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream sources:
# https://github.com/Countly/countly-server/blob/25.03.51/Dockerfile-core
# https://github.com/Countly/countly-server/blob/25.03.51/api/config.sample.js
# https://github.com/Countly/countly-server/blob/25.03.51/frontend/express/config.sample.js
# https://github.com/Countly/countly-server/blob/25.03.51/bin/commands/scripts/healthcheck/accessibility.sh
#
# Two secrets are generated here, on this machine: the dashboard session secret
# and the password secret Countly mixes into every stored password. Both go into
# /srv/countly/.env with mode 600 and neither is ever printed.
#
# DOMAIN_HOST is the address every SDK you install afterwards posts events to, so
# it ends up in the source of every app and page you measure. Choose it once.
#
# This script cannot create the first administrator account or the first
# application: both happen in a browser. It stops with instructions for that.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/countly}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. analytics.example.com"
case "$DOMAIN_HOST" in
*/*|http*) die "DOMAIN_HOST is a bare hostname, with no scheme and no trailing slash" ;;
esac
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
# Upstream publishes countly-core for amd64 only. There is no arm tag.
arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "this box is ${arch}; upstream publishes the Countly image for amd64 only"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; two Node processes plus MongoDB want 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; this install wants 20 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# No directory for Countly itself: upstream defaults file storage to GridFS, so
# uploads and app icons are documents in MongoDB. The database directory is left
# owned by root because the MongoDB image chowns it on first start.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/mongo"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# WEB_SESSION_SECRET replaces the value upstream publishes in its sample config.
# PASSWORDSECRET is mixed into every password before hashing, so it has to exist
# before the first account does; changing it later invalidates every password
# already stored. Read them yourself with
# sudo cat /srv/countly/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
COUNTLY_CONFIG_FRONTEND_WEB_SESSION_SECRET=$(openssl rand -hex 32)
COUNTLY_CONFIG_FRONTEND_PASSWORDSECRET=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-countly"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and neither 8174 nor 27017 is one of them -----------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8174 and 27017 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The image writes its plugin list and loads a city database into MongoDB on
# first boot, before nginx answers anything, so the wait below is generous.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/ping"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/ping" || true)"
[ "$code" = "200" ] && break
sleep 15
done
[ "${code:-}" = "200" ] || die "/ping answered ${code:-nothing}. Check: docker compose logs --tail 40 countly"
# The dashboard's own health endpoint answers only after it has reached MongoDB.
curl -sS "https://${DOMAIN_HOST}/ping" | grep -q 'Success' \
|| die "/ping returned 200 without the word Success. Check: docker compose logs --tail 40 countly"
# The collection API's, which is a different process inside the same container.
curl -sS "https://${DOMAIN_HOST}/o/ping" | grep -q '"result":"Success"' \
|| die "/o/ping did not answer {\"result\":\"Success\"}. The API process is not up."
# The registration screen is served only while the members collection is empty.
curl -sS "https://${DOMAIN_HOST}/setup" | grep -q 'data-localize="setup.ready"' \
|| die "the setup screen is not being served. Either the dashboard is still starting, or this install already has an account."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mongodb mongodump --quiet --archive --gzip > "$APP_DIR/backups/countly-db-${STAMP}.archive.gz"
sudo tar -czf "$APP_DIR/backups/countly-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/countly-db-${STAMP}.archive.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/countly-config-${STAMP}.tar.gz" ] || die "the config archive is empty"
cat <<-DONE
Countly Lite is answering at https://${DOMAIN_HOST}/ping
1. Claim it NOW, at https://${DOMAIN_HOST}/setup
Until you do, the first person who finds that hostname becomes the
administrator. The screen reads "Your Countly server is ready!".
There is no mail server here, so that password has no reset link:
put it in your password manager as you type it. The wizard after it
asks whether to enable Countly's own analytics on this server; the
compose file already answers no.
2. Add your first application in the same wizard, then confirm the
install is claimed and that it accepts events:
cd $APP_DIR
curl -sS -o /dev/null -w '%{http_code}\n' https://${DOMAIN_HOST}/setup
key=\$(docker compose exec -T mongodb mongosh --quiet countly --eval 'const a=db.apps.findOne({}); print(a ? (a.key || (a.keys && a.keys[0] && a.keys[0].key) || "") : "")' | tr -d '\r\n')
curl -sS "https://${DOMAIN_HOST}/i?app_key=\${key}&device_id=selfhost-check&begin_session=1"; echo
The first prints 302 and the second {"result":"Success"}.
3. Point something at it. The web SDK loads from
https://${DOMAIN_HOST}/sdk/web/countly.min.js
and takes the app key printed above plus that URL. Nothing appears on
the dashboard until an SDK is sending, and that is not a fault.
4. Your two secrets are in $APP_DIR/.env, mode 600, and were not printed
here. The password secret is mixed into every stored password, so a
database restored beside the wrong .env gives you back every account
and no way into any of them.
5. First backup written to $APP_DIR/backups: a MongoDB archive and a
config archive. They are on the same disk as the data, which is not a
backup. Copy them off tonight:
scp vps:$APP_DIR/backups/* ~/backups/countly/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Amplitude.
- The free server collects; the paid one analyses. Countly Lite ships sessions, views, custom events with segmentation, device, browser, OS and country breakdowns, crash reporting, push, star ratings, remote config, alerts, hooks, dashboards, the data manager, the compliance hub and both the read and write APIs. Funnels, cohorts, user flows, retention, A/B testing, surveys, revenue, heatmaps and drill, the free-form segmentation engine, are Enterprise plugins whose directories are not in the open repository at all. If funnels are the reason you are leaving Amplitude, read that sentence twice.
- The licence is AGPL-3.0 with a modified Section 7 that forbids removing Countly's name or logo from the source or from any screen an end user can see. GitHub reports no licence for the repository because of it, and the README describes Lite as open-source and non-commercial while the licence file itself adds no non-commercial term. It is copyleft with a branding condition attached, and worth reading yourself before you build a product on it.
- One architecture. Upstream publishes the image for amd64 only, so an Apple Silicon Mac or an arm64 VPS has nothing to pull and both prompts stop at step 1 rather than pretending otherwise.
- You own a MongoDB, and it is the entire install. Every account, application, session, event and uploaded file is a document in it, and file storage defaults to a second database beside the first, so there is no directory to copy instead. Backups are a mongodump of everything, restores are a mongorestore, and the .env beside it carries the password secret without which those accounts come back with no working password.
- Nothing is counted until an SDK is sending. The install ends on an empty dashboard, and the hostname you chose at step 1 is the address baked into every app and page you instrument, so changing it later means shipping all of them again.
Where this came from
“You cannot remove any Countly logo or branding either from source code or user interface and/or any other interface that can be publicly seen by end user.”
- The licence file at this tag states Countly is provided under AGPL-3.0 with a modified Section 7 that forbids removing Countly's name or logo from the source or from any interface an end user can see, which is why GitHub reports no licence for the repository. source
- Upstream's single-image build runs an nginx, the collection API on port 3001 and the dashboard on port 6001 together under runit, so one container is three processes. source
- Upstream's own health-check script calls two endpoints, the dashboard's /ping and the collection API's /o/ping, and both go through the nginx inside the image. source
- The /setup registration screen is rendered only while the members collection is empty, and once the first account exists that URL redirects to /login, which is what makes it a check on whether an install has been claimed. source
- The Enterprise plugin list names drill, funnels, retention_segments, flows, cohorts, surveys, ab-testing, revenue and heatmaps, and none of those directories exist in the open repository this image is built from. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Amplitude?
Not Amplitude itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Countly Lite. Sessions, custom events and crashes from your own apps, collected on a hostname you own, with no monthly event meter. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 120 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Amplitude?
Countly Lite. Sessions, custom events and crashes from your own apps, collected on a hostname you own, with no monthly event meter. The only entry here built for the same shape of data: an SDK in your app posts sessions, custom events, user properties and crashes to a server you own, and there is no monthly event meter on the other end. Be clear about where the line falls, because it falls exactly where Amplitude earns its money. The open Lite server ships collection, the event and segment reports, crash reporting, push, ratings, remote config and the read and write APIs. Funnels, cohorts, user flows, retention and A/B testing are Enterprise plugins whose directories are not in the open repository at all. So this replaces the pipeline and the dashboard, and it does not replace the analysis. Also evaluated and ranked below it: Matomo. The prompt on this page installs Countly Lite only. Countly Lite is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Amplitude?
4096 MB of RAM and 20 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Countly Lite itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Amplitude Plus, $49/mo — $588 a year, a metered rate, not a whole bill.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Countly Lite install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Countly Lite on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Countly Lite on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every SDK has to post its events to http://localhost:8174, which means the reader's own computer in the reader's own browser, so on this path Countly measures software you run on this machine and nothing anyone else ever opens. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.