On macOS a project is bind-mounted from the host into the Podman VM, so a containerised PHP crosses that boundary on every file it touches. The native runtime removes the boundary: PHP-FPM, the CLI, composer and the workers run on the host, nginx and the services stay where they are. It ships as beta and nothing switches without being asked. Beside it, the accent that was one bright red reached by around 260 utilities became twelve themes kept in the config, and the disk figures stopped charging a shared layer once per image that sits on it.
On macOS a project lives on the host and Podman runs in a VM, so a containerised PHP reaches every file across a bind mount. The cost is not subtle. The native runtime removes the boundary rather than tuning it: PHP-FPM, the CLI, composer and the workers run on the host, nginx and the services stay in containers. Linux never had the boundary and is unchanged.
Switching rewrites each site's env for the service addresses the new runtime can reach, regenerates the vhosts, drops the framework config caches and restarts the workers, and tears the old runtime down only once nginx is serving from the new one. It carries the beta label on purpose: the runtime is the ground every site stands on, and the container runtime remains the default.
lerd php:runtimeA host PHP cannot resolve lerd-mysql, so sites reach their services over loopback and the published host ports, reusing the rewrite host-proxy sites already used. It now covers every known service rather than only the ones a .lerd.yaml declares, and understands the dotted keys frameworks use when their configuration is a PHP array.
Native builds exist for 8.1 and newer: below that static PHP carries no OPcache, and 7.4 and 8.0 do not compile against current libxml2 and ICU. The switch refuses before it moves anything and names every site standing in the way. The extension set is fixed at build time, so php:ext and php:pkg refuse under this runtime and lerd shell says there is no container to enter rather than starting one.
Much of what followed was the native runtime meeting the rest of lerd, and each case read the host as broken. Composer writes vendor/bin/wp as a POSIX shell script rather than a PHP file, and the vendor binary fallback always ran lerd php vendor/bin/<name>; feeding a shell script to php echoes it back, so lerd wp printed its own source and exited clean, and drush was broken identically. The fallback reads the shebang now and execs a non-PHP binary directly, carrying the environment lerd php builds. Separately, a doctor fix clicked in the dashboard came back as exit 127 with lerd: command not found while the same line typed in a terminal worked: launchd hands a child the system PATH and nothing else, so ~/.local/bin was not on it while lerd's shim dir was in front. That PATH carries the running binary's own directory now.
lerd php:runtime runtime: container (default) · native available for 8.1+ lerd php:runtime native ✗ 2 sites are on a version with no native build legacy-shop.test php 8.0 no OPcache below 8.1 old-portal.test php 7.4 libxml2 and ICU do not compile # nothing moved, nothing torn down
The accent was one bright red, declared once and reached by around 260 utilities, and enough people said it was too sharp that the colour had to become a choice rather than a brand decision. Making it a choice meant deciding where the choice lives, and a browser's storage was the wrong answer.
The default keeps the brand red and muted softens it. Ocean, Solarized Dark, Monokai, Cobalt, Dracula, Nord and Gruvbox Dark bring the schemes people already read code in. Breeze, Adwaita and macOS take their values from what those desktops ship rather than from an approximation, so a dashboard beside a KDE window is the same blue rather than a near miss.
internal/ui/web/src/stores/palettes.tsLocal storage would have made the theme a property of the tab that set it. It lives in the global config instead, so it follows you to another machine, paints the installed app's own chrome, and reaches every open dashboard the moment it changes rather than on the next reload.
The lerd card has long asked for a star on GitHub, and that was the only thing lerd ever asked for. The Theme card carries a quiet line offering a prefilled post on X, Bluesky or Reddit, about the theme rather than the dashboard on purpose: a dashboard screenshot shows the whole list of someone's local sites, and that is not something to invite people to post.
Everything a framework knows lives in YAML that ships within a day of being written, so the useful question is never what lerd can do, it is what a definition is allowed to say. Four new things it can say this cycle, and one file that can now overrule all of them for one machine.
A .lerd.yaml is committed, so the settings that belong to one machine or one temporary worktree, an extra domain, an isolated database, a PHP version for a single branch, all ended up in the repository, and on a team working with AI assistants they ended up there without anyone deciding to put them there. .lerd.local.yaml overrides it key by key, the merge happening where every reader already goes, and a save from any command keeps the local file's keys out of the committed one rather than baking a temporary choice into the repo. Changing a key the local file owns says so instead of pretending it took. The PHP and Node detectors were reading .lerd.yaml themselves rather than through the loader, so a version pinned locally was applied on link and then reported as the committed one; they agree again.
| What a definition can now say | Where | The problem it answers |
|---|---|---|
| The dev server this worker starts, and the port it holds #1721 #1722 #1727 | worker definition | A worker that runs a dev server needed the port reserved, carried into the container and released again when it stops, and none of that could be declared |
| This package installs a binary globally #1724 | packages/*.yaml | A globally installed CLI has to land where the host can run it, found in whichever composer home actually holds it, which nothing could express |
Serve me on my own .test domain #1704 #1711 | service preset | The address the application used and the address you paste into a browser were two different ones, and preflights had nowhere to be answered |
| Copy this into a fresh worktree #1679 | worktree_include | What a worktree needs that git does not carry had to be copied in by hand every time one was provisioned |
| Run this with these arguments in front #1745 | vendor_bin_args | wp-cli refuses to start as root, which every lerd container is, and the flag that lifts it cannot come from a config file or an environment variable |
| Answers to my own flags, committed #1721 | worker_options | A project running several queues retyped the same values on every start, and a value equal to the framework default was stored, freezing a default that later moved |
A registry that comes back short is the worst thing lerd can do to someone, because the only way out is linking every project again by hand. sites.yaml is copied aside before every change that rewrites it, keeping the last ten in sites.bkp, with lerd sites:restore to read them back.
That matters more than it sounds. The daemon rewrites the registry constantly for idle state and worker toggles, and that churn would push the last real edit out of the window within minutes. It also means a wipe that keeps rewriting an empty registry takes one slot, so the good state stays at the top instead of being flushed out by the failure repeating itself.
The resolver built a fresh entry out of what it could read in the directory and the registry replaced the whole record, so the approved host commands went, and so did the pinned ports, the share ports, the group, the idle bookkeeping and the paused state. A re-link is the only way to make lerd re-check which framework a site uses, so the one action that picks up a new definition was also the one that quietly deleted your consents. It starts from the entry that is already there now.
internal/siteopsCleanup only ever reaped images whose repo the service catalog recognised, so the base layer a custom container pulled was invisible to it forever. Answering that exposed something worse underneath: the numbers lerd had been reporting about disk were not true.
A site whose Containerfile says FROM golang:1.25 pulls that once, carries it across every rebuild, and strands it the moment the Containerfile picks a different base. The interactive tier reaps any tagged image no container holds and nothing lerd references; the unattended tiers stay catalog-only so the daily sweep keeps off anything you own.
Widening what can be reaped is only safe if what is in use is known exactly, so every installed quadlet's image counts as protected, PHP-FPM included. Cleaning up while a site is stopped used to take its image with it, because nothing was holding it at that moment.
The widget could say what was reclaimable without ever saying what the install occupies in the first place. There is a disk figure beside CPU and memory now, what lerd's own images take whether or not anything can be reclaimed, and clicking it opens the breakdown heaviest first.
On macOS the blocks free inside the Podman Machine VM, whose sparse disk image never shrinks on its own. lerd machine reclaim caps the guest journal, vacuums what it had already grown past, trims the filesystem and reports how much host disk came back, touching no container, image, volume or site data.
The store publishes 62 definitions and every refresh pulled them sequentially, so a plain lerd framework update spent around fourteen seconds waiting. Eight at a time through a bounded pool now, with the shared transport keeping that many idle connections per host so each wave reuses what the last one opened. Around two seconds. --check keeps its sequential fetches, since a diff per definition only reads in order.
Docker Hub stopped answering for minio/mc on every tag, and nothing says the registry it moved to keeps it either. The bucket a site gets when it links to rustfs, the Buckets panel and the S3 mirror in the Sail import all speak the same API, so they share one client compiled into lerd: no image to pull, no hop through the lerd network, and bucket handling that keeps working offline.
An update re-execs lerd install to reapply the infrastructure, and then did a second pass of what that install had already done. One update wrote every AI skill file twice over and bounced every daemon twice, which is exactly what the output showed and nobody had read closely enough to question.
The notice and the update both looked at the stable line alone, so a prerelease you had taken went quiet until the stable release of that cycle overtook it. The running version already says which line an install is on, so a prerelease is offered the next one, and the stable release of that cycle outranks the prereleases it supersedes, which is what ends the run.
internal/updateupdate.beta in the config, lerd update:beta on and off at the terminal, and a card on the dashboard's Lerd page. With no argument the command reports where the install sits, and says so plainly when the running build is a prerelease and follows that line whatever the flag says.
lerd whatsnew asked for the latest stable, so on a prerelease install it compared against the older stable, decided nothing was newer and said you were up to date, while lerd status seconds earlier offered the next prerelease. The command meant to explain the update was denying there was one. It resolves the target the same way status and update do.
A project's .lerd.yaml records whether the site is served over HTTPS and link never read it. Day to day that is invisible, because the registry carries the same flag; it surfaces the moment the registry is gone. After an uninstall and a fresh install every site came back as plain HTTP with https refusing outright. The file that survives the wipe is now the one that decides.
A session that starts on 127.0.0.1:7073 is moved to lerd.localhost as soon as nginx answers, and the handover pointed the browser at the vhost root and nothing else. The route of every deep link lives in the hash it threw away, so clicking a captured email focused the app on the dashboard rather than the message. The handover carries the hash across.
lerd puts its shims on PATH once, at install, so anything appending its own PHP below that line takes php back. What follows looks nothing like a PATH problem: the site's env names the service container, which is why the browser keeps working, but a host php cannot resolve that name, so every console command dies on the lookup. Doctor resolves php and composer the way a shell does now and says what leads.
The fixed list covers bugs that shipped in 1.34.3 or earlier. Regressions introduced and fixed inside this cycle never reached anyone, so they are folded into the feature that carried them. The release closed on a full pass of the test plan across the distro guests and a Mac, which is where the first two came from, and they are the same defect wearing different clothes: a command returning before the thing it changed was actually in service.
Restarting an FPM container gives it a new address on the lerd network, and nginx went on connecting to the previous one until its own timeout expired. The command returned a tick in about a third of a second and the next request hung for between thirty nine and sixty seconds before answering 502 or 504. Measured over ten toggles, about half failed that way. Nothing about it was specific to xdebug: php:ext, php:rebuild, php:ini and a runtime switch all recreate the container the same way. A restart now waits for the pool to accept and then drops the address nginx had resolved, and the vhosts bound fastcgi_connect_timeout to five seconds because nginx's default of sixty is what turned a brief miss into a minute.
nginx -s reload signals the master and returns, and the master lets the previous generation of workers drain, so for a moment both generations are serving and a request can land on either. A domain just added answered 404 over http and failed TLS outright; one just removed went on answering. It came from the commands and from the Manage Domains modal alike, which has its own handlers. Both wait for the old generation to retire now, with a ceiling rather than a promise: a worker holding a long connection can outlast any budget, and failing the command over that would be worse than the window it closes.
Reading stdin blindly does not return promptly on a pipe that stays open, it waits for a line that is never coming, so lerd doctor --fix --yes over ssh sat with the question on screen until it was killed. It takes the default now and says which way it went, which also means a destructive prompt piped from a script answers no rather than hanging.
systemd does not run a start while a stop of the same unit is in flight, it queues it, and only the stop read the window the unit declares. On MySQL, which asks for seventy five seconds and uses most of them, any db command issued during a restart failed with a thirty second start timeout while the server came up a second after its turn arrived.
Laravel 13 declares php: ^8.3 and then resolves Symfony 8 components that each require 8.4.1, so lerd isolate 8.3 was accepted on a fresh site and the first request answered 500. The floor composer records in vendor/composer/platform_check.php counts as a constraint now, alongside the framework range and the manifest.
They drive a database service container and SQLite has none, so DB_DATABASE being a file path was handed to the service layer and rejected as an invalid database name. Laravel defaults to SQLite, so this was the out-of-the-box path. It answers about SQLite now, names the file it found and points at --service and lerd db:move.
lerd isolate took the site down #1733Pinning a site to a version the machine had never built repointed it at a runtime that could not start: the switch wrote everything, reloaded nginx, and only then suggested php:rebuild. By that point the FPM unit was failing on an image reference resolving to nothing and the site was serving 502 behind a command that reported success. The image is built first now, through the same disclosed build the install path uses.
FrankenPHP worker mode serves through artisan octane, so a project without the package had nothing to run, and the switch was taken anyway: recorded, reported successful, and the container exited on its first line because the octane namespace holds no commands. It refuses now, reading what composer installed rather than what the manifest names.
db:restore could not find the snapshot #1736Creating a snapshot stamps its name with a timestamp so a label can be reused, and restore and delete looked the name up literally, so the obvious round trip failed moments after the snapshot was reported created. A label resolves to the most recent snapshot carrying it now, with an exact name still winning.
The tab has no stream of its own and refreshed only because every websocket snapshot invalidated the effect reading the site's domain off the prop, so stopping that churn left the list frozen. It polls every five seconds now, only while it is the selected source and not at all for a suspended site, swapping entries in without touching the loading flag or the reader's scroll position.
The generated config imported the project's own, which handed the generated file's module format authority over how every plugin that config imports gets resolved. For most plugins the two builds are interchangeable; for one that is not, the dev server fails under lerd while the same command run by hand works. The wrapper hands the path to the tool's own config loader now.