Getting started
Lich is a CLI plus a long-running daemon. Install the CLI, write a lich.yaml, then lich up.
Install
Available for macOS (arm64 / x64), Linux (arm64 / x64), and Windows via WSL.
curl -fsSL https://lich.sh/install.sh | bashThe installer downloads pre-built lich and lich-daemon binaries from the latest GitHub release and drops them in ~/.local/bin. Make sure that's on your PATH:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # or ~/.bashrc
exec $SHELL -l
lich --helpYou should see the built-in command list.
Your first lich.yaml
Lich expects a lich.yaml at the root of your repo (or anywhere you want to run a stack from). The simplest valid file declares one service:
version: "1"
owned:
api:
cmd: bun run dev
cwd: apps/api
port: { published_env: PORT }
ready_when:
http_get: /healthThat's a complete, valid stack: lich allocates a host port, exposes it as PORT in the api process's env, runs bun run dev, waits for /health to return 200, and reports the stack as ready.
Add a database via the services: block:
version: "1"
services:
postgres:
image: postgres:16-alpine
ports:
- { container_port: 5432, published_env: POSTGRES_HOST_PORT }
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
tmpfs:
- /var/lib/postgresql/data # in-RAM data dir, gone on `lich down`
owned:
api:
cmd: bun run dev
cwd: apps/api
port: { published_env: PORT }
depends_on: [postgres]
ready_when:
http_get: /health
env:
DATABASE_URL: "postgresql://postgres:postgres@localhost:${services.postgres.host_port}/myapp"The interpolation ${services.postgres.host_port} resolves at startup against the port lich allocated for the postgres container, so the api process sees a real DATABASE_URL like postgresql://postgres:postgres@localhost:54317/myapp. The port is different in every worktree, allocated dynamically by lich, never colliding.
For everything you can put in a lich.yaml, see the full reference.
Environment variables
Inline env: is fine for non-secret values like the DATABASE_URL above, but most stacks also need to load secrets from a file or a secret-manager CLI. Both flow into the same per-service env.
From a .env file
env_files:
- .env
- .env.localRelative paths resolve from the worktree root (the directory containing lich.yaml). Multiple files merge in declared order. Later files override earlier ones. Missing files are silently skipped, so listing .env.local even though only .env exists is fine.
Worktree behavior. One .env in your main checkout, shared everywhere. When a relative path doesn't exist in the current worktree, lich falls back to the same path resolved against the main worktree (the directory containing the shared .git dir). So you can keep one .env at your repo root and every git worktree add'd branch picks it up automatically. No symlinks, no copying, no per-worktree setup.
If you also want worktree-specific overrides, declare both files and put the override-only one in the local worktree:
env_files:
- .env # lives in main checkout; loaded by every worktree
- .env.local # if it exists in this worktree, overrides .env valuesAbsolute env_files paths are used as-is with no fallback. Use those when the file lives somewhere outside any repo (/etc/myapp/.env, etc.).
From an external secret CLI
For Infisical, 1Password CLI, vault, aws secretsmanager, doppler, or anything else that prints KEY=VALUE lines (or flat JSON) to stdout:
env_from:
- cmd: "infisical export --env=dev --format=dotenv"
format: dotenv # or: json (for a flat object)The cmd runs every lich up, stdout is parsed in the chosen format, and the result merges into the stack env. Examples:
env_from:
- cmd: "op inject -i .env.tpl" # 1Password CLI
format: dotenv
- cmd: "vault kv get -format=json -field=data secret/myapp/dev"
format: json
- cmd: "aws secretsmanager get-secret-value --secret-id myapp/dev --query SecretString --output text"
format: jsonenv_from also accepts plain strings, which pass through a named env var from the parent shell. Useful for GITHUB_TOKEN or other developer-machine credentials you don't want to commit anywhere:
env_from:
- GITHUB_TOKEN
- NPM_TOKENPrecedence
When the same key appears in multiple places, later layers win in this order:
env_from:(cmd output / pass-through)env_files:env:(literal values inlich.yaml)
So an inline env: value always overrides one from a .env file or a secret CLI. Handy for pinning a value during local debugging without touching the source file.
lich up walkthrough
From the repo root:
lich upWhat happens:
- Parse
lich.yaml. Schema-validate. Resolve the active profile (defaults to the one markeddefault: true, or the only profile, or "everything declared at top level"). - Allocate host ports. Every
port:/ports:declaration gets a real, unused integer assigned. The port map is complete before any service starts allowing for the declarative syntax of thelich.yaml. - Resolve env. Top-level
env:, profile env, per-service env, env_groups are all layered into the final per-service env. - Bring services up. Compose services first (
docker compose up -dagainst a generated per-stack override file), then owned processes in dependency order. Lifecycle hooks fire at the right boundaries (before_up,after_up). - Wait for readiness. Each owned service's
ready_whenruns. Composehealthcheckblocksdepends_on:. - Print URLs. Once everything is healthy, lich prints the friendly URLs and the underlying allocated ports.
lich up
# ... boot output ...
✓ Stack ready (4.2s)
URLs:
api http://api.my-feature.lich.localhost:3300/ -> localhost:54281
web http://web.my-feature.lich.localhost:3300/ -> localhost:54282The *.lich.localhost URLs are served by a single shared daemon. *.localhost resolves to the loopback on every OS. No /etc/hosts edits, no DNS setup. See Daemon + proxy for how the routing works.
Useful next commands
lich logs # tail every service in this stack
lich logs api # tail just one
lich urls # print the URLs again
lich exec # run a one-off command with the stack's env loaded
lich dashboard # open the multi-stack dashboard in your browser
lich down # stop this stack, release the ports, preserve state
lich stacks # list every running stack on this machine
lich nuke # tear down every stack everywhere, from any cwdEvery command is documented in detail in the CLI reference.
Run two stacks at once
Once you have a working stack, the per-worktree isolation is what makes lich actually useful. From a second worktree of the same repo:
cd ../my-repo-feature-branch # a different worktree
lich up # gets its own ports, its own state, its own dashboard entryBoth stacks run simultaneously. The first one is at http://api.main.lich.localhost:3300/, the second at http://api.my-feature.lich.localhost:3300/. See Worktree isolation for the full picture.
Next steps
- Use the lich-instrument skill to wire up your existing repo with an agent.
- Read the full
lich.yamlreference. - Browse recipes for monorepo tooling, install caching, supabase, and other common wrinkles.