Self-hosted Strava analytics for every sport, on your own hardware
A privacy-first alternative to Veloviewer — all your training data, owned by you, forever.
| Page | What you get |
|---|---|
| 📊 Dashboard | Running-first stats, weekly volume chart, personal bests table, sport breakdown |
| 📅 Calendar | GitHub-style activity heatmap by year, monthly breakdown grid |
| 📉 Progression | Scatter plot of pace over time, multi-route map overlay, time period filtering |
| 🏅 Race History | Filter by distance preset or custom range, count summary, best pace highlighting |
| 📋 Activities | Paginated activity log with year/sport filters |
| 🗺️ Activity detail | Route map, tabbed km splits, best efforts, segments with map highlighting and history delta, activity description, GPX download |
| ⬇ Export | Full CSV export of all activities |
Everything runs on your own hardware. No third-party servers, no subscriptions, no ads.
┌─────────────────────────────────────────────┐
│ Synology NAS │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Frontend │ │ Backend │ │
│ │ React/Nginx │ │ FastAPI/Python │ │
│ │ Port 3000 │◄──►│ Port 8000+ │ │
│ └──────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ PostgreSQL │ │
│ │ Port 5432 │ │
│ └──────────────────┘ │
│ │ │
└───────────────────────────────┼─────────────┘
│ Strava API
▼
strava.com/api/v3
- Frontend: React 18 + Vite + Recharts + Leaflet, served by Nginx
- Backend: FastAPI (Python 3.11) with SQLAlchemy + httpx
- Database: PostgreSQL 15 with persistent volume
- Networking: Backend uses host networking for reliable outbound API access on Synology
- Synology NAS running DSM 7+
- Container Manager package installed (formerly Docker)
- Strava account with API access
- Go to strava.com/settings/api
- Create an app — name and description can be anything
- Set Authorization Callback Domain to your NAS's local IP (e.g.
192.168.1.100) - Note your Client ID and Client Secret
# SSH into your NAS
ssh admin@192.168.1.100
# Clone the repo
git clone https://github.com/yourusername/athletiq.git /volume1/docker/athletiq
cd /volume1/docker/athletiq
# Copy and edit the environment file
cp .env.example .env
vi .envFill in your .env:
STRAVA_CLIENT_ID=your_client_id
STRAVA_CLIENT_SECRET=your_client_secret
NAS_IP=192.168.1.100
APP_URL=http://192.168.1.100:3000
BACKEND_URL=http://192.168.1.100:8000
FRONTEND_PORT=3000
BACKEND_PORT=8000
DB_PASSWORD=choose_a_strong_passworddocker compose up -d --buildOpen http://YOUR_NAS_IP:3000 in your browser and click Connect with Strava. Your activities will begin syncing immediately — this may take a few minutes if you have years of history.
| Variable | Description | Example |
|---|---|---|
STRAVA_CLIENT_ID |
Your Strava API client ID | 12345 |
STRAVA_CLIENT_SECRET |
Your Strava API client secret | abc123... |
NAS_IP |
Your NAS local IP address | 192.168.1.100 |
APP_URL |
Full URL to the frontend | http://192.168.1.100:3000 |
BACKEND_URL |
Full URL to the backend API | http://192.168.1.100:8000 |
FRONTEND_PORT |
Port for the web UI | 3000 |
BACKEND_PORT |
Port for the API | 8000 |
DB_PASSWORD |
PostgreSQL password | something_strong |
Click ⟳ Sync Strava in the sidebar at any time.
Set up a scheduled task in DSM → Control Panel → Task Scheduler:
- Type: User-defined script
- Schedule: Daily at 02:00
- Command:
curl -s -X POST http://localhost:YOUR_BACKEND_PORT/api/sync/YOUR_ATHLETE_ID
Your athlete ID appears in the URL after you first connect — or find it at strava.com/athletes/XXXXXX.
Athletiq is built on Strava's free API tier. This is intentional — no subscription required — but it does mean certain things work differently from paid tools like Veloviewer or Strava Summit.
| Feature | How it works |
|---|---|
| Activity sync | Full history via paginated API — no restrictions |
| Km splits | Fetched from activity detail endpoint, cached locally |
| Best efforts | Fetched from activity detail endpoint, cached locally |
| Segment efforts per activity | Included in the activity detail response |
| Segment map highlighting | Derived from route GPS + segment start/end coordinates — no extra API call |
| Segment history delta | Scanned from locally cached activity details |
| Backfill scan | Progressively fetches un-cached activities in the background |
| Feature | Reason | Workaround |
|---|---|---|
| Full segment effort history | Strava's /segment_efforts endpoint does not return historical efforts retroactively — even with Summit, it only reflects efforts recorded after the API token was active. History is built from locally cached activities for all users |
Use the 🔍 Find previous efforts backfill scan |
| Segment leaderboards | Restricted endpoint | Not implemented |
| Athlete heart rate zones | Restricted endpoint | Not implemented |
The Δ vs PR column in the Segments tab is powered by scanning your locally cached activity data. This means:
- The more activities you have opened in Athletiq, the richer the history becomes
- Every time you open an activity modal, its detail is fetched and cached automatically — even on the Overview tab
- Use the 🔍 Find previous efforts button on any segment to scan all remaining un-fetched activities in the background
- A segment marked
🥇 PRby Strava but showing–delta simply means the previous effort hasn't been cached yet — it is a genuine PR
Click ⟳ Sync Strava in the sidebar to fetch new activities. By default this is an incremental sync — it only requests activities newer than your most recent stored one, typically completing in a second or two. A Full re-sync option is available below the button if you need to pull everything from Strava from scratch.
After your initial sync completes, Athletiq automatically queues a background job to fetch full detail (splits, best efforts, segments) for every activity. This runs entirely on the NAS — closing the browser tab doesn't stop it. Progress is shown in the sidebar.
If the server restarts mid-backfill, it resumes automatically on next startup. If progress stalls, a ↺ Resume button appears in the sidebar after 60 seconds — no SSH required.
Existing installs (where initial sync already ran before v1.2.3): seed the task manually and restart:
docker exec athletiq-db psql -U velosyno -d athletiq -c \
"INSERT INTO backfill_tasks (athlete_id, status, total, checked, found) \
VALUES (<your_athlete_id>, 'pending', 0, 0, 0) ON CONFLICT (athlete_id) DO NOTHING;"
docker compose restart backendAthletiq automatically detects your Summit subscription status from your athlete profile. In practice, Summit does not unlock additional history features — Strava's /segment_efforts endpoint does not return historical efforts retroactively regardless of subscription tier. Segment history is therefore built from locally cached activities for all users via the backfill scan.
Strava imposes a limit of 200 requests per 15 minutes and 2,000 per day, per application. These limits apply regardless of subscription tier. Athletiq manages this automatically:
- Normal browsing uses very few requests (one per activity opened)
- The background backfill runs at ~20 requests/min with automatic backoff when the 15-minute limit is approached
- On a 15-minute limit, the backfill sleeps until the next reset boundary (
:00,:15,:30, or:45past the hour) - On a daily limit, the backfill sleeps until midnight UTC and resumes automatically — no action required
- A full backfill of a large history (e.g. 1,000 activities) will likely span multiple days due to the daily cap
- If the backfill stalls, a ↺ Resume button appears in the sidebar after 60 seconds — no SSH required
- The sidebar shows an amber note if progress has stopped, reminding you that the daily limit resets at midnight UTC
The backend exposes a REST API at http://NAS_IP:BACKEND_PORT:
| Endpoint | Description |
|---|---|
GET /docs |
Interactive API documentation |
GET /api/athlete/{id} |
Athlete profile |
GET /api/activities/{id} |
Paginated activity list |
GET /api/stats/{id} |
Aggregate statistics |
GET /api/fitness/{id} |
CTL/ATL/TSB fitness data |
POST /api/sync/{id} |
Trigger activity sync |
GET /api/status/{id} |
Sync status |
GET /api/activity/{id}/{activity_id}/detail |
Km splits, best efforts, segments, description (cached) |
GET /api/segments/{id}/{segment_id}/history |
Scan cached activities for efforts on a segment |
GET /api/segments/{id}/{segment_id}/backfill |
SSE stream — progressively fetch un-cached activity details |
GET /api/activity/{id}/{activity_id}/gpx |
Download GPX file |
GET /api/export/{id}/csv |
Export all activities as CSV |
- Athletiq is designed for local network use. If you expose it to the internet, consider putting it behind a reverse proxy with authentication.
- Your Strava tokens are stored in the local PostgreSQL database — they never leave your network.
- Use a strong
DB_PASSWORD— even on a local network. - Never commit your
.envfile — it's in.gitignoreby default.
If you want to access Athletiq outside your home network:
- Forward both
FRONTEND_PORTandBACKEND_PORTon your router to your NAS IP - Update
APP_URLandBACKEND_URLin.envto use your external IP or domain - Update the Authorization Callback Domain in your Strava API settings to match
- Rebuild the frontend:
docker compose up -d --build frontend
For best security, use Synology's built-in Reverse Proxy (Control Panel → Login Portal → Advanced) with a domain and HTTPS certificate.
Backend can't reach Strava API
The backend uses network_mode: host for this reason. If you're still having issues, check DSM's firewall settings under Control Panel → Security → Firewall.
Activities not syncing
Check the backend logs: docker compose logs -f backend
Verify your access token is valid: docker exec -it athletiq-db psql -U velosyno -d athletiq -c "SELECT athlete_id, expires_at FROM tokens;"
Frontend shows blank page
The VITE_API_URL is baked in at build time. If you change BACKEND_URL in .env, rebuild the frontend: docker compose up -d --build frontend
Port conflicts
Change FRONTEND_PORT and/or BACKEND_PORT in .env and rebuild.
athletiq/
├── backend/
│ ├── main.py # FastAPI application
│ ├── requirements.txt
│ ├── Dockerfile
│ └── entrypoint.sh # Reads BACKEND_PORT at runtime
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Main React application
│ │ └── main.jsx
│ ├── index.html
│ ├── package.json
│ ├── vite.config.js
│ ├── nginx.conf
│ └── Dockerfile # Multi-stage: node build → nginx serve
├── scripts/
│ ├── setup.sh # Interactive first-time setup
│ └── update.sh # Pull latest and rebuild
├── docker-compose.yml
├── .env.example
├── .gitignore
├── CHANGELOG.md
└── README.md
Contributions welcome! See CONTRIBUTING.md for details and CHANGELOG.md for what's changed.
Ideas for future features:
- 🎯 Annual distance goal tracker
- 👟 Shoe/gear mileage tracker
- 📊 Year vs year comparison
- 🏆 Age grade calculator
- 📧 Weekly summary email digest
- 🗺 Full heatmap of all routes
Please open an issue before starting significant work so we can discuss approach.
MIT — do whatever you like with it.