Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1> **Archived.** This project is no longer in use and no longer maintained. Last updated July 2026.
2
3# Analytics
4
5Self-hosted, privacy-respecting website analytics for a single operator.
6
7This exists to answer "how is my site doing?" without handing visitor data to a third party or paying a SaaS bill. You drop a small collector script onto any number of sites you own, and this app collects the events and renders a dashboard: metric cards, time-series charts, a world map, and exportable reports. It is one small axum binary with a SQLite database, so the whole thing runs from a single container with no external services.
8
9It began as a Django app; the data was migrated into this Rust rewrite, which was feature-complete against the original.
10
11## Features
12
13- Embed a small collector script on any number of properties (tracked sites)
14- Tracks page views, clicks, scrolls, sessions, and custom events
15- Dashboard with 16 metric/chart/list aggregations and user-defined custom cards
16- World map with per-country admin-1 drill-down on click
17- GeoIP enrichment (auto-downloads DB-IP City Lite) and user-agent parsing (auto-downloads ua-parser regexes)
18- Bot traffic routed to a separate table so human dashboards never have to filter it
19- Public/private toggle per property; signed-cookie auth for the operator
20- PDF and markdown export of any dashboard view (PDF rendered in-process via embedded Typst, no chromium)
21- Self-tracking: the app tracks its own usage via an auto-created "Proprium" property
22- Single-binary deploy via `git push server master`
23
24## Quickstart
25
26```sh
27cp samplefiles/env.sample .env
28# edit .env to set ANALYTICS_PASSWORD and BASE_URL
29make
30```
31
32`make` (alias `make run`) installs frontend deps if needed, then runs Vite watch and `cargo run` concurrently on port 8000. Visit http://localhost:8000/login.
33
34First boot creates the "Proprium" property and starts tracking the dashboard's own usage.
35
36## System dependencies
37
38Local dev needs all of these on your `PATH`:
39
40| Tool | Why | Version |
41|---|---|---|
42| `rustc` / `cargo` | Build the axum binary | 2021 edition, current stable is fine (1.70+) |
43| `bun` | Frontend deps + Vite + map builder | 1.x |
44| `make` | Run the dev/build targets | any |
45| `pkg-config` + OpenSSL headers | Linked at build time on Linux | distro packages: `pkg-config`, `libssl-dev` (Debian/Ubuntu), `openssl-dev` (Alpine) |
46
47Install hints:
48
49```sh
50# Rust toolchain (recommended via rustup)
51curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
52
53# Bun
54curl -fsSL https://bun.sh/install | bash
55
56# System libs (Debian/Ubuntu)
57sudo apt install -y build-essential pkg-config libssl-dev
58
59# System libs (Alpine)
60sudo apk add musl-dev pkgconfig openssl-dev
61```
62
63The Docker build (see `Dockerfile`) reproduces this on `rust:alpine` + `alpine:3.23`. If you only care about Docker, you do not need any of the above on the host.
64
65Two more files are downloaded into `data/` at runtime, no setup required:
66
67- `data/db.mmdb`: DB-IP City Lite (CC-BY-4.0). Refreshed if older than 30 days.
68- `data/regexes.yaml`: canonical ua-parser regexes (Apache-2.0).
69
70If either download fails the server still boots: UA parsing falls back to a substring heuristic and GeoIP enrichment is skipped.
71
72## Configuration
73
74All config comes from `.env` (loaded via `dotenvy`). The full set:
75
76| Variable | Required | Purpose |
77|---|---|---|
78| `ANALYTICS_PASSWORD` | yes | Single operator password |
79| `BASE_URL` | yes for prod | Used in absolute URLs (sitemap, og tags, embed snippet) |
80| `PORT` | no (default `8000`) | HTTP listen port |
81| `ANALYTICS_COOKIE_SECRET` | no | 32+ bytes for signing the session cookie. Falls back to a SHA-512 of the password, so rotating the password invalidates sessions |
82| `ANALYTICS_DATA_DIR` | no (default `./data`) | Where the SQLite db, mmdb, and regexes live. Production sets this to `/data` |
83| `ANALYTICS_ROOT` | no | Override the project root (where `templates/`, `dist/`, `migrations/`, `static_maps/` are read from) |
84
85## Make targets
86
87| Target | What it does |
88|---|---|
89| `make run` (default) | Vite watch + `cargo run` on port 8000 |
90| `make build` | Vite assets + topojson maps + release binary (`target/release/analytics`) |
91| `make start` | Run the release binary (after `make build`) |
92| `make maps` | Rebuild per-country topojson under `static_maps/` from Natural Earth |
93| `make seed` | Create or refresh a "Seed Test" property with realistic fake events. Override with `SESSIONS=2000 DAYS=60` |
94| `make migrate FROM=<path-to-django.sqlite3>` | One-shot import of an existing Django analytics database, preserving property UUIDs so embedded snippets keep working. Add `FORCE=1` to wipe first |
95| `make pull` | rsync the production db + geoip from `git remote server` into `data/` |
96| `make push` | `git push` to every configured remote |
97| `make clean` | `cargo clean` plus removing `dist/`, `node_modules/`, the SQLite db, and the mmdb |
98
99There are no tests or linters configured.
100
101## Deploy
102
103Production runs on Docker. The standard flow is `git push server master` to a remote whose post-receive hook runs `docker compose up --build --detach`. Sample files in `samplefiles/`:
104
105- `Caddyfile.sample`: reverse proxy with TLS
106- `env.sample`: the same `.env` shown above
107- `post-receive.sample`: the git hook
108
109Data persists to `/srv/data/analytics/` on the host (mounted into the container at `/data`).
110
111## Stack
112
113- **Backend:** axum 0.8, sqlx 0.8 against SQLite (WAL, `synchronous=NORMAL`, `busy_timeout=5s`), tower-cookies for signed sessions
114- **Templates:** minijinja 2 with a Jinja2-faithful HTML formatter
115- **Frontend:** Vite 6, Bootstrap 5 SCSS, Chart.js, d3-geo + topojson, monaspace argon font (self-hosted via `@fontsource`)
116- **Enrichment:** maxminddb (GeoIP), uaparser (UA)
117- **PDF:** embedded Typst (`typst` + `typst-pdf` + `typst-kit`), no chromium subprocess