A quick tunnel hands out a fresh URL every run, which is fine for showing someone a page and useless for a webhook or an OAuth callback. Set a Cloudflare base domain once and every share is served on <site>.<domain> through a named tunnel, ngrok runs from its published image on a machine that never installed it, and a lerd share running in a terminal finally shows up in the dashboard like one of its own. The reason to share is usually to have someone else look at it, so a running dev server is served on the site's own domain instead of advertising a localhost port only you can reach. Underneath, installing became one job again through lerd bootstrap, nvm drives Node alongside the bundled fnm, databases keep moving out of Go and into the store, and every tool lerd downloads is pinned behind a published manifest you can now apply from the System page.
A dev server advertises its own address, so a Vite app renders asset URLs pointing at localhost on a port only this machine can reach. Share the site, open it from the LAN, or front it on a worktree subdomain, and the visitor's browser resolves those URLs against their own machine, so the page arrives unstyled. The share proxy could not fix it, because it only rewrites the site's own domain and the dev server's URLs never carry it.
node_modules from its parent by reflink and would otherwise inherit the parent's domain.Nothing is declared per framework. A worker qualifies when the project has the tool installed and the command starts it directly, following one level of package-manager script indirection. A command that only reaches the tool through a runner is deliberately not matched, since the appended flags would land on the wrong process.
internal/config/devserver.goThe vhost proxies to it, so the tool cannot be left to drift to the next free port whenever several sites run. It is kept clear of other sites, of the site's own worktrees and of whatever else the machine is holding, and a pin something has since taken is re-picked rather than left to fail.
internal/nginx/manager.goThe generated config is JavaScript the tool executes, and its values sat between quotes the template supplied. A project-supplied domain carrying a quote would have closed the literal. Values are encoded as literals now, and a quote is refused in a hostname at registration, so the sink is not the only guard. No released version could hit this either: the generated config arrives with the dev server, both in this release.
Node version management was fnm and nothing else, so a developer who already keeps their versions in nvm had lerd download a second manager and install its own copies beside theirs. A node.manager setting now chooses between the two behind one interface, and every surface that touches Node goes through the active manager rather than calling fnm directly.
lerd node:manager nvm # hand Node back to the nvm you already have lerd node:manager fnm # or keep the bundled one, downloaded on demand
lerd treats a user's nvm as read only: it never installs into it and never rewrites its default alias, keeping lerd's own default in lerd's config instead. It leaves the node, npm and npx PATH shims out entirely under nvm, since a shim ahead of nvm made nvm use re-enter it and hang.
A host worker unit runs against a rebuilt minimal PATH, so a node under snap, linuxbrew or a version manager was invisible and every npm worker died on npm: command not found while node worked fine in the terminal. The generators resolve where node and npm actually live and bake that into the unit.
An install that finds nvm asks once whether lerd should manage Node. The unattended path answers by detection rather than by reading shim state, which on a first install describes nothing, so a package install provisions Node instead of recording it as unmanaged.
Debian and Ubuntu users install from a PPA, where a maintainer script runs as root and cannot answer the sudo prompts a normal install relies on. That produced two implementations of the same four privileged steps, which had already drifted apart. Both routes now converge on lerd bootstrap.
Every unattended-only step is gated behind the flag, so an ordinary install is unchanged. The unit templates no longer hardcode ~/.local/bin/lerd, so a daemon starts from wherever the package put the binary, and lerd update recognises a binary under /usr and points at the package manager rather than fighting apt.
Uninstall gains the other half: the CA bootstrap installs is named for lerd, while uninstall called mkcert -uninstall, which only recognises the filename mkcert itself wrote. Nothing ever deleted lerd's anchor, so uninstalling left a trusted root certificate on the machine permanently.
bootstrap --trust-ca detects the anchor directory across the Fedora, Debian, Arch and openSUSE layouts, and reports where the CA lives on a distribution with no writable store rather than failing the maintainer script outright.
The Databases tab read its list from a preset, but everything else it did was a hardcoded mysql, mariadb or postgres switch in Go. An introspect block now declares entities, each naming the command that lists it with typed columns and the commands behind every action it supports, including which are destructive so the UI confirms first. A store engine gains any of these through a publish, with no binary release.
COPY block passes through byte for byte between its header and its closing marker, and on mysql only DDL lines are touched, so a value holding the word survives.One generic runner validates the entity name before substituting it into the declared command and derives the capability flags from what the engine declares. MongoDB picks up the whole tab this way, and RustFS lists, creates and deletes buckets through an ephemeral client container.
internal/serviceops/entities.goThe UI decided what counts as an engine from a fixed list of four families, so an engine published with a perfectly good introspect block stayed invisible. It asks the engine itself now. What lerd may offer as a project's primary database is left alone, since an analytics column store has no business being offered there.
internal/ui/databases.gopg_dump skips extensions the connecting user does not own, so a vector column arrived with no CREATE EXTENSION anywhere. An entry marked always is created wherever lerd creates a database; the rest wait until a dump names one of their types, so a geometry column brings PostGIS without every database paying 15 MB for it.
The drop terminated other sessions first, but that session is the site's own pool, which reconnects the instant it is closed and is back before the drop lands. One statement closes and drops with no gap to reconnect into.
Dumps from pg_dump 18 open with a restrict line, in which mode psql refuses backslash commands, so the rows changed their complaint and the tally recognised only the older phrasing. One import reported 6 errors where 48 had gone past.
The modal showed five and closed on a count of what it would not show, and the fifth is rarely the one that explains the load. Up to 500 distinct complaints are kept. Separately a .sql.gz upload went straight to a client that reads plain SQL and exited clean having done nothing.
What a fresh install downloaded depended on the day it ran, because fnm and mkcert came through GitHub's releases/latest redirect and composer through its stable channel, so an upstream release could break every new install overnight. Every binary also went through a one-shot request with no retry and no timeout, so a momentary CDN hiccup aborted the whole install.
Composer, fnm, mkcert, phpantom_lsp and the self-update archive share a helper that retries transient failures with a short backoff and cancels a stalled transfer into the retry path instead of hanging forever. A scheduled job checks upstream daily and opens the bump PR.
internal/downloadEach card owns its result, which is what keeps this per tool rather than one button for all three: a download that fails its checksum is refused deliberately, and the card that asked has to name that tool. A stale tool raises an update_available notification, seeded quietly on a process's first snapshot.
The manifest is fetched from a branch rather than shipped in a release, so it reaches every install without one, and a pin was honoured as long as its URL began with https. A pin is now taken only when it points at a host lerd fetches tools from, may carry a sha256, and lands in a temp file moved into place only once complete.
Registering a directory as a site was implemented three times: the full one behind lerd link, a thinner copy the parked-directory watcher used, and a third inside the MCP server that inlined the container and PHP paths. They had drifted badly. A linker package now owns the decision half and returns a plan a second step carries out, and what separates the callers is a policy rather than separate code.
| Parking a tree of projects | before | after |
|---|---|---|
| 50 projects | 92s | 3s |
| 300 projects | unbounded per-project reloads | 16s |
The MCP copy had no name deduplication, no domain-conflict fallback, no PHP clamping and no worktree check, and the park copy read nothing from .lerd.yaml beyond the domain list. Parking now writes each vhost and unit and performs the reloads once for the whole batch rather than per project.
Detecting a framework wrote to the store, including on calls that were only reading, and a project commits only the fields it cares about, so a copy carrying no detection rules left the shared definition with none. After that, nothing detected that framework for any project on the machine.
It looked composer up on the caller's PATH, refused a framework the store publishes but the machine never installed, and scaffolded under the machine default rather than the framework's supported range, leaving composer rejecting every candidate or an empty vendor that only showed up as a 500.
launchd hands lerd-ui and lerd-watcher a PATH of /usr/bin:/bin:/usr/sbin:/sbin, so a tool installed with Homebrew is invisible to them while it works fine in a terminal. That one blind spot reached further than it looks: the share panel reported every tunnel tool as not installed, and the same gap decided which Node a host worker unit runs.
PATH first, then the install prefixes a daemon never sees, and the resolved path is what gets executed, since the binary has to be found at exec time too. A unit written by the CLI resolved the Homebrew node while the same unit written by the dashboard fell through to whatever nvm install was lying around.
internal/hostbinsystemd kills the control group and Pdeathsig covers a non-systemd run; macOS has neither, and a tunnel is deliberately in a process group of its own. Killing lerd-ui left the site public with nothing left to stop it. Since SIGKILL cannot be caught, what is running is written down and reaped at the next start, signalling a pid only while the process under it is still the recorded one.
internal/cli/tunnel_reap.goThe daemon units pinned themselves to the versioned Cellar path, which the next brew upgrade deletes, so they keep Homebrew's symlink instead. Update and uninstall offered an apt or dnf command that does not exist on a Mac. And an nvm installed with brew went undetected, since brew keeps nvm.sh under its own prefix.
A review of the whole cycle turned up three findings that let a checked-out project decide what ran on the machine that linked it, through values it commits in its own .lerd.yaml. All three predate this cycle. Values are now checked where the file is generated rather than field by field, so a field added later is covered without anyone having to remember.
A semicolon and a pair of braces close a server block and open another, and nginx accepted the result as valid, so a project served a block of its own on a hostname it chose. Anyone on 1.30.1 or earlier was exposed.
bootstrap --system wrote the file for whichever user it resolved, and that name became the first field of every rule with nothing checking it, where a space alone produces a drop-in sudo rejects and loses the DNS grant. The name is checked and the rendered file goes through visudo before it is installed. No released version could hit this: bootstrap was written in this same cycle, so the flaw only ever existed in a 1.31.0 beta.
postcss allowed a path traversal through its previous-source-map auto loading, reaching both the dashboard build and the docs site, and dompurify let a custom element bypass afterSanitizeElements, reaching the dashboard through the Monaco editor behind the tinker and config editors.
The long tail, where most of the week actually went.
The resolver looked a directory up by exact registry path, so inside a checkout it always missed, then offered to link, which correctly refused. The advice could never succeed no matter how many times it was followed, taking out share, open, domain, env, worker, runtime, stripe and xdebug pause at once.
lerd use wrote it straight into the global config, after which the pre-built base resolved to a repository the registry answers with 403. The command still reported success. One normalizer reduces every spelling to major.minor, and loading the config repairs an already stored bad value.
Parsed presets are memoised in a cache only invalidated from the process that saved them, and the CLI rewrites those files from its own process. The entry records mtime and size now, which also fixes the fresh-install ordering where the daemon starts before the store files exist.
Without an explicit mount podman derives a container's hosts file from the host's own, so a line mapping a service name to loopback shadowed the container name DNS would resolve. phpMyAdmin, pgAdmin and mongo-express all connect by container name and reached their own loopback.
The start path rewrites the unit, reloads, re-enables and starts, which is a no-op on an already-active unit, so lerd reported the dev server as started while the old command was still running.
An empty base ref means start from HEAD, and the dropdown decided what to render from the value alone, so the entry could never show its label and a good selection looked ignored. Clearing the branch name to retype it also threw away the base you had chosen.
expand_env writes one numbered variable per family member #1176, a JetBrains project points at its own lerd database #1145, resetting a non-default version's php.ini no longer leaves its FPM unable to start #1127, a client shim passes a bare --version probe through untouched #1129, the Logs tab is offered when a worker is a site's only source #1111, a proxy-only site shows as running when its port is listening #1109, the service Databases and Contents tabs show a loading state #1187, and the docs cover Laragon for Linux across the site and landing page #1190.