Hack now uses repo-level env overlay files as the canonical project env model.
The short version:
- commit
.hack/hack.env.default.yaml - optionally commit
.hack/hack.env.<overlay>.yaml - use
.hack/hack.env.local.yamland.hack/hack.env.<overlay>.local.yamlfor worktree-local overrides - keep
.hack.secret.keyout of git, or provideHACK_ENV_SECRET_KEY - let
hack up,hack run,hack restart,hack host exec,hack host shell, and env-aware session flows inject values directly at runtime - use
hack env materializeonly when you explicitly need a compatibility.hack/.env
Canonical env state lives at the repo root:
.hack/hack.env.default.yaml.hack/hack.env.<overlay>.yaml.hack/hack.env.local.yaml.hack/hack.env.<overlay>.local.yaml
Legacy compatibility note:
- if an older repo already tracks
.hack/hack.env.local.yamlas the shared--env localoverlay, Hack keeps reading that file as the shared overlay - in that compatibility case, worktree-local default overrides are written to
.hack/hack.env.default.local.yamlso--localmutations do not overwrite tracked shared config
Compatibility and local-only state still lives under .hack/:
.hack/.env.hack/.env.state.json.hack/.internal/...
Encryption key material is local-only by default:
.hack.secret.key- or
HACK_ENV_SECRET_KEY
hack init now scaffolds .hack/hack.env.default.yaml by default.
Hack owns a committed .hack/.gitignore that keeps machine-local generated
files out of git. Because the file is committed, fresh clones and linked git
worktrees inherit the rules with zero setup. It covers (patterns relative to
.hack/):
.internal/.branch/.env.env.state.jsonhack.env.local.yamlhack.env.*.local.yamltickets/(deprecated Tickets compatibility cache)
How it is maintained:
- written by
hack init, and self-healed byhack upand whenever hack writes.hack/.internal/overrides or--localenv overrides - the entries live inside a marked, hack-managed block; anything you add outside the markers is preserved on every rewrite
- the root
.gitignorestill gets a.hack.secret.keyentry when the secret key is first generated (the key lives at the repo root, not under.hack/) - older repos that added
.hack/.internal/to the root.gitignorekeep working; the entries are equivalent and nothing is removed
Leak detection and repair:
hack doctorruns a "generated files" check: it lists any of the paths above (plus.hack.secret.key) that are tracked in git, except a tracked.hack/hack.env.local.yaml, which is never flagged because older repos may intentionally track it as the shared--env localoverlay (see the legacy compatibility note above)- a tracked
.hack.secret.keyis reported as an error — it is a committed secret; rotate it after untracking hack doctor --fixuntracks the offenders withgit rm --cached(files stay on disk) and re-ensures.hack/.gitignore; commit the removal--fixand--migrate-env-configcannot be combined with--json; run the repair and JSON audit separately so a mutating request is never silently ignored. run them separately from a JSON-mode invocation
Each env file is YAML with:
version: 1environmentsecretsprovider: project_keyvalues
values is split by scope:
global: values applied everywhere<service>: values applied only for that compose servicehost: optional overrides applied to host execution (hack host exec,hack host shell, and lifecycle hooks/processes)
Example:
version: 1
environment: default
secretsprovider: project_key
values:
global:
GLOBAL_FLAG: "true"
API_BASE_URL: "https://api.example.com"
api:
PORT: "4000"
SERVICE_TOKEN:
secure: v1:...Plaintext values are written as scalars. Secret values are written as { secure: ... }.
Hack resolves env in this order:
- load
.hack/hack.env.default.yaml - if
--env=<name>is selected, load.hack/hack.env.<name>.yaml - load
.hack/hack.env.local.yaml - if
--env=<name>is selected, load.hack/hack.env.<name>.local.yaml - merge
values.global - if a service scope is requested, merge
values.<service>on top
Worktree-local overrides win over shared repo overlays. Service values override global values.
Host execution applies host values last. This includes hack host exec, hack host shell,
and lifecycle hooks/processes, which all run on the host rather than in Compose.
Projects can set a default overlay in .hack/hack.config.json:
{
"env": {
"defaultOverlay": "qa"
}
}Use --env=base to bypass that default and read only .hack/hack.env.default.yaml.
Direct runtime injection is the default path.
Hack reads the canonical YAML files and injects the resolved env directly into:
hack uphack runhack restart- lifecycle host processes
hack host exechack host shellhack session start --env ... --service ...hack session exec --env ... --service ...
That means .hack/.env is no longer the primary runtime source of truth.
hack env materialize is manual by design. Use it only when you need a compatibility file for an external tool that expects .env on disk.
For host execution, hack host exec, hack host shell, and lifecycle hooks/processes use a host-local view.
That means Hack prefers host-usable values when it can:
- explicit
hostscope values override the normalglobal+<service>merge - common container-only hostnames such as
host.docker.internaland compose service hosts are rewritten to127.0.0.1
Use --target compose if you explicitly want the container-oriented compose view instead.
hack host exec/hack host shell: run on your host machine with Hack-resolved env injected.hack run <service> ...: run a one-off command in the compose network with an ephemeral service container.hack exec <service> ...: run inside an already-running container when you specifically need that container's live filesystem or process context.
For host execution, --scope means "which env scope should Hack inject", not "where should this run."
For example, hack host exec --scope api -- bun db:migrate still runs on the host.
Inspect resolved env:
hack env list
hack env list --env qa
hack env list --env qa --service api
hack env list --json
hack env list --show-secrets
hack env explain GITHUB_TOKEN --env runner --service installer --target composehack env explain never prints the value. It reports availability, secret classification,
selected/effective scope, source file and precedence, and whether the value is delivered to host
lifecycle code or Compose. Recreate just one service after an env change with:
hack env apply --service api --env qa--show-secrets prints secret values in plaintext instead of the masked default; use it deliberately.
Add or update values:
hack env add API_BASE_URL https://api.example.com
hack env add SERVICE_TOKEN abc123 --service api --secret
hack env add --env qa API_BASE_URL https://qa.example.com
hack env add API_BASE_URL https://api.example.com --localhack env set still works as a compatibility alias for hack env add, but add is the primary UX now.
--local writes to the worktree-local override file (.hack/hack.env.local.yaml or
.hack/hack.env.<overlay>.local.yaml) instead of the shared repo env file — use it for
per-checkout values you don't want to commit.
If you omit key, value, or --service, hack env add prompts for them interactively.
Pass --no-interactive (or set HACK_NO_INTERACTIVE=1) for scripted/agent usage: the
command applies documented defaults or fails fast with E_INTERACTIVE_REQUIRED instead of
blocking on a prompt. hack env unset and hack env set accept --local and
--no-interactive too.
Remove a value:
hack env unset API_BASE_URL
hack env unset SERVICE_TOKEN --service api --env qa
hack env unset API_BASE_URL --localMaterialize a compatibility .hack/.env:
hack env materialize
hack env materialize --env qa
hack env materialize --env qa --service apiRun a host command with injected env:
hack host exec -- bun db:migrate
hack host exec --env qa --scope api -- bun db:migrate
hack host exec --env qa --scope api --target compose -- bun testWhen you want to inspect an injected value, avoid hack host exec -- echo $VAR. Your current
shell expands $VAR before Hack starts the child process, so the command often sees an empty
string.
Use one of these instead:
hack host exec -- printenv APPLE_TEAM_ID
hack host exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"'
hack host exec --shell 'echo $APPLE_TEAM_ID'Open a host shell with injected env:
hack host shell
hack host shell --env qa --scope api
hack host shell --env qa --scope api --target composeExample with an explicit host override:
version: 1
environment: default
secretsprovider: project_key
values:
global:
DATABASE_URL:
secure: v1:...
REDISHOST: redis
host:
REDISHOST: "127.0.0.1"Session flows can now carry env selection too.
Examples:
hack session start my-project --env qa --service api --detach
hack session exec my-project.env-qa.svc-api --env qa --service api "bun db:migrate"When you pass --env or --service, Hack creates a stable scoped workspace name instead of reusing the plain default workspace. That prevents one shell from silently inheriting the wrong env selection.
Secret values in the YAML files are encrypted with the project key.
Default behavior:
- generate
.hack.secret.keythe first time you add a secret - add
.hack.secret.keyto.gitignore - decrypt secrets from
.hack.secret.keyon the local machine - linked git worktrees can reuse the primary checkout's
.hack.secret.keywhen the worktree copy is missing
Linked worktree behavior:
- when a repo uses linked git worktrees, Hack prefers a shared key under the git common dir
- sibling worktrees can decrypt the same committed secrets without manually copying gitignored files
- if a checkout-local
.hack.secret.keyexists, it still wins for that checkout hack upin a linked worktree auto-selects a branch instance (worktree.auto_branch); that selection determines which instance's resolved env is the one actually injected, so keep the key readable from every checkout that runshack up
Full read order for decrypting secrets (first match wins):
- checkout-local
.hack.secret.key - shared key under the git common dir
- the primary checkout's key, inherited automatically for a linked worktree that has neither of the above
HACK_ENV_SECRET_KEY
Write-path guarantees in a linked worktree (first secret added from any checkout):
- an existing checkout-local key is reused as-is
- otherwise an existing shared key (git common dir) is reused
- otherwise the primary checkout's key is adopted by copying it to the shared location, so sibling checkouts converge on one key
- only then is a new key generated, and it is written to the shared location — never silently to the checkout
- if the shared location cannot be resolved (git unavailable) or written, Hack falls back to a checkout-local key and prints a loud divergence warning
hack doctorflags divergent.hack.secret.keycontents across checkouts with the exact paths
CI and managed container fallback:
- if
.hack.secret.keyis missing, Hack falls back toHACK_ENV_SECRET_KEY
That makes this model usable in CI and portable container images without committing the key file.
Published container paths:
- slim managed-container base:
hackdance/hack:slim— the supported CI / managed-container story - full remote runtime:
hackdance/hack:latest— serves the remote/node surface, which is experimental and unsupported (hidden behindhack help --all); only relevant if you have explicitly opted into that surface
In both cases, inject HACK_ENV_SECRET_KEY from the runtime or secret manager instead of copying
.hack.secret.key into the image.
Older repos may still have:
.hack/hack.env.json.hack/.env.hack/.env.<overlay>- configured secret backends via
hack env backend
Hack still detects and reads that legacy format, but the preferred migration path is:
hack doctor --migrate-env-configThat migrates the repo into:
.hack/hack.env.default.yaml.hack/hack.env.<overlay>.yaml.hack.secret.keywhen needed
hack doctor also warns when a repo still depends on the old format.
For modern env repos, hack doctor also warns when materialized compatibility output in
.hack/.env or .hack/.env.state.json is stale and points to hack env materialize.
The modern YAML env model is the canonical repo format today.
Some older surfaces are still compatibility-oriented:
hack env backend ...remains relevant for legacy repos and older secret-store configurations- the daemon's env HTTP route (used by the macOS app) reads and writes the legacy dotenv-shaped
contract (
source: 'plain_env'|'keychain',resolvedFrom: 'dotenv'/'process'/'keychain'/'portable_backend'), not the modern YAMLhack env list --jsonshape; there is no web dashboard surface anymore
If you are writing new docs or new project setup flows, document the YAML overlay model first and treat .hack/hack.env.json as legacy migration context.
- Commit
.hack/hack.env.default.yaml - Commit
.hack/hack.env.<overlay>.yamlwhen the overlay is team-shared - Never commit
.hack.secret.key - Prefer
hack env addover hand-editing encrypted values - Prefer direct runtime injection over materializing
.hack/.env - Use
hack host execor env-aware sessions for host-side scripts like migrations, generators, and admin tasks