Reviewed-on: #9
git-contribution-graph
A self-hosted Symfony service that merges contribution data from GitHub, GitLab and Gitea into a single GitHub-style heatmap SVG you can embed anywhere.
https://your-host/graph.svg?theme=dark
Features
- Three platforms — GitHub, GitLab (cloud or self-hosted), Gitea (self-hosted)
- Merged heatmap — contributions from all sources are summed per day
- GitHub colour palette — exact dark/light theme tokens
- Embeddable — returns
image/svg+xml, works in any<img>tag or Markdown - Cached — responses cached for 1 hour, safe to embed in public READMEs
- Graceful degradation — if one platform fails, the others still render
Deploy
Requirements
- Docker + Docker Compose
1. Create a docker-compose.yml
Use the pre-built image
services:
graph:
image: git.arthurerlich.de/haylan/git-contribution-graph:latest
ports:
- "8080:8080"
env_file:
- .env.local
volumes:
- cache:/var/www/html/var/cache
volumes:
cache:
The image is published to the Gitea container registry. Pull it manually with:
docker pull git.arthurerlich.de/haylan/git-contribution-graph:latest
Semver tags are published (0, 0.0, 0.0.1) alongside latest — see the container registry for all available tags.
2. Configure
Create a .env.local file next to your docker-compose.yml:
APP_SECRET=<generate with: openssl rand -hex 16>
# GitHub
GITHUB_USER=your-github-username
GITHUB_TOKEN=ghp_…
# GitLab (omit GITLAB_URL to use gitlab.com)
GITLAB_USER=your-gitlab-username
GITLAB_TOKEN=glpat-…
GITLAB_URL=
# Gitea
GITEA_USER=your-gitea-username
GITEA_TOKEN=…
GITEA_URL=https://git.example.com
# Optional: restrict to specific hostnames (comma-separated), leave empty to allow all
ALLOWED_HOSTS=
Only configure the platforms you use — unused ones are silently skipped.
3. Start
docker compose up -d
The service listens on port 8080 by default. Put Traefik or nginx in front of it for HTTPS.
4. Verify
curl http://localhost:8080/health
# {"status":"ok"}
API
GET /graph.svg
| Parameter | Required | Description |
|---|---|---|
theme |
✗ | dark (default) or light |
All credentials are configured via environment variables — see Deploy.
Embedding in a README
<!-- Dark theme (default) -->

<!-- Light theme -->

Token setup
GitHub
Fine-grained token (recommended):
- Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
- Set Resource owner to your account
- Under Permissions → Account permissions, set Contribution activity → Read-only
- Generate and copy the token
Classic token: create a token with the read:user scope.
GitLab
- Go to User Settings → Access Tokens
- Add a token with scopes:
read_user,read_api - Generate and copy the token
Gitea
- Go to Settings → Applications → Manage Access Tokens
- Add a token with permission: user → Read
- Generate and copy the token
Development
Local (PHP)
# Install deps
composer install
# Run dev server
APP_ENV=dev php -S localhost:8080 -t public
# Test endpoint
curl "http://localhost:8080/graph.svg" -o graph.svg
Docker (recommended)
docker-compose.override.yml is picked up automatically and targets the dev stage (Xdebug enabled, source mounted).
# Start dev container
docker compose up -d --build
# Shell into the container
docker compose exec graph sh
# Run tests inside the container
docker compose exec graph php bin/phpunit
# Disable Xdebug for faster test runs
XDEBUG_MODE=off docker compose up -d
Xdebug listens on port 9003. On Linux, host.docker.internal is resolved via host-gateway.
To run with production config only (no override):
docker compose -f docker-compose.yml up -d --build
Testing
# Run full suite
php bin/phpunit
# Human-readable output
php bin/phpunit --testdox
# Single file
php bin/phpunit tests/Unit/Service/SvgRendererTest.php
# Filter by name
php bin/phpunit --filter it_renders
Architecture
GET /graph.svg?theme=dark|light
└─ GraphController
├─ host check (ALLOWED_HOSTS env, optional)
├─ cache lookup (filesystem, 1h TTL, key = "graph_{theme}")
│ └─ on miss:
│ ├─ GitHubProvider → GitHub GraphQL API (contributionCalendar)
│ ├─ GitLabProvider → GitLab REST API (/users/:id/events, paginated)
│ └─ GiteaProvider → Gitea REST API (/users/:user/heatmap)
│ each returns array<string, int> (Y-m-d => count)
│ failures are caught and logged; remaining providers still render
│ └─ merge by date (sum counts across providers)
│ └─ SvgRenderer::render()
└─ Response: image/svg+xml, Cache-Control: public max-age=3600
Provider activation: a provider only runs when its env vars are non-empty. GitHub and GitLab require _USER + _TOKEN; Gitea additionally requires _URL. GitLab resolves a numeric user ID from the username via a /api/v4/users?username= lookup before fetching events.
SvgRenderer: builds a 53-column × 7-row grid aligned so the last column always ends on the Saturday of the current week. Five intensity levels (0 → level 0, 1–3 → 1, 4–6 → 2, 7–9 → 3, 10+ → 4) mapped to GitHub's colour tokens. No external assets — the SVG is fully self-contained.
Cache: filesystem adapter (var/cache/), mounted as a Docker volume to survive container restarts. Theme is part of the cache key so dark and light are cached independently.
License
MIT