How to Self-Host OpenAnalytics on VPS
Before you start, OpenAnalytics needs 4 GB RAM, 25 GB free disk, Docker Compose, and four DNS records. See the install steps and what fills the disk.
The footprint, before step one
Before you start, you need a Linux VPS with about 4 GB of RAM, 25 GB of free disk, Docker with the Compose plugin, and four DNS records wey already point to the box. 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 the raw events and the rollups wey the dashboard dey read. Valkey dey run two times: one time as a durable event queue, and another time as cache wey the system fit lose, because both jobs need opposite eviction policies. Na only one process, the query gateway, get permission to read ClickHouse. E dey verify an Ed25519 signature for every query envelope before e run am.
If wetin you want na one binary and one config file, this setup no be that kind. 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. How to choose between self-hosted analytics tools na the post wey explain that trade-off. This guide assume say you don already decide.
Point four DNS records go the box first
Four subdomains must resolve to the server public IP before you start anything, because Caddy dey request Let's Encrypt certificates for first launch, and the challenge go fail if name no resolve yet.
app.example.comdey serve the dashboard.api.example.comdey serve the API and the OAuth callbacks.c.example.comdey serve the collector and the tracker script.rt.example.comdey serve the realtime stream.
Use four A records, or one A record plus three CNAMEs wey point to am. Confirm with dig +short app.example.com before you continue. Name wey you add one minute ago fit still dey cached as NXDOMAIN by any resolver wey Let's Encrypt happen to use, so if first certificate attempt fail, wait small and read the Caddy logs to understand wetin happen. Running the install again no go make DNS propagate faster.
How to self-host OpenAnalytics with Docker Compose
Checkout one tagged release. Default branch na where development dey happen, but release tag na the version wey published images actually match. The commands below assume say Docker and the Compose plugin don already install. Running Docker Compose services for one VPS explain how to do am.
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 -dThe sed '/-/d' for the checkout line dey exclude pre-release tags, so you go land for the newest stable version instead of release candidate. --with-geoip dey fetch the 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. That database dey refresh every month, so fetch am monthly again, otherwise your city data go become outdated.
Back up 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 for an env file. Git dey ignore all of them, and you no fit regenerate any of them 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 them 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 a 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 a signing private key, rotate the key pair. Nothing go lost.
Two secrets must be byte-identical for 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. Everything else dey limited to exactly one service on purpose, and if you give a service a secret wey e no suppose hold, e go exit instead of starting.
Bring the stack up and check am
grep OA_IMAGE .env
docker compose pull
docker compose up -d
docker compose logs -f migrate
docker compose psmigrate dey apply Postgres and ClickHouse schemas, then e go exit. So, if migrate container stop, na the correct final state be that. tracker-build dey compile oa.js into one volume wey Caddy dey serve, then e go exit too. Everything else suppose read healthy for docker compose ps. If service dey restart for loop, almost always na environment validation dey 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 the system reject instead of treating am as unset, and secret wey dem put for the wrong service file.
For arm64, or when you dey use branch, published images no dey available. You go 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/fstabThe build dey take roughly ten minutes. Pulling dey take only a few minutes, na why the release images dey exist.
Make una claim the first account immediately
Open https://app.example.com. If nobody don sign into the deployment before, e no go show sign-in form: e go offer to create the first account. That account na 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 after you. Do am immediately once the stack don healthy, no be the following week.
Tracker install
Add site for dashboard and e go give you the tag. The format fixed:
<script
async
src="https://c.example.com/oa.js"
data-key="YOUR_TRACKING_KEY"
data-collector="https://c.example.com"
></script>Put am for page head. Tracking key na public by design, so e suppose dey inside your HTML where anybody fit read am. The script install window.oa, and calls like oa("track", ...) dey queue by a stub, then e flush dem once 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.
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 batchThe first one suppose print 200 and some kilobytes. Load one page for your site, then check the worker log for batch line within seconds. The collector answer 202 immediately e accept event, while 202 mean say event dey queue, e never store. Na the worker dey move events enter ClickHouse. If events dey accepted but nothing dey show for dashboard, worker don block. Valkey queue depth wey dey continue increase confirm am. The common causes na wrong ClickHouse credentials inside worker.env, or missing grant for table wey new migration add.
Make the collector public, but put auth for the dashboard
Caddy dey inside the compose file and e dey obtain certificates for all four names by itself, so the default setup no need proxy work from you. If the box already dey run 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 collect that address from the connection and never from a header. If you pass CF-Connecting-IP from an untrusted hop, any caller fit claim any address. This one go corrupt geolocation and increase visitor counts at the same time.
Access dey split clearly by hostname. c. and rt. must dey reachable by every visitor for every site wey you dey measure, so never put basic auth or an IP allowlist in front of those two. Only people wey sign in need reach app. and api.. The application's own auth na wetin protect the dashboard: password sign-in dey on by default through AUTH_PASSWORD_SIGNIN=enabled for env/api.env, while the Google or GitHub buttons go appear only when both the client ID and client secret dey available for that provider. Magic links need mail transport. Without one, the API only writes the send to an outbox, so nothing dey delivered and no error dey returned.
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 missing, the API no go send CORS (cross-origin resource sharing) headers. The browser go reject every call, and you go get dashboard wey render the layout but show no data, even though docker compose ps report say everything dey healthy.
As you dey work for 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 for the server go keep some of that traffic out of the database before e affect both accuracy and disk space.
Wetin cookieless mean here, and wetin e cost you
No cookie dey. Visitor identity na salted hash, the salt dey rotate every day, and dem no dey ever store raw IP addresses. Geolocation dey resolve locally against the DB-IP file for your own disk, so no lookup about any visitor ever comot from the host.
Wetin this give you na say no identifier dey persist for visitor device. Na this specific thing dey bring tracker under EU ePrivacy consent rules. Aggregate-only setup like this one commonly dey run without consent banner because of that. GDPR still govern anything wey you store and how long you store 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 with no workaround. Daily unique counts dey accurate. Weekly and monthly unique counts dey build from daily ones and go overstate reach. So any long-window "returning visitor" figure no dey measure wetin the label talk. Sessions and journeys dey 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 honour Do Not Track and Global Privacy Control, the browser signal wey dey 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 remember the answer for localStorage under key oa.consent. Setting data-storage="none" dey turn off browser storage completely.
Why disk dey full after six months
Na this one dey kill self-hosted analytics box, and most times events no be the reason.
Start with the images. One release dey publish ten of dem, and dem fit take around 13 GB for disk. Upgrade go pull the new generation before e remove the old one, so for some time you go hold two generations. Na this one dey use most of the 25 GB requirement, before even one page view arrive.
Then snapshots. snapshot.sh dey stop the stack, archive both data volumes together with every secret, then restart am. Cold copies na the only safe type for here, because ClickHouse dey merge parts for background and copy wey dem take during merge no consistent. upgrade.sh dey create one automatically before every upgrade, so the archives go accumulate for the same disk until you limit their number.
./snapshot.sh create --label before-something-risky
./snapshot.sh list
./snapshot.sh --keep 3For host wey disk dey near the limit, reclaim the previous generation before you upgrade. This one safe while the stack dey run, because images wey dey back running containers still get references:
docker image prune -a -fThen the events themselves. ClickHouse dey compress columnar data well, so raw event volume dey grow slower than most people expect, and 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/clickhouseFor 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 that reading for week one and again for week four. Two points give you growth rate, and growth rate tell you when the volume need resizing. The self-hosting guide no document any retention or time-to-live knob for raw events as of August 2026, so size the disk based on the rate wey you measure instead of assuming say old rows go expire by themselves.
One deletion trap dey worth knowing before e cause problem. When you delete site or account, e queue work for the worker, and that worker need CLICKHOUSE_MAINTENANCE_USER and CLICKHOUSE_MAINTENANCE_PASSWORD set, with matching oa_maintenance user existing for ClickHouse. Without dem, the deletion queue go remain forever. The site go disappear from dashboard and every row go remain for disk, so e go look like cleanup happen, but you no get any space back.
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.shupgrade.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 completely and discard every row wey dem write after dem take that snapshot. Disk na the third cost. 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 clickhouseDashboard 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; restart no go fix am. Web container log dey print the origins wey e start with. Na the fastest way to confirm say the fix enter.
If ClickHouse refuse to start after config edit, read the first line of its log. If line start with oa-entrypoint:, entrypoint dey reject a value wey you set. Anything else usually mean say config file no be valid XML. The commonest cause na double hyphen inside XML comment, and that one illegal there.
AGPL-3.0, and the name
The code dey under AGPL-3.0 license. If you run am without changes for your own sites, e no create any publishing obligation at all. The obligation start when you modify the code and run that modified version as a network service: the license then require you to offer the modified source to users of that service. This include giving clients dashboards for your instance, and e also include bundling am inside something wey you dey sell. If you keep your changes for public fork, e satisfy the requirement without any extra process.
The brand separate from the code. The "OpenAnalytics" name and the project's hosted domain identify the instance wey the authors dey operate, and dem no dey part of the license grant. Your deployment dey run the software without carrying the brand, so give the service its own name before you put am in front of paying customers.
FAQ
I fit run OpenAnalytics for 1 GB VPS?
No. 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 1 GB box, containers go start, then kernel out-of-memory killer go take one of dem, usually ClickHouse. If 1 GB plan na the hard limit, use single-binary tool like GoatCounter, wey dey run on SQLite without external database.
I need cookie banner with OpenAnalytics?
Na question for your lawyer, and the technical facts favour you. Cookie no dey, visitor identity na salted hash wey dey rotate every day, and raw IP addresses no dey stored ever, so nothing durable dey written to identify the visitor. GDPR still control wetin you store and how long you keep am. If you want make collection require clear 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 show for dashboard?
202 mean say collector accept and queue the event, e no mean say e 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. Common causes na wrong ClickHouse credentials for worker.env or missing grant on table wey recent migration create.
Why dashboard empty when every container healthy?
First check AUTH_TRUSTED_ORIGINS for env/api.env. E must match the dashboard origin exactly. If e no match, API no go send 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 dem substitute 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 get 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 use, you must offer those users your modified source. Public fork satisfy this requirement. Separately, the "OpenAnalytics" name no dey licensed together with the code, so anything you sell need its own name.