L lerd lerd-env/lerd v1.29.0…v1.30.0 ↗
ENGINEERING DIGEST · 2026-07-16 → 2026-07-22

v1.30.0

ONE SET, EVERY IMAGE · What a PHP version owns keeps shrinking

Custom extensions and packages stop belonging to a PHP version and become one declared set applied to every image lerd builds, with a realised set read back off each build so an extension a version cannot carry is reported absent rather than falsely present. php.ini follows with a shared scope that fills the gaps under each version's own file, so a site changing version no longer silently loses a setting. The dashboard reaches all the way down to the data: an engine's databases are managed from its service page, with snapshots, export, import and one click into an admin tool, on the back of a single store-declared introspect query. lerd doctor stops merely reporting and learns to repair what it safely can, notifications grow a native desktop sink and a bell that keeps them, logs render in colour and hand themselves to a terminal, and git worktrees finally resolve their own PHP version, image, pin and database from wherever a command is run.

78
commits
78
merged PRs
491
files
+25,425
insertions
−4,293
deletions
3
contributors
web UI 46% cli 12% podman 7% dns 5% config 4% rest 26%
01

PHP Version Sets

FEATFIX

Extensions, packages and php.ini were all stored per PHP version, which quietly punished the one thing lerd makes easy. A site built under 8.4 with chromium dropped it the moment the site moved to 8.3, and a directive set on one version simply did not exist on the next. Both now sit at a scope above the version: one declared set of extensions and packages applied to every image lerd builds, and a shared 95-lerd-shared.ini mounted below each version's own file. Neither is a merge, and a per-version value still wins.

DECLARED SET → BUILT IMAGE → REALISED SETwhat you asked for vs what an image can carry
you declare
redis · mongodb · chromium
once, not per version
──►
lazy rebuild
fingerprinted by the set
next time the version is used
──►
read back
realised set per image
mongodb absent below 8.1
──►
reported
absent, not present
php:ext list · PHP page · doctor
A set that cannot always be honoured — the legacy Alpine tiers and older versions genuinely cannot carry every entry, so lerd records what each built image actually has rather than advertising the declaration and letting a site fail at runtime.
one set, applied everywhere, reported per versionbash
lerd php:ext add redis        # declared for every version, not just the current one
lerd php:ext list            # the declared set, with per-version reality beside it
lerd php:ini shared          # the scope that fills the gaps under 98-lerd-user.ini
FEAT

Extensions belong to you, not to a version #943

One declared set across every image, a realised set read back per build, lazy fingerprinted rebuilds, and one shared path for a version change so the CLI, dashboard and MCP stay in step. The dashboard gains a PHP Extensions tab.

internal/phpsets
FEAT

A shared php.ini scope #944

conf.d loads alphabetically, so 95-lerd-shared.ini fills the gaps under the per-version 98-lerd-user.ini and a per-version key still wins. Additive, nothing merged, back-filled on the next quadlet write. An unknown directive on one version is a notice, not a fatal.

internal/phpini
FIX

The fast path had no toolchain #1044

php:ext add failed for every extension on the default path: the fast path layers onto the runtime image, which carries no compilers, so pecl install stopped at phpize. Compilers now install, build and purge inside one layer. A || true per step had been absorbing the total failure as if it were a capability boundary.

02

Databases

FEATSECSTORE

Every database-level operation was reachable only from the CLI and the MCP db tool, so there was no way to see your databases at all. Each engine's detail page now opens on a Databases tab: what is actually inside the running container, as cards with their sizes, each one able to create, drop, export, import, snapshot, copy a connection string, or open in an installed admin tool. The one new capability underneath is introspection, and it is declared in the service preset as introspect.list_databases and run generically, so nothing in Go branches on the engine name and a new engine works as soon as its preset ships the query.

PRESET QUERY → ENGINE → CARDSno engine name anywhere in Go
service preset
introspect.list_databases
YAML, ships without a release
──►
running engine
names + sizes
template1 baseline netted off
──►
owned by
site · branch · group main
_testing folds into its parent
──►
loopback only
tab hidden over LAN
reads data out and overwrites it
The size is your data, not the engine's overhead — every postgres database inherits roughly 7.5 MB of system catalogs from template1, so an empty database now reads as empty, matching what MySQL always reported.
FEAT

Manage an engine from its page #966

Create, drop, export to a plain SQL dump, import one, copy a per-database connection string, open an admin tool. Snapshots live on the card of the database they belong to, named ones stamped with a UTC timestamp so they never collide.

internal/ui · internal/serviceops
FEAT

Agents can ask what an engine holds #1007

The MCP db tool could create, export and snapshot but never list. A list action resolves the engine the way the snapshot actions do, so naming a service picks it and omitting it falls back to the project's own database.

internal/mcp
FIX

A half-failed import claimed success #1056

psql exits 0 whether a dump loaded cleanly or every statement failed. Imports now count what the engine wrote and keep its errors in order, folding the COPY cascade that follows a missing table into one line so it cannot crowd out the cause.

SECURITY · FIXED BEFORE STABLE
The database endpoints took the database and name values straight from the request, and two sinks downstream assumed they had already been slugged. The snapshot handlers joined the name into the snapshot path, so a traversing value resolved outside the snapshot root and reached os.RemoveAll on a real directory, with directory creation and arbitrary dump reads available the same way. Names are checked against a strict pattern now, at the HTTP boundary and inside the snapshot entry points, so MCP and CLI callers share one guard. The MySQL and MariaDB existence lookup also escaped the quote but not the backslash, and MySQL treats a backslash as an escape unless NO_BACKSLASH_ESCAPES is set, so a backslash escaped the doubled quote and the rest ran as a second statement. Separately the whole subtree answered the LAN with remote-control credentials, letting an authenticated client on the same network export a database in full or drop it; it now sits behind the same loopback gate as a site's raw .env, and the tab is not offered at all on a LAN-exposed dashboard. Both arrived with the Databases tab during the 1.30 betas and were fixed before this release, so no stable version carried either. #1008 #1082
FIX

Migrate reached a stopped engine #1032

A service no site uses is auto-stopped, which is the normal state to press Migrate from, but the migrate went straight to dumping and failed on "no such container". It brings the unit up and waits on the family's readiness probe first.

FIX

MariaDB has no mysqldump #1025

The mysql-family migrate spelled its dump as a literal mysqldump, and the MariaDB images carry only the mariadb-named binaries, so a MariaDB migrate died on the dump before the data dir was touched. The three in-container commands resolve their tool at runtime now.

03

Doctor & Repair

FEAT

Doctor told you what was wrong and left the fixing to you. Every finding now carries a fix in one of three tiers, and the tier is the whole contract: auto is lerd's to run and needs no privilege, manual needs sudo so lerd prints the exact command and never runs it, and the rest is external state it leaves alone with a hint. Fixes confirm one at a time, the heavy ones confirm even under --yes, and --dry-run shows what would happen without touching anything.

THE THREE TIERSwhat lerd will and will not do for you
1
auto · no privilege

create a missing directory, enable linger, install the network-online drop-in, rebuild a missing PHP image, repair the DNS wiring, and after confirmation the heavier reinstalls and disk reclaims #958

2
manual · needs sudo

lerd prints the command and stops. Two fixes had been shelling into paths that write the resolver and the podman config through sudo, so under --yes an assistant could have rewritten systemd-resolved unattended #1010

3
external · hint only

state lerd does not own. The MCP diag tool gains a doctor_fix action and reads the same structured report through a new doctor --json, so the checks and their tiers live in one place

The estimate matches the reclaim — the reclaimable-disk finding was sized with the safe scope while its fix ran the deep one, so you consented to one number and got another. Both resolve through one helper now #1011.
FEAT

Repair, not just report #958

Every finding carries a tiered fix, with --fix, --yes and --dry-run, plus a doctor_fix MCP action over a new doctor --json.

internal/doctor
FIX

A LAN IP is a valid DNS answer #1092

The rungs treated 127.0.0.1 as the only acceptable answer, so a healthy lan:expose host where dnsmasq legitimately answers the LAN IP was reported broken while every site served fine.

FIX

The site doctor opened onto nothing #945

On a host-proxy running Python, Ruby or Go there is no framework and no lockfile, so every check self-skipped and the modal opened empty. It keys on whether any check applies now, which still catches a Node host-proxy's package.json.

04

Notifications & Desktop

FEATUI

Notifications only reached browsers, so a user with no dashboard open and no push subscription got nothing, even though the daemon runs the whole time. lerd-ui can now post straight to org.freedesktop.Notifications with no new dependency, as a mutually exclusive sink so it never doubles up. The other half is that a notification raised while you were working inside lerd used to vanish: everything now lands in the page as a toast and in a sidebar bell that keeps the last fifty across reloads.

ONE EVENT, ONE DESTINATIONfocus decides, the bell always keeps it
event
migration failed
raised by the daemon
──►
window focused?
skip the desktop
per-connection, not per-tab
──►
sink
native · or browser
notifications.target
──►
always
toast + bell
last 50, survives reload
A popup for something already on your screen is noise — while any window has focus the desktop post and the Web Push are skipped, the event still rides the socket to the page, and delivery resumes the moment you switch away.
FEAT

A native desktop sink #968

Chosen by notifications.target, unset resolving to browser so every upgrade is unchanged. Clicking one opens the Lerd desktop app through its lerd:// scheme, or an installed PWA, falling back to the dashboard.

internal/desktopnotify
FEAT

The Lerd desktop app #968

Lives at lerd-env/lerd-desktop and ships as a Flatpak, served from lerd.sh so flatpak install --user https://lerd.sh/lerd.flatpakref resolves and flatpak update keeps it current.

FEATUI

A bell that keeps them #1036

Fifty entries with an unread count, each linking back to what it was about. A failure stays until dismissed and is shown even when notifications are muted, since an operation you started failing is not something to swallow.

FIX

Most worker alerts described a recovery #1018

Worker units restart themselves after five seconds, and the watcher's five second tick plus a five second batch put the notification right in the middle of the window systemd uses to fix the thing itself. A new failure is held for thirty seconds and health re-read before dispatch, so recovered workers drop out and the rest are described as they actually are. Arriving at a green dashboard teaches you to ignore the ones that matter.

FIX

Unbounded D-Bus calls #1018

Every call used the zero flag, so a wedged notification daemon blocked the caller forever: one stuck goroutine per event through the mailpit webhook and the service handlers, and a held lock that left a PHP version unable to be reinstalled without restarting lerd-ui. All calls give up after three seconds, and discovery no longer autostarts a dbus-daemon that outlives lerd over SSH.

05

Logs & Debug

FEATUI

Log panes have always been monochrome, and the renderer was never the reason. Workers and UI commands write to a pipe, a launchd file or the journal, so artisan, composer, vite and pest see no terminal and strip their own colour before lerd ever reads the line. One definition of the force-colour environment now travels with every path that starts a worker or a command, and NO_COLOR turns all of it back off. On the debug side, a test suite could flush every genuine dump out of the buffer, and the only lever was disabling the bridge entirely, before the run rather than after noticing.

WHY THE COLOUR WAS NEVER THEREthe tool stripped it, not the renderer
artisan · composer
is this a terminal?
pipe · launchd · journal
──►
answer: no
colour stripped at source
before lerd sees a byte
──►
force-colour env
on every start path
quadlets · exec · units
──►
SGR parser
256 + 24-bit + CR frames
progress bars collapse
A terminal button per pane — the stream path goes to the daemon, which resolves it back to its unit and tails it with podman logs, tail or journalctl, so a long tail survives closing the tab. Loopback only, like every other host-spawning action.
FEATUI

Colour, and a real terminal #1065

Backgrounds, dim, italic, underline, the 256 colour cube and 24-bit colour, with carriage-return progress frames collapsed the way a terminal does instead of concatenating every frame npm emits.

internal/logcolor
FEAT

Test runs tagged and hidden #1048

Keyed on PHPUnit's own bootstrap constant, so it holds for any framework and any runner built on it, set at all three capture seams. A checkbox brings them back and says how many are held back. Dumping inside a failing test is arguably when dd() earns its keep.

FIX

A terminal command had no site #1041

The devtools extension reads LERD_SITE and lerd php never passed it, so every query, job and event from a terminal command arrived unattributed while dumps quietly guessed one from the directory name.

Debug viewBeforeAfterPR
Lens renderingEntire filtered array painted at once, up to 10,000 events held and 3,000 replayed on connectNewest 100 rows, next 100 on scroll, across all eight tabs and both the system window and per-site tab#1039
Stream updatesEvery event rewrote the store and changed its identity, so one request's 7–18 events rebuilt every mounted lensOne update per animation frame; haystacks and SQL fingerprints cached weakly by event identity#1052
Measured13s blocking time, 5 fps (300 requests, Queries lens open)No blocking time, steady 60 fpsLaravel site
06

DNS & Resolution

FIX

On systemd-resolved hosts the resolver refuses to answer anything once no link is routable, over glibc NSS as well as resolvectl, so a fresh .test lookup failed with no network at all even though lerd-dns kept answering on loopback. The tell was a site that stayed reachable while the browser held a cached answer and failed the moment it was reopened. lerd now owns an always-up dummy link carrying the routing domain, so resolved keeps forwarding .test to lerd-dns offline.

THREE WAYS .test WENT DARKoffline, after a sleep, and after an uninstall
1
No routable link

An always-up lerd0 dummy link carries the routing domain. It removes the global drop-in it supersedes rather than leaving a catch-all that hung every ordinary offline lookup on an upstream that was not there, and it is best effort: a host that cannot carry it loses offline resolution and nothing else #936

2
Woken from sleep

The watcher logged "DNS resolution broken" every thirty seconds without being able to act, because its repair only rewrote the host resolver, which cannot help when the daemon it points at is gone. It asks dnsmasq directly now and restarts the unit, ahead of the privilege gate. The dispatcher's restart-per-interface also blew the default start rate limit on a wake and parked the unit in failed #1088

3
Uninstalled

Uninstall left every root-owned piece behind and said nothing: the link unit stayed enabled so every boot recreated lerd0 pointing at a dnsmasq that no longer existed, FallbackDNS stayed empty for good, and a passwordless sudoers grant survived the tool it was for. Once the binary was gone nothing could undo any of it #1022

The guard only covered the first run — a host that had lerd0 and later lost it kept the drop-in from the run that worked, leaving fallbacks off and no link, which is precisely the state the guard exists to prevent. The failure path hands the fallback servers back now.
FIX

An http-level directive could not be overridden #1072

Setting client_max_body_size in the global http override made nginx refuse the whole config with "directive is duplicate", since the file is included inside http {} where lerd already sets it and nginx fails on a repeated simple directive rather than letting the later one win. The render now yields the field for any directive the override declares, name-driven rather than a list of special cases.

FIX

…except the repeatable ones #1082

log_format and access_log are repeatable and nginx is happy to carry both, so commenting lerd's log_format out left its access_log naming a format nginx no longer knew. The config check failed and every site went down. Both are exempt now, which also keeps the access feed that idle-suspend and request timing read.

07

Worktrees

FIX

A git worktree checked out inside its parent site's directory matches that site by path prefix, and four separate lookups stopped there. Each failure was invisible on its own and the combination was worse: the browser served the branch from one PHP version and one image while the terminal in the same directory ran another. All four now resolve the checkout the same way, and the rules live in one place that the CLI and the MCP server both delegate to.

LookupWhat it resolved toThe symptomPR
PHP versionParent's registry version, while the vhost was generated from the worktree's pinBrowser served the branch on one version, lerd php and lerd composer ran another#999
ContainerShared per-version container for a sibling checkoutA site running its own image from a project Containerfile had nothing that image adds present in the branch#1001
Version pinParent site rootDeclining an install and picking another version wrote .php-version at the parent and left the branch with no pin at all#1021
Database shimParent's DB_HOSTA branch with an isolated database could be dumped from the parent's data; a branch on a different engine hit the wrong container#1024
Inherit-by-default is unchanged: a worktree with no pin still follows its parent, and a registered site still wins over its own dotfiles so the framework clamp applied at link time is never undone. Switching a worktree's version now also runs the same runtime setup a site switch does, where before it wrote the pins and the vhost and left a branch moved to a version with no quadlet on the machine pointing at a container that was never created.
08

Idle Cost

PERF

Five background costs ran at full rate whether or not anyone was looking, and on macOS each one crosses into the podman VM, which is where the cost actually lands. None of this is visible as a feature; it is the difference between a daemon you leave running and one you notice.

WhatWasNowMeasuredPR
Worker healthOne launchctl fork per unit every 5s, forever60s while nothing is visible, 5s while a tab is−83% idle CPU (25 workers)#1073
Stats poll5s poll against a 3s cache, so every poll missed and paid a 2s podman stats streamOne TTL named beside the data, honoured by dashboard and TUI alike31% → 17% of wall clock#1074
Container cachePolled podman ps forever after lerd stop, for containers deliberately downSettles on the post-teardown state, then quiet until the marker clears7 polls → 1 (150s window)#1079
Unit-state sweepOne podman inspect subprocess per unit, cold from every CLI and MCP callOne container snapshot up front, every unit answered from it930ms → 155ms (MCP, 25 workers)#1075
Reload watcherchokidar re-stat'ing every watched file 10× a second across virtiofsOnce a second, doubling on battery and again in low power mode~30% → 11.8% host VM (reported at 161%)#1078
THE CADENCE FOLLOWS THE MACHINEpower state read over D-Bus, not from a file that isn't there
UPower
mains or battery
system bus, bounded
──►
power-profiles-daemon
power-saver toggle
both bus names tried
──►
interval
1s · 2s · 4s
baked into the unit
──►
unplugged mid-session
polling workers restart
one round per 5 min
The sysfs file it used to key on is simply absent on much hardwareplatform_profile exists only where the ACPI driver does, so on an AMD desktop or an older Intel laptop the low-power tier was unreachable no matter what the user selected.
FEAT

Profiling a running daemon #1076

There was no way to see where lerd-ui spends its time on a user's machine, so every CPU report above was an inference until it was a measurement. The standard Go endpoints now sit behind two gates: a marker file read per request, since the process worth profiling is usually one misbehaving right now and restarting it would discard the state being chased, and loopback, since these hand out goroutine stacks and heap contents. Both answer 404 rather than 403.

internal/ui
09

Services & Surfaces

FEATUI

The services list had grown long once a project pulled in databases, caches, search and mail side by side, and a service a project asks for but never had looked identical to one that is simply stopped. Both are driven by the preset, not a name in Go: grouping reads each service's declared category, and the install offer resolves the bundled preset that matches.

FEATUI

Grouped by service type #964

Labelled sections in the same order the discovery grid uses, per-site worker groups still listed below. Driven entirely by each service's category as resolved from its preset.

FEAT

Install what the project asked for #946

A service in .lerd.yaml that was never installed fell back to "Stopped" and clicked through to nothing. It shows an amber Not installed and offers to install the preset inline. Reinstalling a removed built-in like redis works again too.

FEAT

Bounce a wedged dev server #1058

A host-proxy site's dev server is its whole runtime. The restart waits for the unit to go inactive and the port to come free, because a server that drains queues on shutdown holds its port and re-execing gets the app killed by address already in use.

FEAT

Semantic highlighting in tinker #973

phpantom_lsp 0.9.0 colours each token from what it resolved to rather than from the static PHP grammar, so a resolved method reads differently from an unresolved one and framework magic is coloured like the symbol behind it.

DEPS

The TUI moves to Charm v2 #960

bubbletea's new renderer redraws differentially, adds synchronized updates that stop the flicker, and handles wide characters and emoji correctly. lipgloss dropped render-time adaptive colour, so the palette starts dark and flips once the terminal background is reported.

internal/tui
UI

One theme icon, not three letters #1038

Three stacked buttons labelled L, A and D became a single button showing the mode in effect. On auto the tooltip names what the system resolved to, since the icon alone cannot say whether the page is currently light or dark.

10

Fixes & Docs

FIX

The long tail: a missing directory that took every site down, a crash-looping agent, an install that hung with no output, and a round of translation work that had drifted without anything looking wrong from the outside.

FixWhat was happeningPR
A missing bind source took down every sitePodman refuses to start a container whose bind source is gone, so a project subdirectory removed by a branch checkout aborted the start, and because nginx serves every site nothing was reachable. A start-time preflight sweeps the quadlets and names the path and the site it belongs to#1084
Projects outside $HOME were unreachableThe mount was written into the quadlets but the containers kept the mounts they booted with, and the rewrite that would have restarted them diffed against the file it had just written, found no change, and did nothing. Both ask the running container what it is actually mounted now#1054
The shared ssh-agent crash-loopedssh-agent refuses to bind a socket that exists and only unlinks it on SIGINT/SIGTERM/SIGHUP, never on the image's SIGQUIT, so the socket survived every stop and every start failed instantly under Restart=always#1067
pest:browser install hung foreverPlaywright's Node zip extractor deadlocks writing into the bind-mounted cache inside the FPM container: every archive downloaded to 100% then sat there with no error and no timeout. lerd fetches with curl and unpacks with the image's unzip, keeping Playwright's mirror fallback and registry link#1009
Idle-suspend slept proxy-only sitesThere is no process to stop for a site that only forwards to a dev server you start yourself, so suspending achieved nothing while still presenting it as asleep. lerd idle status reads "proxy only" now#1086
Every slow route offered to profile itselfSPX ships in the FPM image, so on FrankenPHP, a custom container, a host-proxy or a static site the click armed the profiler and left you in front of one that would never receive a capture#1060
Unlink left request and idle state behindUnlink tore down the vhost, certs, workers and registry entry but left the per-site state the watcher writes, so an unlinked site kept its snapshot entries and recorded requests indefinitely. Nothing broke; it was unbounded growth#948
cleanup left several GB unreclaimedOnly ledger-recorded images counted as reclaimable, but a service quadlet starting for the first time has podman auto-pull without going through lerd's wrapper. Cleanup reported tens of MiB while GB of old service versions sat there, visible only to podman system df#981
Values reached the browser unescapedTranslated messages render through {@html} and paraglide compiles to a template literal that escapes nothing, so the remote-control username, LAN address, setup code and configured TLD all travelled verbatim#1082
The dashboard rendered English anywayEvery failed API call fell back to a raw English string, so a non-English user hit English the moment anything went wrong. Key parity held so nothing looked wrong from outside, but a large number of values still sat on their English source text across all thirteen non-English catalogs#1081
Also in: a host path can be bind-mounted into the containers via a new mounts key #954, lerd php refuses an unreachable working directory instead of leaking a crun chdir error #954, the auto theme follows a live system light/dark switch #971, and the dashboard cards fill a tall window instead of leaving a band of dead space under the second row #983.