The rider who fetches the bottles so the others can just ride.
Two riders, two different head units, one shared set of routes. Domestique keeps a route library in sync with each rider's Garmin Connect and Wahoo account, so a route added once shows up on a Garmin Edge and a Wahoo ELEMNT.
Free software under the GNU AGPL-3.0: use it, change it, self-host it, for anything at all. The one condition that matters — if you run a modified version as a network service, its users must be able to get your source. That is section 13, and it is the reason for this licence rather than a permissive one.
Contributions are welcome — see CONTRIBUTING.md.
This repository holds no route data — see Where routes live.
The library, diff engine, CLI, HTTP API and web UI work end to end, routes convert to FIT courses,
and both Garmin and Wahoo push are live — a route added once pushes to its owner's own linked
accounts, and courses already on Garmin can be listed, imported back, and de-duplicated from the UI.
mode: oidc (Auth0, including Google as
a social sign-in) and an admin People page for inviting riders and managing access are live
alongside the original mode: proxy — see Logging in.
In a database. Routes are rows and the GPX is a blob in the row.
| Engine | For | DSN |
|---|---|---|
| PostgreSQL | A deployment | postgres://user:pass@host/domestique |
| SQLite | A laptop | data/domestique.db |
The DSN picks the engine — a postgres:// URL means PostgreSQL, anything else is a SQLite file
path. Both are tested against the same suite.
Routes get in three ways: uploaded through the web UI, imported from Komoot, or loaded in bulk from a folder of files:
just import ./some-folder-of-gpxThat last one is a one-off. Nothing keeps reading that folder afterwards.
GPX files are personal location data — a route usually starts at somebody's front door — so this repository holds none, and the database is yours.
Only Docker installed:
just up # PostgreSQL + the app on :8080
just logs
just down # `just reset` also drops the databaseThat runs against the same PostgreSQL a deployment uses, so local and deployed
differ as little as possible. just docker-test, just docker-check and
just docker-build do the rest without a local toolchain either.
Komoot import is on in that stack: open the app and sign in to Komoot from the panel. The compose file carries a throwaway encryption key so that works out of the box.
For Garmin, install the app keys once — see Linking a head unit for what they are and where they come from:
just garmin-keys # after `just up`; again after `just reset`With Go and Node installed, which is quicker:
just install
just build
just demo # a local SQLite library with the example route loadedEither way, open http://localhost:8080.
For frontend work run just api and just web side by side and use the Vite server on :5173.
Without the UI:
just validate # read the library, report problems
just plan # what would change on each account
just push -- --dry-run # same, in push's own wordsDomestique has two ways to identify a rider — pick one, they are mutually exclusive.
mode: proxy trusts a reverse proxy in front of it. Put it behind
Traefik with an Authelia forwardAuth middleware and it reads the identity
Authelia passes down.
auth:
mode: proxy
trusted_proxies: [10.0.0.0/8]
roles:
admin: [route-admins]
rider: [riders]
viewer: [guests]The app must not be reachable except through the proxy. It believes the
Remote-Userheader — and so would anyone who can talk to it directly.trusted_proxiesnarrows that; leave it empty only for a ClusterIP-only service. This warning is specific tomode: proxy: it is what "trust a header" costs, and it is whymode: proxyis the right shape for a deployment sitting behind SSO you already run, not for one facing the public internet directly.
mode: oidc authenticates riders itself, against any OpenID Connect
issuer — Auth0, Keycloak, Zitadel, whatever you already have. Riders sign in
at /sso/login; the app verifies what comes back and holds its own
server-side session. See docs/oidc.md for the full design.
auth:
mode: oidc
oidc:
issuer: https://your-tenant.example.com/
client_id: domestique
# client_secret from DOMESTIQUE_OIDC_CLIENT_SECRET, never the config file
redirect_url: https://app.example.com/sso/callback
scopes: [openid, profile, email]
groups_claim: groups # empty if your issuer sends no groups at all
roles:
admin: [domestique-admins]
rider: [cyclists]This mode's version of "what must stay true": client_secret only ever
comes from DOMESTIQUE_OIDC_CLIENT_SECRET, and the session cookie needs
DOMESTIQUE_ENCRYPTION_KEY set — without it there is nowhere safe to put
either the session or the short-lived sign-in state, and /sso/login refuses
outright rather than degrade. The proxy-mode warning above does not apply
here: the app is verifying signed tokens itself, not trusting a header, so it
is the mode for a deployment that faces the public directly.
| Role | Can |
|---|---|
viewer |
read routes, download GPX, see what would be pushed |
rider |
+ upload, import from Komoot, link their own head units, push, edit and delete their own routes |
admin |
+ edit and delete anyone's routes and head units, and set up Garmin for the deployment, and manage who has access (see below) |
With mode: none (the default) there is no login at all and every visitor is
an admin. That is right for a laptop and wrong for anything else; the UI says
so in the header.
Under mode: oidc the issuer can offer more than one way to sign in — this
deployment adds Google alongside the database connection, so a rider without
a password to remember can just click "Sign in with Google". Signing in with
a new provider creates a new, separate identity even for an email address
that already has access another way; it still needs to clear the same
required_group gate as anyone else. An admin grants that from the
People page (mode: oidc only, needs Auth0 Management API credentials —
see docs/oidc.md): inviting an email that already has an identity on the
issuer (a prior Google sign-in, say) grants the requested role to it directly
instead of creating a second account, and no invite email goes out — they can
already sign in.
Nothing about riders or devices is configured. Each rider links their own head
unit from Settings, keyed to their Authelia username. A route with no targets
of its own goes to its owner's own linked head units only. Naming a crew in
targets shares the route with that crew and lets the crew's own "Sync now"
action push it to a fellow member's devices — see
Getting a route onto a device below.
Garmin is linked by signing in. Enter your Garmin Connect email and password on the Settings page: the password is used for that one sign-in and discarded, and what Garmin gives back is stored encrypted in its place.
Before anyone can do that, the deployment needs one pair of Garmin app keys — the OAuth1 consumer Connect's own clients use. This is not a per-rider credential: one pair signs everybody's sign-in, and an admin sets it once. Two ways, and the first needs no file:
- Paste them into Settings. An admin sees a "Garmin app keys" panel under
the Garmin card; saving there stores them encrypted in the database and the
sign-in form appears for every rider. Replaceable and removable from the
same panel. Locally,
just garmin-keysdoes the same thing without the copying — it fetches the pair, checks it looks usable, and installs it through that same endpoint. - Supply them in the environment, if you would rather keep them in Vault:
GARMIN_OAUTH_CONSUMER_KEYandGARMIN_OAUTH_CONSUMER_SECRET. Anything set in Settings wins over these, and removing it falls back to them.
Either way needs an encryption key (domestique keygen, as below). The keys
themselves are deliberately not in this repository — baking scraped
credentials into a source-available project invites them to be treated as ours
to publish.
Two limits worth knowing before you try:
- An account with two-factor authentication cannot be signed in to this way. There is no code challenge to answer — Garmin offers no other route for an app like this one, and the UI says so rather than blaming your password.
- The sign-in lasts about a year, then it stops working and Settings shows when that will be. Signing in again replaces it.
Wahoo is linked by authorizing, not a password form: a rider clicks
"Connect Wahoo" on Settings, signs in and consents on Wahoo's own site, and is
redirected back — standard OAuth2, nothing Wahoo-specific to type. Before
anyone can do that, the deployment needs its own Wahoo app registration
(WAHOO_CLIENT_ID/WAHOO_CLIENT_SECRET in the environment, requested at
developers.wahooligan.com/cloud)
and wahoo.redirect_url in domestique.yaml, set to exactly what is
registered with Wahoo. Same encryption key as Garmin/Komoot — a session is
stored encrypted or not stored.
komoot:
enabled: trueEach rider then signs in to their own Komoot from the web UI and imports from their own account. That needs an encryption key, because a sign-in has to be kept somewhere:
domestique keygen # prints a DOMESTIQUE_ENCRYPTION_KEYWithout one the sign-in form is not offered at all — a session is stored
encrypted or not stored. KOMOOT_EMAIL and KOMOOT_PASSWORD remain an
alternative: one shared account for the whole deployment, which a rider's own
sign-in overrides. On the command line, which has no session to sign in with:
just komoot list
just komoot import # everything not already hereKomoot has no public API. This uses the same undocumented endpoints their apps do, so it will break from time to time — treat it as a convenience, not a dependency. Already-imported tours are skipped, so running it twice is safe.
Both providers push automatically once a rider links their account (see
Linking a head unit above) — every general-purpose push
(the CLI, the Library page's "Push to devices" button, and auto-sync) reaches
only the route's own owner's own linked accounts, regardless of what targets
names. Naming a crew in targets shares the route with that crew and makes it
eligible for that crew's own explicit "Sync now" action, on the crew's ride
schedule — the one deliberate way a route reaches a crew fellow's device.
For a device you would rather not link an account on at all, write a route
out as a FIT course and copy it over USB instead:
just fit kemmelberg-loop
just fit kemmelberg-loop --cues # add inferred turn cuesOr download one from the running app: GET /api/fit/<slug> (add ?cues=1).
Turn cues are inferred from the shape of the track, not from a road map. They are off by default and worth checking before you rely on them at a junction — a route planner that knows the roads does this better.
The Helm chart lives in its own repo — wncservices/Domestique-chart — published on every chart change:
helm repo add domestique https://wncservices.github.io/Domestique-chart
helm repo update
helm install domestique domestique/domestique \
--namespace domestique --create-namespaceOut of the box that is one pod, a SQLite library on a volume, no ingress and
no authentication — every visitor an admin. Set config.auth.mode: proxy
and put it behind Authelia before exposing it, or config.auth.mode: oidc
pointed at an issuer if you want the app to authenticate people itself — see
Logging in for both. For PostgreSQL, supply
DOMESTIQUE_SOURCE_DSN from a Secret rather than writing a password into
values.
The chart repo's own README
has the detail, and charts/domestique/ci/full-values.yaml there is a
complete worked example.
The image and the chart release separately — different repos entirely now — because they change for different reasons: a values default is not an app change, and an app change usually needs no chart edit.
| Track | Trigger | Produces |
|---|---|---|
| Container image, dev | every merge to main |
:dev, :sha-<short> |
| Container image, release | tag v<x.y.z> |
:x.y.z, :x.y, :x, :latest |
| Binaries | tag v<x.y.z> |
a GitHub Release with tarballs |
| Helm chart | any change to the chart in Domestique-chart | a GitHub Release there + the Helm repo at wncservices.github.io/Domestique-chart |
Each image is pushed to two registries from one build, under the same tags:
ghcr.io/wncservices/domestique # canonical — what the chart pulls
docker.io/wilant/domestique # mirror
Same digest either way, so they cannot drift apart.
:dev moves under you. Pin :sha-<short> when you want to know exactly what is
running. Every image is built for amd64 and arm64 and carries a provenance
attestation.
The chart's appVersion is the image tag it deploys — bumping it is how a chart
release picks up an app release.
Copy domestique.example.yaml to domestique.yaml. It is deliberately small: where the database
is, and how to recognise a user. No routes, no accounts, no riders, no credentials — routes
and head units live in the database, and credentials come from the environment (in a cluster,
Vault → ExternalSecret → envFrom).
A general-purpose push (CLI, "Push to devices", auto-sync) always reaches only the route's own
owner's own linked head units, whether or not targets names a crew. Naming a crew in targets
shares the route with that crew and makes it visible there, and lets that crew's own "Sync now"
action — and only that action — push it on to a fellow member's device.
| Path | What |
|---|---|
apps/api/ |
Go service: CLI and HTTP API |
apps/web/ |
Vue 3 + Vite frontend |
examples/routes/ |
A sample .gpx, so the demo has something to import |
docs/plan.md |
Why the providers work the way they do — read before touching an adapter |
The Helm chart lives in its own repo now — Domestique-chart — not under this one.
| Phase | What | Status |
|---|---|---|
| 1 | Library, diff engine, CLI, API, web UI | ✅ |
| 2 | GPX → FIT course conversion, with inferred turn cues | ✅ |
| 3 | Garmin: sign-in, FIT course upload, push wired to the library, course import + de-duplication | ✅ |
| 4 | Wahoo push (Cloud API) | ✅ |
| 5 | Deploy: Helm chart ✅, mode: oidc (Auth0 + Google) ✅, admin People page ✅, scheduled reconcile ⬜, Vault-backed tokens ✅ |
🟡 |
| 6 | Metrics (/api/metrics, push success/failure) ✅, OpenTelemetry tracing ✅, ServiceMonitor + alert rules ⬜ |
🟡 |
Phase 4 is what is left to succeed or fail on. Wahoo's API is approval-gated and wants FIT rather
than GPX — Garmin, once the harder of the two, is done. docs/plan.md has the detail, including
what to do if Wahoo says no.
just check runs the typecheck, vet and tests. Keep the Go side close to the standard library —
the dependencies today are a YAML parser, a pure-Go SQLite driver, a FIT SDK, an OIDC client
(discovery, JWKS, ID-token verification), and the OpenTelemetry family (tracing, metrics), and
that is the budget.