Appearance
Env vars & secrets
Starting state
Your team's "how do I get a working .env" process is verbal. Someone keeps .env.example committed with placeholder values; new devs fill in the real ones from a 1Password note that gets shared during onboarding. A working .env once got DM'd to a contractor in Slack; that channel is still in scrollback and the DATABASE_URL in it points at the (since-rotated) production read replica.
A dev once edited .envrc to flip DATABASE_URL to their local Postgres, then committed it with a benign-sounding message. GitHub's secret scanner caught the staging password they didn't realize was in the surrounding lines. Rotation took an afternoon.
The reach for daft: env vars and secrets should be automatic per-worktree. Populated when the worktree is created, gone when it's removed, never typed by hand, never committed.
What changes
A worktree-post-create job seeds the worktree's .env (or .envrc) from a trusted source — a vault, a sops-encrypted file in the repo, or a committed template that contains nothing sensitive. The on-disk artifact is per-worktree, gitignored, and never touched manually.
What this page is not about: non-secret env defaults (a placeholder DATABASE_URL, LOG_LEVEL, NODE_ENV). Those belong in Declarative envs → mise [env]. This page covers what you can't commit, plus per-worktree dynamic values.
Recipe
The simplest case: a committed .envrc.example with placeholder values, seeded into a per-worktree .envrc and trusted via direnv. This is the right starting point when most of your env vars are non-secret defaults the dev edits locally.
yaml
# daft.yml
hooks:
worktree-post-create:
jobs:
- name: seed-envrc
run: |
if [ ! -f .envrc ]; then
cp .envrc.example .envrc
direnv allow .
fiRepository setup:
bash
# .envrc.example (committed — placeholders only, no real secrets)
export DATABASE_URL="postgres://localhost/myapp_dev"
export API_KEY="set-me"# .gitignore (add)
.envrcWhat happens: a fresh worktree gets .envrc copied from the template, direnv is told to trust it, and the next cd into the worktree exports the vars. The [ -f .envrc ] guard makes the job idempotent — re-running the hook never overwrites local edits.
Prerequisites: direnv installed and its shell hook loaded (eval "$(direnv hook zsh)" or your shell's equivalent).
Variants
By source — where the secret value comes from. The hook shape stays the same; only how it gets the bytes differs.
Vault lookup at hook time (1Password, Vault, Bitwarden)
For real secrets, don't put them in .envrc.example. Fetch them at worktree-create time from a vault you trust:
yaml
- name: seed-envrc
run: |
op inject -i .envrc.tpl -o .envrc
direnv allow ..envrc.tpl is the committed template — it contains op:// references that op inject resolves, so the vault path is in version control but the secret value is not:
bash
# .envrc.tpl (committed)
export DATABASE_URL="$(op read 'op://daft-dev/staging-db/url')"
export API_KEY="$(op read 'op://daft-dev/staging-api/key')"The same shape works with HashiCorp Vault (vault kv get), Bitwarden (bw get), AWS Secrets Manager — anything with a CLI that reads a named secret.
The advantage over committing encrypted files: revocation is centralized. Rotate in the vault, every new worktree picks up the new value automatically. Old worktrees can re-run the hook to refresh.
sops + age — encrypted secrets in the repo
sops encrypts a file with age (or KMS) keys. The encrypted file is committed; decryption happens per-worktree at create time:
yaml
- name: decrypt-secrets
run: |
sops --decrypt secrets.enc.env > .env
chmod 600 .env
env:
SOPS_AGE_KEY_FILE: ${HOME}/.config/sops/age/keys.txtRepository:
secrets.enc.env # committed, encrypted
.env # gitignored, decrypted per worktree
.sops.yaml # routes encryption ruleschmod 600 restricts the decrypted file to the owning user — a basic mitigation against shared-machine readers.
This works well when the team shares an age recipient list. Onboarding a new dev is "add their public key to .sops.yaml, re-encrypt, commit" — no separate vault token to hand over.
Per-job env: — no shell loading
Sometimes you don't want vars in your shell at all — only available to a specific hook job. Use the job's env: field:
yaml
- name: migrate
run: ./scripts/migrate.sh
env:
DATABASE_URL: postgres://localhost/myapp_dev_${DAFT_BRANCH_NAME}
LOG_LEVEL: debugenv: values export only to that job's process. They never reach the parent shell, never appear in .envrc, never persist past the hook run. Useful when a hook needs ad-hoc context that shouldn't leak to your interactive shell.
Don't expose secrets via env: on backgrounded jobs
A long-running background job has its env vars visible in ps -e ww to anyone on the machine. Read-only env files (chmod 600) are fine; env vars on a backgrounded process aren't. See Anti-pattern: secrets in version-controlled hooks.
Per-worktree derived values
Some "env vars" aren't fetched, they're computed — per-worktree ports, worktree-derived project names, database names. Don't hash them in bash: declare them, and daft derives them deterministically from the worktree's name:
yaml
env:
salt: myapp
ports:
- PORT
values:
DB_NAME: "myapp_{worktree_slug}"Hooks and tasks receive PORT and DB_NAME automatically; your shell gets them via eval "$(daft env --export)" in .envrc; and daft env PORT answers from anywhere — the same worktree name yields the same values on every machine, with no central registry and no race conditions. (Earlier versions of this recipe hashed $DAFT_BRANCH_NAME with cksum in a hook — the declared section replaces that job, with different, worktree-keyed numbers.)
For full service orchestration with port allocation and host wiring, see Services with ports, which builds on this pattern.
Idempotency & safety
Idempotent seeding requires a guard against overwriting local edits:
yaml
# Wrong — overwrites .envrc on every hook run, destroys per-worktree edits
- name: seed-envrc
run: cp .envrc.example .envrc
# Right — only seed if missing
- name: seed-envrc
run: |
[ -f .envrc ] || cp .envrc.example .envrcFor vault-fetched secrets, the same applies: refresh-on-every-run is slow (a network call per worktree), and overwrites local debugging tweaks. Default to seed-if-missing; force-refresh only when you deliberately want to pick up rotated values (daft hooks run worktree-post-create).
Where to next
- Declarative envs — for non-secret defaults (mise
[env]), which is the half of "what gets exported on cd" that doesn't belong in this recipe. - Services with ports — the next step when "per-worktree env" includes booting compose stacks on derived ports.
- Anti-pattern: secrets in version-controlled hooks — the failure modes when a recipe like this gets wired wrong.