You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add env:// SecretRef and Dockerfile for cloud deploy (#43)
* feat: add env:// SecretRef and Dockerfile for cloud deploy
SecretRef gains a second shape — { source: env, var: NAME } — that reads
process.env directly with no spawn. The existing { source: exec } shape
is unchanged. This makes cloud platforms (Railway, Fly, Docker, k8s)
work end-to-end, since op:// and keychain:// can't run there.
Also adds a multi-stage node:20-alpine Dockerfile with a /data volume
for state, and a README "Deploy in the cloud" section covering Docker,
Railway, Fly, and which platforms to avoid.
* chore: address simplify findings
- Drop redundant `npm cache clean --force` after `npm ci` (no-op).
- Add `*.log`, `.env.*`, `coverage`, `.vitest-cache` to .dockerignore.
- Add isSecretRef test for bare `var:` (undefined) in YAML.
* chore: address review findings
- Add _detectPrevBackend test for env-source SecretRef returning "unknown".
- Extract ExecSecretRef / EnvSecretRef named types so the public
signature of _resolveSecretRefWithOverrides reads ExecSecretRef
instead of Extract<SecretRef, { source: "exec" }>.
- Switch README docker run example to --env-file to keep secrets out
of shell history and ps output.
* docs(readme): note that openai-codex provider is unsupported in cloud deploy
Copy file name to clipboardExpand all lines: README.md
+85-3Lines changed: 85 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -163,26 +163,42 @@ Env vars take precedence over YAML.
163
163
164
164
## Storing secrets outside config.yaml
165
165
166
-
If you don't want API keys to live as plaintext in `~/.cycling-coach/config.yaml`, any secret field (`llm.api_key`, `intervals.api_key`, `telegram.bot_token`) can be replaced with a **SecretRef** — a reference to an external command that prints the secret to stdout. Cycling Coach runs the command at startup, reads stdout, and uses the value.
166
+
If you don't want API keys to live as plaintext in `~/.cycling-coach/config.yaml`, any secret field (`llm.api_key`, `intervals.api_key`, `telegram.bot_token`) can be replaced with a **SecretRef**. Two shapes are supported:
167
+
168
+
- **`source: exec`** — runs an external command (1Password CLI, Vault, `age`, etc.) and reads its stdout. Best for local desktop use with a password manager.
169
+
- **`source: env`** — reads a process env var directly (no spawn). Best for cloud / Docker / Railway / Kubernetes where the platform injects secrets as env vars.
**Precedence**: env var > SecretRef > plain YAML. Setting `ANTHROPIC_API_KEY` in your shell still wins — useful for debugging a vault issue without touching YAML.
190
+
**Precedence**: env var (the legacy `ANTHROPIC_API_KEY` / `INTERVALS_API_KEY` / `TELEGRAM_BOT_TOKEN` keys) > SecretRef > plain YAML. Setting `ANTHROPIC_API_KEY` in your shell still wins — useful for debugging a vault issue without touching YAML.
179
191
180
-
**Requirements**:
192
+
**`exec` requirements**:
181
193
- The `command` must print **only the secret** to stdout. JSON blobs, labels, or extra output will be stored verbatim and downstream APIs will reject them.
182
194
- A single trailing `\n` or `\r\n` is trimmed; all other whitespace is preserved.
183
195
- Empty output, non-zero exit, a 30s timeout, or output over 64KB is a fatal startup error with a clear stderr message.
184
196
- `shell: false` — `command` and `args` are passed directly to the OS. `~`, `$HOME`, globs, and shell operators are **not** expanded. Use absolute paths.
185
197
198
+
**`env` requirements**:
199
+
- `var`must name an env var that is set and non-empty at startup. Unset → `ENOENT`; empty string → `EMPTY`. Both are fatal startup errors.
200
+
- The value is used verbatim — no trimming, no shell interpretation. If your platform's secret manager appends a newline, set the env var without it.
201
+
186
202
### Using the setup wizard with a password manager
187
203
188
204
If you have the [1Password CLI (`op`)](https://developer.1password.com/docs/cli/) or you're on macOS, `cycling-coach setup` can create the backend items for you — no YAML hand-editing, no manual `op item create` / `security add-generic-password` calls.
@@ -268,6 +284,72 @@ telegram:
268
284
269
285
SecretRef support was added in a recent release. Downgrading cycling-coach while `config.yaml` contains SecretRef blocks will fail at startup — older versions treat non-string secret values as malformed. Keep plain strings or env vars if you need to roll back.
270
286
287
+
## Deploy in the cloud
288
+
289
+
Cycling Coach is a single Node process holding a long-polling connection to Telegram, with state on a local volume at `$CYCLING_COACH_HOME` (defaults to `~/.cycling-coach`). It needs:
290
+
291
+
- An always-on container or VM (no scale-to-zero — long-polling stops when the process stops).
292
+
- A persistent volume mounted at `/data` (or wherever you point `CYCLING_COACH_HOME`).
293
+
- Secrets injected as env vars, referenced from `config.yaml` via `source: env` (see [Storing secrets outside config.yaml](#storing-secrets-outside-configyaml)).
294
+
- One instance only — sessions are sharded by Telegram chat ID on local disk; do not enable autoscaling.
295
+
- A BYOK provider (`anthropic` / `openai` / `google`). `LLM_PROVIDER=openai-codex` is **not supported in containers** — it depends on an interactive OAuth flow that writes to the data dir, which can't run headless. Use Anthropic, OpenAI, or Google in the cloud.
296
+
297
+
### Docker
298
+
299
+
A `Dockerfile` is included. Build and run locally:
300
+
301
+
```bash
302
+
docker build -t cycling-coach .
303
+
304
+
docker run -d --name cycling-coach \
305
+
-v cycling-coach-data:/data \
306
+
--env-file .env \
307
+
cycling-coach
308
+
```
309
+
310
+
Use `--env-file` rather than inline `-e KEY=value` flags — inline values land in shell history and are visible to other users via `ps`. Your `.env` should contain `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`), `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, and `TELEGRAM_BOT_TOKEN`.
311
+
312
+
The image runs as a non-root user, mounts `/data` for state, and reads `/data/config.yaml` if present. With the `-e` env vars above, no `config.yaml` is required — the legacy env-var fallback (`ANTHROPIC_API_KEY` etc.) covers the three secret fields.
313
+
314
+
For finer control (custom model, idle timeout, etc.) drop a `config.yaml` into the volume:
315
+
316
+
```yaml
317
+
# /data/config.yaml
318
+
llm:
319
+
provider: anthropic
320
+
model: claude-sonnet-4-6
321
+
api_key:
322
+
source: env
323
+
var: ANTHROPIC_API_KEY
324
+
intervals:
325
+
api_key:
326
+
source: env
327
+
var: INTERVALS_API_KEY
328
+
athlete_id: "i12345"
329
+
telegram:
330
+
bot_token:
331
+
source: env
332
+
var: TELEGRAM_BOT_TOKEN
333
+
```
334
+
335
+
### Railway
336
+
337
+
Railway autodetects the `Dockerfile` and deploys with no extra config files. Steps:
338
+
339
+
1. Push this repo to GitHub.
340
+
2. In Railway, **New Project → Deploy from GitHub repo**.
341
+
3. Add a **Volume** mounted at `/data`.
342
+
4. Add the env vars in **Variables**: `ANTHROPIC_API_KEY`(or `OPENAI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY`), `INTERVALS_API_KEY`, `INTERVALS_ATHLETE_ID`, `TELEGRAM_BOT_TOKEN`.
Railway does not scale services with attached volumes to zero, so the bot stays connected.
346
+
347
+
### Other platforms
348
+
349
+
- **Fly.io** — works with the same Dockerfile. In `fly.toml` set `auto_stop_machines = false` and `min_machines_running = 1`, otherwise Fly will stop the machine on idle inbound HTTP and the bot stops polling. Mount a 1 GB volume at `/data`.
350
+
- **VPS (Hetzner, DigitalOcean, Lightsail, Oracle Free)** — `docker run` as above, or use systemd. Mount a host directory at `/data` for state.
351
+
- **Avoid** scale-to-zero serverless (Lambda, Cloud Run with `min=0`, Cloudflare Workers, Vercel) and platforms with ephemeral filesystems (Heroku) — both break this app.
`Config field ${path} is not a valid SecretRef. Expected a string, or { source: "exec", command: string, args?: string[] }.`,
123
+
`Config field ${path} is not a valid SecretRef. Expected a string, { source: "exec", command: string, args?: string[] }, or { source: "env", var: string }.`,
0 commit comments