SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Self-host OpenAnalytics on a VPS

The real footprint before you start: ClickHouse, Postgres, Valkey, 4 GB RAM, 25 GB free and four DNS records. Then the install, and what fills the disk.

The footprint, before step one

To self-host OpenAnalytics 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 already pointing at the box. That is the honest headline, and it belongs before the first command rather than after it.

The stack is six application services and three data stores. Postgres holds the control plane: accounts, sites, API keys and share links. ClickHouse holds the raw events and the rollups the dashboard reads. Valkey runs twice, once as a durable event queue and once as a cache the system can afford to lose, because those two jobs need opposite eviction policies. Only one process, the query gateway, is allowed to read ClickHouse, and it verifies an Ed25519 signature on every query envelope before it runs one.

If what you wanted was one binary and one config file, this is not it. GoatCounter is the single-binary option in this category: one Go executable, SQLite by default, no external database at all. The heavier stack buys you funnels, web vitals, revenue attribution from your own Stripe account, and an MCP (model context protocol) server. Choosing between self-hosted analytics tools is the post that weighs that trade. This guide assumes you already made the call.

Point four DNS records at the box first

Four subdomains must resolve to the server's public IP before you start anything, because Caddy requests Let's Encrypt certificates on first launch and the challenge fails on a name that does not resolve yet.

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

Use four A records, or one A record and three CNAMEs pointing at it. Confirm with dig +short app.example.com before you continue. A name you added a minute ago can still be cached as NXDOMAIN by whichever resolver Let's Encrypt happens to use, so a first certificate attempt that fails is worth waiting out and reading in the Caddy logs. Re-running the install does not make DNS propagate faster.

How to self-host OpenAnalytics with Docker Compose

Check out a tagged release. The default branch is where development happens, and a release tag is what the published images actually match. The commands below assume Docker and the Compose plugin are already installed, which running Docker Compose services on a VPS covers.

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' in the checkout line drops pre-release tags, so you land on the newest stable version rather than a release candidate. --with-geoip fetches the DB-IP city database during generation. Skip it and every event carries a null country, so the geography view shows nothing at all. You can add it later by running infra/selfhost/geoip/fetch-dbip.sh, setting GEOIP_DB_PATH=/geoip/dbip-city-lite.mmdb in env/collector.env, then recreating the collector with docker compose up -d --force-recreate collector. That database is refreshed monthly, so repeat the fetch monthly or your city data drifts.

Back up the generated secrets before you go further

The generator writes three things. .env holds the domain names and the image references. env/*.env holds one file of secrets per service. docker-compose.override.yml holds three Ed25519 key pairs as YAML block scalars, because a multi-line PEM cannot live in an env file. All of it is git-ignored, and none of it can be regenerated to the same values.

Copy those files off the machine now. Each loss costs something specific:

  • Lose the store passwords and you are locked out of Postgres and ClickHouse, resettable only from inside the containers.
  • Lose OA_CREDENTIAL_KEYRING and every stored third-party credential is unrecoverable, so anyone who connected a Stripe account has to connect it again.
  • Lose ANONYMOUS_IDENTITY_SECRET and visitor identity re-baselines: yesterday's visitors all count as new, and the break is visible in the charts.
  • Lose AUTH_SECRET and every session is invalidated, so everyone signs in again.
  • Lose a signing private key and you rotate the pair. Nothing is lost.

Two secrets must be byte-identical across two files each. ANONYMOUS_IDENTITY_SECRET appears in collector.env and worker.env, because the collector computes the visitor hash and the worker writes it. OA_CREDENTIAL_KEYRING appears in api.env and worker.env. Everything else is scoped to exactly one service on purpose, and a service handed a secret it must not hold exits instead of starting.

Bring the stack up and check it

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

migrate applies the Postgres and ClickHouse schemas and then exits, so a stopped migrate container is the correct end state. tracker-build compiles oa.js into a volume Caddy serves and exits too. Everything else should read healthy in docker compose ps. A service restarting in a loop is almost always failing environment validation, and the log prints every problem in one list rather than one per restart. The two usual causes are a variable left blank, which is rejected rather than treated as unset, and a secret placed in the wrong service file.

On arm64, or from a branch, there are no published images and you build locally with docker compose up -d --build. A 4 GB host runs out of memory partway through that build. Add swap first, which is needed only while building:

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

Building takes roughly ten minutes. Pulling takes a few, which is why the release images exist.

Claim the first account immediately

Open https://app.example.com. A deployment nobody has signed into yet does not show a sign-in form: it offers to create the first account. That account is permanently the privileged one, and it is the only account that sees the deployment settings screen. Once it exists the route answers 409, so nobody can walk in behind you. Do this the minute the stack is healthy, not the following week.

Install the tracker

Add a site in the dashboard and it hands you the tag. The shape is fixed:

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

Put it in the page head. The tracking key is public by design, so it belongs in your HTML where anyone can read it. The script installs window.oa, and calls like oa("track", ...) are queued by a stub and flushed once the file loads, so a custom event fired early is not dropped. If something else on the page already owns window.oa, the tracker installs as window.openanalytics instead.

Then check the whole path 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 should print 200 and a few kilobytes. Load a page on your site, then look for a batch line in the worker log within seconds. The collector answers 202 the moment it accepts an event, and 202 means queued, not stored. The worker is what moves events into ClickHouse. Events accepted with nothing appearing in the dashboard means the worker is blocked, and a Valkey queue depth that keeps climbing confirms it. The usual causes are wrong ClickHouse credentials in worker.env, or a missing grant on a table a migration just added.

Keep the collector public and the dashboard behind auth

Caddy ships inside the compose file and obtains certificates for all four names on its own, so the default path needs no proxy work from you. If the box already runs an nginx reverse proxy, front the stack with the supplied infra/selfhost/nginx.conf.example instead, and keep its header handling intact:

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 derives the daily visitor hash from the client IP, so it must take that address from the connection and never from a header. Passing CF-Connecting-IP through from an untrusted hop lets any caller claim any address, which corrupts geolocation and inflates visitor counts at the same time.

Access splits cleanly by hostname. c. and rt. must be reachable by every visitor of every site you measure, so never put basic auth or an IP allowlist in front of those two. app. and api. only need to be reachable by people who sign in. The application's own auth is what protects the dashboard: password sign-in is on by default through AUTH_PASSWORD_SIGNIN=enabled in env/api.env, and the Google or GitHub buttons appear only when both the client ID and the client secret exist for that provider. Magic links need a mail transport, and without one the API only writes the send to an outbox, so nothing is delivered and nothing errors.

One setting decides whether the dashboard works at all. AUTH_TRUSTED_ORIGINS in env/api.env must match the dashboard origin exactly. Wrong or missing, the API emits no CORS (cross-origin resource sharing) headers, the browser refuses every call, and you get a dashboard that renders its layout and shows no data while docker compose ps reports everything healthy.

While you are in the proxy config, deal with automated traffic. Crawlers hit the collector like anything else, and their page views land in ClickHouse and in your numbers. Blocking AI crawlers at the server keeps a share of that out of the database before it costs you both accuracy and disk.

What cookieless means here, and what it costs you

There is no cookie. Visitor identity is a salted hash, the salt rotates every day, and raw IP addresses are never stored. Geolocation is resolved locally against the DB-IP file on your own disk, so no lookup about a visitor ever leaves the host.

What that buys you is the absence of an identifier persisted on the visitor's device, which is the specific thing that pulls a tracker into the EU ePrivacy consent rules. Aggregate-only setups like this one are commonly run without a consent banner for that reason. GDPR still governs whatever you do store and for how long, and your own counsel decides your case, not a README.

What it costs you is cross-day identity. The salt rotation means a person who visits on Monday and again on Wednesday is counted as two visitors, by design and with no workaround. Daily unique counts are sound. Weekly and monthly unique counts are built from daily ones and will overstate reach, so any long-window "returning visitor" figure is not measuring what its label says. Sessions and journeys are reliable inside a single day. Rotating ANONYMOUS_IDENTITY_SECRET has the same effect as a day boundary, so treat that rotation as a data change rather than as routine hygiene.

The collector honours Do Not Track and Global Privacy Control, the browser signal that tells a site not to sell or share personal data. The script tag carries its own switches for the same ground: data-respect-gpc, data-respect-dnt, and data-require-consent, which holds all collection until consent is granted and remembers the answer in localStorage under the key oa.consent. Setting data-storage="none" turns off browser storage entirely.

Why the disk fills up six months in

This is what kills a self-hosted analytics box, and the events are usually not the reason.

Start with the images. A release publishes ten of them, and they come to roughly 13 GB on disk. An upgrade pulls the new generation before it drops the old one, so for a while you hold two generations. That is most of the 25 GB requirement, before a single page view arrives.

Then the snapshots. snapshot.sh stops the stack, archives both data volumes along with every secret, and restarts. Cold copies are the only safe kind here, because ClickHouse merges parts in the background and a copy taken during a merge is not consistent. upgrade.sh takes one automatically before every upgrade, so the archives accumulate on the same disk until you cap them.

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

On a host close to the limit, reclaim the previous generation before upgrading. This is safe while the stack is running, because images backing running containers are still referenced:

docker image prune -a -f

Then the events themselves. ClickHouse compresses columnar data hard, so raw event volume grows more slowly than most people expect, and the rollup tables the dashboard reads are small next to the raw table. Measure rather than guess:

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

For the per-table figure, run this with the ClickHouse credentials the generator wrote 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 in week one and again in week four. Two points give you a growth rate, and a growth rate tells you when the volume needs resizing. The self-hosting guide documents no retention or time-to-live knob for raw events as of August 2026, so size the disk against your measured rate instead of assuming old rows expire on their own.

One deletion trap is worth knowing before it bites. Deleting a site or an account queues work for the worker, and that worker needs CLICKHOUSE_MAINTENANCE_USER and CLICKHOUSE_MAINTENANCE_PASSWORD set, with a matching oa_maintenance user existing in ClickHouse. Without them the deletion queues forever. The site vanishes from the dashboard and every row stays on disk, so you get the appearance of a cleanup and none of the 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.sh

upgrade.sh prints three costs before it acts. Downtime is real: events attempted while the collector is down are lost, because the tracker does not retry them. Rollback loses data, since rollback.sh --to backups/<snapshot> replaces both stores wholesale and discards every row written after that snapshot was taken. Disk is the third cost, which is the snapshot pile described above.

Two restart rules are easy to get wrong. Bring the query gateway up before the API, because a newer API sends query fields an older gateway rejects. And ClickHouse needs a recreate rather than a restart, because docker compose restart reuses the container's original environment and silently ignores your edit:

docker compose up -d --force-recreate clickhouse

The dashboard has the same shape of trap. The three NEXT_PUBLIC_* origins in env/web.env are compiled into the browser bundle and substituted when the container starts, so a dashboard calling the wrong hostname is fixed with docker compose up -d --force-recreate web and never with restart. The web container's log prints the origins it started with, which is the fastest way to confirm the fix landed.

If ClickHouse refuses to start after a config edit, read the first line of its log. A line beginning oa-entrypoint: is the entrypoint rejecting a value you set. Anything else usually means the config file is invalid XML, and the most common cause is a double hyphen inside an XML comment, which is illegal there.

AGPL-3.0, and the name

The code is licensed AGPL-3.0. Running it unmodified for your own sites creates no publishing obligation whatsoever. The obligation begins when you modify the code and run that modified version as a network service: the license then requires you to offer your modified source to the users of that service. That covers giving clients dashboards on your instance, and it covers bundling it into something you sell. Keeping your changes in a public fork satisfies it with no further process.

The brand is separate from the code. The "OpenAnalytics" name and the project's hosted domain identify the instance its authors operate, and they are not part of the license grant. Your deployment runs the software without carrying the brand, so give the service its own name before you put it in front of paying customers.

FAQ

Can I run OpenAnalytics on a 1 GB VPS?

No. The project asks for about 4 GB of RAM and 25 GB of free disk, because one deployment runs six application services next to Postgres, ClickHouse and two Valkey instances. ClickHouse alone is not a small process. On a 1 GB box the containers start and the kernel out-of-memory killer then takes one of them, usually ClickHouse. If a 1 GB plan is the hard constraint, use a single-binary tool such as GoatCounter, which runs on SQLite with no external database.

That is a question for your lawyer, and the technical facts are in your favour. There is no cookie, visitor identity is a salted hash that rotates daily, and raw IP addresses are never stored, so nothing durable is written to identify the visitor. GDPR still governs what you store and how long you keep it. If you want collection gated explicitly, set data-require-consent on the script tag: the tracker then collects nothing until consent is granted and keeps the answer in localStorage under oa.consent.

Why do events return 202 but never appear in the dashboard?

202 means the collector accepted and queued the event, not that it stored it. The worker drains that queue into ClickHouse, so an empty dashboard with successful requests points at the worker. Read docker compose logs --tail=50 worker and watch the Valkey queue depth. A queue that keeps growing means the worker is blocked, and the usual causes are wrong ClickHouse credentials in worker.env or a missing grant on a table that a recent migration created.

Why is the dashboard empty when every container is healthy?

Check AUTH_TRUSTED_ORIGINS in env/api.env first. It must match the dashboard origin exactly, and when it does not the API emits no CORS headers, so the browser refuses every call and you see a working layout with no data. The second thing to check is the three NEXT_PUBLIC_* values in env/web.env, which are substituted when the web container starts. Correcting them requires docker compose up -d --force-recreate web, because a plain restart keeps the old values.

Does AGPL-3.0 stop me offering this to clients?

No, it attaches one condition. Run the code unmodified and you owe nothing to anyone. Modify it and run that modified version as a service other people use, and you must offer those users your modified source, which a public fork satisfies. Separately, the "OpenAnalytics" name is not licensed along with the code, so anything you sell needs its own name.