L lerd lerd-env/lerd v1.31.0…v1.32.0 ↗
ENGINEERING DIGEST · 2026-07-29 → 2026-08-05

v1.32.0

YOUR MACHINE, YOUR PROXY · and daemons that stop working while you are not

Every public share so far went through somebody else's tunnel service. Point a wildcard you control at the machine, set the base domain once, and lerd answers on <site>.<domain> through the reverse proxy or VPN you already run, with the site's ordinary .test vhost untouched beside it. Pinggy joins the SSH tools as a table entry rather than another special case, managed engines reach the LAN as a deliberate opt-in, and the dashboard actions that touch the host sit behind a switch of their own. Underneath, the watcher stopped rewriting snapshots identical to the ones already on disk, stopped re-running the same composer install every minute forever, and went from 62 wakeups a second to 39 at roughly a third of the idle CPU. The boot scan that left the last worktree unroutable for minutes now lands six of them in 24 milliseconds.

40
commits
40
merged PRs
357
files
+20,458
insertions
−2,171
deletions
5
contributors
cli 26% web UI 12% docs 12% ui server 11% config 4% rest 35%
01

Sharing

FEATUISEC

Every route out of the machine so far ended at a tunnel service. That is the right trade for showing a colleague a page, and the wrong one for a setup where a self-hosted VPN or an nginx in front of the dev box already points a wildcard subdomain at this machine. A public share is the sibling of the LAN share for exactly that case: the same in-process Host-rewriting proxy on a stable port, reached through the proxy you run rather than by LAN IP.

TUNNEL SERVICE → YOUR OWN PROXYwho has to be up for your URL to work
quick tunnel
random.trycloudflare.com
dead tomorrow
──►
named tunnel
<site>.example.com
stable, someone else's uptime
──►
public share
<site>.dev.example.com
your wildcard, your proxy
Nothing is added to the site's domains, the nginx server_name, or its certificates, it is purely a runtime share, started and stopped from the share menu, restored on boot and cleared when the base is cleared.
FEATUI

A share through your own proxy #1303

Building it on the LAN share proxy means Host rewriting, response-URL rewriting and Vite HMR all work with no new serving code. Worktrees share independently on the flat <site>-<branch>.<base>, so a single wildcard covers every branch.

internal/cli/publicshare.go
SEC

A share carries its reach #1320

The LAN proxy forwards /__lerd_vite__/<port>/ to any loopback port, which is the point on a LAN where a phone has no localhost of yours to fetch assets from. On the open internet the same route would hand every visitor the mailpit inbox and the database dashboard. A public share closes it, and never rewrites a loopback URL into a path it refuses to serve.

internal/cli/lanshare.go
FEAT

One reach at a time #1303

LAN, public share and tunnel are mutually exclusive, refused in the backend and disabled in the menu while another is live or opening. A tunnel counts as active from the moment it starts opening rather than only once its URL arrives, which is the window a second share used to slip through.

FEATREFACTOR

Pinggy, and an SSH mode that generalises #1301

The SSH mode was shaped exactly like serveo: one hardcoded invocation with the provider host as the only variable. Pinggy does not fit that form: it listens on 443, wants the server to pick the remote port, and takes a token as the SSH user instead of nokey. The mode carries a provider's whole connection shape now, with serveo and localhost.run keeping their exact current invocations, so the next SSH provider is a table entry. Tokens became per provider along the way: a bare share:token still means ngrok, and one stored for pinggy moves the connection to pro.pinggy.io for a stable subdomain. The free tier hands out URLs on two different domains, confirmed against a live tunnel, so the pattern accepts either.

internal/cli/share.go
02

Access

FEATSECUI

A LAN-exposed install served the sites and nothing behind them, so a phone on the WiFi could open the app while a colleague's machine could not reach the database it runs on. Managed engines can be published to the LAN now, and because that is a decision about the network rather than about a service, the switch lives under the LAN exposure it depends on instead of in a card of its own offering something it cannot deliver.

WHO IS ASKING, AND FROM WHEREa reverse proxy relaying someone else's browser connects from 127.0.0.1
before
TCP peer == loopback
a proxy passes as local
──►
forwarding test
X-Forwarded-For · X-Real-IP
handlers, not just middleware
──►
after
ui.remote_full_access
granted by name, or not at all
The Host header is deliberately not part of that decision, since a browser on the machine may legitimately arrive as the hostname or an /etc/hosts alias, and turning those away leaves the local user with no way in and no credential that helps.
FEAT

Managed services on the LAN #1247

Rebinding converges rather than reporting success optimistically. Every affected unit is attempted and the failures reported together, and which units to restart comes from comparing each container's published ports against the policy it should follow, so a toggle interrupted halfway, quadlets rewritten while containers still run the old bind, is noticed and cleared.

internal/podman/quadlet.go
SEC

Host actions behind their own switch #1247

Raw .env reads, filesystem browsing, database drops, terminals and shutting lerd down stay local unless you grant them. Both the CLI and the dashboard toggle refuse to run from a remote session, so a session can never widen its own authority, and remote-control off clears the setting with the credentials rather than leaving it armed for the next password.

internal/ui/server.go
UI

A switch that does nothing is not offered #1259

The dashboard stated "loopback only, LAN devices cannot reach it" directly above a switch offering those devices access to every database, and flipping it only stored a preference. It is hidden while lerd is loopback-only and refused by the CLI and the API too. Turning it off always works, and a setting already on keeps showing, so re-exposing cannot quietly republish databases.

03

Idle Cost

FIXPERF

The watcher rewrote the request-timing snapshot every ten seconds and the idle activity map every thirty, for the life of the process, whether or not anything had changed, which on a quiet machine is over ten thousand write-and-rename cycles a day to persist bytes identical to the ones already on disk. The cost that matters there is not CPU, it is that the disk and the writeback path never get to settle, which is exactly what a laptop feels.

WHERE THE IDLE COST ACTUALLY WAScontext switches in two-second buckets, flat baseline, one enormous spike every 30s
1
A staleness check that could never converge

A worktree is seeded with a copy of the main repo's vendor directory, so its install marker arrives carrying the main repo's older timestamp against a freshly checked out lockfile. The recovery rescan therefore re-ran composer install on that worktree every minute, indefinitely, and would have gone on forever. Markers are stamped once the install that owns them succeeds. #1271

2
Forks and round trips on a timer

The host-gateway watch forked podman every tick to read an address that only moves when the container is recreated. The idle engine asked systemd for each suspended site's timer through a call that fetches the unit's entire property dictionary to read one field, where the batched snapshot the dashboard keeps warm already had it. #1271

3
Re-walking and re-parsing what had not moved

The source-file watcher re-walked every watched tree each pass to discover new directories, which a checkout of any size makes thousands of readdirs, but new directories already arrive as create events on their parent. Worker detection re-read and re-decoded a site's composer.json once per rule per site. #1271

4
Schedulers sized to the host

All three daemons gave themselves dozens of threads on a many-core machine and turned every wakeup into futex traffic across all of them. They are event handlers waiting on sockets, timers and subprocesses, so GOMAXPROCS caps at four; goroutines blocked in syscalls still get threads of their own, so subprocess concurrency is unchanged. The MCP server gets the same cap, since it lives as long as the session that spawned it. #1271

62 wakeups a second → 39, idle CPU cut by roughly two thirds, thread counts across the three daemons roughly halved. The systemd DBus unit-state watcher went with it, since nothing had ever called it.
04

Worktrees

FEATFIX

A bare git worktree add from any tool is set up automatically, and a tool that then wanted to act on the new tree had no way to ask whether that pipeline had finished. Probing the tree is a trap: composer's extraction phase fills existing vendor directories and node_modules exists from the very first extracted package, so file counts and mtimes sit still through the longest stretch of an install and read as finished.

a wait a script or an agent can actually usebash
lerd worktree wait              # blocks on the current directory
# exit 0  the tree is safe to touch
# exit 1  timed out, still going
# exit 3  not a worktree lerd manages, ask someone else
FEAT

Readiness consults the lock #1256

Two installers writing one tree is how vendor ends up holding packages but no autoload.php, which presents as a Composer bug rather than as the race it is. The probe takes a shared non-blocking flock rather than a try-lock, so it neither overwrites the pid the real holder recorded nor claims a lock a starting installer would have to poll for.

internal/cli/worktree_wait.go
FEAT

The MCP tool carried the same race #1256

Its add action ran bare git and handed the tree straight back to the assistant. It waits by default now and reports whether the tree is provisioned, a wait action covers a worktree created outside the tool, and the reference injected into assistants states that readiness must never be inferred from the tree's contents.

internal/cli/aidocs/lerd-reference.md
FIX

Orphaned units, looping forever #1245 #1323

Deleting a checkout outside lerd leaves systemd retrying a command that fails at CHDIR before it runs. Coding agents create and destroy their own worktrees and have no reason to know lerd exists. Such a unit never settles into failed, it flaps between activating and active for as long as the machine is up, so the missing checkout is what decides it. macOS reads the path back out of the guard script, since launchd exposes none.

internal/workerheal/
FIXPERF

Every vhost first, installs afterwards #1281

The boot scan walked a site's worktrees one at a time and, for each, ran the dependency install before writing the vhost, so a subdomain waited not only on its own composer and npm run but on every worktree ahead of it in the list. What that looks like is a worktree that was added successfully, has its vendor and its env in place, and 404s, with nothing in status or doctor pointing anywhere. Nothing about routing depends on the install: six worktrees on a four-core machine went from the last vhost landing seventeen seconds in to all six landing in twenty-four milliseconds. The vhost that scan wrote also named the parent site's PHP container rather than the version the worktree pins in its own .lerd.yaml, so a branch held back on an older PHP was served by the newer one after any watcher restart.

internal/watcher/
05

Host Tools

FEATFIXSEC

The client shims exec their tool inside a throwaway container joined only to the lerd network, so a hostless psql -l routed correctly while an explicit -h 127.0.0.1 at the port lerd published always failed, because inside that container, loopback is its own. That is also the documented way to reach a lerd database from the host, so it hit anyone running psql, pg_dump, mysql or mysqldump by hand or from a script.

what you typedwhere it went beforewhere it goes now
psql -llerd-postgres, internal portunchanged
psql -h 127.0.0.1 -p 55432the throwaway container's own loopback → refusedlerd-postgres, internal port
psql -h db.example.comstraight throughunchanged
psql postgres://user@host/dbread as no host → local default, carrying lerd's admin PGPASSWORDstraight through, host intact
SEC

A URI is never ours to touch #1235

The host decision had moved to a flag-only test, so a connection URI or a libpq conninfo string read as no host at all and fell into the local-default branch, arriving at an external server with lerd's own credentials. One function owns the whole question now, answering for both shapes before it looks at a single flag.

internal/shims/
FEAT

A path the container cannot see #1321

PhpStorm runs its quality tools against a temp copy of the buffer under /tmp, which lerd deliberately never mounts, so phpstan started fine and reported the path did not exist. Such arguments are staged into a scratch dir and the paths rewritten back in the output, in both the plain and the json_encode escaped shape, and a copy the tool edited in place is written home, which is what keeps php-cs-fixer working rather than silently discarding its fixes.

internal/cli/php_path_args.go
FEAT

Shims off the PATH, on request #1282 #1295

Some prefer a bare php pointed at a host install, and the only way was deleting the rc line by hand, which the next install wrote straight back. lerd path:disable persists it. Making the promise true took a fix: lerd run and MCP commands_run handed framework command strings to a shell without prepending the shim dir, so they died on php: command not found while the dashboard ran them fine.

internal/cli/path_toggle.go
FIX

npm globals belong to you, not to lerd #1282 #1269

The npm shim forced npm_config_prefix to lerd's node-global dir for every run, so npm install -g landed inside ~/.local/share/lerd, invisible the moment the PATH shim came off, and deleted outright by an uninstall with data removal, which nobody expects from removing a dev tool. A prefix you configured yourself now wins, and globals that did land in the managed prefix are listed as name@version specs and offered back rather than lost. Separately, the wrappers activate Node through fnm or nvm, which clobbers the prefix, so it is injected after activation and shell-quoted, since a data directory containing a space split into two words and env took the tail as the command to run.

internal/node/
06

Trust

FIXSEC

A certificate can sit in the macOS keychain with its trust settings cleared independent of the certificate item itself, which is most commonly what a system update does. security find-certificate reports that identically to a genuinely trusted CA, so lerd treated a reinstall as a no-op, skipped the sudo announcement, and left every site's certificate invalid in the browser while podman and the vhost looked completely healthy.

ASKING THE RIGHT QUESTIONpresence is not trust, and the tool is not the store
macOS
find-certificate → present
says nothing about trust
──►
macOS
trust-settings-export -d
keyed by SHA-1 fingerprint
browsers
certutil is on PATH
a question about the tool
──►
browsers
NSS + every Firefox profile
compared against the CA we sign with
mkcert's own "already installed" self-check cannot notice this, it verifies the self-signed root against itself, which succeeds cryptographically regardless of trust settings, the same false positive the fix exists to avoid.
FIX

Presence of the entry is the decision #1237

Per SecTrustSettings an absent or empty trustSettings array means trust as a root, which is how macOS records everything add-trusted-cert installs, and only mkcert writes an explicit one. Reading an absent array as untrusted would have had every later install re-run the repair and prompt for authorization each time.

internal/certs/
FIX

The browser check looks in the store #1258

A machine with nss-tools installed but the CA never in the NSS store got a clean bill of health while every browser refused the certificates lerd signs. It compares what the store exports against the CA lerd signs with, rather than accepting any certificate wearing the mkcert name, so a CA regenerated after a profile imported the old one is reported.

internal/certs/browser_trust.go
SEC

Unknown is not untrusted #1237 #1320

An export that could not be read reports nothing rather than untrusted, so a failing security cannot drive a privileged repair on its own. A failed repair is named rather than swallowed, since macOS draws the authorization dialog itself and a run with no window server behind it fails there.

07

Dev Servers

FIX

Serving a dev server on the site's own domain shipped in 1.31.0. Three things it read exactly once, when it started, and never revisited: the scheme, the domains, and the address it tells a plugin to publish from.

FIX

Following the site's scheme and domains #1241

Unsecuring a site left the server still advertising https, so every asset URL named a scheme the site no longer serves and the page arrived unstyled, the exact symptom the base prefix exists to remove. The config follows from the HTTPS toggle and the vhost regeneration every domain change already goes through, deciding on the config's contents rather than on the event, so a vhost rewrite that moves no address never bounces a server somebody is working against.

internal/siteops/devserver.go
FIX

An origin is not a CORS allowlist #1241

A site with several domains serves all of them from one origin, which is inherent, since a dev server can advertise only one. What was not inherent: naming an origin at all makes some framework plugins adopt it as their entire allowlist, in place of a default that accepted any domain of the site, so a page opened on a second domain had every module script refused.

FIX

http://[::]:5173 #1250

A plugin released before Vite grew server.origin ignores it and publishes whatever address the server bound to, which with host: true is the IPv6 wildcard. Nothing can route there, and on a secured site the browser refuses the plain request as mixed content. The one URL a browser actually reaches the server at is watched for on the config's way to disk.

internal/config/devserver.go
FIX

A dev server made its site look like the busiest one on the machine #1243

It answers on the site's own domain under lerd's prefix, so everything it served arrived in the request-timing feed looking like a route the app had served. The static-asset test never stood a chance: the modules a dev server rewrites carry extensions the asset list has no reason to know, and the ones rebuilt on every save ended up reading as the busiest routes on the site. The site usage query had drifted from the shared predicate too, testing only the extension while its comment claimed otherwise, so a dev server made a site look busy in the sites list as well. Both decide through one test of the URI now.

internal/reqstats/
08

Fixes & Docs

FIXDOCS

1.31.0 was tested across distro guests and reviewed end to end, and both passes are in here. What is listed as fixed is what a 1.31.0 install actually runs into, and anything broken and repaired inside this cycle folds into the feature that carried it, since no released version ever had it.

FIX

A snapshot stored as restorable at twenty bytes #1336

The dump was piped into gzip, which succeeds regardless of what happened upstream, so a failed dump became a successful snapshot holding an empty stream that meta.json recorded as restorable. Restore drops and recreates before it reads the dump, so it would have replaced live data with nothing. The guard runs on the restore side too, before the drop, since verifying only at creation leaves every earlier snapshot on disk.

internal/serviceops/
FIX

Ready before the engines were #1336

systemd calls a unit active well before a database accepts connections, so the first request to a database-backed site returned 500. lerd start waits for the engines it started, bounded so a wedged one cannot hold the start hostage. A PHP version switch and secure/unsecure had the same shape: both printed a URL and returned while the old vhost was still answering.

internal/cli/startstop.go
FIX

One directory, two sites #1336

The guard compared canonical path strings, and resolving symlinks only folds the spellings that pass through a link, and a case-insensitive volume, which is what macOS formats by default, spells one directory many ways. Identity is asked of the filesystem now, os.SameFile, which settles case, symlinks and bind mounts together and needs no knowledge of the volume.

internal/siteops/
FIX

The dashboard opened with no sites #1260

A caller that lost the build lock got the cache, which on a cold cache is nil, written out as a zero-byte body, twenty-four of twenty-five concurrent reads after a restart. The client's catch kept the initial value and the dashboard stayed empty for minutes. A caller with nothing to show waits for the build in flight, because answering from a cold cache is a claim about the registry nobody checked.

FIX

Killed before it could report ready #1261

The watcher signalled readiness only after its boot work, which provisions every worktree needing it. An install slower than the unit's start timeout earned a SIGTERM, systemd restarted, and the fresh process began the same install to be killed at the same point. Readiness goes first; raising the timeout only moves the cliff.

FIX

VSCode rejected every MCP tool #1267

Array properties were emitted without the items the spec requires. Two of them are not string arrays, and declaring string elements there would have been worse than declaring none: a strict client sends strings, the handler's map assertion skips every element silently, and the framework comes back saved with no setup and no logs in it.

FIX

Sixteen colours in every container exec #1287

podman forwards none of the host environment, so Symfony Console never saw COLORTERM. TERM stays out of it deliberately: the images carry terminfo only for the xterm family, and Symfony matches it against a pattern that foot and wezterm fail.

FIX

Postgres back on the wrong major #1254

A data dir that outlived its quadlet fell through to whatever the bundled YAML calls canonical, mounting 16 over a cluster written by 18. A preset declares where its engine records the version that wrote its data, and that reading wins over every pin.

FIX

Unexpected release URL format #1298

A repo or org rename inserts an extra redirect hop, and the checker parsed the tag out of the first Location alone, so every pre-rename binary failed. It follows the chain up to five hops until a /tag/ URL appears.

DOCS

Six framework walkthroughs, written from real runs #1289 #1290 #1293

The docs shipped walkthroughs for Laravel, Symfony and WordPress while the store supports nine frameworks. Drupal, CakePHP, CodeIgniter, Statamic, Tempest and Magento close the gap, and writing them from real installs is what surfaced the things a reader trips on: CakePHP ships .env loading switched off, so config/.env is never read and the failure surfaces as a PDO constructor error rather than as anything pointing at the file that needs changing. Site Management, 550 lines answering six questions from one sidebar entry, splits into focused pages with the old URL kept live #1308, and pointing a project at a host-installed database gets its own recipe with the Linux prerequisites spelled out #1288.

docs/