Skip to content

Garmin Connect

Dreeve imports anything dropped into its watch/ folder. Getting your Garmin activities into that folder is what the Garmin connector does: it periodically lists new activities, downloads the original .fit files and drops them in. No manual exports.

Garmin Connect → connector → watch/ → Dreeve imports it

The connector is a separate container from its own repository, dreeveapp/dreeve-garmin-connector. It is not part of Dreeve itself, it only writes files into the folder you already mount.

Add this alongside the app and daemon services in your docker-compose.yml:

docker-compose.yml
# Pulls activities out of Garmin Connect into the watch folder.
garmin-connector:
image: ghcr.io/dreeveapp/dreeve-garmin-connector:latest
container_name: dreeve-garmin-connector
restart: unless-stopped
volumes:
# The same ./watch folder the app and daemon mount.
- ./watch:/watch
- ./garmin/state:/state
- ./garmin/tokens:/tokens
env_file: ./.env

Add to your .env:

.env
GARMIN_EMAIL=[email protected]
GARMIN_PASSWORD=your-garmin-password
# How far back to reach on the very first run. A date (2026-01-01), a relative
# offset (-30d, 720h) or 'now'.
SINCE=-30d

SINCE only matters for the first run.

Terminal window
> docker compose run --rm garmin-connector login

This is the only command that ever uses your password.

On success your session is stored in ./garmin/tokens and refreshes itself from then on. You can remove GARMIN_PASSWORD from your .env afterward; nothing else needs it.

Terminal window
> docker compose up -d
> docker compose logs -f garmin-connector

A cycle runs immediately, then one every hour. Files land in the watch folder as <activityId>.fit, and the daemon container will import them, exactly as if you had dropped them there yourself.

A first run against a large history is probably hundreds of downloads, and asking for all of them at once is the most reliable way to get your Garmin account rate-limited. So a cycle downloads at most MAX_DOWNLOADS_PER_CYCLE activities (25 by default) and picks up where it left off next time.

At the default hourly interval that is around 600 activities a day, so a long backfill takes days on purpose. Fetch the status by running:

Terminal window
> docker compose exec garmin-connector dreeve-garmin-connector status
KeyMeaning
healthyfalse once authentication is broken, or when three POLL_INTERVALs have passed without a completed cycle.
cyclesCycles attempted since that start, successful or not.
lastSuccessfulSyncEnd of the last cycle that completed. null until the first one does.
nextRunAtWhen the next cycle is due, jitter and backoff included.
backoffSecondsHow long the connector is currently backing off after a rate limit. 0 when all is well.
authenticationok, or the error Garmin returned. Anything else means the session is dead and login has to be run again.
lastErrorThe last failure message, cleared by the next successful cycle.
lastCycleWhat the last cycle did. null before the first one.
lastCycle.listedActivities Garmin returned for the window
lastCycle.deliveredFiles written to the watch folder this cycle. Capped by MAX_DOWNLOADS_PER_CYCLE.
lastCycle.failedDownloads that went wrong and will be retried.
lastCycle.skippedActivities older than SINCE, so deliberately not fetched.
lastCycle.withoutFileActivities Garmin has no file for.
lastCycle.backlogSame as the top-level backlog.
backlogActivities still owed a download, whatever cycle they were listed in. This is the number to watch during a backfill.
activitiesThe whole ledger counted by status.
VariableDefaultWhat it does
GARMIN_EMAIL-Required. Also accepts GARMIN_EMAIL_FILE for Docker secrets.
GARMIN_PASSWORD-Only needed to log in. Also accepts GARMIN_PASSWORD_FILE.
GARMIN_IS_CNfalseUse Garmin China.
GARMINTOKENS/tokensWhere the session is stored. Mount it as a volume.
WATCH_DIR/watchDreeve’s watch folder.
STATE_DIR/stateWhere the ledger is stored. Mount it as a volume.
SINCE-Required on the first run. A date (2026-01-01), an ISO instant, a relative offset (-30d, 720h) or now. Resolved once, then remembered.
POLL_INTERVAL3600Seconds between cycles.
POLL_JITTER_PCT10Randomises the interval by ±this much, so every deployment of this image does not hit Garmin on the same second.
LOOKBACK_DAYS7Re-lists the last few days each cycle, catching watches that synced late and activities edited afterwards.
MAX_DOWNLOADS_PER_CYCLE25The per-cycle cap. See above.
DOWNLOAD_DELAY_SECONDS2Pause between downloads.
FALLBACK_FORMATtcxtcx, gpx or none, for activities that have no .fit file.
ON_CONFLICTskipskip or overwrite, when the file is already in the watch folder.
MAX_ATTEMPTS5How often an activity may fail before it is left alone.
MAX_BACKOFF_SECONDS21600Cap on the backoff after a rate limit (6 hours).
MAX_CYCLES00 runs forever; anything else runs that many cycles and exits.
LOG_LEVELinfodebug, info, warning, error or critical.
LOG_FORMATtexttext or json.
PUID / PGID-Own the delivered files as this user. Set them to the same values Dreeve runs as.
UMASK / TZ-As usual.
Terminal window
# Log in. Once, interactively.
> docker compose run --rm garmin-connector login
# Run a single cycle and exit.
> docker compose run --rm garmin-connector sync-once
# Show what it would fetch, without downloading anything.
> docker compose run --rm garmin-connector sync-once --dry-run
# Ask a running connector what it is doing.
> docker compose exec garmin-connector dreeve-garmin-connector status

The connector is built on cyberjunky/python-garminconnect, which talks to the same endpoints Garmin’s own mobile app does. Garmin neither documents nor supports this, and using it is at your own risk.

When Garmin changes something, the symptom is a burst of authentication errors in the logs. Check for a newer version of the Docker image first.