Your rides. Your food. Your hardware. Your friends.
Self-hosted cycling analytics, nutrition and body tracking — no cloud, no subscription, no one else touching your data.
Headwind is a single self-hosted web app that brings together the things most riders spread across four or five services:
It runs on a Raspberry Pi, a NAS, a spare PC or a VPS. Your data lives in one SQLite file you can copy, back up and inspect. It only talks to services you explicitly configure (plus a few public data APIs listed below).
Design principles
| Yours | One folder of data. Export any ride as GPX, back up everything as one zip, no lock-in. |
| Quiet | No account, no analytics, no ads. A single optional, anonymous install ping — fully disclosed, one-time, opt-out. |
| One person per instance | Your stats, your PRs, your coaching. To track someone else, they run their own Headwind and you link them as a friend. |
| Honest | It's a beta; this README lists what's rough. The AI coach is deliberately blunt rather than flattering. |
Headwind 0.1.0-beta is the first public release. It is a real, working application used daily by its author, and it has been through a security and reliability review (restore safety, login hardening, injection fixes, Garmin sync reliability, resource limits). It is still a beta:
All images: demo data — a fictional rider "Alex" riding real roads around Hathersage, Castleton, Edale and Bakewell.
Lifetime totals, a 12-week performance chart you can flip between distance, elevation, speed and calories, and your recent rides.
Satellite map, elevation + heart-rate and cadence charts, weather at the start (temperature, wind speed/direction and a headwind / tailwind / crosswind call computed from your route bearing), and effort comparison.
Everywhere you've ridden, filterable by date range and sport, with HD export (3440×1440).
Draw a segment on any ride; Headwind scans your whole history, ranks every effort, charts your trend, rates the difficulty and tracks PRs. Linked friends' segments and efforts appear on the same leaderboard.
Speed over time, monthly distance, year-on-year, ride-length distribution, day-of-week, weather scatter charts — all filterable by sport.
A diary built for speed: barcode scan, per-meal recent foods, saved meals, hydration, macro rings, and calorie goals that include what you burned riding.
Saved a meal but ate less? Scale the whole meal in one tap — weight and every macro together — or open any item and change its weight.
Edit the weight of any logged item and calories, protein, carbs, fat and fibre scale in proportion. Entries with no recorded weight get a baseline from the first weight you type.
Daily weigh-ins with a smoothed trend line, rate of change, and progress against your goal.
Plan a route on the map, see distance and the elevation profile, and export it as GPX.
All 61 screens below use the same synthetic demo data (a fictional rider on real roads). Open a group to browse it.
Dashboard: lifetime totals, a 12-week performance chart (distance, elevation, speed, calories) and your recent rides.
Ride detail: satellite map, elevation and heart-rate charts, cadence, weather at the start and a headwind/tailwind/crosswind call.
A full ride page with the AI coach's analysis, segment efforts and ride notes.
Creating a segment: slide the start and finish markers along the ride, then name it.
Segments list with efforts and difficulty.
Segment leaderboard: every effort ranked, your trend over time, and linked friends' efforts on the same board.
GPS heatmap of everywhere you've ridden, filterable by date range and sport.
The same heatmap on satellite imagery (also Dark, Light and Satellite + labels, with HD export).
Analytics: best efforts, climbing records, speed trend, monthly distance, distribution, weather scatters, and more.
Profile and trophy case: lifetime stats, segment PRs, best efforts and badges.
Route planner with a saved route loaded: distance, waypoints and GPX export.
Walks, runs and hikes are kept separate from rides so they never change your ride stats.
Import: drop in .fit / .gpx files or a full Strava export zip.
Recovery from Garmin: resting heart rate, body battery, sleep, steps and stress, with 30/60/90-day views.
Steps with a daily goal; log a day by hand if you have no tracker.
Weight trend: daily weigh-ins, a smoothed line and your rate of change.
Backfill past weigh-ins in bulk.
The diary: calories and macro rings, meals, hydration and your weight.
"View Nutrition": the day's totals against your goals, by meal, with a macro split.
<img src="docs/screenshots/nutrition-meal-sheet-day.jpg" alt=""View Nutrition": the day's totals against your goals, by meal, with a macro split." width="800">
Add food: this meal's recent and most-logged foods first, one tap to log.
Search Open Food Facts by name (barcode scanning is next to it).
Go-Tos: your favourites.
Recipes and saved meals, loggable at ½×, 1×, 1½× or 2×.
My Foods: your own foods, per 100 g.
Change a food's weight and the calories and macros follow.
"Smaller or bigger portion?": scale every item in a meal at once.
<img src="docs/screenshots/nutrition-meal-portion.jpg" alt=""Smaller or bigger portion?": scale every item in a meal at once." width="800">
Manual entry for anything without a barcode.
Daily goals, weight unit, diet start date and goal weight.
Copy yesterday: a whole day or just one meal.
Weigh in.
AI food-photo estimate (needs your own AI key).
Import a food diary or recipe from screenshots of another app (needs your own AI key).
Build a meal or recipe from ingredients.
Save what you've logged as a reusable meal.
Create your own food.
Weekly summary: eaten, burned, net and macros per day.
Friends: your feed token, add a friend by URL + token, and auto-sync.
AI Coach: your coaching goals, and bulk analysis of rides by date range.
Settings: units, login details, AI provider, Garmin Connect, weather backfill, backup and restore.
Home Assistant: connect with a long-lived token and import health readings.
Pair a phone with a QR code and a per-device token (HTTPS required).
About: what's in the app.
Sign in.
Step 1: your name (or restore from a backup).
Step 2: units.
Step 3: Garmin Connect (optional, MFA-capable).
Step 4: Home Assistant (optional).
Choosing a health entity from Home Assistant.
Step 5: the one-time anonymous install ping, with a permanent opt-out.
Step 6: done.

.fit, .gpx (and gzipped variants), or a Strava data-export zip, with a live progress bar. Duplicate detection is time-aware.POST /import/api/upload-ride with an X-Upload-Token header (set RIDE_UPLOAD_TOKEN) lets a script, a Pi attached to a bike computer, or an automation push GPX files in.You need Docker with the Compose plugin. No clone required:
mkdir headwind && cd headwind
curl -O https://raw.githubusercontent.com/lordmaa/headwind/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/lordmaa/headwind/main/.env.example
docker compose up -d
Open http://localhost:5001. On the first start Headwind generates a random signing key and an admin password and prints it once in the logs:
docker logs headwind 2>&1 | grep -A3 "generated one"
Sign in with admin and that password, then change it under Settings. To choose your own credentials instead, copy .env.example to .env and set SECRET_KEY, APP_USERNAME and APP_PASSWORD before the first docker compose up.
Data lives in ./data (database, avatars, food photos, generated login) and ./garmin_tokens, next to your docker-compose.yml, so it survives restarts and upgrades.
Images are published for amd64 and arm64 on Docker Hub: lordmerchant99/headwind (tags: latest, 0.1.0-beta).
The same Docker instructions work on a Raspberry Pi 4/5 (64-bit OS). The arm64 image is new — if something misbehaves, please report it. Tips:
./data on an SSD or good SD card; SQLite is happiest on reliable storage.Deploy as a stack using the Compose file from this repo and set SECRET_KEY, APP_USERNAME and APP_PASSWORD under Environment variables.
Download Headwind-windows-x64.zip from the Releases page, extract it somewhere writable (not Program Files), and run Headwind\Headwind.exe. It runs as a desktop app: a tray icon (bottom right, near the clock — you may need to click the ^ arrow) and Headwind opens in its own app-style window. A dialog shows your first sign-in.
Tray menu (right-click, or double-click to open): Open Headwind, Show sign-in details, Open data folder (%LOCALAPPDATA%\Headwind), Start with Windows, Quit Headwind. Closing the window does not stop it (it keeps syncing); use Quit.
HEADWIND_HOST=0.0.0.0 before launching.pip install -r requirements.txt waitress pyinstaller then python scripts\build_windows.py dist.git clone https://github.com/lordmaa/headwind.git
cd headwind
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python3 app.py # http://localhost:5001
python3 app.py is for development: it listens on all interfaces. Set HEADWIND_DEBUG=1 for Flask's debugger only on a machine nobody else can reach. For anything long-lived use Docker (gunicorn) or put it behind a proper service manager.
To build the Docker image yourself:
docker compose -f docker-compose.dev.yml up --build
# multi-arch:
docker buildx build --platform linux/amd64,linux/arm64 -t yourname/headwind:dev --push .
A new install walks you through six short steps. Everything except step 1 is skippable and can be changed later in Settings.
No Garmin? Go to Import and drop in .fit / .gpx files or a Strava export zip.
Set these in a .env file next to docker-compose.yml (Compose reads it automatically) or in your service environment.
| Variable | Default | What it does |
|---|---|---|
SECRET_KEY | generated | Session signing key. If blank or a known placeholder, a random one is generated and stored in the data folder. |
APP_USERNAME | admin | Login username. |
APP_PASSWORD | generated | Login password. If blank or a known placeholder, a random one is generated and printed once in the logs. A password changed in Settings persists across container recreation unless you later edit this value. |
APP_URL | http://localhost:5001 | The URL others (and your phone) reach this instance at — used in notification links and the phone pairing QR code. |
DATABASE_URL | /data/bike.db (Docker) | Path to the SQLite database. |
TELEMETRY | (unset) | Set to off to skip the telemetry question and never send the install ping. |
HEADWIND_MULTI_RIDER | (unset) | 1 enables the advanced multi-rider mode (several local riders on one instance). |
RIDE_UPLOAD_TOKEN | (unset) | Enables POST /import/api/upload-ride (authenticated by the X-Upload-Token header). Disabled when unset. |
SESSION_COOKIE_SECURE | false | Set to true when serving over HTTPS so the login cookie is never sent over plain HTTP. |
HEADWIND_MQTT_PREFIX / HEADWIND_MQTT_NAME | (defaults) | Give a second instance on the same MQTT broker its own entity prefix and device name so they don't collide. |
HEADWIND_MQTT_NUTRITION | 1 | 0 publishes only the ride sensors, not nutrition. |
HEADWIND_ENV | ./.env | Point an instance at a different env file (useful for running two instances from one code folder). |
PORT | 5001 | Port for python3 app.py. |
HEADWIND_DEBUG | (unset) | 1 turns on Flask's debug mode for python3 app.py (never on a reachable machine). |
Windows-build only: HEADWIND_HOST, HEADWIND_DATA, HEADWIND_NO_TRAY.
Garmin credentials, the MQTT broker, Home Assistant URL/token and mappings, AI provider/key/model, coaching personality and goals, display units, your login details and backups are all configured under Settings — not in .env. Calorie and macro goals live under Nutrition → Goals, and friend-sync is under Friends → Auto-Sync.
| Path (in the container) | Contents |
|---|---|
/data/bike.db | The SQLite database (WAL mode) — rides, food, weight, settings, including credentials you enter in Settings. |
/data/avatars/ | Profile photos. |
/data/foodimg/ | Food photos fetched or uploaded. |
/data/.secret_key, /data/.admin_login | The generated session key and login (mode 600). |
/app/.garmin_tokens/ | Garmin login tokens (so MFA isn't needed every sync). |
HEADWIND_MQTT_PREFIX values, or they'll overwrite each other's sensors.docs/ha.Sync is pull-based, over HTTP(S). Peer URLs must be http:// or https://; redirects to a different host are refused so a peer can't bounce your token elsewhere. Use HTTPS over the internet.
When a new ride syncs, Headwind can notify you through Home Assistant (companion-app notification with a link to the ride). There is also an ntfy hook in services/notify.py, but it has no field in the Settings screen yet — treat it as a developer feature for now.
Headwind has a login, throttling and cross-site protections, but it is a single shared admin account guarding years of health and location data. If you expose it:
SESSION_COOKIE_SECURE=true once you're on HTTPS.admin/admin.A minimal Caddyfile:
headwind.example.com {
reverse_proxy localhost:5001
}
Behind a proxy Headwind trusts one X-Forwarded-* hop; make sure your proxy sets those headers and that port 5001 isn't directly reachable from outside.
.env — but the database itself holds anything you configured in Settings (Garmin email/password, AI key, MQTT/HA credentials), so a backup is a secret. Store it accordingly.bike.db.pre-restore; the swap itself is atomic. A backup this version can't use is rejected with "Nothing was changed".docker compose pull && docker compose up -d. Migrations run automatically at start-up. Take a backup first on a beta.changeme login: the placeholder is no longer accepted — a random login is generated; read it from docker logs../data and ./garmin_tokens, or restore a backup and re-enter your Garmin login.What leaves your machine — only to services you configure or the public APIs below:
| Service | What for | What's sent |
|---|---|---|
| Garmin Connect | Activity/health sync | Your login (to Garmin), requests for your own data |
| Open-Meteo | Weather, elevation | Ride start coordinates + time |
| Open Food Facts | Food search/barcodes | The barcode or search text |
| Esri / map tile hosts | Map tiles in your browser | Normal tile requests (your browser's IP) |
| CDNs (unpkg, jsDelivr) | Chart/map libraries in your browser | Normal asset requests |
| Your AI provider (if configured) | Coaching, photo estimates | Ride summaries / the image you submit |
| Your MQTT broker / Home Assistant (if configured) | Automation | Sensor values / notifications |
| Linked friends (if configured) | P2P sync | Rides and segments you've chosen to share |
Telemetry (the one exception). At the end of setup, Headwind can send one single, one-time, anonymous ping — a random install id and the version number, nothing else — so the author has a rough idea how many instances exist. No rides, food, weight, location or any personal data is ever sent. The collector does not store IP addresses. The wizard shows this text and a permanent opt-out; you can also set TELEMETRY=off in your .env to skip the question entirely. The code is services/telemetry.py — read it rather than take our word for it.
Headwind 0.1.0-beta shipped after a dedicated review. In short:
Known limits: one shared admin login; the password is stored in plaintext in your data folder / .env; in the advanced multi-rider mode phone devices aren't scoped per rider. See Known limitations. To report a vulnerability, please open a private security advisory on GitHub rather than a public issue.
A native Kotlin/Jetpack Compose companion — nutrition logging with barcode scan, ride recording, widgets and Health Connect — exists but is a work in progress and not publicly released. It pairs with your server via a QR code (Phones page) and a per-device token, and requires HTTPS. Until it's released, everything works from the browser, and the nutrition tracker installs as a PWA.
| Problem | Try |
|---|---|
| Can't sign in / forgot the generated password | docker logs headwind 2>&1 | grep -A3 "generated one". The login is also stored in ./data/.admin_login. To set your own, put APP_PASSWORD in .env and recreate the container. |
| "Too many attempts" | The login locks for a few minutes after 8 failures. Wait, or restart the container. |
| Port 5001 is in use | Change the left side of ports in docker-compose.yml, e.g. "5002:5001". |
| Garmin asks for MFA every time | The garmin_tokens folder must be a persistent volume; check it's mounted and writable. |
| Garmin sync finds nothing | Make sure activity sync is enabled in Settings → Garmin and the account matches the one you connected. Failed rides are retried on the next sync. |
| Phone app can't pair | It needs an HTTPS address. Put Headwind behind a TLS proxy/tunnel and set APP_URL to that address. |
| Home Assistant sensors missing | Check the MQTT broker settings, that discovery is enabled in HA, and that no other Headwind uses the same HEADWIND_MQTT_PREFIX. |
| Map is blank | Your browser needs to reach the tile and CDN hosts listed under Privacy. |
| Windows SmartScreen warning | The build is unsigned. More info → Run anyway, or use Docker. |
| Restore says "not compatible" | The backup's database couldn't be migrated. Nothing was changed — your current data is intact. |
| Want to start over | Stop the container and move ./data aside (don't delete it until you're sure). |
Stack — Python 3.11 / Flask, SQLite (WAL), Jinja templates, vanilla JavaScript, Chart.js, Leaflet. One gunicorn worker with several threads (background jobs — Garmin sync, MQTT, friend sync — must not be duplicated), shipped as a multi-arch Docker image. Windows build: PyInstaller + waitress + pystray.
Layout
app.py application factory, auth guard, background jobs
config.py environment + settings loading
database.py schema + forward-only migrations (never drops tables)
routes/ one blueprint per area (rides, segments, nutrition, friends, settings, setup, api_v1 …)
services/ Garmin, parsing, segments, best efforts, AI, MQTT, backup, credentials, limits …
templates/ static/ UI
scripts/ smoke tests, Windows build, helpers
tests/ unit tests
docs/ Home Assistant builders, screenshots, notes
Data model (SQLite) — Activity (rides, with GPS/HR/power streams), BestEffort, Segment + SegmentEffort, Workout, FoodLog, HydrationLog, WeightLog, SavedMeal(+Item), FoodFavourite, CustomFood/FoodOverride, Friend (+ imported riders/segments/shared foods), Settings, Rider. Schema changes are applied at start-up by migrate_db(); restoring an older backup migrates it first.
Known limitations (0.1.0-beta)
.env.Roadmap (no promises)
pip install -r requirements.txt
python3 -m pytest tests -q # unit tests
# End-to-end smoke tests — throwaway instance only (they write data):
d=$(mktemp -d); printf "DATABASE_URL=$d/t.db\nSECRET_KEY=x\nAPP_USERNAME=t\nAPP_PASSWORD=t\n" > $d/env
HEADWIND_ENV=$d/env PYTHONPATH=. python3 scripts/smoke_setup_wizard.py
Other smoke scripts: smoke_client_ops, smoke_api_v1, smoke_recipes_sync, smoke_ride_upload, smoke_single_rider.
migrate_db() — add columns/tables, never drop/recreate.%-d %b %Y); the UI uses CSS custom properties (no hard-coded colours).Headwind stands on a lot of generous open data and software: Open Food Facts, Open-Meteo, OpenStreetMap contributors, Esri imagery, Leaflet, Chart.js, Flask, garminconnect, fitparse, gpxpy, paho-mqtt and Home Assistant.
Released under the MIT licence. If Headwind is useful to you, a coffee is always appreciated.
Python
55.2%
HTML
43.1%
CSS
1.5%
Your rides. Your food. Your hardware. Your friends.
Self-hosted cycling analytics, nutrition and body tracking — no cloud, no subscription, no one else touching your data.
Headwind is a single self-hosted web app that brings together the things most riders spread across four or five services:
It runs on a Raspberry Pi, a NAS, a spare PC or a VPS. Your data lives in one SQLite file you can copy, back up and inspect. It only talks to services you explicitly configure (plus a few public data APIs listed below).
Design principles
| Yours | One folder of data. Export any ride as GPX, back up everything as one zip, no lock-in. |
| Quiet | No account, no analytics, no ads. A single optional, anonymous install ping — fully disclosed, one-time, opt-out. |
| One person per instance | Your stats, your PRs, your coaching. To track someone else, they run their own Headwind and you link them as a friend. |
| Honest | It's a beta; this README lists what's rough. The AI coach is deliberately blunt rather than flattering. |
Headwind 0.1.0-beta is the first public release. It is a real, working application used daily by its author, and it has been through a security and reliability review (restore safety, login hardening, injection fixes, Garmin sync reliability, resource limits). It is still a beta:
All images: demo data — a fictional rider "Alex" riding real roads around Hathersage, Castleton, Edale and Bakewell.
Lifetime totals, a 12-week performance chart you can flip between distance, elevation, speed and calories, and your recent rides.
Satellite map, elevation + heart-rate and cadence charts, weather at the start (temperature, wind speed/direction and a headwind / tailwind / crosswind call computed from your route bearing), and effort comparison.
Everywhere you've ridden, filterable by date range and sport, with HD export (3440×1440).
Draw a segment on any ride; Headwind scans your whole history, ranks every effort, charts your trend, rates the difficulty and tracks PRs. Linked friends' segments and efforts appear on the same leaderboard.
Speed over time, monthly distance, year-on-year, ride-length distribution, day-of-week, weather scatter charts — all filterable by sport.
A diary built for speed: barcode scan, per-meal recent foods, saved meals, hydration, macro rings, and calorie goals that include what you burned riding.
Saved a meal but ate less? Scale the whole meal in one tap — weight and every macro together — or open any item and change its weight.
Edit the weight of any logged item and calories, protein, carbs, fat and fibre scale in proportion. Entries with no recorded weight get a baseline from the first weight you type.
Daily weigh-ins with a smoothed trend line, rate of change, and progress against your goal.
Plan a route on the map, see distance and the elevation profile, and export it as GPX.
All 61 screens below use the same synthetic demo data (a fictional rider on real roads). Open a group to browse it.
Dashboard: lifetime totals, a 12-week performance chart (distance, elevation, speed, calories) and your recent rides.
Ride detail: satellite map, elevation and heart-rate charts, cadence, weather at the start and a headwind/tailwind/crosswind call.
A full ride page with the AI coach's analysis, segment efforts and ride notes.
Creating a segment: slide the start and finish markers along the ride, then name it.
Segments list with efforts and difficulty.
Segment leaderboard: every effort ranked, your trend over time, and linked friends' efforts on the same board.
GPS heatmap of everywhere you've ridden, filterable by date range and sport.
The same heatmap on satellite imagery (also Dark, Light and Satellite + labels, with HD export).
Analytics: best efforts, climbing records, speed trend, monthly distance, distribution, weather scatters, and more.
Profile and trophy case: lifetime stats, segment PRs, best efforts and badges.
Route planner with a saved route loaded: distance, waypoints and GPX export.
Walks, runs and hikes are kept separate from rides so they never change your ride stats.
Import: drop in .fit / .gpx files or a full Strava export zip.
Recovery from Garmin: resting heart rate, body battery, sleep, steps and stress, with 30/60/90-day views.
Steps with a daily goal; log a day by hand if you have no tracker.
Weight trend: daily weigh-ins, a smoothed line and your rate of change.
Backfill past weigh-ins in bulk.
The diary: calories and macro rings, meals, hydration and your weight.
"View Nutrition": the day's totals against your goals, by meal, with a macro split.
<img src="docs/screenshots/nutrition-meal-sheet-day.jpg" alt=""View Nutrition": the day's totals against your goals, by meal, with a macro split." width="800">
Add food: this meal's recent and most-logged foods first, one tap to log.
Search Open Food Facts by name (barcode scanning is next to it).
Go-Tos: your favourites.
Recipes and saved meals, loggable at ½×, 1×, 1½× or 2×.
My Foods: your own foods, per 100 g.
Change a food's weight and the calories and macros follow.
"Smaller or bigger portion?": scale every item in a meal at once.
<img src="docs/screenshots/nutrition-meal-portion.jpg" alt=""Smaller or bigger portion?": scale every item in a meal at once." width="800">
Manual entry for anything without a barcode.
Daily goals, weight unit, diet start date and goal weight.
Copy yesterday: a whole day or just one meal.
Weigh in.
AI food-photo estimate (needs your own AI key).
Import a food diary or recipe from screenshots of another app (needs your own AI key).
Build a meal or recipe from ingredients.
Save what you've logged as a reusable meal.
Create your own food.
Weekly summary: eaten, burned, net and macros per day.
Friends: your feed token, add a friend by URL + token, and auto-sync.
AI Coach: your coaching goals, and bulk analysis of rides by date range.
Settings: units, login details, AI provider, Garmin Connect, weather backfill, backup and restore.
Home Assistant: connect with a long-lived token and import health readings.
Pair a phone with a QR code and a per-device token (HTTPS required).
About: what's in the app.
Sign in.
Step 1: your name (or restore from a backup).
Step 2: units.
Step 3: Garmin Connect (optional, MFA-capable).
Step 4: Home Assistant (optional).
Choosing a health entity from Home Assistant.
Step 5: the one-time anonymous install ping, with a permanent opt-out.
Step 6: done.

.fit, .gpx (and gzipped variants), or a Strava data-export zip, with a live progress bar. Duplicate detection is time-aware.POST /import/api/upload-ride with an X-Upload-Token header (set RIDE_UPLOAD_TOKEN) lets a script, a Pi attached to a bike computer, or an automation push GPX files in.You need Docker with the Compose plugin. No clone required:
mkdir headwind && cd headwind
curl -O https://raw.githubusercontent.com/lordmaa/headwind/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/lordmaa/headwind/main/.env.example
docker compose up -d
Open http://localhost:5001. On the first start Headwind generates a random signing key and an admin password and prints it once in the logs:
docker logs headwind 2>&1 | grep -A3 "generated one"
Sign in with admin and that password, then change it under Settings. To choose your own credentials instead, copy .env.example to .env and set SECRET_KEY, APP_USERNAME and APP_PASSWORD before the first docker compose up.
Data lives in ./data (database, avatars, food photos, generated login) and ./garmin_tokens, next to your docker-compose.yml, so it survives restarts and upgrades.
Images are published for amd64 and arm64 on Docker Hub: lordmerchant99/headwind (tags: latest, 0.1.0-beta).
The same Docker instructions work on a Raspberry Pi 4/5 (64-bit OS). The arm64 image is new — if something misbehaves, please report it. Tips:
./data on an SSD or good SD card; SQLite is happiest on reliable storage.Deploy as a stack using the Compose file from this repo and set SECRET_KEY, APP_USERNAME and APP_PASSWORD under Environment variables.
Download Headwind-windows-x64.zip from the Releases page, extract it somewhere writable (not Program Files), and run Headwind\Headwind.exe. It runs as a desktop app: a tray icon (bottom right, near the clock — you may need to click the ^ arrow) and Headwind opens in its own app-style window. A dialog shows your first sign-in.
Tray menu (right-click, or double-click to open): Open Headwind, Show sign-in details, Open data folder (%LOCALAPPDATA%\Headwind), Start with Windows, Quit Headwind. Closing the window does not stop it (it keeps syncing); use Quit.
HEADWIND_HOST=0.0.0.0 before launching.pip install -r requirements.txt waitress pyinstaller then python scripts\build_windows.py dist.git clone https://github.com/lordmaa/headwind.git
cd headwind
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python3 app.py # http://localhost:5001
python3 app.py is for development: it listens on all interfaces. Set HEADWIND_DEBUG=1 for Flask's debugger only on a machine nobody else can reach. For anything long-lived use Docker (gunicorn) or put it behind a proper service manager.
To build the Docker image yourself:
docker compose -f docker-compose.dev.yml up --build
# multi-arch:
docker buildx build --platform linux/amd64,linux/arm64 -t yourname/headwind:dev --push .
A new install walks you through six short steps. Everything except step 1 is skippable and can be changed later in Settings.
No Garmin? Go to Import and drop in .fit / .gpx files or a Strava export zip.
Set these in a .env file next to docker-compose.yml (Compose reads it automatically) or in your service environment.
| Variable | Default | What it does |
|---|---|---|
SECRET_KEY | generated | Session signing key. If blank or a known placeholder, a random one is generated and stored in the data folder. |
APP_USERNAME | admin | Login username. |
APP_PASSWORD | generated | Login password. If blank or a known placeholder, a random one is generated and printed once in the logs. A password changed in Settings persists across container recreation unless you later edit this value. |
APP_URL | http://localhost:5001 | The URL others (and your phone) reach this instance at — used in notification links and the phone pairing QR code. |
DATABASE_URL | /data/bike.db (Docker) | Path to the SQLite database. |
TELEMETRY | (unset) | Set to off to skip the telemetry question and never send the install ping. |
HEADWIND_MULTI_RIDER | (unset) | 1 enables the advanced multi-rider mode (several local riders on one instance). |
RIDE_UPLOAD_TOKEN | (unset) | Enables POST /import/api/upload-ride (authenticated by the X-Upload-Token header). Disabled when unset. |
SESSION_COOKIE_SECURE | false | Set to true when serving over HTTPS so the login cookie is never sent over plain HTTP. |
HEADWIND_MQTT_PREFIX / HEADWIND_MQTT_NAME | (defaults) | Give a second instance on the same MQTT broker its own entity prefix and device name so they don't collide. |
HEADWIND_MQTT_NUTRITION | 1 | 0 publishes only the ride sensors, not nutrition. |
HEADWIND_ENV | ./.env | Point an instance at a different env file (useful for running two instances from one code folder). |
PORT | 5001 | Port for python3 app.py. |
HEADWIND_DEBUG | (unset) | 1 turns on Flask's debug mode for python3 app.py (never on a reachable machine). |
Windows-build only: HEADWIND_HOST, HEADWIND_DATA, HEADWIND_NO_TRAY.
Garmin credentials, the MQTT broker, Home Assistant URL/token and mappings, AI provider/key/model, coaching personality and goals, display units, your login details and backups are all configured under Settings — not in .env. Calorie and macro goals live under Nutrition → Goals, and friend-sync is under Friends → Auto-Sync.
| Path (in the container) | Contents |
|---|---|
/data/bike.db | The SQLite database (WAL mode) — rides, food, weight, settings, including credentials you enter in Settings. |
/data/avatars/ | Profile photos. |
/data/foodimg/ | Food photos fetched or uploaded. |
/data/.secret_key, /data/.admin_login | The generated session key and login (mode 600). |
/app/.garmin_tokens/ | Garmin login tokens (so MFA isn't needed every sync). |
HEADWIND_MQTT_PREFIX values, or they'll overwrite each other's sensors.docs/ha.Sync is pull-based, over HTTP(S). Peer URLs must be http:// or https://; redirects to a different host are refused so a peer can't bounce your token elsewhere. Use HTTPS over the internet.
When a new ride syncs, Headwind can notify you through Home Assistant (companion-app notification with a link to the ride). There is also an ntfy hook in services/notify.py, but it has no field in the Settings screen yet — treat it as a developer feature for now.
Headwind has a login, throttling and cross-site protections, but it is a single shared admin account guarding years of health and location data. If you expose it:
SESSION_COOKIE_SECURE=true once you're on HTTPS.admin/admin.A minimal Caddyfile:
headwind.example.com {
reverse_proxy localhost:5001
}
Behind a proxy Headwind trusts one X-Forwarded-* hop; make sure your proxy sets those headers and that port 5001 isn't directly reachable from outside.
.env — but the database itself holds anything you configured in Settings (Garmin email/password, AI key, MQTT/HA credentials), so a backup is a secret. Store it accordingly.bike.db.pre-restore; the swap itself is atomic. A backup this version can't use is rejected with "Nothing was changed".docker compose pull && docker compose up -d. Migrations run automatically at start-up. Take a backup first on a beta.changeme login: the placeholder is no longer accepted — a random login is generated; read it from docker logs../data and ./garmin_tokens, or restore a backup and re-enter your Garmin login.What leaves your machine — only to services you configure or the public APIs below:
| Service | What for | What's sent |
|---|---|---|
| Garmin Connect | Activity/health sync | Your login (to Garmin), requests for your own data |
| Open-Meteo | Weather, elevation | Ride start coordinates + time |
| Open Food Facts | Food search/barcodes | The barcode or search text |
| Esri / map tile hosts | Map tiles in your browser | Normal tile requests (your browser's IP) |
| CDNs (unpkg, jsDelivr) | Chart/map libraries in your browser | Normal asset requests |
| Your AI provider (if configured) | Coaching, photo estimates | Ride summaries / the image you submit |
| Your MQTT broker / Home Assistant (if configured) | Automation | Sensor values / notifications |
| Linked friends (if configured) | P2P sync | Rides and segments you've chosen to share |
Telemetry (the one exception). At the end of setup, Headwind can send one single, one-time, anonymous ping — a random install id and the version number, nothing else — so the author has a rough idea how many instances exist. No rides, food, weight, location or any personal data is ever sent. The collector does not store IP addresses. The wizard shows this text and a permanent opt-out; you can also set TELEMETRY=off in your .env to skip the question entirely. The code is services/telemetry.py — read it rather than take our word for it.
Headwind 0.1.0-beta shipped after a dedicated review. In short:
Known limits: one shared admin login; the password is stored in plaintext in your data folder / .env; in the advanced multi-rider mode phone devices aren't scoped per rider. See Known limitations. To report a vulnerability, please open a private security advisory on GitHub rather than a public issue.
A native Kotlin/Jetpack Compose companion — nutrition logging with barcode scan, ride recording, widgets and Health Connect — exists but is a work in progress and not publicly released. It pairs with your server via a QR code (Phones page) and a per-device token, and requires HTTPS. Until it's released, everything works from the browser, and the nutrition tracker installs as a PWA.
| Problem | Try |
|---|---|
| Can't sign in / forgot the generated password | docker logs headwind 2>&1 | grep -A3 "generated one". The login is also stored in ./data/.admin_login. To set your own, put APP_PASSWORD in .env and recreate the container. |
| "Too many attempts" | The login locks for a few minutes after 8 failures. Wait, or restart the container. |
| Port 5001 is in use | Change the left side of ports in docker-compose.yml, e.g. "5002:5001". |
| Garmin asks for MFA every time | The garmin_tokens folder must be a persistent volume; check it's mounted and writable. |
| Garmin sync finds nothing | Make sure activity sync is enabled in Settings → Garmin and the account matches the one you connected. Failed rides are retried on the next sync. |
| Phone app can't pair | It needs an HTTPS address. Put Headwind behind a TLS proxy/tunnel and set APP_URL to that address. |
| Home Assistant sensors missing | Check the MQTT broker settings, that discovery is enabled in HA, and that no other Headwind uses the same HEADWIND_MQTT_PREFIX. |
| Map is blank | Your browser needs to reach the tile and CDN hosts listed under Privacy. |
| Windows SmartScreen warning | The build is unsigned. More info → Run anyway, or use Docker. |
| Restore says "not compatible" | The backup's database couldn't be migrated. Nothing was changed — your current data is intact. |
| Want to start over | Stop the container and move ./data aside (don't delete it until you're sure). |
Stack — Python 3.11 / Flask, SQLite (WAL), Jinja templates, vanilla JavaScript, Chart.js, Leaflet. One gunicorn worker with several threads (background jobs — Garmin sync, MQTT, friend sync — must not be duplicated), shipped as a multi-arch Docker image. Windows build: PyInstaller + waitress + pystray.
Layout
app.py application factory, auth guard, background jobs
config.py environment + settings loading
database.py schema + forward-only migrations (never drops tables)
routes/ one blueprint per area (rides, segments, nutrition, friends, settings, setup, api_v1 …)
services/ Garmin, parsing, segments, best efforts, AI, MQTT, backup, credentials, limits …
templates/ static/ UI
scripts/ smoke tests, Windows build, helpers
tests/ unit tests
docs/ Home Assistant builders, screenshots, notes
Data model (SQLite) — Activity (rides, with GPS/HR/power streams), BestEffort, Segment + SegmentEffort, Workout, FoodLog, HydrationLog, WeightLog, SavedMeal(+Item), FoodFavourite, CustomFood/FoodOverride, Friend (+ imported riders/segments/shared foods), Settings, Rider. Schema changes are applied at start-up by migrate_db(); restoring an older backup migrates it first.
Known limitations (0.1.0-beta)
.env.Roadmap (no promises)
pip install -r requirements.txt
python3 -m pytest tests -q # unit tests
# End-to-end smoke tests — throwaway instance only (they write data):
d=$(mktemp -d); printf "DATABASE_URL=$d/t.db\nSECRET_KEY=x\nAPP_USERNAME=t\nAPP_PASSWORD=t\n" > $d/env
HEADWIND_ENV=$d/env PYTHONPATH=. python3 scripts/smoke_setup_wizard.py
Other smoke scripts: smoke_client_ops, smoke_api_v1, smoke_recipes_sync, smoke_ride_upload, smoke_single_rider.
migrate_db() — add columns/tables, never drop/recreate.%-d %b %Y); the UI uses CSS custom properties (no hard-coded colours).Headwind stands on a lot of generous open data and software: Open Food Facts, Open-Meteo, OpenStreetMap contributors, Esri imagery, Leaflet, Chart.js, Flask, garminconnect, fitparse, gpxpy, paho-mqtt and Home Assistant.
Released under the MIT licence. If Headwind is useful to you, a coffee is always appreciated.
Python
55.2%
HTML
43.1%
CSS
1.5%