gitea-heatmap-sidecar
A tiny HTTP service that exposes daily contribution counts for an allowlisted Gitea user — including private repo activity — for rendering a GitHub-style heatmap on a public profile page.
Stock Gitea (and Forgejo) intentionally do not expose private contribution
counts to anonymous viewers. This is a sidecar workaround: read directly from
Gitea's action table with a read-only DB user, return per-day counts as
JSON, and let a custom profile template render the squares client-side.
What it shows / what it doesn't
- ✅ Daily counts (the green squares)
- ✅ Hover tooltips with the date and count
- ❌ Repo names, commit messages, branches, file content — none of that ever leaves the database. Only counts.
Architecture
[anonymous visitor] → GET /didericis (Gitea)
↓ profile.tmpl override loads
<script> fetches https://heatmap.dideric.is/heatmap/didericis.json
↓
[this service] → SELECT FROM action WHERE act_user_id=? → JSON
Setup
1. Create a read-only Postgres user
Run db/setup.sql against the Gitea database as a superuser. Edit the
password first.
psql -U postgres -d gitea -f db/setup.sql
Only SELECT on "user" and "action" is granted. If Gitea ever renames
either table in a migration, the service will break loudly — that's the goal.
2. Build and run the sidecar
Edit docker-compose.example.yml, then:
docker compose -f docker-compose.example.yml up -d --build
Make sure the networks block matches your existing Gitea Docker network so
the sidecar can reach gitea-db by hostname.
Required env vars:
| Var | Description |
|---|---|
DATABASE_URL |
postgres://heatmap_ro:...@host:5432/gitea?sslmode=... |
ALLOWED_USERS |
Comma-separated lowercase usernames (e.g. didericis) |
ALLOWED_ORIGIN |
CORS origin — must match Gitea's URL |
OP_TYPES |
Optional. Comma-separated op_type ints. See below. |
LISTEN |
Optional. Default :8080. |
3. Reverse proxy
Expose the service at a hostname Gitea's frontend can reach over HTTPS — e.g.
heatmap.dideric.is → heatmap:8080. Use the same TLS setup as Gitea
itself (Caddy/Traefik/nginx).
4. Install the profile template override
Copy templates/user/profile.tmpl from the Gitea source matching your
running version into $GITEA_CUSTOM/templates/user/profile.tmpl, then merge
in the snippet from templates/profile-snippet.tmpl near the existing
heatmap block.
Replace HEATMAP_BASE_URL in the snippet with your sidecar's public URL
(e.g. https://heatmap.dideric.is) and didericis with the username you're
exposing.
Restart Gitea, hit the profile page in incognito, and you should see the heatmap populate.
Op type reference
Gitea's action.op_type is an integer enum. Defaults are commits-only
(5,9,18), which is the closest analog to what GitHub counts as a
"contribution" in their heatmap. Common values:
| Value | Meaning |
|---|---|
| 1 | Create repo |
| 5 | Push commits |
| 6 | Create issue |
| 7 | Create pull request |
| 9 | Push tag |
| 11 | Merge pull request |
| 18 | Mirror sync push |
| 24 | Publish release |
Set OP_TYPES=5,6,7,9,11,18,24 for a more inclusive count.
Endpoints
GET /heatmap/{username}.json— JSON[{"date":"YYYY-MM-DD","count":N}, ...]for the past ~53 weeks. 1-hour cache header.GET /healthz— 200 if the DB is reachable, 503 otherwise.
Maintenance
- Gitea schema changes. The query reads from
"action"and"user". These tables have been stable for many Gitea versions. After each Gitea upgrade, hit/healthzand the JSON endpoint to confirm. - Template drift. The harder part. Gitea's
profile.tmpldoes change between versions — diff the new upstream against your override on each upgrade and re-merge the snippet. - Cache. 1-hour
Cache-Controlheader keeps load trivial. Drop to 5 minutes for snappier updates if needed; the underlying query is cheap thanks to the existing index on(user_id, act_user_id, created_unix)added in Gitea 1.24.
Local dev
go run . # requires DATABASE_URL and ALLOWED_USERS in env
On NixOS:
nix-shell -p go_1_23 postgresql
Limitations
- Postgres only. MySQL support would mean swapping
to_timestamp(...)forFROM_UNIXTIME(...)and adjusting identifier quoting — straightforward but not done here. - Single-tenant by allowlist. The service is meant for a personal Gitea instance with a small known set of users opting in. Don't expose it to arbitrary usernames; that would leak private activity counts for users who haven't consented.
- No auth on the JSON endpoint by design — the data is intentionally public.
License
MIT