# Can I self-host Amplitude?

**YES** — it's called Countly Lite. ONE EVENING setup · ~2 hours to running · 4 GB RAM minimum · $49/mo you stop paying ($588/yr on the Plus plan) — a metered rate, not a whole bill.

Countly Lite authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/amplitude/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

```bash
#!/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/

DONE
```

## Also evaluated

Ranked below Countly Lite for this swap. The prompts above install Countly Lite only.

- **Matomo** — 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 page this mirrors: https://caniselfhostit.com/self-host/amplitude/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
