Self-hosted GitHub repo metrics that outlive GitHub's 14-day traffic window.
Please leave a ⭐ star if watchpost is useful - it helps others find it :).
One page per repo - the metrics on top, the events that caused them marked on every chart.
GitHub throws your traffic data away after 14 days. watchpost samples it hourly into a local SQLite file and keeps it, next to a timeline of what you did to earn it: post a release to Hacker News, add it as an event, and the spike lands under a marker instead of being a spike you no longer remember the cause of. It talks to nothing but the GitHub API.
watchpost runs in Docker. Get it at get.docker.com, or install Docker Desktop on macOS/Windows. Make sure it's running before you continue.
Linux / macOS:
curl -fsSL https://raw.githubusercontent.com/0xzerolight/watchpost/main/scripts/install.sh | bashWindows (PowerShell):
irm https://raw.githubusercontent.com/0xzerolight/watchpost/main/scripts/install.ps1 | iexThis pulls the image, starts the container on http://127.0.0.1:8080, and opens the setup page. Both scripts run code fetched from this repository - read the note at the top of either one, and pin a release tag with WATCHPOST_REF if you would rather not track main.
The setup page asks for a fine-grained personal access token and checks it against GitHub before saving it. Under Repository permissions grant:
| Permission | What it buys you |
|---|---|
| Metadata: read | The repository list and the basic counts. Selected for you, cannot be removed |
| Administration: read | Traffic - views, clones, referrers, popular paths |
| Contents: read | Releases and asset download counts |
| Pull requests: read | The open pull request count |
A classic token with the repo scope also works. A missing permission only costs that one part of a sync, not the whole sync - without Administration: read the traffic charts stay empty while everything else still lands. Traffic is only served for repositories you own or administer, whatever the token says.
Collection starts as soon as the token is saved, so the repo list appears within a minute; traffic data follows on the same pass. Pick which repos to track on the Settings page - nothing is tracked until you say so.
Manual install (without the script)
Prebuilt image - the same image the script uses, you just supply the compose file:
mkdir -p watchpost/data && cd watchpost
curl -fsSL https://raw.githubusercontent.com/0xzerolight/watchpost/main/compose.prod.yml -o docker-compose.yml
printf 'PUID=%s\nPGID=%s\n' "$(id -u)" "$(id -g)" > .env # only if your host uid is not 1000
docker compose up -dBuild from source - no prebuilt image, builds from the Dockerfile:
git clone https://github.com/0xzerolight/watchpost.git
cd watchpost
docker compose up -dWithout Docker (Rust 1.88+, no system dependencies beyond a C toolchain - SQLite is compiled in):
git clone https://github.com/0xzerolight/watchpost.git
cd watchpost
cargo run --releaseThen open http://127.0.0.1:8080 and paste a token into the setup page. Outside Docker the database lands in ./data/watchpost.db; set WATCHPOST_GITHUB_TOKEN beforehand to skip the setup page entirely. The release profile uses fat LTO and a single codegen unit, so the first build takes a couple of minutes.
Updating
curl -fsSL https://raw.githubusercontent.com/0xzerolight/watchpost/main/scripts/update.sh | bashor, in the install directory:
docker compose pull && docker compose up -dThe database is backed up before any schema migration the new image performs, and the newest three backups are kept.
Choosing a different port
WATCHPOST_HOST_PORT in the install directory's .env is the port on your machine; the installer writes it. It is deliberately not WATCHPOST_PORT, which is the port the binary binds inside the container and which the image fixes at 8080.
WATCHPOST_HOST_PORT=9000 curl -fsSL https://raw.githubusercontent.com/0xzerolight/watchpost/main/scripts/install.sh | bashConfiguration
All settings are environment variables, read from .env by compose. See .env.example.
| Variable | Default | Meaning |
|---|---|---|
WATCHPOST_GITHUB_TOKEN |
(unset) | Token used for every API call. Optional: with none set, watchpost serves a setup page that saves one to the database instead. When it is set it wins over a saved token, and the settings page offers no way to change it |
WATCHPOST_CRON |
0 5 * * * * |
Collection schedule, six fields (seconds first), UTC. An unparseable value falls back to the default. Leave it unset to set the interval on the settings page instead (10m, 6h, 1h 30m; between 5m and 14d), which applies without a restart; setting it here wins over that and removes the field |
WATCHPOST_DB_PATH |
./data/watchpost.db |
SQLite file. /app/data/watchpost.db in the image |
WATCHPOST_HOST |
127.0.0.1 |
Bind address. The image sets 0.0.0.0 |
WATCHPOST_PORT |
8080 |
Bind port |
WATCHPOST_LOG |
info |
tracing filter, e.g. watchpost=debug |
WATCHPOST_GITHUB_API_BASE |
https://api.github.com |
Override for GitHub Enterprise. Must be http/https; a missing trailing slash is added |
WATCHPOST_TZ |
UTC |
IANA zone name (e.g. Europe/Madrid) the UI displays times in. An unknown name is a startup error, not a silent fall back to UTC |
Two more are read by compose rather than by the binary: WATCHPOST_HOST_PORT (the published host port) and PUID/PGID (the host uid/gid the container drops to, default 1000).
- Outlives the 14-day window - views, clones, referrers and popular paths sampled hourly and kept forever, long after GitHub has forgotten them
- Promo event timeline - add a post, a talk or a release announcement and it renders as a marker on every chart for that repo, so spikes have causes
- Stars, forks, watchers, issues and open PRs sampled daily, with star history backfilled from the stargazers API on the first sync
- Release asset download counts per tag
- One SQLite file, one static binary - no database server, no CDN, no JavaScript build step. Outbound traffic goes to the GitHub API and nowhere else
- Instant period switching - 7, 30, 90, 365 days or all time. The whole history ships with the page, so zooming never hits the server
- Honest numbers - missing days render as gaps rather than invented zeros, and uniques are never summed across days
More features
- Sortable all-time referrer and popular-path tables
- Freeform event kinds with markdown notes, each kind auto-assigned a colour shared by its badge and its chart markers
- Manual Sync now with a live status banner, plus per-repo error and backoff state on the Settings page
- Dark mode following your OS, reduced-motion support, and WCAG AA contrast throughout
watchpost --doctorprints a secret-safe diagnostic snapshot - config, schema version, row counts, rate-limit budget and per-repo sync state- Pre-migration database backups taken through SQLite's own backup API, so they are consistent even mid-write
How It Works
- Every hour, and once at startup, watchpost calls the GitHub API for each tracked repository.
- Results are written to a local SQLite file, one row per repo per UTC day. Writes are idempotent, so a repeated pass overwrites rather than double-counts.
- Traffic days GitHub is about to forget are already stored, so history accumulates past the 14-day window.
- Your events are drawn as vertical markers on every chart for that repo, lining spikes up with whatever caused them.
More detail, and the caveats worth knowing before you draw conclusions from the numbers, in ARCHITECTURE.md.
No built-in authentication by design (single-user tool). Anyone who can reach the port gets the whole app, including write access to your events, the sync button and the token form. Both defaults keep it private: WATCHPOST_HOST is 127.0.0.1 and both compose files publish to 127.0.0.1:8080 only. For remote access, put it behind a reverse proxy that does the authenticating (Authelia, Authentik, Caddy basicauth) and forward X-Forwarded-Proto: https. See SECURITY.md.
| Issue | Fix |
|---|---|
| No repositories listed | Open Settings, press Refresh from GitHub, tick the repos you want and Save. Nothing is tracked by default. |
| Views and clones charts are empty | The token is missing Administration: read, or the repo is not one you own or administer. --doctor shows the per-repo last error. |
| Container exits, or "unable to open database" | data/ is not writable by the uid the container runs as. Set PUID/PGID in the install directory's .env to your own uid/gid and restart. |
| Syncs stop and nothing updates | You are rate limited. --doctor prints the remaining budget and the reset time. Collection resumes on its own. |
| "database was written by a newer build" | You downgraded. Reinstall the newer version, or restore one of the data/watchpost.v*.bak files. |
| Startup fails with a timezone error | WATCHPOST_TZ must be an IANA zone name such as Europe/Madrid, not an abbreviation or an offset. |
| Times look shifted | Displayed timestamps follow WATCHPOST_TZ, but day buckets are always UTC - GitHub returns traffic already summed per UTC day and those buckets cannot be re-cut. |
Still stuck? Run docker compose exec watchpost watchpost --doctor for a secret-safe diagnostic snapshot - effective config (the token as last-4 and length only, plus where it came from), database path, schema version, row counts, rate-limit budget and per-repo sync state - and paste it into a bug report. Logs: docker compose logs -f.
Contributions of any kind are welcome.
- New here? Start with CONTRIBUTING.md.
- Architecture overview: ARCHITECTURE.md.
- Security: SECURITY.md.
Bug reports and feature requests -> Issues. Questions and discussion -> Discussions.
GNU Affero General Public License v3.0 or later. See LICENSE.
