L lerd lerd-env/lerd v1.30.1…v1.31.0 ↗
ENGINEERING DIGEST · 2026-07-24 → 2026-07-29

v1.31.0

AN ADDRESS THAT SURVIVES · Sharing stops being a link you paste once

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.

57
commits
57
merged PRs
442
files
+33,699
insertions
−4,086
deletions
5
contributors
cli 29% web UI 21% serviceops 11% node 6% config 5% rest 28%
01

Sharing

FEATUIFIX

The CLI could already tunnel a site through five tools; the dashboard's share button only ever started the LAN proxy. Both halves close here. The button becomes a menu, a base domain set once gives every Cloudflare share a hostname that outlives the run, and ngrok gains a container route so a machine with nothing installed can still share. The last piece is visibility: a share was only ever visible where it was started, so a terminal lerd share now records itself and the dashboard can stop it.

QUICK TUNNEL → NAMED TUNNEL → BASE DOMAINhow long the URL you sent someone stays alive
quick tunnel
random.trycloudflare.com
dead tomorrow
──►
--domain
one hostname, typed each run
stable, but manual
──►
share:domain
<site>.example.com
set once, every share
The label comes from the site, not the folder, so a scorediviner.test living in a score-diviner directory is shared as scorediviner, and a worktree's subdomain flattens to one label because a Cloudflare certificate covers one level and no more.
set it once, or override for a single runbash
lerd share:domain example.com   # myapp.test is now shared on myapp.example.com
lerd share:token 2abcXYZ...     # lets ngrok run from its image, with nothing installed
lerd share --domain one.off.com # still wins for this run only
FEATUI

A public tunnel from the dashboard #1174

The button opens a menu on hover without giving up its one-click LAN toggle. lerd-ui owns the process, building the same invocation as the CLI through a shared command builder. Tunnels are deliberately not persisted: they end when stopped or when lerd-ui does, and are never resurrected.

internal/cli/tunnelshare.go
FEAT

Your own Cloudflare base domain #1216

The dashboard asks the first time you pick Cloudflare, and ticking remember stops it asking whichever way you answered. A named tunnel needs cloudflared authorized once, which a dashboard cannot do, so it says what to run rather than hanging on a prompt nobody can see.

internal/cli/share_domain.go
FEAT

ngrok from its published image #1225

An installed binary always wins, since pulling an image is the slower route to the same URL. The token is a credential: the config file is tightened to owner-only, it reaches the container through the environment rather than the command line, and the dashboard is told only whether one exists.

internal/cli/share_ngrok.go
FIX

A secured site sent the visitor to a .test domain #1228

ngrok and Expose were aimed straight at nginx, which answers the first request with a redirect to https on whatever Host it was given, so the visitor landed on a domain only the developer's machine resolves. Both go through the same local proxy Cloudflare always used now. That proxy's own rewrite had a gap: JSON escapes its slashes, so an URL embedded in a page payload reached the browser untouched, and an external redirect does not always travel in a Location header.

internal/cli/share.go
02

Dev Servers

FEATSEC

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.

ONE PREFIX, WHATEVER THE HOSTNAMEnothing to rewrite, because the client works it out
before
http://localhost:5173/x.js
meaningless to a visitor
──►
generated config
base · origin · allowedHosts
serve only, never build
──►
after
/@lerd-vite/x.js
assets and HMR on one location
Nothing in the project is edited, lerd writes a generated config next to the project's own and imports it, rewritten on every start since a worktree seeds node_modules from its parent by reflink and would otherwise inherit the parent's domain.
FEAT

Served on the site's own domain #1218

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.go
FEAT

A pinned port, kept clear #1218

The 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.go
SEC

Every value encoded, not quoted #1223

The 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.

03

Node

FEATFIX

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.

whichever manager is active drives all of itbash
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
FEAT

nvm drives Node alongside fnm #1113

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.

internal/node/nvm.go
FIX

Host workers could not find an unmanaged Node #1183

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.

internal/node/system.go
FIX

One Node question, answered by detection #1192 #1196

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.

04

Install & Packaging

FEATFIX

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.

ONE PRIVILEGED PATH, TWO FRONT DOORSsame machine state however lerd got on
interactive
sudo lerd bootstrap --system
re-execs itself, up front
──►
sysctl · linger · sudoers
the root-level steps
one implementation
◄──
package
lerd install --unattended
no questions, PPA maintainer script
All the privileged work happens first, so someone who cannot use sudo finds out immediately rather than several minutes in, and a host where the re-exec cannot run falls back to the old per-step path.
FEAT

Unattended package installs #980

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.

internal/cli/install.go
FIX

Every route applies setup through bootstrap #1194

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.

internal/cli/uninstall.go
FIX

The trust store is found the way mkcert finds it #980

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.

05

Databases

STOREFEATFIX

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.

A PROVIDER DUMP, END TO ENDerrors that were never the user's mistake
as taken
320 errors
80 of them ownership
──►
filtered in
owners · DEFINER
what --no-owner does at dump time
──►
--fresh
empty first
no collisions to drown in
──►
landed
11 errors, 44/45 tables
what was filtered rides back
Row data is never rewritten, a postgres 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.
STORE

Entities and actions declared in the preset #1181

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.go
FIX

A store engine reaches the tab #1220

The 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.go
FEAT

An engine declares its extensions #1139

pg_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.

FIX

The drop lost a race with the site's pool #1141

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.

FIX

The COPY cascade went uncounted on pg 18 #1142

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.

FIX

Every distinct error reaches the modal #1131

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.

06

Host Tools

FEATUISEC

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.

PINNED, VISIBLE, APPLIEDa broken pin is one commit, not a release
manifest
fetched, daily cache
embedded as offline fallback
──►
download
retries · sha256 · temp file
host-constrained URLs
──►
stamped
version sidecar
status · doctor · MCP
──►
applied
from the card that flagged it
loopback only
Offline never reads as nothing to update, a fetch that cannot reach the endpoint keeps the cached pins rather than falling back to the embedded ones.
FEAT

Pinned behind a published manifest #1185

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/download
UI

Applied from the dashboard #1212

Each 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.

internal/ui/tools.go
SEC

A pin could name any host #1204

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.

07

Linking & Frameworks

REFACTORFIX

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 projectsbeforeafter
50 projects92s3s
300 projectsunbounded per-project reloads16s
REFACTOR

One policy-driven path #1159

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.

internal/linker
FIX

A link damaged the definition it found #1160

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.

FIX

lerd new, four ways it failed #1161 #1167 #1170

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.

08

macOS

FIX

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.

FIX

Host binaries resolve through one place #1214

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/hostbin
FIX

A tunnel could outlive its owner #1214

systemd 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.go
FIX

Homebrew paths, three ways #1214

The 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.

09

Security

SEC

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.

WHAT A REPOSITORY COULD REACHthree sinks, one guard
node_version
worker unit command line
unquoted by fnm
custom_workers
label · restart · schedule
systemd ran an added ExecStartPre
domains
vhost server_name
nginx accepted a second server block
Paths are treated separately, since they reach the template quoted and a directory legitimately named with a semicolon still has to serve.
SEC

A project decided what ran #1204

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.

SEC

A sudoers drop-in for an unvalidated name #1204

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.

SEC

postcss and dompurify advisories #1206

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.

10

Fixes & Docs

FIXUI

The long tail, where most of the week actually went.

FIX

Directory commands dead-ended in a worktree #1200

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.

FIX

php84 was stored verbatim #1178

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.

FIX

A store preset stayed stale in lerd-ui #1184

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.

FIX

Custom services inherited /etc/hosts #1105

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.

FIX

A changed worker unit was started, not restarted #1175

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.

UI

The worktree base branch picker #1227

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.

Also in: the tray menu groups into submenus and summarises per-site workers, taking the top level from about thirty five rows to twelve #1198, a republished PHP base image surfaces as an update on the version #1188, the N+1 warning always names where the duplicate queries ran #1180, a never-migrated database reports a finding rather than unknown #1179, the php.ini tab edits the version and shared file side by side #1177, the tinker split flips direction and goes full screen #1166, a service dependency is satisfied by an installed drop-in #1148, 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.