Link a Drupal site and ask lerd about it, and a dozen places answered as though every project on earth keeps a .env with Laravel's key names in it. The databases card could not see the database. The doctor offered migrations against a file the application never opens, and called a site healthy while it picked mysql and served every request off SQLite. lerd env reported success and wrote to a file Drupal ignores. This release answers all of it from the framework's own declaration: which file, in which format, at which keys, with which command, so lerd writes the $databases array a site actually runs on. Meanwhile the + in Sites became a wizard that scaffolds or links a project, asks what lerd init asks, and runs setup with composer streaming into the modal, the docs moved into the binary so they work with no internet, and both stores learned to ship the mark and brand colour every service and framework is drawn with.
The second design law says no feature may know the name Laravel. This is what happened when a real Drupal site was linked and every surface was asked about it in turn. None of these were separate bugs so much as one assumption made in a dozen places, each of them found by driving the actual application rather than a fixture.
Which database on which service a site uses is answered once now: the env file the definition names, read in the format it names, at the keys derived from its service vars. A framework wiring through a single DSN is read from that value instead. Worktree isolation had grown its own near-identical copy of the binding logic; it is gone.
internal/config/sitedb.goThe fix was matched against a constant spelled "migrate". Symfony declares doctrine:migrations:migrate and Drupal declares updb, so neither ever matched and both got a finding naming a remedy the user had to go and find. Laravel is the only framework spelled the way the constant was, which is what kept the assumption invisible.
A Drupal site answered 403 on every request: the vhost rooted it at the project while Drupal serves from web/. That root is a guess whenever the project never declared one, since linking walks for an index.php and a project without dependencies has none. It expires now when it holds no index and the definition's does.
A WordPress 7 site asks the store for wordpress/7.yaml, a request that can only 404, and the recovery only ever looked downward. The site ended up with no framework at all, losing its workers, doctor checks, env wiring and database with it.
A site running 7.0.2 read "WordPress 6". One flag carried two claims at once, which version to report and whether the borrowed definition's PHP range may constrain the project. A definition is borrowed in both directions; only one of those claims is.
BusyBox's sendmail connects to 127.0.0.1:25 and nothing listens there inside a container, so every framework that sends through mail() failed while Mailpit ran the whole time. One conf.d ini mounted into every PHP image fixes all of them at once, with no framework knowledge.
Reading a framework's own configuration is half of it. Writing one is harder, because settings.php is mostly guidance a user may have edited and a config file lerd mangles is a dead site. The php-vars format addresses top-level assignments by a dotted path, rewrites only the statements whose values change, and leaves the rest of the file byte for byte.
// before: lerd wrote here, the site used the line below it $databases['default']['default'] = ['host' => 'lerd-mysql']; // lerd wrote here $databases['default']['default'] = ['host' => 'other']; // the site uses this // after: the owning statement is the last one to reach the key $databases['default']['default'] = [ 'host' => 'old', 'port' => 3307, ]; $databases['default']['default']['host'] = 'lerd-mysql'; // reads back host lerd-mysql, port 3307
The store has no engine version field. A definition naming php-vars lands on a machine running a binary from before it existed.
Which appends key=value lines into a PHP file: Parse error: syntax error, unexpected token "default". A dead site, on a machine whose owner did nothing but leave lerd running.
An unknown format is named and the project is left exactly as it was. Reading one returns nothing rather than inventing keys out of whatever the file happens to contain.
app_file and app_format name the file the application reads; file and fallback_file say what an older binary should do with the same definition. An unknown field is ignored on parse, so every install behaves as well as its version allows. Verified both ways against one definition.
Drupal's installer leaves settings.php and its directory read-only, which is right for a deployed site and no reason to refuse to configure a local one. The writer restores permission for the duration and puts the mode back exactly, even when the write fails.
lerd env appended constants to settings.php next to the array Drupal reads, created the databases, and left the site on SQLite with nothing to say so. A definition naming a primary file means that file is lerd's to write; a fallback is a read source.
A site showed postgres everywhere while running on SQLite: DATABASE_URL set twice, lerd reading the first and Symfony the last, both correct. Laravel's loader keeps the first, Symfony's array keeps the last, so no single rule is right for both and this reports rather than resolves. The editor lists each occurrence with its line and keeping one drops the others as an ordinary unsaved change.
The + in Sites opened one screen that browsed the host directories and streamed a link, and stopped there. It could not create a project, it never asked the questions lerd init asks, and nothing ran the setup steps, which is where composer install, the JS build and the workers actually happen. Someone working from the dashboard ended up back in a terminal to finish what they started.
runSetup as lerd setup --list-steps and --step, useful at the terminal in their own right.Everything reaching the host goes through one run endpoint that builds every argv itself from a named kind, behind the same authority the editor and terminal sit behind. Scaffolding is minutes of composer, so a run lives in lerd-ui rather than in the request that started it: close the tab mid-install and it replays where it was.
The docs button opened lerd.sh in an iframe, so the one moment you most need the docs, a machine with no internet, is the moment it had nothing. The pages have been embedded all along for lerd man; a shared normaliser rewrites VitePress containers into portable markdown so both surfaces read the same set.
lerd new is a wizard #1435It scaffolded Laravel without asking and ended by printing three commands to type. It asks which framework and major, then links the result. The catalogue only offers what can start a project, since a definition with no create command was offered and then refused after every question had been answered.
The Sites and Services widgets hand-rolled their own rows while the tabs behind them drew cards, so one thing read two ways depending on where you met it. Both render the tile the grid uses, in a compact variant, which brings the app name, PHP version, padlock, share markers and per-worker dots along for free.
The widget was a run of anonymous text rows. Each worker type is a line with its mark and a summary now, opening to one row per unit with the last line a failing one printed. A worker that keeps failing had only heal on offer, which is starting it again; the banner stops them instead, and the detector leaves a disabled unit alone.
A service a dozen sites use pushed the pane down before anything about the service was on screen, every pill carrying a grey dot that read as a status and said nothing. One button beside the action menu, a row per site coloured by what that site is doing. The Node card's chips went the same way.
More of what lerd does is a line of YAML than it was a release ago. A store change ships to every install within a day with no binary release and no Go code, which is the whole point of the first design law, and this cycle pushed several more decisions across that line.
| What moved to the store | Key | Why it could not stay in Go |
|---|---|---|
| Shutdown grace #1439 | stop_timeout | Five seconds is right for chromium and wrong for a database checkpointing a buffer pool, which comes back replaying its write-ahead log |
| Structural complaints #1449 | expected_errors | A pg_dumpall replay always fails on the role and templates it drops; which complaints are noise belongs next to the image that makes them |
| First-run steps #1452 | missing_file | setup --all runs every step it is offered, so Magento's setup:install needed a way to say "only where nobody has bootstrapped yet" |
| Cache clearing #1409 | cache_clear | cr, cache:clear, optimize:clear: every framework spells it differently and nothing but the definition can say |
| Declining a warning #1414 | notifications.nplusone | A CMS's entity and cache layers issue the repeats themselves, and lerd cannot tell those from a developer's loop |
| Marks and colour #1457 #1458 | <name>.svg, color | A service could borrow one of fourteen bundled glyphs, and mysql, mariadb and postgres all rendered the same category indigo |
It heads for an html block, so it is cut down on the way in to a plain drawing subset: script, foreignObject, event handlers, external references and any fill or style of its own are dropped. A monochrome silhouette through currentColor, because full-colour brand marks would sit badly beside the outlines they render next to and would not survive a theme flip.
Tints are full static class strings picked from a fixed record, so a value out of the store can never become a class. It arrives as a custom property instead, validated as a plain hex, with a tone derived per theme so a near-black brand still separates from the dark card.
An admin UI with no mark of its own draws the mark of the engine it administers, read from the admin_for its preset already declares. pgAdmin comes up as PostgreSQL, and Mongo Express, Elasticvue, RedisInsight and the OpenSearch dashboards stop drawing stand-ins as a side effect.
Nothing seeded the framework store, and the cached index only appeared once the long-running watcher reached its first refresh, so until then the only definitions lerd could resolve were the two compiled into the binary and detection was narrower than it would be a few minutes later. Install pulls the index early, before the vhost pass that resolves a definition per site, then fetches every definition the index publishes. The refresh at the end of install built its target list by reading the store directory, so it could only revisit what was already there and did nothing at all on a machine with none. Two things this leans on were wrong: definitions were ranked by sorting their names as strings, which puts laravel@9 above laravel@12 and hands the newest definition to nobody, and the index cache was left untouched when a refresh found the catalogue unchanged, so a store that published nothing new for a day looked like a store nobody had reached in one.
A check that names a problem and offers nothing is half a check. Three of them stopped at a command to copy, and one of those names the most likely reason a site does not boot at all, which made it the worst place to stop short.
# before: healthy, on a database nobody chose ✓ Service Wiring ✓ Database lerd site:doctor --fix ⚠ Service Wiring Drupal picks mysql, settings.php points at sqlite fix: env_sync → wrote the connection values into the $databases array ✓ Service Wiring ✓ Database # settings.php mode before -r--r--r--, after -r--r--r--, HTTP 200 throughout
Both halves are a button: what is missing gets installed, what is stopped gets started, through the host-side flow the services page uses, so the image pull and the readiness wait report as they happen. Install state comes from the source of truth the services list uses, since a leftover unit had a site told it was merely stopped.
internal/sitedoctor/services.goA vhost is written on link, secure, rename or a PHP switch and nothing looks at it again. The doctor renders what lerd would write right now and names the first line the two disagree on. Rendering and writing were the same step in eight generators, which is why nothing could ask the question without doing it.
internal/nginx/render.goThe comparison needs no parsing and no framework knowledge: the store declares which services a framework wires, and a project points at one when its config names that service's container, which is a text question every format answers the same way. A drop-in is measured against the block it stands in for.
Installing, upgrading, stopping and removing: four moments where lerd is holding somebody's data or somebody's PATH, and four places this cycle found something that could lose either.
Homebrew retires the keg each version lives in. Every user service and every shim recorded the resolved path, meaning that keg, so the next brew upgrade left the daemons failing with a bare exit status 203 and php reporting no such file, on a machine where nothing was changed. There was a guard for exactly this: it compared the executable path with its symlink-resolved form, and on Linux the executable path is already resolved, so the two were always equal and it never fired.
--purge and --reset-data renamed the data dir aside and called that recoverable. It is only recoverable by the image that wrote it, so a reinstall landing on another version leaves a directory nothing installed can open, which is exactly the case where somebody wants their data back. It is automatic rather than offered, because the person it exists for is the one who did not think to ask.
The sweep reimplemented a subset of the unlink core rather than calling it, and the two drifted: certificates, per-site containers, an open share and the recorded timings all outlived the project. Removing certificates turned out to be dead code on every path, deleting certs/<domain>.crt while site certificates live under certs/sites/.
The Podman Machine was killed abruptly on restart, so timescaledb came back into a multi-minute crash recovery. The watcher already runs under launchd for the whole session, so it runs the same teardown lerd quit does. It must never stop its own unit, since asking launchd to bootout the job you are running inside blocks until that process exits. macOS only: on Linux systemd already does it, so running it there would only cost something.
Every service command reads the registered definition, except install, which rewrote the quadlet from the copy still sitting in the site file. Frozen at link time, so a service came back on the host port it carried that day, found it published by another admin UI, and spent its restart budget every single install.
Removing a service keeps its config entry, deliberately, because the port memory is worth having. The reservation scans read that entry as a live claim, so a port belonging to a service that no longer exists stayed refused and skipped by the automatic shift.
The resources widget reported lerd-ui holding almost two gigabytes while the process held fifty megabytes, and it spent eighty milliseconds of CPU per refresh finding that out. Both halves were the measurement rather than the thing measured.
cpu.stat sits in the same cgroup directory the memory read uses, so one pass over a handful of files measures the containers and lerd's own daemons alike. The rate is taken between refreshes rather than inside one, so a row reports what a service cost over the interval, idle stretches included, instead of a spike caught while lerd was busy measuring itself.Host rows subtracted inactive_file, the working-set formula podman and cAdvisor use, chosen so a daemon reading big log files is not reported holding what it can release. A poller re-reads the same files every tick, and the kernel promotes a page to the active list on the second read, so its cache was entirely active and the subtraction removed nothing.
Container rows came from podman applying the same formula to the payload cgroup, so one list summed two different measurements into one total. CPU reads as a share of the whole machine now, the way memory already did: a row pegging one core of a 32-thread box is three percent, not a hundred. Twenty-six rows summing to 0.1817% against a reported 0.1817%.
Arming returned as soon as the toggle answered, but that regenerates every vhost and an nginx reload drains the old workers rather than swapping config in place, so the request the new tab fired could still be served unprofiled. SPX then opened in the same tick as the navigation. Both failures look identical from outside: the profiler simply seems not to have run.
The long tail, and the things that only show up when you drive the real application rather than a fixture.
| What broke | Why | PR |
|---|---|---|
| phpMyAdmin opened on the wrong engine | The preset declares discover_family:mysql,mariadb so mysql leads, and the collection sorted the whole list afterwards. Installing mariadb made a user's mysql databases look like they had vanished | #1463 |
| Every admin dashboard POST failed | The overlay embeds them at lerd-ui's origin, so the session cookie is third-party and Chromium blocks it. A GET renders on a fresh session, a POST cannot. All three moved behind the same-origin proxy | #1470 |
| A cached definition read mid-write | A plain os.WriteFile, and a definition cut above the workers key still parses, so the reader gets a valid-looking framework with nothing to run. The index went through a temp file with a fixed name, which is worse | #1354 |
| The wizard's Workers step was empty | huh v2 sizes an unsized multi-select's viewport to its option count, then subtracts the title and description from that same number. Two options plus both lands on zero height. Services had been hiding opensearch | #1348 |
| N+1 warnings on aggregated assets | A framework building aggregates through PHP does run queries, and each aggregate carries its own hash, so the once-per-route guard never bit. The timing view has dropped static assets for years by a predicate the notifier never asked | #1411 |
| Every query blamed the PDO wrapper | The frame walk stopped at the first path without /vendor/, and Composer installs Drupal core at web/core. On that site 80 of 195 packages install outside vendor | #1415 |
| A shim toggle recorded what it had not done | The decision was stored first and reconciled after, so the state said enabled with nothing on disk and the next toggle read that back | #1360 |
db:snapshot never worked on mariadb | set -o pipefail with the complaint redirected away, and dash, which is /bin/sh on those images, exits on the unknown option before the export runs | #1447 |
| A PHP card kept its old patch | The probe was keyed on the containerfile hash, and a base image update leaves the containerfile untouched. Keyed on the image ID now, which moves on every rebuild whatever caused it | #1444 |
Anyone on a build older than 1.26 cannot update and nothing released can reach them, so the docs are the only way across. The update notice fails quietly there, so a good number of affected users see no error at all: they simply stopped hearing that new releases exist.
The Symfony and WordPress guides opened by having the reader hand-write a framework YAML. Because --from-file writes a user definition, that thinner copy was merged on top of the real one rather than ignored, so the guide walked the reader into shadowing a better definition with a worse one.