Skip to content

Secrets

Secrets are files in deploy/secrets/ (git-ignored), mounted at /run/secrets/<name>. Each container runs as its own user and reads only its own files, and coachd refuses a key file that group or others can read.

The helper

Every secret file is written the same way: no trailing newline, then owned by the container’s user with mode 0400. Define this once in your shell, in deploy/:

put_secret() {  # put_secret <file> <owner:group>
  local v
  printf '%s (hidden; Enter for empty): ' "$1" >&2
  IFS= read -rs v; echo >&2
  rm -f "secrets/$1"
  (umask 077; printf '%s' "$v" > "secrets/$1")
  docker run --rm --network none -v "$PWD/secrets:/s" --entrypoint sh \
    tailscale/tailscale:v1.104.2@sha256:b11583ad2e93e4671128538c88cf11f460444a5d7e449311dd1933a400ab849d \
    -c "chown $2 /s/$1 && chmod 0400 /s/$1"
}

It reads the value at a hidden prompt (so it stays out of shell history) and hands the file to its owner with a throwaway container: no sudo, no network. The image is the Tailscale one compose already uses. Run it again to replace a secret.

The files

FileOwnerNeededSet up in
garmin_api_key65532:65532alwaysGarmin key
ntfy_token65532:65532always; may be emptyntfy
tailscale_authkey0:0alwaysTailscale
grafana_cloud_otlp_token, grafana_cloud_pyroscope_token473:473with Grafana CloudGrafana Cloud
grafana_cloud_pdc_token30000:30000with Grafana CloudGrafana Cloud
grafana_admin_password472:472with local monitoringLocal Grafana
grafana_alert_url472:472with local monitoring; may be emptyLocal Grafana
restic_password0:0with off-site backupsrestic

For example:

put_secret garmin_api_key 65532:65532
put_secret ntfy_token 65532:65532        # Enter for empty if you don't use ntfy

Compose mounts every file its services use, so docker compose up fails with “bind source path does not exist” until each one exists, even for a feature you don’t use. Never put tokens in .env.

Last updated on