Git Worktrees
Lerd treats every git worktree as a first-class scope: each branch checkout gets its own subdomain, its own optional database, its own PHP and Node versions, and its own slot in the dashboard. Branch names are sanitised to be subdomain-safe, /, _, and . are replaced with -, and non-alphanumeric characters are stripped.
cd ~/Lerd/myapp # parent site, branch: main
git worktree add ../myapp-feature feature/auth # creates the worktree
# Lerd writes the vhost feature-auth.myapp.test → ~/Lerd/myapp-feature/publicPlain git worktree add from any tool (CLI, IDE, GitLens) is enough to get a usable URL. For the full opinionated flow with prompts for DB isolation and a frontend build, use the lerd worktree add wrapper described below.
lerd worktree add and lerd worktree remove
The wrapper commands mirror git worktree's subcommand layout, every flag passes straight through to git, and add an interactive setup pipeline on top. lerd worktree wait and lerd worktree setup are the non-interactive companions for scripts and tools that use plain git, see waiting for the pipeline and finishing a worktree another tool created.
lerd worktree add <git args>
cd ~/Lerd/myapp
lerd worktree add feature/auth # check out an existing branch
lerd worktree add -b feat-x # create a new branch named feat-x
lerd worktree add --detach feat-x abc1234 # detached checkout at a commit
lerd worktree add --track -b feat-x origin/feat # tracking branchAfter git completes, the wrapper:
- Polls until the watcher has installed dependencies (
composer install+npm ci) and synced the worktree's env file. Which file that is comes from the framework definition'senv.file, so it follows the framework rather than assuming.env: Symfony settles on.env.localand Magento onapp/etc/env.php, while Laravel and CodeIgniter use.env. The env file is set up before installs sonpmbuild steps that readVITE_*env vars compile against the right values. - Prompts what should serve the worktree's frontend assets. The select lists every framework worker eligible to replace the build (asset workers with
replaces_build: trueand a passingcheck, e.g.vite), every production-build script declared inpackage.json(build,prod,build:prod,build-prod,production), and a Skip option. The default is the first asset worker that's already opted into the parent's.lerd.yaml workers:, then the first npm script, then skip. Picking an asset worker starts it as a per-worktree unit (withpersist=false, so it doesn't get added toworkers:); picking an npm script runs it once. - Prompts how to set up the worktree's database, see Per-worktree database below.
- If you pick "isolated empty", asks whether to run
php artisan migrate --forceagainst the new schema right away.
Run without a terminal, from a script or an assistant, the wrapper asks nothing and takes each default instead: the asset worker or build script the prompt would have preselected, the parent's database unless the framework definition requires an isolated one, and the definition's worktree setup commands. When the definition declares its worktree.migrations folder, the database is picked by comparing the worktree's migrations with the parent checkout's: the same set shares, extra ones on the branch get a copy of the parent's database, and a branch missing some of the parent's gets an empty one. The definition's doctor.migrate_command then runs on any new database, and on an empty one in every case. The MCP worktree tool's add finishes the same way, and its build and db arguments pick a different option.
The asset-worker option appears even when the worker isn't in the parent's workers: list, as long as the framework declares it and its check passes (e.g. node_modules/vite exists). That lets you opt into vite for a single worktree without editing the parent yaml. The choice is per-session: on a daemon restart only opted-in workers get auto-started by scanWorktrees, so an ad-hoc pick won't survive a lerd stop && lerd start. If you want it permanent, run lerd setup to add it to workers:.
When an asset worker is opted-in on the parent, the watcher also auto-starts it as a per-worktree unit independently of the prompt, a systemd .service on Linux, a launchd plist on macOS. Multiple worktrees can run Vite simultaneously, each gets its own unit (lerd-vite-<site>-<branch>) and Vite auto-increments ports. The same auto-start runs at daemon boot too, so per-worktree units recover after a host reboot or lerd stop && lerd start even when fsnotify hasn't fired. On lerd worktree remove, the matching units are stopped before git tears the worktree down, since a dev server still running writes its cache back into the tree and makes the removal fail, and their unit files are removed once git has succeeded, without that step the supervisor would restart-loop the unit against the deleted WorkingDirectory. The dashboard's remove and the MCP worktree tool stop them first the same way.
Skipping both the asset worker and the npm script leaves the worktree without a Vite manifest, which means the first request will throw ViteManifestNotFoundException until you run npm run dev or npm run build yourself. That's intentional, the alternative is silently rendering main's compiled UI on the worktree, which is worse.
lerd worktree remove <git args>
lerd worktree remove main
lerd worktree remove --force main # discards local modificationsThe wrapper runs git worktree remove and, when needed, falls back interactively. If git refuses with the use --force hint (modified or untracked files in the worktree) the wrapper offers a select prompt to retry with --force instead of forcing the user to rerun the command.
After git succeeds the wrapper asks whether to delete the worktree's isolated database. The default is Keep the database: if you re-create the worktree on the same branch later, the dashboard prompt will offer to reuse the preserved data without rebuilding it. Picking Drop drops the database and removes its registry entry.
Auto-setup pipeline (any git worktree add)
Whether you use lerd worktree add or the bare git command, the daemon's watcher (lerd-watcher) sees the new entry under .git/worktrees/ and runs the same setup steps in this order:
- Wait for
HEADto be a final ref or SHA, git writesgitdir/HEADover multiple steps and the watcher must avoid acting on a half-written detached state. - Copy main's SQLite database into the worktree when the project runs on one, together with its
-walfile when present. The file is gitignored, so git never brings it along, and without it the site fails its first request. The path comes from the framework's declared env keys, the same waylerd doctorfinds it; an absolute or in-memory path is left alone, and a worktree that already has its file keeps it. - Seed
vendor/andnode_modules/from the main repo when the worktree'scomposer.lock/ JS lockfile matches main's, using reflinks where the filesystem supports them (btrfs, xfs-reflink, APFS) and a plain copy elsewhere. - Copy any path the main repo's
.lerd.yamllists inworktree_include, for gitignored files the app needs that git doesn't check out (see extra files below). - Sync the framework's env file from main with its base-URL key rewritten to the worktree's vhost domain. The file, its format and the key all come from the framework definition, so Laravel gets
.env/APP_URL, Symfony.env.local/DEFAULT_URI, CodeIgniterconfig/.env/app.baseURL, and the PHP-config frameworks are seeded through their own writers: WordPress'swp-config.php(rewritingWP_HOME) and Magento'sapp/etc/env.php(carried across for its database credentials). Magento keeps its base URL in the database rather than in a single env key, so it declaresworktree_url_keysinstead, and the seededenv.phpoverrides the database with the worktree's own domain. Writing that base URL changes the config hash Magento keeps in the database, so its definition also declares aworktreeblock: the worktree gets its own database cloned from main, andapp:config:importruns against it once the file and the database are both in place. When.lerd.yamldefinesenv_overrides, those dotenv templates are resolved on top (see env overrides below). - Run
composer install(skipped when the marker is at-or-newer thancomposer.lock) andnpm ci/pnpm install --frozen-lockfile/yarn install --immutable/bun install --frozen-lockfile(skipped under the same marker rule, and whenpackage.jsondeclares no packages, since npm writes nonode_modulesfor it). - Generate the worktree's nginx vhost. It inherits the parent site's framework, so the document root follows the framework's
public_dir(pubfor Magento,webfor Drupal,webrootfor CakePHP) rather than assumingpublic, and any nginx snippet the framework declares is spliced in, expanded against the worktree's own checkout.
Frontend build (npm run build) is not part of the watcher pipeline, it's heavy, project-specific, and can fail silently. lerd worktree add runs it interactively after asking; after a bare git worktree add, lerd worktree setup runs it.
public/build/ is also intentionally not seeded from main: it's a build artefact of the source tree, and copying it would render main's compiled UI on the worktree until the user noticed.
| Resource | Behaviour |
|---|---|
vendor/ | Reflink/copy from main when composer.lock matches; otherwise skip and let composer install build from scratch (no stale autoload entries). |
node_modules/ | Same lockfile-match guard against pnpm-lock.yaml / yarn.lock / bun.lock* / package-lock.json / npm-shrinkwrap.json (whichever exists). The install counts as done when the package manager's own marker in node_modules/ is newer than the lockfile; npm 6 and yarn 1 write none, so lerd writes node_modules/.lerd-installed after their install succeeds. Editing the lockfile afterwards triggers a fresh install either way. |
public/build/ | Not seeded. Run npm run dev (Vite dev server, hot reload) or npm run build (static manifest) inside the worktree. |
worktree_include paths | Copied from main when the worktree doesn't already have them; see extra files in a worktree. |
| env file | The framework's env file (Laravel .env, Symfony .env.local, CakePHP config/.env) copied from main; the framework's base-URL key (APP_URL, DEFAULT_URI, app.baseURL) rewritten to http(s)://<branch>.<site>.test (or resolved via env_overrides when defined). Realigned on every subsequent watcher pass so a branch rename keeps the value current. |
Why not symlink?
Earlier lerd versions symlinked vendor/ to save disk. PHP resolves __DIR__ through symlinks to the real path, so Composer's ClassLoader would initialise against the main repo and silently load stale classes. Real copies (or reflinks) avoid the problem at no meaningful disk cost on modern filesystems.
Waiting for the pipeline
A tool that creates a worktree and then wants to act on it, run tests, run a composer script, seed config, has to let the pipeline finish first, or the two write the same tree at once. lerd worktree wait blocks until it settles:
git worktree add ../myapp-feature feature/auth
lerd worktree wait ../myapp-feature --timeout 10mWithout a path it waits on the current directory. Nothing is printed on success, the exit status is the contract:
| Status | Meaning |
|---|---|
0 | Provisioned, and no install is running. |
1 | The timeout elapsed first. |
3 | This path is not a worktree lerd manages, so nothing will ever provision it. |
Do not try to infer this from the tree's contents. Composer's extraction phase fills existing vendor/<org>/ directories, so neither an entry count nor a shallow mtime moves during the longest part of an install, and node_modules/ exists from the first extracted package. The command watches the pipeline's outputs and its install lock together, which is why it can tell a finished install from one still running. lerd worktree add uses the same wait internally.
Finishing a worktree another tool created
Editors and agent tools such as T3 Code create worktrees with plain git, so they get the install but not the rest of what lerd worktree add does. lerd worktree setup is that rest: it waits for the install as above, then runs the asset build, picks and migrates the database, and runs the framework's worktree setup commands, taking the same defaults lerd worktree add takes without a terminal. One line in the tool's worktree setup script is enough:
lerd worktree setupIt takes an optional path, --timeout, and the --build (auto, skip, worker:<name>, script:<name>) and --db (share, empty, clone-main, clone-<branch>, reuse, reset) choices the prompts would offer. The exit statuses match lerd worktree wait, and nothing is built or migrated when the install does not settle in time.
HTTPS
If the parent site is secured with lerd secure, worktree subdomains inherit HTTPS automatically. When a worktree is created on a secured site, lerd reissues the parent's mkcert certificate to include *.branch.myapp.test SANs, so deep subdomains (e.g. app.feature-auth.myapp.test for multi-tenant apps) are also covered. The worktree's nginx vhost includes *.branch.myapp.test in its server_name directive.
lerd secure myapp
# myapp.test → https
# feature-auth.myapp.test → https (automatic)
# app.feature-auth.myapp.test → https (wildcard SAN)The framework's base-URL key in each worktree's env file (APP_URL for Laravel, DEFAULT_URI for Symfony) is rewritten to https:// when you secure the parent (and back to http:// on lerd unsecure).
Env overrides
By default lerd only rewrites the framework's base-URL key in worktree env files. Multi-tenant apps and other projects that derive multiple env variables from the site domain can define env_overrides in .lerd.yaml:
env_overrides:
APP_URL: "{{scheme}}://app.{{domain}}"
CENTRAL_DOMAIN: "{{domain}}"
DB_DATABASE: "{{parent}}_{{branch}}"
CACHE_DRIVER: redisValues can use template placeholders or be plain static strings. When a worktree is created (or the watcher re-syncs), each value is resolved and written into the worktree's .env. Newlines or \r characters in a value are rejected so a malformed override can't inject a second key into .env; the same check rejects = or newline characters in keys.
| Placeholder | Resolves to | Example |
|---|---|---|
{{domain}} | Worktree domain | feature-branch.myapp.test |
{{scheme}} | http or https | https |
{{branch}} | Worktree branch slug (no parent) | feature-branch |
{{parent}} | Parent site name, slugified | myapp |
{{site}} | Database-safe slug of the full worktree domain. Prefer {{branch}} / {{parent}} for clarity | feature_branch_myapp_test |
When APP_URL is present in env_overrides it takes precedence over the default scheme://domain rewrite. Without env_overrides, behaviour is unchanged.
DB_DATABASE is the one templated key the worktree-DB isolation flow owns: when the worktree is marked db_isolated: true in its own .lerd.local.yaml (set by lerd db:isolate or the dashboard's Isolated DB toggle), the watcher leaves the DB_DATABASE value alone on subsequent ticks instead of re-rendering it from the parent's env_overrides template. Switching isolation back off restores the parent's value and the template applies again on the next pass.
Extra files in a worktree
git worktree add only checks out tracked files, so anything gitignored that the app needs to boot stays behind in the main checkout. Beyond the framework's env file (which lerd already carries across), list those paths in the main repo's .lerd.yaml:
worktree_include:
- auth.json
- storage/oauth-private.key
- .config/local/Paths are relative to the project root and can be files or directories. Each one is copied from the main repo into the worktree when the worktree does not already have it, so a file you edit inside a worktree is never overwritten by a later watcher pass, and a path the main repo doesn't have is simply skipped. Missing parent directories are created.
A gitignored mise.toml belongs here too: lerd reads the Node version from it, so listing it gives the worktree the same Node the main checkout runs, and lerd's own install uses it.
Paths that resolve outside the project root (../secrets, or an absolute path) are ignored. .lerd.yaml is committed and travels with the repository, so cloning a project must never be able to pull files from elsewhere on the machine into a checkout.
Web UI
In the Sites tab, a git-backed site carries a tab strip above its address bar: the main checkout first, then one tab per worktree, each wearing a git-branch icon. Selecting a tab switches the whole detail view to that checkout, and the path on the tab row follows it. A tab whose checkout has uncommitted work shows a * after the branch name, the way a shell prompt does, with ⇡ or ⇣ when it is ahead of or behind its upstream and the * in red on a merge conflict; hovering spells it out ("3 untracked · 11 modified"), and a clean checkout shows nothing. When the selected tab has uncommitted work, a Show uncommitted changes button sits at the left of the git buttons, even on a branch with no remote. It lists the staged, unstaged and untracked files by section, each as a tree of folders that fold on click, a folder holding only one folder sharing its row, and the list scrolls sideways when a path runs past its edge. Picking one opens it in a side-by-side diff editor, with how many lines it adds and removes, staged files against the last commit and unstaged ones against what is staged. Long unchanged stretches fold away, PHP, env, ini and nginx files are highlighted, and Hide whitespace drops changes that only re-indent lines, so a reformatted file shows just what really changed. A binary file, one over 1 MB, a file mid merge conflict or a submodule is named but not compared, and an oversized file is refused on its size without being read. The sidebar button hides the file list to give the diff the full width, the split button switches between side by side and unified, and the maximize button next to close fills the window with it. Closing the dialog from full screen closes it outright, and the next diff opens full screen and in the same view. For uncommitted work, hovering a file shows a button that stages or unstages it, and in a modified file the + in the diff's gutter stages just that block of changes, or the − on its staged side takes it back out, so a file can be committed in part: it then appears under both Staged and Unstaged, each showing its own lines. A block is refused if the file changed since it was shown. Then the box under the list commits what is staged, with Amend last commit to rewrite the last commit instead, its message filled in to edit, and Commit and push to send it right after. Hooks run as usual and signing works as configured, and when git refuses, its reason is shown with the message kept. A push that fails after the commit is made is reported on its own, so only the push needs retrying, from the tab strip. A remote dashboard session without full access does not get the diff or the commit box, since untracked files can hold secrets. When the selected tab's branch tracks an upstream, the right end of the tab strip carries Fetch, Pull and Push for that checkout, the main one or a worktree alike. Pull and Push show how many commits the branch is behind or ahead, and Push is disabled when there is nothing to send. Fetch refreshes those counts; Push asks for confirmation first. The push confirmation and the Pull dialog both list the commits that would move, newest first with their short hash, subject and how long ago each was committed, folded under a heading that opens them, and a publish lists the ones no remote has yet. Clicking one opens what that commit changed in the same side-by-side diff editor, over the dialog, which stays where it was underneath. The push confirmation shows how many commits the branch is behind and ahead and how many files the push changes, like the Pull dialog does. In the Pull dialog, the push confirmation and the Switch branch dialog, the Files changed card opens every file that would change the same way: for a pull from the checked-out commit to the one the pull stops at, for a push from where the branch left its upstream to the checked-out commit, and for a switch from the checked-out commit to the branch picked. Pull opens the same comparison as Switch branch: it fetches, shows how many commits are coming in and how many files they touch, and offers composer install, the project's JS install and the framework definition's migrate command, each ticked when the incoming changes call for it, with Snapshot the database first ticked when they bring migrations. When the branch has nothing to pull, the dialog says it is up to date and offers only OK. Files with uncommitted work the pull would overwrite are listed and hold it back, unless Stash my changes first is switched on: the changes, untracked files included, are set aside for the fast-forward and put back on top after it, and only that stash entry is touched, so stashes you keep are left alone; what was staged comes back staged. If putting them back clashes with what came in, the partial put back is undone so the folder is clean on the new code, the changes stay in git's stash, and the ticked steps still run; the dialog stays open to name the files that clashed and the stash entry to bring back with git stash pop, and offers no snapshot restore, since nothing failed. Once the code has moved, a pull that clashed or failed can be taken back with Undo pull: the branch goes back to the commit it started from, never past a commit made after the pull, the stashed changes come back on top of it, composer and the JS install run again if they ran, and the database snapshot is restored when migrations ran on one. The ticked steps run right after the fast-forward. On a branch that has diverged, ahead and behind at once, Pull and Push are held back with a tooltip saying so, since git refuses both until the branch is merged or rebased. Pull only ever fast-forwards and Push is never forced: a diverged branch, local changes in the way or a remote that moved on make git refuse, and its reason is shown, so nothing is left mid-merge and no one's commits are overwritten. After each one the button shows a check with git's summary for a few seconds. The main checkout's tab also has a Switch branch button that checks out another local or remote branch in place, never forced: uncommitted changes come along, and git refuses when the switch would overwrite them. A branch already open in a worktree is not offered. The same dialog can create a branch instead: New branch takes a name and a base, the current branch unless you pick another, and the comparison below is made against that base; branching off the current commit changes no code, so only a step that is genuinely due, such as an install never done here, is listed. The new branch tracks nothing, so its first push does not land on the base's upstream. A branch with no upstream shows Publish in place of Fetch, Pull and Push when the repository has an origin remote: after a confirmation it pushes the branch to origin under the same name, never forced, and tracks it from then on, so the usual three buttons take over. Branches are listed newest commit first with how long ago each last moved, and the list can be searched by typing; the Add Worktree branch pickers use the same list. Picking a branch compares it with the current one before anything changes, showing how many commits and files differ and offering what to run afterwards: composer install, the project's JS install (such as npm ci) and the framework definition's migrate command. Each is ticked when the diff calls for it, a changed lockfile, a missing vendor or node_modules, or new migrations in the folder the definition declares, and can be ticked or cleared before switching. Migrations the branch lacks raise a warning that the database is ahead of the code; nothing is rolled back. Files with uncommitted work that the target branch also changes are listed before switching, since git would refuse to overwrite them, and the switch waits until they are committed or stashed, or until Stash my changes first is switched on, which works as it does for Pull. When the site's database engine takes snapshots, the list starts with Snapshot the database first, ticked whenever the two branches' migrations differ: the database is copied before anything moves and saved as branch-<name> after the branch being left, so it serves both as the undo for a migration that goes wrong, offered as Restore snapshot if the migrations or a snapshot restore fail (a failed install leaves the database alone, so it is not offered then), and as that branch's copy for when you come back. Each branch keeps one such copy per database: a newer one replaces the last once it is written, and snapshots taken by hand are never touched. When worktrees still share the main checkout's database and a switch or a pull of the main checkout brings migrations, Give worktrees their own database first is ticked too: each of them gets a copy of the database as it is before anything moves, the same isolation lerd db:isolate gives, so the migrations leave their schema alone. Isolating a worktree, here or with lerd db:isolate and lerd db:share, restarts the workers running for it, so none keeps a connection to the database it left. If a snapshot tagged with the target branch exists, the newest one is offered as Restore branch's database, never ticked by default since it replaces whatever the database holds now; it loads right after the switch, before any migration runs on top of it, and is refused if the target branch points the site at a different database. Worktree tabs have no switch, since a worktree's domain is named after its branch. The state is read while the site is open and refreshed every few seconds and whenever the window regains focus, and the last state stays on the tab until the next one arrives. A branch name too long for its tab scrolls to its end while the tab is hovered or focused, and the tab's tooltip always spells out the full branch with its domain.
A site whose folder is not under git yet shows an Initialize git button in place of the strip. It runs git init in the site's folder, and the tabs appear once the repository exists. A site living in a subfolder of a bigger repository is already under git, so it only offers the button when that repository ignores the folder. Whether the folder is under git is read again every few seconds and whenever the window regains focus, and the last answer stays on screen while it is read, so the button never blinks out.

Picking a tab re-scopes the rest of the detail view to that worktree:
- The site title and the Open / Terminal buttons target the worktree's domain and checkout path.
- The App logs tab tails
storage/logsfrom the worktree's directory rather than main's. - The Tinker tab REPL runs inside the worktree's PHP context (its own
.env, its own vendor). - The Request timing panel shows the branch's own traffic: requests to the worktree's subdomain are recorded against it, and its slow routes open and profile on that subdomain. The parent's timing stays separate, though a worktree's requests still count toward the site when the sites list is ordered by traffic. See Request timing.
- The PHP and Node version selectors show the worktree's effective version. A dashed violet border indicates "Inherits from main"; changing the value persists a worktree-only override.
- Worker toggles (queue, schedule, Horizon, Reverb, custom workers) collapse into a "Workers run from main" pill, those run against main's checkout regardless of which worktree is active. Switch to main to start or stop them.
- The domain-edit pencil disappears (worktree domains are derived from the parent's primary).
Adding and removing worktrees
Both live on the tab strip, so neither needs a terminal. A worktree tab carries an inline ×, and the trailing + opens the add form.
- Remove (the × on a worktree's tab) expands an inline confirm with a Discard uncommitted changes checkbox (passes
--forcetogit worktree remove) and, when the worktree has an isolated database, an Also drop database checkbox (off by default, matchinglerd worktree remove, so the data survives a re-add). The per-worktree worker units are stopped before git tears the worktree down and removed after it, and, if requested, the isolated DB is dropped; the watcher cleans up the vhost and LAN-share port asynchronously. - Add worktree (the trailing + tab) opens a form with the same choices
lerd worktree addasks for: a new branch (the Based on picker starts on the main checkout's current branch, which is why that branch has no separate entry in the list, and offers every other local and remote-tracking branch as a start point) or an existing branch (the dropdown lists local branches not already checked out plus remote-tracking branches, which git dwims into a fresh local branch); the database (share the parent's, isolated empty schema, isolated cloned from main, isolated cloned from another worktree, or, when a preserved isolated DB exists for that branch, reuse/reset it); a Run migrations checkbox shown when the database starts empty and the project is Laravel; and the frontend-asset choice (Automatic, an eligible asset worker, annpm runbuild script, or skip). The checkout directory is chosen automatically inside the project,<project-path>/<project-name>-<sanitized-branch>, and a/<project-name>-*/pattern in.git/info/excludekeeps it out of the parent'sgit status. Submitting streams thegit worktree add+ dependency-install + build + DB-setup progress live in the modal. When the frontend-asset choice is Automatic, the modal log emits anAutomatic: ...line explaining which branch the resolver took (asset worker started, npm script run, or nothing-to-do), so the picked path is never silent. Skip picks also log a one-liner so the modal never goes quiet after the dependency-install phase.

Per-worktree PHP and Node versions
Each worktree can pin its own PHP or Node version without affecting the parent site, the natural way to test a runtime upgrade on a feature branch.
When you pick a non-default value from the version selector while a worktree is active, lerd writes the override to .lerd.yaml inside that worktree's checkout. The file lives in the working tree, so the choice travels with the branch in git.
# .lerd.yaml inside the feat-php84 worktree only
php_version: "8.4"
node_version: "24"The override is honoured wherever lerd materialises worktree state on disk: vhost generation on add, rename, pause/unpause, the watcher's startup sweep, and lerd secure/lerd unsecure. The commands that run PHP in a container follow it too, so lerd php and lerd composer from inside the checkout use the worktree's version rather than the parent's, whether the worktree sits beside the project or inside it. Switching a worktree's version writes the target version's FPM quadlet the same way a site switch does, so the vhost it generates always has a container behind it. Worktrees with no override inherit the parent's pinned version (not the highest-installed satisfier of composer.json/package.json constraints, that detection only kicks in for unregistered directories).
Site-level resources stay shared and cannot be overridden per worktree: domain (derived from the parent), TLS certificate (parent's wildcard cert), LAN share port (worktree-scoped LAN share is a separate toggle), workers, and any custom container settings.
On a site running its own per-site image (runtime: fpm-custom or a port-bearing container: site), a worktree is served from that image rather than the shared per-version container, and lerd php and lerd composer inside the checkout exec into it too. Whatever the Containerfile adds is therefore present on the branch exactly as it is on the parent, wherever the checkout lives.
Per-worktree database
By default every worktree shares the parent site's database. The dashboard's Isolated DB toggle and the lerd worktree add prompt opt the worktree into its own schema, named <parent_db>_<sanitized_branch> in the same service the parent uses (mysql, mariadb, or postgres). The worktree's env file is rewritten so its database-name key points at the new schema, and db_isolated: true is recorded in the worktree's untracked .lerd.local.yaml, which lerd adds to the site's .git/info/exclude, so the choice stays with this checkout instead of dirtying the branch and following it into main when it merges. The env file, its format and the host/name keys are resolved from the framework definition, so Laravel's DB_DATABASE in .env and Magento's db.connection.default.dbname in app/etc/env.php are rewritten the same way.
A worker you start by hand inside a worktree (lerd worker start, lerd queue:start, lerd stripe:listen) is recorded the same way, in the worktree's .lerd.local.yaml rather than its committed .lerd.yaml, so starting one never leaves the tree modified and lerd worktree remove is not refused over it.
A db_isolated: true an older lerd committed to a branch's .lerd.yaml still isolates that worktree, and sharing it again writes db_isolated: false to the local file, which wins over the committed value. See Local overrides.
When isolation is enabled lerd asks where the new schema should start from:
| Source | What happens |
|---|---|
| Empty | New schema with no tables. The wrapper then offers to run php artisan migrate --force against it. |
| Clone from main | mysqldump --single-transaction (or pg_dump) of the parent's DB piped into the new schema, entirely inside the service container. |
| Clone from another isolated worktree | Same dump/restore pipeline, source is an existing isolated worktree's DB. Useful when staging migrations on top of an already-migrated branch. |
The isolated database survives lerd worktree remove by default, the wrapper asks at the end whether to drop it, and Keep the database is the default. Re-adding the same branch via lerd worktree add later detects the preserved entry and offers two extra options at the top of the DB-isolation prompt:
- Reuse preserved isolated DB: reconnects the worktree to the same data with no schema changes.
- Reset preserved DB to a fresh empty schema: drops the existing DB, recreates it empty, then offers to run migrations.
The same toggle is available on the dashboard for already-active worktrees: flipping Isolated DB off opens a confirmation modal naming the database that is about to go, and on confirm it drops the database and restores the parent's database-name value in the worktree's env file. The toggle only appears when the parent uses a lerd-managed mysql/mariadb/postgres service. A SQLite project needs no opt-in: every worktree starts with its own copy of main's database file (step 2 of the auto-setup pipeline), so lerd worktree add skips the database prompt and the dashboard's Add worktree form offers only that copy.
Per-worktree LAN share
LAN share has a separate toggle that's worktree-aware: when a worktree is active in the dashboard, the toggle controls a proxy bound to a per-worktree port (next free >= 9100 across all sites and worktrees). The proxy targets the worktree's vhost domain so devices on your network reach the worktree's URL directly, no DNS setup on the client. Removing the worktree releases the port via the watcher's cleanup pass; the dashboard QR-code popover honours ?branch= so the QR encodes the worktree's URL. Running lerd lan:share from inside the checkout does the same thing from the CLI.
Per-worktree public tunnel
Public tunnels are worktree-scoped too. lerd share run from inside a checkout tunnels <branch>.<site>.test, and the dashboard's share menu acts on whichever branch tab is active. A worktree's tunnel is a process of its own: it can run alongside the parent site's, each with its own public URL, and stopping one does not touch the other. Under --domain, the Cloudflare named tunnel is keyed to the branch as well, so routing a hostname to a worktree never takes over the one the parent site is using.
Commands that act on a directory resolve a worktree to its parent site automatically. lerd share, lerd open, lerd domain, lerd env, lerd worker, lerd runtime and the rest run from inside a checkout without linking it as a site of its own. lerd code opens the checkout you are standing in rather than the parent, since that is the copy of the files you are working on.
Cleanup ordering
When a worktree is removed (via git worktree remove directly or lerd worktree remove) the watcher tears state down in this order so that any earlier failure leaves the database intact:
- Per-worktree host-worker units (
lerd-<worker>-<site>-<branch>; systemd.serviceon Linux, launchd plist on macOS), stopped and removed so the supervisor doesn't restart-loop them against the deletedWorkingDirectory. - nginx vhost (URL stops resolving).
- LAN-share proxy + registry entry (port released).
- Isolated database, only via
lerd worktree remove's explicit prompt or the daemon'sscanWorktreesstartup sweep. Plaingit worktree removeleaves the DB and its registry entry alone, so the user can recover by re-adding the worktree without losing migrations or seed data.
The startup sweep also catches any registry entries whose worktree directory disappeared while the watcher was offline, restarting lerd-watcher reconciles state.
The sweep runs in the background, after the watcher has reported itself ready. It provisions worktrees it finds unprovisioned, which can mean a full composer install, and that has no duration worth waiting on: the watcher is up and watching from the first moment either way, so a long install shows up as a worktree that finishes setting itself up a few minutes in, not as a daemon that refuses to start. Use lerd worktree wait when you need to block until a specific worktree is actually ready.
Within the sweep, every worktree vhost is written and nginx reloaded before any dependency install starts, so a repo's subdomains all resolve within seconds of the watcher coming up rather than one at a time behind the installs ahead of them. The installs then run several worktrees at a time, since a worktree's install has nothing to do with its neighbours'. How many depends on the machine: two cores are always left for the daemons, the containers serving your other sites, and whatever you are in the middle of, and the count is capped at four beyond which the wait is the network rather than the CPU. A four-core laptop runs two installs at a time, a small VM runs one. What a subdomain serves in the meantime is a checkout whose dependencies are still landing, which is the same state the pipeline leaves it in for the first minute after git worktree add.
A lockfile the JS package manager refuses is the same lockfile in every worktree seeded from that repo, and it fails identically in each. The sweep pays that failure once per pass: after the first npm ci rejects a given lockfile, the remaining worktrees carrying it are logged as skipped instead of spending seconds each reaching the same conclusion. Fix the lockfile, or wait for the next pass, and it is tried again.
A checkout deleted without git
That ordering hangs off git's own .git/worktrees/ entry disappearing, which is what git worktree remove deletes. Deleting the checkout directory on its own leaves that entry behind, so the watcher never hears about it and the worker units keep retrying against a working directory that has gone, failing before the command runs and restarting on the supervisor's own interval indefinitely. This is no longer unusual: coding agents create and destroy their own worktrees, and they have no reason to know lerd exists.
Such a unit is reported as orphaned rather than failing, and is never offered a heal, because starting it again cannot do anything except fail in the same place. It is removed instead, on the daemon's reconciliation pass and again at startup, so an install already stuck this way recovers on its own. Nothing is preserved because there is nothing left to preserve; re-adding the worktree recreates the unit from scratch. Orphans also stay out of the failing-worker notifications, since they name a problem that resolves itself.
Deciding this needs the directory the unit is pinned to. On Linux that is the unit's own WorkingDirectory=. launchd has no equivalent to read back, so on macOS it comes from the guard script lerd writes alongside every worker plist, which is where a host-mode worker's cd and an exec-mode worker's podman exec -w already record it. The two files are written and removed together, so a unit that still exists still has one.
lerd sites output
Worktrees are shown indented under their parent site, with their own effective PHP / Node / framework version (which may differ from the parent's when an override is set in .lerd.yaml):
NAME DOMAIN PHP NODE TLS PATH
myapp myapp.test 8.5 22 ✓ ~/Lerd/myapp
↳ feature-auth feature-auth.myapp.test 8.5 - - ~/Lerd/myapp-feature
↳ feat-php84 feat-php84.myapp.test 8.4 24 - ~/Lerd/myapp-php84