SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

How to Self-Host OpenAnalytics for VPS Setup

Before you start, OpenAnalytics needs ClickHouse, Postgres, Valkey, 4 GB RAM, 25 GB free disk space, Docker Compose, and four DNS records.

The things wey you need before step one

To self-host OpenAnalytics, you need Linux VPS wey get about 4 GB RAM, 25 GB free disk space, Docker with the Compose plugin, and four DNS records wey already point to the server. Na this be the honest requirement, and e suppose dey before the first command, not after am.

The stack get six application services and three data stores. Postgres dey hold the control plane: accounts, sites, API keys, and share links. ClickHouse dey hold raw events and the rollups wey dashboard dey read. Valkey dey run two times: one as durable event queue, and one as cache wey system fit afford to lose, because both jobs need opposite eviction policies. Na only one process, the query gateway, get permission to read ClickHouse. E verify Ed25519 signature for every query envelope before e run am.

If na one binary and one config file you want, this setup no be that kind thing. GoatCounter na the single-binary option for this category: one Go executable, SQLite by default, and no external database at all. The heavier stack give you funnels, web vitals, revenue attribution from your own Stripe account, and an MCP (model context protocol) server. Choosing between self-hosted analytics tools na the post wey explain this trade-off. This guide assume say you don already decide.

Point four DNS records at the box first

Make sure four subdomains resolve to the server public IP before you start anything. Caddy go request Let's Encrypt certificates for first launch, and the challenge go fail if the name never resolve.

  • app.example.com dey serve the dashboard.
  • api.example.com dey serve the API and the OAuth callbacks.
  • c.example.com dey serve the collector and the tracker script.
  • rt.example.com dey serve the realtime stream.

Use four A records, or use one A record and three CNAMEs wey point to am. Confirm with dig +short app.example.com before you continue. Resolver fit still cache a name wey you add one minute ago as NXDOMAIN, depending on the resolver wey Let's Encrypt use. So, if the first certificate attempt fail, wait small and check the Caddy logs. Running the install again no go make DNS propagate faster.

How to self-host OpenAnalytics with Docker Compose

Checkout one tagged release. Na default branch developers dey use for development, but release tag na the version wey published images actually match. The commands below assume say Docker and the Compose plugin don already dey installed. Running Docker Compose services for VPS explain how to do this.

git clone https://github.com/OpenLabs-so/openanalytics
cd openanalytics
git checkout "$(git tag -l 'v*' --sort=-v:refname | sed '/-/d' | head -1)"
cd infra/selfhost
./generate-secrets.sh --domain example.com --email you@example.com --with-geoip
docker compose pull && docker compose up -d

The sed '/-/d' for the checkout command dey exclude pre-release tags, so you go land for the newest stable version instead of release candidate. --with-geoip dey fetch DB-IP city database during generation. If you skip am, every event go carry null country, so geography view no go show anything. You fit add am later by running infra/selfhost/geoip/fetch-dbip.sh, setting GEOIP_DB_PATH=/geoip/dbip-city-lite.mmdb inside env/collector.env, then recreating the collector with docker compose up -d --force-recreate collector. Dem dey refresh that database every month, so fetch am monthly again or your city data go become outdated.

Backup the generated secrets before you continue

The generator dey write three things. .env get the domain names and image references. env/*.env get one secrets file for each service. docker-compose.override.yml get three Ed25519 key pairs as YAML block scalars, because multi-line PEM no fit stay inside an env file. Git dey ignore all of dem, and you no fit regenerate any of dem with the same values.

Copy those files comot from the machine now. Each loss get specific consequence:

  • If you lose the store passwords, you go lock yourself out of Postgres and ClickHouse. You fit reset dem only from inside the containers.
  • If you lose OA_CREDENTIAL_KEYRING, every stored third-party credential no fit recover again. Anybody wey connect Stripe account go need connect am again.
  • If you lose ANONYMOUS_IDENTITY_SECRET, visitor identity go start counting from new baseline: yesterday visitors go all count as new, and the break go show for the charts.
  • If you lose AUTH_SECRET, every session go become invalid, so everybody go sign in again.
  • If you lose signing private key, rotate the key pair. Nothing go lost.

Two secrets must be byte-identical across two files each. ANONYMOUS_IDENTITY_SECRET dey inside collector.env and worker.env, because the collector dey calculate the visitor hash and the worker dey write am. OA_CREDENTIAL_KEYRING dey inside api.env and worker.env. Every other secret belong to exactly one service on purpose, and if you give a service secret wey e no suppose hold, e go exit instead of starting.

Bring di stack up and check am

grep OA_IMAGE .env
docker compose pull
docker compose up -d
docker compose logs -f migrate
docker compose ps

migrate dey apply the Postgres and ClickHouse schemas, then e go comot, so make migrate container stop na the correct final state. tracker-build dey compile oa.js inside a volume wey Caddy dey serve, then e go comot too. Everything else suppose show healthy for docker compose ps. If service dey restart over and over, e almost always mean say environment validation fail. The log go print all the problems for one list instead of one problem for each restart. The two common causes na variable wey dem leave blank, wey system reject instead of treating am as unset, and secret wey dem put inside the wrong service file.

For arm64, or if you dey use a branch, no published images dey available, so you build locally with docker compose up -d --build. A 4 GB host fit run out of memory during the build. Add swap first. You only need am while you dey build:

fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

Build dey take roughly ten minutes. Pull dey take few minutes, na why release images dey exist.

Claim the first account immediately

Open https://app.example.com. Deployment wey nobody don sign into yet no dey show sign-in form; e go offer to create the first account. That account go remain the privileged one permanently, and na only that account fit see the deployment settings screen. Once e don exist, the route go answer 409, so nobody fit enter behind you. Do am immediately after the stack don healthy, no be the following week.

Install the tracker

Add one site for the dashboard and e go give you the tag. The format dey fixed:

<script
  async
  src="https://c.example.com/oa.js"
  data-key="YOUR_TRACKING_KEY"
  data-collector="https://c.example.com"
></script>

Put am inside the page head. The tracking key public by design, so e belong inside your HTML where anybody fit read am. The script installs window.oa, and calls like oa("track", ...) dey enter queue through a stub, then the system flush dem after the file load, so custom event wey fire early no go lost. If another thing for the page already dey use window.oa, the tracker go install as window.openanalytics instead. If the same site still dey answer as onion service, no put the tag for that build, because script wey load from c.example.com go carry Tor Browser visitor back to clearnet and link both addresses for the same page load.

Then check the complete path from end to end:

curl -s https://c.example.com/oa.js -o /dev/null -w '%{http_code} %{size_download}\n'
curl -s https://api.example.com/health | head -c 200
docker compose logs --tail=50 worker | grep -i batch

The first command suppose print 200 and some kilobytes. Load one page for your site, then check the worker log for batch line within some seconds. The collector go answer 202 as soon as e accept event, while 202 mean say event dey queued, e never store. Na the worker dey move events enter ClickHouse. If events dey accepted but nothing dey show for dashboard, worker dey blocked. Valkey queue depth wey continue to increase confirm am. The common causes na wrong ClickHouse credentials inside worker.env, or missing grant for table wey recent migration add.

Keep the collector public and make the dashboard require auth

Caddy dey inside the compose file and e dey collect certificates for all four names by itself, so the default setup no need proxy work from you. If the box already dey run an nginx reverse proxy, put the stack behind the supplied infra/selfhost/nginx.conf.example instead, and leave its header handling as e be:

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header Fly-Client-IP "";

The collector dey calculate the daily visitor hash from the client IP, so e must take that address from the connection and never from a header. If you pass CF-Connecting-IP through from an untrusted hop, any caller fit claim any address. This one go spoil geolocation and increase visitor counts at the same time.

Access dey split cleanly by hostname. c. and rt. must dey reachable by every visitor for every site wey you measure, so never put basic auth or an IP allowlist in front of those two. app. and api. only need to dey reachable by people wey sign in. The application's own auth na wetin dey protect the dashboard: password sign-in dey on by default through AUTH_PASSWORD_SIGNIN=enabled for env/api.env, and the Google or GitHub buttons go show only when both the client ID and the client secret dey available for that provider. Magic links need mail transport. If mail transport no dey, the API only writes the send operation to an outbox, so nothing dey delivered and no error dey show. If your other self-hosted apps already dey behind one Authentik login, decide early whether this dashboard go join dem or keep its own accounts, because the first account wey you create here go remain the privileged one permanently.

One setting decide whether the dashboard go work at all. AUTH_TRUSTED_ORIGINS for env/api.env must match the dashboard origin exactly. If e wrong or e no dey, the API no go emit CORS (cross-origin resource sharing) headers. The browser go reject every call, and you go get dashboard wey renders the layout but shows no data, while docker compose ps reports say everything dey healthy.

As you dey work on the proxy config, handle automated traffic too. Crawlers dey hit the collector like every other visitor, and their page views dey enter ClickHouse and your numbers. Block AI crawlers at the server go keep part of that traffic out of the database before e affect both accuracy and disk space.

Wetin cookieless mean here, and wetin e dey cost you

No cookie dey. Visitor identity na salted hash, the salt dey rotate every day, and dem no dey store raw IP addresses ever. Geolocation dey resolve locally against the DB-IP file for your own disk, so no lookup about visitor ever comot from the host. Keeping lookups local remove the vendor, but e no remove the data. Na the same limit wey show when you run your own SearXNG instance and your server IP become wetin search engines dey see.

Wetin this one give you na say no identifier dey persist for visitor device. Na this specific thing dey bring tracker under EU ePrivacy consent rules. People commonly run aggregate-only setup like this without consent banner for that reason. GDPR still govern anything wey you store and how long you keep am. Na your own counsel go decide your case, no be README.

Wetin e cost you na identity across different days. Because the salt dey rotate, person wey visit on Monday and come back on Wednesday go count as two visitors, by design, and no workaround dey. Daily unique counts correct. Weekly and monthly unique counts dey build from daily ones, so dem go overstate reach. Therefore, any long-window "returning visitor" figure no dey measure wetin the label talk. Sessions and journeys reliable inside one day. Rotating ANONYMOUS_IDENTITY_SECRET get the same effect as day boundary, so treat that rotation as data change, no be routine hygiene.

The collector dey respect Do Not Track and Global Privacy Control, wey be browser signal wey tell site make e no sell or share personal data. The script tag get im own switches for the same purpose: data-respect-gpc, data-respect-dnt, and data-require-consent. data-require-consent dey hold all collection until consent dey granted, and e remember the answer for localStorage under key oa.consent. Setting data-storage="none" dey turn off browser storage completely.

Disk fit full after six months

Na events usually no be wetin dey kill self-hosted analytics box. Na disk usage dey cause the problem.

Start with the images. One release dey publish ten images, and all of dem dey take about 13 GB for disk. During upgrade, e go pull the new generation before e remove the old one. For some time, you go get both generations. This one alone dey account for most of the 25 GB requirement, before anybody even open one page.

Then snapshots. snapshot.sh dey stop the stack, archive both data volumes together with every secret, then start everything again. Cold copies na the only safe type for here, because ClickHouse dey merge parts for background, and copy wey dem take during merge no dey consistent. upgrade.sh dey create one automatically before every upgrade, so the archives go pile up for the same disk until you limit how many to keep.

./snapshot.sh create --label before-something-risky
./snapshot.sh list
./snapshot.sh --keep 3

For host wey disk space don nearly finish, reclaim the previous generation before you upgrade. This one safe while the stack dey run, because images wey running containers dey use still get references:

docker image prune -a -f

Then the events themselves. ClickHouse dey compress columnar data well, so raw event volume dey grow slower than most people expect. The rollup tables wey dashboard dey read small compared with the raw table. Measure am instead of guessing:

docker system df -v
docker compose exec clickhouse df -h /var/lib/clickhouse

For the per-table figure, run this with the ClickHouse credentials wey the generator write under infra/selfhost/env/:

SELECT table, formatReadableSize(sum(bytes_on_disk)) AS size, sum(rows) AS row_count
FROM system.parts
WHERE active
GROUP BY table
ORDER BY sum(bytes_on_disk) DESC;

Take the reading for week one, then take another one for week four. Two readings go give you growth rate. The growth rate go tell you when you need resize the volume. As of August 2026, the self-hosting guide no document any retention or time-to-live knob for raw events. So size the disk based on the rate wey you measure. No assume say old rows go expire by themselves.

You need know one deletion trap before e cause problem. When you delete site or account, the system queues work for the worker. That worker need CLICKHOUSE_MAINTENANCE_USER and CLICKHOUSE_MAINTENANCE_PASSWORD to dey set, and one matching oa_maintenance user must exist for ClickHouse. Without dem, the deletion queue go remain forever. The site go disappear from dashboard, but every row go remain for disk. So e go look like cleanup happen, but you no go recover any space.

Upgrades, and the three costs

git fetch --tags
git checkout "$(git tag -l 'v*' --sort=-v:refname | sed '/-/d' | head -1)"
cd infra/selfhost
./upgrade.sh

upgrade.sh dey print three costs before e act. Downtime na real cost: events wey dem try while collector dey down go lost, because tracker no dey retry dem. Rollback dey lose data, because rollback.sh --to backups/<snapshot> dey replace both stores wholesale and discard every row wey dem write after dem take that snapshot. Disk na the third cost, and na the snapshot pile wey we describe above.

Two restart rules easy to get wrong. Bring query gateway up before API, because newer API dey send query fields wey older gateway dey reject. And ClickHouse need recreate instead of restart, because docker compose restart dey reuse the container original environment and silently ignore your edit:

docker compose up -d --force-recreate clickhouse

Dashboard get the same kind trap. The three NEXT_PUBLIC_* origins for env/web.env dey compiled inside browser bundle and substituted when container start, so if dashboard dey call wrong hostname, use docker compose up -d --force-recreate web fix am; never use restart. The web container log dey print the origins wey e start with. Na the fastest way to confirm say the fix don apply.

If ClickHouse refuse to start after config edit, read the first line of the log. Line wey start with oa-entrypoint: mean entrypoint reject a value wey you set. Anything else usually mean config file no be valid XML. The commonest cause na double hyphen inside XML comment, and XML no allow am there.

AGPL-3.0, and di name

Di code dey under AGPL-3.0 license. If you run am without change for your own sites, e no create any publishing obligation at all. Di obligation go start when you modify di code and run dat modified version as network service: di license then require you to offer your modified source to di users of dat service. Dis cover when you give clients dashboards for your instance, and e also cover when you bundle am inside something wey you dey sell. If you keep your changes for public fork, dat satisfy di requirement without any extra process.

Di brand dey separate from di code. Di "OpenAnalytics" name and di project's hosted domain identify di instance wey di authors dey operate, and dem no dey part of di license grant. Your deployment dey run di software without carrying di brand, so give di service its own name before you put am in front of customers wey dey pay.

FAQ

I fit run OpenAnalytics for one 1 GB VPS?

No. The project need about 4 GB RAM and 25 GB free disk, because one deployment dey run six application services together with Postgres, ClickHouse and two Valkey instances. ClickHouse by itself no be small process. For one 1 GB box, the containers go start, then the kernel out-of-memory killer go terminate one of dem, usually ClickHouse. If na 1 GB plan be the hard limit, use one single-binary tool like GoatCounter, wey dey run on SQLite without external database.

Na question for your lawyer, but the technical facts dey support you. No cookie dey used, visitor identity na salted hash wey dey rotate daily, and raw IP addresses no dey stored ever, so system no dey write any durable data to identify the visitor. GDPR still control wetin you store and how long you keep am. If you want make collection require explicit consent, set data-require-consent for the script tag: tracker no go collect anything until consent dey granted, and e go keep the answer for localStorage under oa.consent.

Why events dey return 202 but dem never appear for dashboard?

202 mean say collector accept and queue the event; e no mean say collector store am. Worker dey drain that queue enter ClickHouse, so empty dashboard with successful requests dey point to the worker. Read docker compose logs --tail=50 worker and monitor the Valkey queue depth. If queue continue to grow, worker dey blocked. The usual causes na wrong ClickHouse credentials for worker.env or missing grant on one table wey recent migration create.

Why dashboard empty when every container dey healthy?

Check AUTH_TRUSTED_ORIGINS for env/api.env first. E must match dashboard origin exactly. If e no match, API no go emit CORS headers, so browser go reject every call and you go see working layout without data. The second thing to check na the three NEXT_PUBLIC_* values for env/web.env, wey dey substituted when web container start. To correct dem, you need docker compose up -d --force-recreate web, because plain restart go keep the old values.

AGPL-3.0 fit stop me from offering this to clients?

No, e attach one condition. If you run the code without modification, you no owe anybody anything. If you modify am and run the modified version as a service wey other people dey use, you must offer those users your modified source. A public fork satisfy this requirement. Separately, the "OpenAnalytics" name no dey licensed together with the code, so anything you sell need its own name.