tadoru
v0.6.3
Published
Cookieless, privacy-first, self-hosted web analytics. One process, one file, no consent banner.
Readme
Tadoru (辿る)
Self-hosted web analytics without cookies, without a consent banner, and without sending anything to anyone else. One Node process and one SQLite file on your own server.
Tadoru is Japanese for following a trail — which is what analytics should do to pages, not to people.
Why no consent banner
Not because it avoids cookies. Removing cookies is not enough on its own: EDPB Guidelines 2/2023 extended ePrivacy Art. 5(3) to tracking pixels, tracking links, fingerprinting and certain IP-based tracking. What actually removes the banner is the audience-measurement exemption, and Tadoru is built to satisfy it by construction rather than by configuration.
- Visitors are counted with a hash of a salt that is destroyed every 24 hours, so nothing links one day to the next.
- The site's domain is part of that hash, so the same person on two sites is two unrelated visitors. Cross-site tracking is not disabled; it is impossible.
- IP addresses and user-agents are never written to disk. They are used in memory to derive the hash and to look up a country, then discarded.
- No fingerprinting. No canvas, no WebGL, no font list, no exact timezone, no exact resolution, no plugin list. The tracker payload is a closed allow-list guarded by a test.
- Do Not Track and Global Privacy Control are honoured before any request is sent.
- Raw events expire after 25 months by default.
The full reasoning, condition by condition, is in docs/compliance.md. A
paste-ready privacy notice is in docs/privacy-policy-template.md.
You remain the data controller for your installation. This is engineering documentation, not legal advice.
Try it on your own computer first
Before touching a server, run it locally. Nothing is installed system-wide and you can delete it
afterwards by deleting one folder. You need Node.js 22 or newer — check with
node -v.
npm install -g tadoru
TADORU_ADMIN_PASSWORD=test1234 \
TADORU_SITES=example.com \
TADORU_DATA_DIR=./tadoru-data \
tadoru startOpen http://127.0.0.1:3000 and log in with test1234. The dashboard will be empty, which is
correct — nothing has visited yet.
If something else on your machine already has port 3000 — a development server, usually —
Tadoru notices and tries 3001, 3002, and so on up to 3009 until it finds a free one. The startup
line printed in your terminal always names the port it actually bound, e.g. listening on
127.0.0.1:3001 (3000 was busy; set TADORU_PORT to pin it) — use that number below instead of
3000 if it differs.
To see it record something, leave that running and in another terminal:
curl -H 'Referer: https://example.com/hello' http://127.0.0.1:3000/t.gifReload the dashboard and there is your first visit. Press Ctrl+C in the first terminal to stop,
then rm -rf ./tadoru-data to remove everything it wrote.
Install on a server
If you already run servers, this is the whole thing:
sudo npm install -g tadoru
sudo tadoru install-service --sites example.com,other.dev
sudo systemctl enable --now tadoruinstall-service creates the tadoru system user, writes /etc/tadoru/tadoru.env with a freshly
generated admin password (printed once — write it down), writes the systemd unit, and reloads
systemd. It never starts or enables the service itself: that stays a separate, explicit step, which
is what systemctl enable --now above is. Re-running install-service is safe — it never
overwrites an existing tadoru.env, so your admin password never changes underneath you.
Then a reverse proxy for HTTPS, and the snippet on your site. Both are in the walkthrough below.
Step by step, assuming nothing
This section explains every step and every word. Skip it if the three commands above were enough.
What you need before starting
A server that stays on. A cheap VPS is fine — Tadoru uses about 100 MB of memory and one small file for storage, so the smallest plan any provider sells will do. Hetzner, DigitalOcean, OVH and Scaleway all rent one for a few euros a month. When you create it, choose Ubuntu or Debian if you are unsure; the commands below assume one of those.
A domain name. You will point a subdomain like stats.yoursite.com at the server, so the
dashboard has an address. If your site already has a domain, you can use a subdomain of it and pay
nothing extra.
A way to connect to the server. Your provider gives you an IP address and a password or key.
You connect from your own terminal with ssh root@THE-IP-ADDRESS. Everything below is typed in
that connection, not on your own computer.
1. Install Node.js
Tadoru is a Node.js program, so the server needs Node 22 or newer. Check what is there:
node -vIf that prints nothing, or a number below v22, install it:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejsRun node -v again. You want to see v22 or higher.
Node 20 and older are not supported. Node 20 stopped receiving security updates in April 2026, and running a public service on an unpatched runtime is a bad trade.
2. Install Tadoru
sudo npm install -g tadorunpm is Node's package installer. -g means "install it for the whole machine", so the tadoru
command works anywhere. Check it landed:
tadoru --version3. Set it up
Replace example.com with the domain of the site you want to measure. You can list several,
separated by commas and no spaces.
sudo tadoru install-service --sites example.comThis prints a list of what it did, ending with a password. Copy that password somewhere safe now — it is shown once and never again. It is how you log into the dashboard.
If you want to see what it would do without doing it, add --dry-run first.
4. Start it
sudo systemctl enable --now tadorusystemctl manages background programs on Linux. enable means "start this automatically whenever
the server reboots" and --now means "and also start it right now". Check it is running:
sudo systemctl status tadoruYou want a line saying active (running). Press q to get out of that screen.
5. Point your domain at the server
In whoever manages your domain — your registrar or DNS provider — add an A record:
| Field | Value |
|---|---|
| Type | A |
| Name | stats |
| Value | your server's IP address |
An A record is simply the line that tells the internet "this name lives at this IP address". After
adding it, stats.yoursite.com points at your server. It usually works within minutes, though it
can take longer.
6. Give it a way in from the internet
Tadoru listens only on the server's own internal address — 127.0.0.1:3000 — so nothing outside
can reach it yet. That is deliberate: it should never face the internet directly.
What sits in front is your choice, and this guide is not going to pick for you. nginx, Caddy, Apache, Traefik, HAProxy, a load balancer your host provides — any of them works, and on a server you already use, one of them is probably running and holding ports 80 and 443 already. Installing a second one will simply fail to start.
So instead of a recipe, here is the contract. Point a subdomain at your server, terminate HTTPS
there, and forward to 127.0.0.1:3000 passing four things:
| What to pass | Why it matters if you do not |
|---|---|
| The request itself, to 127.0.0.1:3000 | Nothing works at all. |
| Host: the public hostname | The dashboard builds its ready-to-paste snippet from this. Get it wrong and it hands you a snippet pointing at 127.0.0.1. |
| X-Forwarded-Proto: https | The admin session cookie loses its Secure flag, so it can travel over plain HTTP. |
| X-Forwarded-For: the real client IP | Every visitor resolves to your server's own country, and the country breakdown becomes meaningless. |
One detail worth getting right, because most tutorials get it wrong. Tadoru reads the first
entry of X-Forwarded-For. Your proxy must replace that header with the connecting IP, not
append to it. If it appends — nginx's widely copied $proxy_add_x_forwarded_for does exactly this
— then a visitor who sends their own X-Forwarded-For ends up first in the list, and can choose
which country they appear from. Since your proxy is the one facing the internet, the IP it sees is
the true one; replace with that.
Two examples, not recommendations. nginx:
server {
listen 80;
server_name stats.yoursite.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then add HTTPS however you normally do — certbot --nginx -d stats.yoursite.com is the usual way.
Caddy, which sets all three headers itself and obtains the certificate unprompted:
stats.yoursite.com {
reverse_proxy 127.0.0.1:3000
}TADORU_TRUSTED_PROXY=true is already set in /etc/tadoru/tadoru.env, which is what allows those
forwarded headers to be believed. Leave it on only while something really is in front: with nothing
there, anyone could send the headers themselves.
Now open https://stats.yoursite.com in a browser. You should see the login page. Enter the
password from step 3.
7. Add the snippet to your site
In the HTML of the site you are measuring, just before </head>:
<script defer src="https://stats.yoursite.com/t.js"></script>For visitors with JavaScript turned off, add this too:
<noscript><img src="https://stats.yoursite.com/t.gif" alt="" width="1" height="1"></noscript>8. Check that it works
This is the step people skip and then wonder whether they finished.
- Open your site in a browser and click through a couple of pages.
- Go back to
https://stats.yoursite.comand reload. - You should see those visits.
If you see them, you are done.
When it does not work
The dashboard says nothing was recorded. Check that the domain in --sites is exactly the one
your visitors use. Tadoru silently ignores traffic from any domain not on that list, including
www. differences.
systemctl status tadoru says failed. Read the reason:
sudo journalctl -u tadoru -n 30The most common cause is a missing admin password — Tadoru refuses to start rather than run with no credentials, and says so plainly.
The browser cannot reach the address. The DNS record has probably not taken effect yet. Check what the world sees:
dig +short stats.yoursite.comIt should print your server's IP. If it prints nothing, wait and try again.
HTTPS shows a certificate warning. Whatever issues your certificate could not get one — almost
always because the DNS record is not pointing at this server yet, or because ports 80 and 443 are
blocked by a firewall. Your proxy's own log says which: sudo journalctl -u nginx -n 30, or
caddy, or whatever you are running.
Your proxy refuses to start with address already in use. Something else already holds port 80
or 443 — on a server you already use, that is normal. Find it with sudo ss -tlnp | grep -E ':80|:443'
and add Tadoru to that server rather than installing a second one.
You lost the admin password. /etc/tadoru/tadoru.env stores only a scrypt hash and its salt
(TADORU_ADMIN_PASSWORD_HASH / TADORU_ADMIN_PASSWORD_SALT), never the plaintext. A hash cannot
be turned back into the password, so losing it is genuinely unrecoverable rather than merely
inconvenient — reset it instead:
sudo tadoru reset-password
sudo systemctl restart tadoruThis backs up the existing env file to a dated copy alongside it, generates a new password, prints it once, and rewrites only the credential lines — every other line in the file (comments, sites, host, port) is left untouched.
Measuring more than page views
The snippet already records pages, referrers, campaigns, countries, devices, engagement time, scroll depth and outbound clicks without any configuration. Two things you can add.
Goals and custom events. Call tadoru with a name wherever something happens that matters —
a signup, a purchase, a form sent:
tadoru('signup', { plan: 'pro' })The name appears in the dashboard; the properties are stored alongside it.
Visitors with JavaScript disabled. The <noscript> pixel from step 7 covers them. It records
the page, referrer, country and device, but not engagement, scroll or clicks — those need a script
running in the page.
Operating it
systemctl status tadoru # is it running
journalctl -u tadoru -f # what is it doing
curl localhost:3000/health # database and scheduled jobs
tadoru status # one command answering "is Tadoru working?"
tadoru backup # a consistent copy of the whole database
sudo npm update -g tadoru && sudo systemctl restart tadoruOne command, one answer. tadoru status checks whether a server is answering, opens the
database read-only to report its size and per-site event counts, and shows each scheduled job's
health — without you having to chain systemctl, curl and sqlite3 by hand. It also contacts
the npm registry to check whether a newer version has been published, and if so reports it by name
alongside the upgrade command; --no-update-check skips that request entirely. It exits non-zero
if the server isn't answering or the database can't be read, so it doubles as a monitoring check.
Add --json for scripting:
tadoru status --json | jq .serverSalt rotation, nightly rollups and retention purging run inside the process. There is no crontab to configure.
Backups are one file. tadoru backup writes a dated, consistent copy; restoring is copying it
back. That simplicity is the direct payoff of choosing SQLite.
Restoring is a command, not "copy the file back". tadoru restore <backup-file> does the copy
for you, at the moment an operator is most likely to destroy the data they meant to save:
tadoru restore /var/lib/tadoru/tadoru-backup-2026-09-08T12-34-56.sqliteIt refuses to run while the server is answering — restoring into a database a live process is
writing to can corrupt both files, and this is the one guard that isn't recoverable, so --force
is required to skip it. It refuses any file that isn't actually a Tadoru database, checking that it
opens as SQLite and carries the tables Tadoru expects, so a mistyped path can't silently clobber
your installation with an unrelated .db file. Before touching anything it moves the database
currently in place to a dated file next to it — so restoring the wrong backup still leaves you a
way back — and only then replaces it, removing any stale -wal/-shm files so the restored
database never starts from an inconsistent journal. --dry-run runs every one of those checks for
real and prints the plan without changing anything on disk.
Lost the admin password? tadoru reset-password. It backs up the env file, generates a new
password, prints it once, and rewrites only the credential lines — see "When it does not work"
above for the full walkthrough.
Keeping country lookups accurate. sudo tadoru update-geoip refreshes the bundled GeoIP
database. Countries are resolved locally, against a copy that ships inside the package, so nothing
is ever asked of an outside service — the cost of that is a copy that slowly ages. Restart
afterwards.
tadoru init is not part of this. It writes a tadoru.config.json and a tadoru.env in the
current directory, for running tadoru start yourself. A systemd installation reads neither: it is
configured entirely from /etc/tadoru/tadoru.env, which install-service writes. Running init
before install-service is harmless but pointless, and the password it prints is not the one that
opens the dashboard.
A note on hardening. Running natively means there is no container isolating the process, so
the shipped systemd unit does that work instead: dedicated unprivileged user, no capabilities,
ProtectSystem=strict, a restricted syscall filter. Check it with
systemd-analyze security tadoru.
Dashboard language. The dashboard is available in English, Spanish and Japanese. It follows
your browser's Accept-Language by default; TADORU_LANG (en, es or ja) fixes it to one
language instead. install-service always writes that key into the environment file, and the unit
loads that file with EnvironmentFile=, which takes precedence over the manager environment — so
change it there rather than with systemctl set-environment, which would be silently ignored:
sudo sed -i 's/^TADORU_LANG=.*/TADORU_LANG=es/' /etc/tadoru/tadoru.env
sudo systemctl restart tadoruDocker, if you prefer it
A single-service docker-compose.yml lives in docker/. There is no database
container because there is no database server.
What it collects
| Without JavaScript | Additionally, with the script | |---|---| | Page, referrer, campaign | SPA route changes | | Country | Time actually visible on the page | | Browser, OS and device family | Scroll depth milestones | | Language | Outbound clicks and file downloads | | | Custom events and goals | | | Core Web Vitals (LCP, CLS, INP) | | | Colour scheme, screen size bucket |
Behavioural depth, no identity depth. That trade is the entire design, and
docs/adr/ records why each part of it was chosen and what it costs.
Collected is not yet the same as shown. Everything above is recorded and kept, but the dashboard does not have a view for all of it. Today it reports pages, referrers, campaigns, countries, devices, browsers, operating systems, screen size, language, colour scheme and scroll depth. Outbound clicks, custom events and goals, and Core Web Vitals are stored and queryable in the database, but have no section yet — they are recorded from the day you install, so the history is there whenever those views arrive.
What it will never do
Returning visitors, retention curves, cohort analysis and cross-site attribution are all impossible here, by construction. Anyone who needs those needs consent, and therefore a banner, and therefore a different tool.
Development
npm install
npm test # the full suite
npm run typecheck # server and tracker, separate configs
npm run dev # run with --watch
npm run build:tracker # bundle the browser script, enforcing its size budgetNode 22.18 or newer is required to develop, because the server runs TypeScript directly with no build step. The published package is compiled, so installing it works on any Node 22, which is supported until April 2027.
Start with AGENTS.md — it holds the invariants, the non-obvious toolchain rules and
the working method. Behaviour is specified in specs/; every requirement there names
the test that enforces it.
Licence
AGPL-3.0-only. You may run it, modify it and redistribute it freely. If you offer a modified version to other people over a network, section 13 requires you to offer them its source too — which is why every dashboard page links back to the repository and the running version.
That is the whole intent: improvements to a privacy tool should stay available to the people whose
privacy it protects. See docs/adr/0006-agpl-and-the-network-clause.md.
