Changelog
All notable changes to Lerd will be documented here.
The format follows Keep a Changelog. Lerd uses Semantic Versioning.
[1.25.0] - 2026-06-17
This release adds activity-driven worker suspension, makes bun a first-class JS runtime, and opens up how a PHP site's image is built. Idle-suspend gracefully stops a site's suspendable workers (queue, scheduler, Horizon, Reverb, the Stripe listener, Vite) after a period of no activity and brings them back on the next request, CLI command, MCP call, or source-file save, with a global toggle, a configurable timeout, and per-site pinning. bun can now drive a site's JS tooling on the host and, opt-in, run inside the PHP-FPM container, switchable from the dashboard, the TUI, or lerd js:runtime. PHP sites gain two ways to shape their image, extra Alpine packages baked in with lerd php:pkg and a fully custom Containerfile, and the FrankenPHP runtime now reaches extension and debug-tooling parity with FPM, so redis, gd, the database drivers, Xdebug, the dump bridge and the Debug window all work under Octane, alongside in-container Pest browser testing on musl chromium and per-project Octane auto-reload. The dashboard grows a Laravel Doctor panel, a Resources widget that accounts for lerd's whole footprint, a Clear logs action, and APP_NAME on the site tiles, RabbitMQ and RedisInsight surface their dashboards same-origin inside lerd-ui, the MCP server gains a logs tool, and the dashboard ships in six more languages. Alongside the features this release hardens the localhost/HTTPS paths, runtime switching, FrankenPHP relinks, and lerd stop.
Added
- Activity-driven worker suspension (idle-suspend) (#525, #540, #541, #542). lerd watches each site for activity (HTTP requests via the nginx access feed, CLI commands, MCP tool calls, and source-file saves) and, after a configurable idle timeout, gracefully suspends that site's suspendable workers, the queue, scheduler, Horizon, Reverb, the Stripe listener, and the host Vite dev server, bringing them back the moment the site is used again. The suspend engine runs inside lerd-watcher, worktrees keep their own per-branch timers, and Vite builds its assets once on suspend so the site keeps serving. It is off by default and managed with
lerd idle on/off,lerd idle timeout <duration>,lerd idle status, andlerd idle pin/unpin <site>to exempt a site, mirrored by a dashboard toggle. Turning it off resumes every suspended worker, and the engine reconciles against reality across installs and restarts so nothing is left stranded. - bun as a first-class JS runtime (#506, #521, #522). bun can drive a site's JS install, dev, and build on the host (auto-selected for bun projects, or when Node is unmanaged and only bun is present), toggled from the site's Node dropdown, the TUI picker, or
lerd js:runtime [bun|node|auto]. Forlerd shelland in-container tooling,lerd php:bun installdrops a musl bun into a persistent/root/.bunvolume that survives image rebuilds and is shared across PHP versions, withlerd php:bun update,lerd php:bun version, andlerd php:bun remove. lerd never version-manages bun; the user installs it andbun upgradeself-updates. - Custom Alpine packages in the FPM image with
lerd php:pkg(#508).lerd php:pkg add/remove/list <packages> [--php version]bakes extra Alpine packages (CLI tools, libraries) into the FPM image, persisted underphp.packagesin config so they survivephp:rebuildand base-image updates, layered onto the shared image rather than the published base. - Serve a PHP site from a custom
Containerfile(#511). A PHP project can ship its ownContainerfilebuiltFROMthe lerd base, so a site that needs a bespoke image is served from it directly rather than the shared FPM container. - In-container Pest browser testing (#518).
lerd pest:browser installsets uppestphp/pest-plugin-browserinside the shared FPM container by baking Alpine's musl chromium into the image, persisting the Playwright registry in a volume, and shimming Playwright's glibc browser to the system chromium, withlerd pest:browser doctorandlerd pest:browser remove. Chromium only, current PHP versions only. - FrankenPHP extension and debug-tooling parity with FPM (#527). lerd builds a derived FrankenPHP image carrying the same runtime extension set the FPM image ships (redis, gd, the pdo drivers, intl, imagick, mongodb, and the rest), so they are present from first boot, and the dump bridge, Debug window, and Xdebug toggle all apply to a FrankenPHP site; only the SPX profiler stays FPM-only.
- Per-project Octane auto-reload (#518, #520). A FrankenPHP worker-mode site can run Octane under a file watcher that reloads workers on code changes, toggled with
lerd octane:reload [on|off]or from the dashboard, kept throughlerd install, the Octane equivalent of Horizon's auto-reload. - Xdebug pause into running workers, plus on-demand mode (#516).
lerd xdebug on --on-demandsetsstart_with_request=triggerso nothing auto-connects, andlerd xdebug pause [site] [--list] [--pid]uses Xdebug's control socket (xdebugctl) to break the IDE debugger into a running queue/Horizon worker, scheduled task, or CLI script. PHP-FPM sites only. - MCP
logstool (#515). An eleventh grouped MCP tool reads every log source (app, FPM, nginx, workers, services) withgrep,since/until,level, andlinesfiltering, so an assistant can tail and search logs without shelling out. - Embedded RabbitMQ and RedisInsight dashboards (#514). lerd-ui proxies the RabbitMQ and RedisInsight management UIs same-origin under
/_svc/<service>/rather than sending you to a new tab, so they open inline like the other service surfaces. - Laravel Doctor (#509, #528). The site detail view and the TUI gain an on-demand Doctor panel that runs the framework's own health checks, sourced from the framework definition, with one-click fixes; it is worktree-aware and only warns on env keys the project actually requires.
- Dashboard Resources widget (#536). A Resources card reports total CPU and memory across lerd's whole footprint, the
lerd-*containers plus lerd's own host-side processes (the UI, watcher, and tray daemons and any host worker such as Vite), with the memory share of host RAM and a ranked list of the heaviest contributors. - Clear logs and
APP_NAMEon the dashboard (#510, #507). The App Logs tab gains a Clear logs button that reports the total log size and reclaims the disk behind a confirmation, and the site overview tiles are titled by a custom LaravelAPP_NAMEwhen one is set. - Six more dashboard languages (#505). The dashboard now ships in fourteen languages, adding Simplified Chinese, Japanese, Romanian, Italian, Polish, and Vietnamese.
- TUI 1.24 integrations (#529). The terminal UI picks up the release's work: a bun entry in the Node picker, a FrankenPHP site's PHP picker limited to runnable versions, idle-suspended workers shown as suspended, the Laravel
APP_NAMEin the site header, an on-demand Laravel Doctor tab, opening a service's dashboard in the browser withO, and the services pane's workers sub-grouped by owning site.
Changed
- A FrankenPHP site's PHP version picker is limited to runnable versions (#523). The dashboard dropdown only lists the versions FrankenPHP publishes an image for, so it never offers one that would silently downgrade the site.
Fixed
- FrankenPHP runs the site's pinned PHP image instead of downgrading (#523), so a site pinned to 8.5 runs that image rather than dropping back to 8.4, and a committed FrankenPHP runtime no longer silently upgrades a site's PHP on relink (#538).
- Workers stay healthy when switching a site's PHP runtime (#524), and a stale FrankenPHP container or orphaned quadlet is reconciled when a re-linked site is no longer FrankenPHP (#534).
- A paused runtime site's container no longer autostarts at boot (#526).
- Consistent localhost and HTTPS handling (#533, #537).
.localhostmode behaves the same acrosslerd init,lerd secure, and the installer, a committed HTTPS choice survives the wizard on a localhost box, and a secured project stays on http when managed DNS is disabled, withSetSecuredgated behind managed DNS so the UI and MCP match the CLI. lerd stopretries systemd jobs reported "canceled" (#532), so it no longer leaves containers running on Linux.- lerd's php shim stays ahead of a global php for node child processes (#539), and FrankenPHP worker quadlets translate to launchd correctly when
Exec=is quoted on macOS (#519). - The Xdebug dashboard toggle applies to FrankenPHP and custom-FPM containers (#538).
- Dependency advisories cleared (#535) by bumping esbuild, vite, and form-data.
- Hardening from pre-release review and smoke testing (#543, #546, #547, #531), covering FrankenPHP downgrades, log filtering, the dashboard service proxy, and idle-suspend reconciliation.
[1.24.1] - 2026-06-11
A fix release for v1.24.0. On macOS a fresh lerd install left every container unable to start: nginx, all PHP-FPM and all service containers failed with statfs /Users/<user>/.local/share/lerd/...: no such file or directory. The Podman Machine was initialised with -v /Volumes:/Volumes to keep external drives mounted, but Podman's --volume is a stringArray whose default set is /Users,/private,/var/folders, and passing any -v replaces that whole set, so the VM mounted only /Volumes and never the host home that every bind mount is sourced from. Init now passes all four required mounts explicitly so the home mount survives. A machine already broken this way can't be repaired by editing its config, because Podman writes the guest mount units once at init via Ignition and never regenerates them on restart, so lerd now detects a machine missing the home mount and recreates it on the next lerd install or lerd start (a machine in this state has no working containers, so nothing is lost). Machines created before the regression kept Podman's defaults and are unaffected, which is why it only surfaced on fresh installs. Linux is unaffected.
Fixed
- macOS containers fail to start on a fresh install because the host home mount was dropped from the Podman Machine (#513). The regression came in with #458, which added
-v /Volumes:/Volumesatpodman machine initto keep external drives reachable; because--volumeis a stringArray, that single-vreplaced Podman's default/Users,/private,/var/foldersset, so the VM lost/Usersand every bind mount sourced from~/.local/share/lerdfailed. Init now passes all four paths explicitly so fresh machines mount the host home from the start, and a machine already broken this way (only/Volumes) is detected and recreated on the nextlerd start. Recreation is required because Podman's guest mount units are baked at init by Ignition and a config edit alone never makes the path appear inside the VM./Volumesis still mounted, so external drives keep working.
[1.24.0] - 2026-06-09
This release reworks how AI assistants talk to lerd and broadens what the dashboard shows. The MCP server collapses its roughly eighty flat tools into ten resource groups selected by an action argument, which roughly halves the per-session manifest and keeps it static so it no longer changes shape mid-session, and lerd now registers with Codex CLI, Gemini CLI, GitHub Copilot and Google Antigravity alongside Claude Code, Cursor, JetBrains Junie and Windsurf, all reading one shared, single-source tool reference. The Sites tab gains an overview dashboard when no site is selected, mirroring the redesigned, category-grouped Services dashboard, sites can be grouped under one base domain with shared or separate databases, and the list can be sorted and drag-reordered. Node and other non-PHP projects can run as host-proxy sites where lerd supervises the dev server on the host and nginx proxies to it, databases can be moved between same-family services with db:move, and soketi, OpenSearch, RedisInsight and Beanstalkd join the preset catalogue. The Stripe webhook listener now works for any framework with a configurable path, Horizon can auto-reload in development, and frameworks can declare unconditional env.vars defaults. Alongside the features, this release hardens the systemd and quadlet generation against directive injection from a cloned repo, gates host-proxy dev commands behind explicit consent, and stops lerd stop from raising spurious worker heal notifications.
MCP integrations: the tool surface is a breaking change. The old flat tool names (
sites,artisan,db_set, …) no longer exist; call the matching group with anactioninstead (for exampleexecwithaction: "artisan", ordbwithaction: "set"). Re-runlerd mcp:enable-global(orlerd mcp:inject) so your assistant picks up the new manifest and reference.
Added
- Grouped MCP tool surface and four more assistants (#497, #498). The MCP server exposes ten resource-grouped tools,
site,service,db,env,runtime,worker,exec,framework,diagandworktree, each driven by anaction, routed through one dispatch table that reuses every existing handler, which cut thetools/listpayload from roughly thirty-two thousand bytes to sixteen and made the manifest static so framework-specific actions no longer toggle the tool set mid-session. Client registration is table-driven: Codex CLI (~/.codex/config.toml), Gemini CLI (~/.gemini/settings.json), GitHub Copilot (~/.config/Code/User/mcp.json, VS Code'sserverskey withtype: stdio) and Google Antigravity (~/.gemini/config/mcp_config.json) are now supported next to Claude Code, Cursor, Junie and Windsurf, and every assistant renders the same single embedded tool reference so the guidance can't drift between them. - Sites overview dashboard (#492, restored in #501). When no site is selected the Sites tab shows an overview instead of a bare prompt: a running-versus-total count, a paused tally and a failing-workers indicator, with sites laid out as click-through tiles grouped by framework plus Other and Paused buckets, each showing the favicon or status dot, the domain with a TLS lock, a framework and PHP or Node subline, worktree and worker indicators, and an open-in-browser shortcut. The chrome shared with the Services dashboard is extracted into reusable header, section, site-icon and worker-indicator components so the row and tile views stay in sync.
- Redesigned services dashboard with categorized discovery (#491). The Services tab is reorganised into categorized sections with preset cards and a suggestion banner so installable services are discoverable at a glance.
- Site groups (#484). Related sites can be grouped so a main owns a base domain and the rest occupy its subdomains, with an optional shared database per secondary, managed from a grouping modal on the site header and the
siteMCP tool'sgroup_*actions. - Sortable, drag-reorderable sites list (#486). The sites list can be sorted, and dragging a site switches to a manual order that is persisted.
- Host-proxy sites for non-PHP dev servers (#477, #487, #494, #495). A project can declare a
proxycommand in.lerd.yamland lerd supervises that dev server on the host while nginx reverse-proxies the.testdomain to it, with an auto-assigned free port, git-worktree support, and explicit consent before lerd runs a command from a project file. db:movebetween same-family services (#488).lerd db:move --from <a> --to <b>relocates one or more sites' databases between two installed services of the same family (for examplepostgrestopostgres-18), dumping from the source, creating and restoring on the target and repointing each site's.env, leaving the source data intact.- soketi, OpenSearch, RedisInsight and Beanstalkd presets (#489). Four services join the bundled preset catalogue, installable like any other preset.
- Framework-agnostic Stripe webhook listener (#490, #493). The Stripe listener works for any project, not just Laravel: the secret is read from
STRIPE_SECRET,STRIPE_SECRET_KEYorSTRIPE_API_KEY, and a config control sets the forward path in.lerd.yamlso a non-Laravel route works. - Horizon auto-reload for development (#471). Laravel Horizon can run under a file watcher that reloads workers on code changes, toggled per project from the dashboard.
- Unconditional framework
env.varsdefaults (#483). Framework definitions can declare top-levelenv.varsthat are applied as defaults onlerd env, seeded only when the key is absent so a value set directly in.envis preserved, with.env.lerd_overrideand detected-service values still taking precedence.
Changed
- MCP tools are grouped, not flat (#497). The roughly eighty individual MCP tools are consolidated into the ten grouped tools above. This is a breaking change for anything that called the old names directly; see the note at the top of this release.
Fixed
- Reject inputs that could inject systemd unit or quadlet directives (#495, #500). Worker unit generation refuses a newline or NUL in the command at the generation boundary, so the boot and restore path is covered and a cloned repo's
custom_workersentry can't inject a second systemd directive, and custom-service validation runs again at the quadlet boundary afterdynamic_envresolves so inline services and dynamic env values are checked too. - Host-proxy dev commands stay behind consent on every path (#500). The watcher's worktree setup and
lerd unpausenow honourhost_proxy.disabledand only run the command approved at link time, so a command edited into or shipped by a cloned.lerd.yamlis never run unattended. lerd stopno longer raises worker heal notifications (#500). A lifecycle marker records an intentional shutdown, so the health watcher suppresses heal notifications, the dashboard banner and the heal surfaces for workers that lerd itself stopped; the workers stay enabled and come back on the next start, and a stop-then-reboot autostart resumes detection.db:movekeeps an explicitdb.serviceblock in sync (#500), so laterlerd dbcommands resolve the service the site was moved to rather than the old one, and clears the block for sqlite which has no container service to target.- Host-proxy worktree ports and teardown (#500). Worktree dev-server ports are reserved against collision, and unlinking sweeps per-worktree worker units by name so the watcher's stale-site prune tears them down even after the project directory is gone.
- Debug window events are separated by worktree (#481), so a worktree's captures no longer mix into the parent site's view.
- MariaDB readiness and Vite worker boot ordering (#479, #480). MariaDB is probed with
mariadb-adminrather thanmysqladmin, and host Vite workers are ordered after the FPM container at boot so wayfinder doesn't run before PHP is up. - Recover from podman overlay-storage corruption on macOS (#476).
lerd startdetects the overlay-storage error and heals it, resetting the Podman Machine with a sized memory allocation when needed. The heal is gated to darwin with a tightened matcher so it never fires on Linux. - The dashboard
.enveditor scrolls to the bottom (#485), instead of cutting off its last lines on longer files. - Key generation runs through the framework console (#482), instead of a hardcoded
artisancall, so non-Laravel frameworks generate their app key correctly. - Untranslated UI strings (#502). The site grouping modal, the command palette's generated labels, and the command run modal and dropdown are translated across all seven non-English locales; the dashboards already shipped their own translations.
[1.23.1] - 2026-06-04
A fix release for v1.23.0. Installing Postgres from the dashboard failed on Apple Silicon with podman pull ... exit status 125, and left a broken service registered behind it. The quadlet already pinned --platform=linux/amd64 so the container ran under emulation, but the explicit pull step that 1.23.0 added to surface progress ran a bare podman pull with no platform, and since postgis/postgis (like mysql:5.7) publishes no arm64 manifest for any tag, podman couldn't choose an architecture from the manifest list and exited 125 while the run path was happy. Pull and run now key off one shared predicate so they always agree on platform, and the streaming install pulls the image before it writes any config or quadlet, so a failed pull no longer leaves a service registered with no working image. It also fixes the dashboard's admin-tool banner, which only ever appeared on the bare postgres or mysql service and never on a versioned install like postgres-17. Linux and Intel macs are unaffected.
Fixed
- Postgres install on Apple Silicon, with no half-installed service on a failed pull (#473). A new
PlatformPullArgsinjects--platform=linux/amd64for the amd64-only upstream tags (postgis/postgis,mysql:5.7) on darwin and is a no-op on linux, and every pull path now routes through it, the three pull functions in the podman package, the FPM base pull, and the two CLI start and warm-up pulls, so the platform the quadlet runs with and the platform the pull fetches are decided by the sameimageLacksArm64predicate and can't drift apart by service name. Separately, the streaming install now resolves the preset, pulls the image, and only then registers it (config plus quadlet), because quadlet presence is the install-state truth: a failed pull used to write the YAML and quadlet first, so the service showed as installed but never started. - The admin-tool banner shows on any database service, regardless of version or family member (#474). The dashboard offers pgadmin for Postgres and phpmyadmin for MySQL through a banner on the database service, but the suggestion was keyed on the exact service name, so a multi-version install like
postgres-17or a MariaDB never got one even though the store already had adetectServiceFamilyhelper that resolves them back to the right family.suggestionForandsuggestedPresetFornow route through that helper, the wayadminServiceForalready did. The matching gap was on the install side: an admin tool hard-depends on the bare family service (pgadmin onpostgres, phpmyadmin onmysql), soMissingPresetDependenciesrefused to install it on a versioned-only host. A family dependency is now satisfied by any installed member of that family, and by any installed member of a sibling family the tool co-discovers viadiscover_family, so apostgres-17covers pgadmin'spostgresdependency and a MariaDB covers phpmyadmin'smysqlone, matching what they already auto-connect to. The generatedservers.json,pgpassandPMA_HOSTSalready enumerate every family member with matching credentials, so the admin tool can browse all of them immediately after install.
[1.23.0] - 2026-06-03
This release moves lerd's whole configuration surface into the dashboard. Every file a developer used to open in an editor, a project's .env, a per-version php.ini, a site's nginx override, and a service's runtime tuning, is now editable in the browser, each through the same snapshot, optional timestamped backup, atomic off-glob write, validate and auto-rollback pipeline so a save that breaks the config restores the previous bytes and tells you it did. The System pages are tabbed, PHP versions install and remove from the dashboard with a live build log, and every editor is also reachable from the CLI and from MCP. The dump bridge becomes a full Debug window: a first-party Zend extension captures SQL queries with their real bindings alongside mail, views, events, jobs, cache and outgoing HTTP, grouped per request with N+1 detection, and the same window lands in the dashboard, the TUI and an analyze_queries MCP tool. The terminal UI grows a system view, a dashboard mirroring the web home page, modal overlays, toasts and a command palette. And Windows users get a dedicated WSL2 guide with lerd wsl:setup.
Added
- Editable
.envfiles in the dashboard (#434). The site detail panel gains an Env tab with a dropdown across env variants (.env,.env.local,.env.testing,.env.example), a CodeMirror editor with dotenv highlighting and line numbers, an opt-in backup checkbox that timestamps the prior contents as<file>.bkp.<YYYYMMDD-HHMMSS>, and a revert that restores the most recent backup over the file, peeling backups newest-first on repeated reverts. Writes are atomic and preserve the on-disk file mode. The CodeMirror bootstrap Tinker already carried was extracted into a sharedCodeEditorprimitive that both editors now use, and the new/api/sites/{domain}/envPUT and backup subroutes inherit the same loopback gate that already covered raw.envcontent. - Per-version
php.iniediting and tabbed System pages (#436, #443, #448). The System dashboard folds its one-row-per-PHP-version list into a single PHP row whose detail page is tabbed: a worktree-style strip selects the version with a default star and a running dot, and the body splits into Logs, Sites and Config, where Config edits the per-version98-user.iniand restarts FPM on save. Nginx gains a matching Logs and Config pair, and DNS and Watcher render their log viewers directly. A browser-style plus button installs any supported PHP version not yet present, building the FPM image server-side while streaming the build log live into the modal so closing it doesn't cancel the build, with a notification when it finishes; removing a version goes through a confirmation that warns when sites still use it. The global http-level override and thephp.iniboth save through the shared snapshot, optional backup, atomic off-glob write, validate and rollback pipeline. - Nginx config editing from the address bar, per site and per worktree (#437, #440, #445). A sliders button at the end of a site's address bar opens a modal that edits the per-site
custom.d/<domain>.confoverride, and a global http-level editor on the System nginx page editshttp.d/zz-lerd-user.conffor gzip, proxy buffers and the like. Worktrees get their own override, seeded once from the main branch then independent, and the editor scopes to whichever worktree tab is active. Saves runnginx -tinside the container before the new bytes land, roll back to the snapshot and surface the captured stderr when validation fails, and leave an innocent save alone when the failure points at a pre-existing broken neighbour vhost. The same read, edit and reset are reachable fromlerd nginx show/edit/reset(with--branchto target a worktree) and asite_nginxMCP tool. Underneath all of this a newcfgeditservice owns atomic staged writes, timestamped backups, snapshot and rollback and a process-wide save lock, and the per-site nginx, global nginx,php.iniand env editors all route through it, pulling a few hundred lines of duplicated logic out of the web server. - User-editable runtime tuning for every service family (#429, #438, #439, #441, #442, #453). Each service exposes a runtime tuning override that lerd seeds once with a commented template and never overwrites afterward, bind-mounted after the bundled preset config so any value set there wins, editable from
lerd service config <name>, a Config tab on the service detail panel, and aservice_configMCP tool. mysql and mariadb land first through their auto-includedconf.d; redis and postgres load no config by default, so the family declares a container command that points the image at the file, and becauseinclude_diris rejected via-c, postgres starts against a small lerd-managedconfig_file=wrapper that includes the cluster's ownpostgresql.confand then the override directory. Custom services opt in with atuning:block on their YAML carrying the mount target, the seeded template and an optional command, so lerd doesn't have to recognise their family. The web editor mirrors the env and nginx tabs with a save-and-backup modal, a restore diff and a Reset to bundled defaults, and a save that crashes the service auto-rolls back to the prior bytes and reports "save reverted, prior config restored" rather than a bare "service did not become ready" that reads as if the service is still broken. - The dump bridge becomes a full Debug window (#462, #464, #468). A first-party Zend extension,
lerd_devtools, captures SQL queries at the PDO layer with their real bindings, whether the app passes them toexecute()or binds them one at a time, and framework-agnostic seams capture outgoing mail, rendered views, dispatched events, queued jobs and outgoing HTTP; Laravel keeps the richer data from its in-app adapter and the agnostic seams stand down while it's active so nothing is captured twice. Every kind ships over the same socket and ring as dumps and renders as sibling tabs in one Debug view, grouped per request with N+1 detection and slow-query flags, and the dump bridge and collector share one enable sentinel so a single toggle arms the whole window, renamed the Debug bridge throughout the dashboard, tray, TUI, CLI and docs. The extension now compiles in the prebuilt base image instead of on each user's machine at install time, with its source hashed into the Containerfile so an extension-only change still drifts the image and existing users rebuild. Ananalyze_queriesMCP tool returns the N+1 and slow-query report with the originating file and line,dumps_recentgains a kind filter, and the TUI's Debug view gets the same eight lenses (Dumps, Queries, Jobs, Views, Mail, Cache, Events, HTTP) switched with[and], with per-lens buffered counts and a worker-capture toggle onw. - A system view, dashboard and modal layer for the TUI (#417). The terminal UI gains a System pane (
Y) covering DNS, nginx, watcher, notifications, the Debug bridge, per-version PHP with Xdebug, Node and worker mode with reversible toggles that route through the lerd CLI so the TUI shares the same code paths as a manual invocation, and a Dashboard pane (F) mirroring the web home page with a hero status line, an overview roll-up, a system health block and a resources block backed by a newinternal/statspackage shared with lerd-ui through a singleflighted podman-stats cache. The command palette, the PHP/Node version picker, the help reference and a new destructive-action confirm render as centered modal overlays; toasts replace the disappearing status bar with per-kind TTLs and a dismiss key; the dumps view picks up search and context chips; and the services pane groups into Core, Custom and Workers with the header pill naming the failing units instead of just counting them. - Personal
.env.lerd_overridefor per-project overrides (#469). A personal, never-committed.env.lerd_overrideat the project root lets each developer override the values lerd writes into a project's.envwithout hand-editing it after everylerd env. EveryKEY=VALUEis layered on last so it wins over lerd's defaults and computed values likeDB_DATABASEandAPP_URL, written verbatim so quotes are preserved, and the one reserved key,LERD_EXTERNAL_SERVICESwith a comma-separated list, marks services lerd should write connection vars for but not start or provision, which is how a project keeps using a developer's own local postgres while lerd manages everything else. A newlerd env:overridecommand scaffolds the file from a commented template and makes sure it's gitignored, anenv_overrideMCP tool exposes the same flow, andlerd env:checkskips the file since it's a partial overlay. A build guard now fails if any registered MCP tool is ever missing from the injected skill and guidelines again, after nine tools were found undocumented. - Windows (WSL2) getting-started guide and
lerd wsl:setup(#432). A dedicated page covers installing lerd on Windows through WSL2: enabling systemd in/etc/wsl.conf, mirrored networking in.wslconfig, theevents_logger = "journald"tweak incontainers.confwithout which every dashboard log pane shows a podman log-driver mismatch error, keeping projects in$HOMEso bind mounts don't route through 9P, masking lerd-tray since WSL2 has no tray host, and exporting the mkcert root CA to the Windows trust store sohttps://*.testis trusted by Chrome and Edge. Both.testand.localhostDNS modes are presented as viable, validated by a community user on the issue thread, and WSL flag support ships as beta. - Check for updates on the service header (#418). A Check for updates action on each service header forces a fresh registry lookup instead of waiting for the cached availability snapshot to age out, dropping both the 30-second in-memory and 6-hour on-disk registry-tag caches before recomputing and surfacing the outcome (already up to date, update available with the new tag, or check failed) in a toast.
POST /api/services/{name}/updatesis now the trigger; GET still serves the cached value so snapshot rebuilds stay cheap.
Fixed
composer requiresurvives cold container boots (#450).composer require/installdied with "exceeded the timeout of 300 seconds" while runningartisan package:discoveras a post-autoload-dump script, because every artisan command boots the full application and on a cold container filesystem a heavy provider tree, reliably reproduced once Reverb is installed, legitimately runs past composer's stock 300s. lerd now injects a 1800sCOMPOSER_PROCESS_TIMEOUTinto every path that runs composer inside the FPM container: thelerd composerCLI, thecomposerMCP tool, and the setup-time autocomposer install. Closes #449.- Long worktree branch names no longer take nginx down globally (#456). A worktree with a long branch name emits a
server_namethat overflows nginx's defaultserver_names_hash_bucket_size: 64, so nginx fails to start and every site goes down, including the dashboard. Thehttp {}block now carriesserver_names_hash_max_size 1024andserver_names_hash_bucket_size 128at the source so every generated config has it. Fixes #455. lerd startstops flagging its own port owners as conflicts (#461). On macOS the preflight printed false port-conflict warnings on a healthy machine, for:5300on every start and for service ports after any stop/start cycle, even though.testkept resolving and the stack started fine. lerd's own dns and gvproxy port owners are no longer reported as conflicts, so the warning stops crying wolf and training users to ignore it.- A site's custom nginx override follows a domain rename (#444). Renaming a site's primary domain through the UI silently dropped its custom nginx config: lerd deleted the old vhost and generated a new one including
custom.d/<newdomain>.conf, a file that never existed, so the user's directives vanished from the live site while the old file sat orphaned on disk.RegenerateSiteVhostnow moves the override and its timestamped backups to the new name whenever the primary changes, never overwriting backups, andlerd migrate-tlddoes the same for every site whose primary domain shifts when the TLD changes. .envAPP_URLsyncs when the primary domain changes in the UI (#435). The web UI's domain handlers regenerated the vhost, reissued the cert and reloaded nginx but never touched the project.env, so renaming the primary domain leftAPP_URLand the reverb keys pointing at the old host; the CLI already had the guard but the UI is the only surface that can rename a domain in place. A newsiteops.SyncEnvIfPrimaryChangedseam both surfaces call rewrites the parent.envand each worktree'sAPP_URLto the new host, collapsing five copies of the same conditional.- PHP-only controls hidden on static sites (#446). A static site is just a public dir of HTML served by nginx with no PHP, but the detail panel showed every PHP-only surface because the UI gated on
php_version, which lerd populates from the global default even for non-PHP projects. A properuses_phpsignal, a detected framework, acomposer.json, a top-level.phpfile or apublic/index.php, and never a custom container, now lives inphp.SiteUsesPHPand gates the version dropdown, the Tinker and Dumps tabs and the FPM logs;RestartSiterefuses a static site rather than bouncing the shared FPM container every PHP site on that version depends on, and Xdebug moves to a bug-icon toggle in the address bar. - Installed-detection unified on quadlet presence (#433). The MCP
service_preset_list, the web UI presets endpoint andlerd service presetanswered "is this installed?" by probing for the YAML in~/.config/lerd/services/, while the rest of the codebase probes the quadlet, so an orphanlerd-mysql.containerwith nomysql.yamlread as a running MySQL the MCP layer insisted was not installed. A canonicalserviceops.ServiceInstalledprobes the quadlet because that's what podman actually runs, and every presence check across MCP, the web UI, the CLI, the TUI and thelerd checkvalidators routes through it, with thelerd checkfailure path now suggesting the concrete remediation command. - macOS mounts system directories to the Podman machine on first init (#458). lerd now mounts the system directories during the initial Podman machine initialization on macOS, so the path no longer depends on a later run to wire them up. Closes #452.
- Pre-1.23 review follow-ups (#459, #467). A sweep of everything merged since 1.22.1 fixed: the config editor's atomic writer staging a
.tmpthat matched the nginx include glob, now a dot-prefixed temp throughos.CreateTemp; a data race clearing the TUI dump buffer from atea.Cmdgoroutine, now routed through a message applied on the update loop; a first-time global nginx save on an install predating thehttp.dmount reporting success while the container crash-looped, now it waits for the container and validates withnginx -t, rolling back on failure; an upgrade writing a custom service's tuning mount without restarting the running container;.envand service-tuning restore ignoring the previewed backup and always restoring the newest; a tuning auto-rollback showing the raw error with no sign the old config was back; a notifications toggle for the possible N+1 query warning that previously always fired; the worker-queries switch now hiding already-buffered worker queries rather than only stopping future capture; and a stray NUL byte innotify.tsthat had been making git treat the file as binary.
Security
- Cross-origin requests to the dashboard are blocked (#447). Any page open in the developer's browser could fire a CORS-simple
text/plainPOST tohttp://127.0.0.1:7073and reach state-changing endpoints, including the per-site tinker route that runs arbitrary PHP in an FPM container with the project.envand host.sshmounted, since loopback bypassed every existing check and a simple POST carries no preflight and no custom header. State-changing methods now have to prove they came from lerd's own dashboard: a request passes when the browser labels it same-origin, same-site or none, when it's cross-site but carries one of lerd's own origins, or, for clients that send noSec-Fetchheaders at all, when it carries a non-emptyX-Lerd-CSRFheader that a forged cross-origin request can't set without a preflight lerd only answers for its own origins. The dashboard injects the header automatically on unsafe requests; the unix socket, the remote-setup bootstrap, the mailpit webhook, the notify bridge and the non-destructive per-site unpause keep their own source gates and are exempt. - vitest bumped to v4 (#466). Dependabot flagged vitest 3.2.4 as critical (GHSA-5xrq-8626-4rwp, arbitrary file read and execute when the Vitest UI server is listening), a dev-only dependency lerd never runs the UI server for, so real exposure was minimal; the fix lands in 4.1.0 so this moves to
^4.1.8, and regenerating the lockfile pulled the semver-safewspatch that cleared the remaining moderate advisory, leavingnpm auditclean.
[1.22.1] - 2026-05-27
A fix release polishing v1.22.0. The PHP-FPM image rebuild path is now self-healing after a brew upgrade, with an OCI label on every built image so a stale cache file can't strand users on the old image. Podman older than 4.5 fails install with an actionable error instead of a raw cobra panic, and the requirements page lists the affected distros (Ubuntu 22.04, Zorin 17, Debian 11 and 12) alongside the per-distro workaround. The nginx catch-all _default.conf survives hand edits across lerd start thanks to a hash sidecar lerd stamps when it writes the file, and the rewrite itself is now atomic and mode-preserving. lerd dns:check no longer false-alarms on hosts running a sibling dnsmasq, lerd lan expose pre-flights the forwarder port, and lerd service reinstall is transactional and routes through the preset name so a failed reinstall no longer wipes the old install before failing. macOS gains a gvproxy port-forward heal after a Podman Machine restart, the tray Quit menu item actually stops services and exits, and the tray surfaces a stderr warning when the lerd binary can't be located under launchd. The TUI's update-check banner stops false-firing on stable installs.
Fixed
- PHP-FPM images rebuild when the Containerfile changes (#415).
lerd install(and the install pass insidelerd update) used to advance~/.local/share/lerd/php-image-hashwithout doing the build, becauseensureFPMQuadletno-opsBuildFPMImagewhen the image already exists but unconditionally stamped the current Containerfile hash. After abrew upgradethat introduced template changes (SPX in v1.22.0), the on-disk hash matched the new template even though the image was still the old one, soNeedsFPMRebuildreported "up to date" and the rebuild was silently skipped.StoreFPMHashnow runs insidebuildFPMImageonly after a real build completes, so no path advances the hash without actually rebuilding.lerd installalso re-checksNeedsFPMRebuilditself and re-execsphp:rebuildwhen a drift is detected, sobrew upgrade && lerd installis self-healing without needinglerd updateto run the same check afterwards. The rebuild is gated onautostart enabledso it does not re-start units that the user explicitly stopped, and the parallel per-version builds serialise their hash writes through a mutex so they can't truncate each other. - OCI labels on PHP-FPM images detect a poisoned hash cache (#427). The previous fix corrected the path that advanced the cache, but couldn't auto-heal users already in the poisoned state: their cache file already matched the new Containerfile, so
NeedsFPMRebuildreturned false and the rebuild was still skipped. Every PHP-FPM image lerd builds now carries adev.lerd.fpm.containerfile-hashOCI label recording the hash it was actually built from.NeedsFPMRebuildchecks both signals: cache file versus embed (the fast path) and each installed image's label versus embed (the recovery path). Empty labels (images built by pre-fix lerd binaries) are treated as mismatches, so the firstlerd installafter the upgrade triggers a one-time rebuild that stamps the labels. From then on the labels are authoritative and the cache file is just a fast-path optimisation. - PHP-FPM rebuild check no longer scans orphaned legacy images (#430). The label scan introduced above used to iterate every
lerd-php*-fpm:localimage on disk, including pre-v1.22.0 images for PHP versions the user had since removed. Those orphans carry no containerfile-hash label, so the comparison always failed andlerd installre-fired the rebuild banner on every run, even after a successful rebuild of the active versions.NeedsFPMRebuildnow reads the active version list fromphpDet.ListInstalledand only checks labels on those versions' images. A centralisedFPMImageNamehelper keeps the rebuild check and the build paths agreeing on the naming convention, and aContainerfileHashread error now forces a rebuild instead of being silently treated as up-to-date. _default.confsurvives hand edits acrosslerd start(#425).EnsureDefaultVhostrewrote the catch-all conf on every start, clobbering any local patches. Users tuning the catch-all (e.g. swappingssl_reject_handshake onforoffon a staging machine) had to re-apply the change after every restart. lerd now stamps a sentinel_default.conf.lerd-managed-hashalongside the conf with the sha256 of what it last wrote. On subsequent runs the on-disk content is hashed and compared: a matching hash means lerd updates the file in step with template changes, a mismatch means lerd preserves your edit and logs a one-line INFO that it skipped the rewrite. A missing sentinel with matching content reclaims management (recovers from a transient FS failure on the sentinel write), a missing sentinel with diverging content is treated as user-managed with a message that explicitly calls out the upgrade case and tells you the exactrmto restore lerd's default. The sentinel filename suffix stays outside nginx's*.confinclude glob so it never gets loaded as config._default.confis written atomically and preserves file mode (#430).EnsureDefaultVhostused two back-to-backos.WriteFilecalls for the conf and its sentinel, so a crash between them left lerd-managed content with no sentinel, indistinguishable from a user-edited file on the next run. An in-place truncate also briefly exposed half-written nginx config to a concurrent reload. A newwriteFileAtomichelper writes to a sibling.tmp, chmods to the desired mode to defeat umask, then renames atomically into place. Crashes mid-write leave the destination at its previous state, never partial. When the destination already exists, the helper preserves its mode so an out-of-band chmod survives the rewrite.- Clear error when podman is older than 4.5 (#421).
podman network create --dnswas added in podman 4.5 (April 2023). Older releases used to faillerd installwith a raw cobra "Error: unknown flag: --dns" and no hint about what to do next. The install path now translates that specific stderr signature into a message that names the minimum supported version and points at the requirements docs. The substring match is anchored to bare--dnsfollowed by a newline so flag names sharing the prefix don't trip the rewrite, and the wrap usesfmt.Errorf %wso callers (including theErrNetworkNeedsMigrationsentinel on the migration path) still see the original error viaerrors.Is. Refs #420. lerd dns:checkrecognises a legacy host-side resolver (#426). The diagnostic reported "lerd-dns container not running" as a hard FAIL whenever the container was absent, even if a host-side resolver (Homebrew/launchd dnsmasq, system package) owned:5300and was answering the TLD correctly. The top-line summary read "DNS is NOT working" when DNS was in fact working perfectly, just not through lerd. Rung 1 now distinguishes three sub-cases when the container isn't running: port:5300in use and answering.testwith127.0.0.1reads as WARN "host-side resolver is handling DNS; lerd isn't managing it", port:5300in use and answering with a different IP also reads as WARN (a LAN-IP setup for cross-device testing, with the actual answer surfaced in Detail), and port:5300free or held by a non-DNS process reads as FAIL with the probe error in Detail so NXDOMAIN, timeout, and refused stay distinguishable. Hints now emit on WARN as well as FAIL so users see the remediation suggestion in both cases. Reported in #414.lerd lan exposepre-flights the forwarder port (#424).lerd lan exposeused to install lerd-dns-forwarder (listening onlanIP:5300) without checking whether the address was already taken. On hosts running their own dnsmasq via Homebrew/launchd or a sibling tool, the new unit would write fine and then both daemons would race for the same address on every boot. Pre-flight now tries to bind UDP and TCP onlanIP:5300and refuses the install if either fails, with an lsof line (macOS) orss -tulpnhint (Linux) identifying the holder. The check is skipped when lerd's own forwarder is already active (re-runs oflerd lan exposereplace themselves cleanly), and also when launchd or systemd report the unit asdeactivating, so a quicklerd lan unexpose && lerd lan exposedoesn't catch our own forwarder mid-stop and report it as the conflict. Reported in #414.lerd service reinstallis transactional and routes via the preset name (#423).lerd service reinstall <name>had two failure modes that left a service in a half-broken state. First, the custom-service path was passing the SERVICE name (e.g. "mariadb-10-11") toInstallPresetStreaming, which expects the PRESET name (e.g. "mariadb"), so the call failed with "unknown preset mariadb-10-11" afterRemoveServicehad already deleted the YAML and container, leaving the user with neither the old install nor a fresh one.CustomService.Presetis now consulted as the breadcrumb to the source preset and routed throughspec.presetName. Second, configuration errors (missing preset, bad version, unsatisfied deps) and network errors (image registry unreachable) fired AFTERRemoveServicehad run. A pre-flight pass now replays the install path's checks without side effects (preset load, version resolve, missing dependencies, plus a "version-on-single-version-preset" sanity check) and runs the image pull beforeRemoveService, so on-disk state stays intact when validation or the pull fails. Default-preset and custom-service paths share the same validation so both fail closed under the same conditions. A separate fix suppresses the family-consumer regen pass during the reinstall's remove step (it was rendering a partial plist that excluded the service being reinstalled, which on macOS could race with the post-install regen and leave consumers pointing at the wrong env), with explicit regen restored on the install-failure path so a failed reinstall doesn't strand consumers pointing at a deleted container.- gvproxy port forwards heal after a Podman Machine restart on macOS (#422). After a Podman Machine restart (
machine stop/start, a Mac reboot, or a crash), lerd-* containers come back up inside the VM via--restart=always, but gvproxy on the host loses its port-forward table and only learns about forwards whenpodman run -pis invoked from the host CLI. The result was sites that looked "running" but refused on:80/:443, with nolerd start,service restart nginx, or launchd kick fixing it on its own. lerd now records the machine'sLastUpat every successful start and install, and compares it on the next run. When the value moved (external restart) it force-removes running lerd-* containers so the StartUnit pass that follows invokespodman run -pfresh from the host and gvproxy re-registers the forwards. TheLastUpis sampled beforeensurePodmanMachineRunningso internal stop+starts (rootful or memory adjustment) don't trigger a phantom heal. The same pattern runs fromlerd installso users reaching container-start vialerd updateget healed too. Linux is unchanged: every function is a no-op since podman runs natively without a VM or gvproxy. - macOS tray Quit actually exits and stops services (#419). Two bugs combined to make the tray's Quit menu item appear to do nothing. The launchd plist for lerd-tray (and lerd-ui, lerd-watcher) was generated with a bare
KeepAlive=truewhenever the systemd unit hadRestart=on-failure, so launchd respawned the job on any exit including a cleanos.Exit(0), which is not whatRestart=on-failuremeans.Restart=on-failurenow maps toKeepAlive: SuccessfulExit=falseinstead, andRestart=alwaysstill emits the bareKeepAlive=true. Separately,handleQuitranexec.Command("lerd", "quit")but launchd's job environment has no PATH covering/opt/homebrew/bin(where the Homebrew-installed lerd binary lives) so the call silently failed and none of the containers Quit was supposed to stop got stopped. The lerd binary path is now resolved once per call viaexec.LookPathplus a known-prefix fallback, and every tray-sidelerd …invocation routes through a small wrapper. Re-resolving on every call (instead of caching) keeps the tray correct across mid-session reinstalls. - Tray warns when the lerd binary can't be located (#430). Under launchd the tray runs with an empty PATH, so
lerdBinwalks a fixed list of candidate install dirs before falling back to the bare string "lerd". That fallback used to be silent: menu clicks would fail with an exec error that surfaced nowhere obvious, leaving the user staring at an unresponsive tray. The function now prints a deduped warning to stderr the first time it falls through to the bare-name fallback, so launchd's error log surfaces the missing binary instead. Async.Oncekeeps the warning from spamming on every menu click. - TUI update-check banner stops false-firing on stable installs (#416).
tui.Runwas receivingversion.String()(e.g. "1.22.0 (commit ABCDEF, built 2026-05-25T15:43:56Z)") and threading it through toCachedUpdateCheck.splitPrereleasefinds the first-in the string and treats everything after it as a pre-release suffix, so the comparison degraded to "latest (no pre) > current (has pre)" and the banner fired even when the user was on the latest stable. The TUI now passesversion.Versioninstead, matching every other caller and keeping the header line short.
[1.22.0] — 2026-05-25
The headline is a profiler: SPX is bundled into every PHP-FPM image behind a single global toggle, and while it is on every request to every shared-FPM site is recorded as a flame graph, viewable in a same-origin Profiler overlay in the dashboard or driven from lerd profile and two MCP tools. The dashboard's System health card turns interactive, with the profiler, the dump bridge and notifications all becoming live antenna-style toggles backed by a reusable PulseToggle component. lerd init now seeds its setup wizard from an existing herd.yml, .ddev/config.yaml or .lando.yml so teams migrating from Herd, DDEV or Lando start from real values instead of a blank form. A new postgres-pgvector preset lands as a first-class service for vector-embedding workloads, alongside a reworked database picker that builds its option list dynamically so every installed family alternate shows up by name. And DNS gains a real handle on VPN clients: health goes three-way so a VPN rewriting the system resolver reads as a yellow degraded state rather than a red outage, lerd-watcher and lerd-ui both subscribe to kernel rtnetlink events so a connect or disconnect is reflected within a second in the dashboard pill and the new DNS entries in the Recent Activity feed, and container DNS is re-synced automatically against the new host resolvers so PHP can reach VPN-internal endpoints without a manual restart.
Added
- DNS state changes appear in the dashboard's Recent Activity feed (#410). Transitions surface as DNS degraded (amber, with a "VPN active" suffix when a tunnel is up), DNS down (red), and DNS recovered (green), so users get a historical record of when DNS state shifted, not only a live pill colour. Diff logic is a pure helper that emits exactly one event per status change and stays silent on the vpn-only flag flipping under a steady degraded state. Labels are translated across all eight existing locales.
- postgres-pgvector preset and a dynamic database picker (#392). The postgres-pgvector preset (v18 canonical, plus v17 and v16 alternates, image
pgvector/pgvector:pg<N>, host ports 5518/5517/5516) lands as a first-class lerd service for vector-embedding workloads instead of a hand-rolled custom yaml, and participates in pgadmin auto-discovery viadiscover_family:postgres. Wiring it up fixed three rough edges in the postgres-family code paths. Family resolution is now a single helper:config.FamilyOfandconfig.FamilyOfNameprefer the explicitFamilyfield over name-pattern inference, which previously only matched the<family>-<digit>shape and so resolved postgres-pgvector to no family at all, with the result thatenv.godecided it was not a database and skipped theDB_DATABASErewrite and database creation.config.IsDBServiceNameis the same idea for the boolean used bydb_setvalidation. The env wizard and thedb_setMCP tool now build their option list dynamically from the installed mysql and postgres family members plus sqlite, so any alternate (mariadb, postgres-pgvector, future ones) shows up by name instead of a hardcoded three-way choice. The multi-version preset picker gains canonical versions, marked(default)in the label and preselected, via a newconfig.PresetVersionServiceNamehelper that centralises canonical-versus-suffixed name resolution across the CLI table, the web UI, and the MCP list. Refs #378. lerd initseeds the setup wizard from Herd, DDEV and Lando config (#393). Teams migrating to lerd from Laravel Herd, DDEV or Lando already have a project config file that lerd previously ignored. Whenlerd initruns in a project that still carries aherd.yml, a.ddev/config.yamlor a.lando.yml, with both filename spellings recognised for each tool, it detects the file and asks whether to use it for the wizard defaults. Accepting it translates the PHP version, Node version, HTTPS preference, document root, domains and the database or service list into the wizard's defaults, which are then reviewed and confirmed exactly like any other run; declining leaves the wizard on plain auto-detection. The prompt only fires when no.lerd.yamlexists yet, so it never touches an existing lerd configuration. The translation is deliberately partial and everything it drops or changes is printed as a note before the wizard opens: pinned service versions and ports are dropped because lerd resolves those per machine, MariaDB folds into MySQL with a note pointing atlerd service preset mariadbfor anyone who needs it specifically, the framework is never translated since lerd auto-detects it more reliably, and seeded domains and document root are surfaced in the notes because the wizard has no prompt for them.- SPX profiler with a global toggle and dashboard view (#395). lerd gains a profiler for the question dumps and logs cannot answer, where a request actually spends its time. SPX, a low-overhead PHP profiler, is bundled into every PHP-FPM image behind a single global on/off switch; while it is on, every HTTP request to every shared PHP-FPM site is recorded as a flame graph. Turning it on or off flips a config flag, regenerates the FPM site vhosts so nginx injects an SPX cookie into each request, and reloads nginx, with no FPM restart and nothing to add to projects. The SPX cookie is suppressed whenever an
X-Forwarded-Hostheader is present, so the profiler stays unreachable through tunnels and LAN shares. The SPX report UI is reverse-proxied same-origin under/_spx/bylerd-uiand appears as a Profiler entry in the dashboard icon rail, opening in the same overlay the service dashboards use. From the terminal there islerd profile on/off/status/open,lerd profile runto profile a one-off artisan or CLI command, and theprofiler_toggleandprofiler_statusMCP tools. Profiling covers shared PHP-FPM sites only, since FrankenPHP and custom-container sites run images without the SPX extension. - Profiler, dump bridge and notification toggles in the dashboard System health card (#396). The System health card gains interactive toggles: the SPX profiler gets one, the dump bridge row becomes a real toggle instead of a read-only dot, and the notifications row is a bell toggle that flips the master switch and prompts for browser permission on first use, dimming when the browser has blocked notifications. Each is an antenna-style emerald icon with a slow pulsing dot while the feature is live, and all six System health indicators now sit in one centered column so the card reads as a single set. The left rail's profiler icon carries a green dot whenever profiling is on. The Profiler overlay gains a Clear data button, with clearing captured reports also available as
lerd profile clear, theprofiler_clearMCP tool, and the/api/profiler/clearendpoint, all backed by a sharedprofiler.ClearData. Profiler state is broadcast over the websocket as a newprofiler_statuskind so the indicator and the rail dot stay live when the profiler is toggled from the CLI, MCP, or another browser tab, matching how the dump bridge already behaved. The shared antenna-style button was extracted into a reusablePulseTogglecomponent.
Fixed
- VPN connect and disconnect react within a second via rtnetlink (#410). The beta's container DNS re-sync and the dashboard pill were both poll-driven on a 30 second interval, so after a VPN toggle the pill could sit on a stale red colour and PHP inside containers could keep resolving against the wrong forwarders for up to a minute. lerd-watcher and lerd-ui now both subscribe to rtnetlink
RTMGRP_LINK/RTMGRP_IPV4_IFADDR/RTMGRP_IPV6_IFADDRmulticast events with a 750ms debounce, so a kernel-reported link or address change kicks an immediate re-probe and re-sync. The 30 second poll stays as a safety net so a missed kernel event cannot strand the system. VPN detection is also broadened: ProtonVPN'sproton0andprotonwire0, Mullvad, and any branded client whose interface carries the POINTOPOINT flag now register as a VPN, so the degraded-state downgrade fires for them too. The same fallback engages for cellular tethering, where reading a broken system resolver as degraded rather than down is the desired outcome. On hosts where rtnetlink is unavailable (seccomp-filtered services, certain hardened container namespaces),LinkChangesemits a one-shot warning to journalctl and the existing poll takes over. macOS keeps the build-tagged no-op path: both watchers fall back to the 30 second poll, unchanged from before, since macOS containers resolve through the podman machine VM. - DNS no longer false-alarms under a VPN, and container DNS re-syncs automatically (#397). Connecting a corporate VPN such as Cisco AnyConnect made the dashboard show a red DNS outage even though lerd-dns was healthy and answering, because the VPN client rewrites the system resolver so the TLD stops routing to lerd-dns. DNS health is now three-way: when the system resolver path fails, lerd queries dnsmasq directly on
127.0.0.1:5300, and a direct answer reads as a yellow degraded state rather than a red down. The degraded state renders consistently across the dashboard pill, the tray icon, the TUI header andlerd status,lerd doctordowngrades the system lookup rung to a warning when a VPN tunnel is up, and the UI status watcher debounces transitions across two ticks so a momentary blip while a VPN connects cannot latch the pill. Separately, PHP inside the containers could not reach VPN-internal API endpoints until lerd was restarted, because the lerd network's aardvark-dns was left on the pre-VPN forwarders and a stale cache. The watcher now fingerprints the host resolver environment every tick and, when it changes, re-syncs the container network DNS automatically, re-pointing aardvark-dns at the current resolvers and reloading the network so containers pick them up with a fresh cache. The re-sync runs on Linux only, since macOS containers resolve through the podman machine VM. Reported in #394.
[1.21.2] — 2026-05-22
A hotfix for three issues a user hit while migrating an Ubuntu development stack to Lerd: a spurious MySQL readiness warning printed in front of every PHP command, a fatal parse error from the dump bridge on legacy PHP, and a failed PHP 7.2 image build.
Fixed
could not start mysqlwarning on every PHP command even though MySQL was healthy. The readiness probe ranmysqladmin pingwith no host, so the client fell back to the default Unix socket at/var/run/mysqld/mysqld.sock. MySQL images that keep their socket elsewhere, and MariaDB which uses a different path again, failed the probe for the full 30 second timeout while the server was up and serving connections, leaving amysql is active but not yet readywarning in front of every command. The probe now connects over IPv4 loopback TCP, which is independent of the image's socket path and, because it targets the container's own loopback, stays correct on macOS and on IPv6-only host networks. The MySQL service-migration probe, dump, and restore commands force TCP the same way solerd service migrateis consistent with the Postgres path.- Dump bridge crashed legacy PHP with a fatal parse error. The
dump()/dd()bridge that Lerd installs as anauto_prepend_filewas written withmatch,mixedandnevertype declarations,str_contains/str_ends_with, andarray_key_first, none of which exist on PHP 7.4 (and, for several of them, 8.0). On those versions PHP aborted withsyntax error, unexpected '=>'before running any code, breaking every CLI command and web request until the mounted ini was removed by hand. The bridge is rewritten in syntax that parses and runs on every PHP version Lerd builds, down to the 7.2 floor, with identical behaviour on current PHP. lerd php:rebuild 7.2failed to build the image. PHP 7.2's Alpine 3.12 base predates several things the Containerfile assumed: theicu-data-fullpackage, gd's post-7.4 configure flags, current phpredis and xdebug, thebatpackage, and a transitivelibgompthat the ImageMagick extension needs at runtime.icu-data-fullnow installs tolerantly because older Alpine folds the full ICU locale data intoicu-libs, gd's configure branches onPHP_VERSION_ID, xdebug and phpredis are pinned to the last releases supporting the older PHP line,batinstalls tolerantly, andlibgompis added explicitly so ImageMagick loads. PHP 7.2 stays an unshipped legacy tier with no pre-built base image, but a locallerd php:rebuild 7.2now produces a working image.
[1.21.1] — 2026-05-19
A targeted fix for npm install -g and the same long-standing issue for composer global require. npm globals went into a fnm version-specific bin directory that nothing has on PATH, so npm install -g pm2 succeeded but the resulting pm2 was unreachable from the user's shell. Composer globals had the same shape, landing in ~/.config/composer/vendor/bin/ which is also not guaranteed on PATH (especially on macOS Homebrew installs), so composer global require psy/psysh left psysh callable only via its full path. lerd now points npm's prefix at a stable per-user path under ~/.local/share/lerd/node-global/, mirrors composer's existing global vendor/bin, and writes small wrapper scripts for every global binary from either source into ~/.local/share/lerd/bin/, the directory lerd install already adds to PATH on both Linux and macOS regardless of whether lerd itself came from Homebrew or a curl-pipe install. Node wrappers exec the real bin through fnm exec --using=default, composer wrappers exec through lerd php, so #!/usr/bin/env node and #!/usr/bin/env php shebangs both resolve against lerd's managed runtimes from any directory. npm uninstall -g and composer global remove clean their wrappers alongside the package. Each category uses its own marker so the two syncs coexist without removing each other's wrappers, and files in the bin dir without any lerd marker (existing node, npm, npx, php, composer, laravel, fnm, mkcert shims, plus anything the user dropped in by hand) are never touched. The node, npm, npx, and composer shell shims route through the lerd binary first so the new behaviour applies to bare npm install -g and composer global require, not only to lerd npm and lerd composer, falling back to a direct fnm or composer.phar invocation in containers where the glibc lerd binary cannot exec.
Fixed
- Globally installed npm packages were orphaned in fnm's version directory (#390).
npm install -g pm2finished cleanly butpm2was not callable because the binary landed under~/.local/share/fnm/node-versions/<v>/installation/bin/, which is not on the host PATH. lerd now setsnpm_config_prefix=~/.local/share/lerd/node-globalfor everynpmandnpxinvocation routed through its shim, and after the command exits it mirrors every executable in that prefix'sbin/to a wrapper script in~/.local/share/lerd/bin/. That directory is the onelerd installadds to PATH via the user's shell rc on every supported platform, so the wrappers are reachable from a fresh shell whether lerd itself was installed via Homebrew on macOS, curl-pipe on Linux or macOS, or a packaged distro build. The wrapper exec's throughfnm exec --using=defaultso the#!/usr/bin/env nodeshebang resolves under the fnm-managed default node regardless of cwd, project-pinned versions, or whether the tool is invoked from outside any project. Uninstalls remove the wrapper too. Files in~/.local/share/lerd/bin/without lerd's marker comment are left alone, so the existingnode,npm,npx,php,composer, andlaravelshims that share the directory are never clobbered, and the head-only marker check rejects native binaries (which can carry the marker bytes as a string constant) so the cleanup pass cannot accidentally removelerditself or any other Go binary that happens to live nearby. - Bare
npm install -gdid not route through the new prefix logic (#390). Thenode,npm, andnpxshims in~/.local/share/lerd/bin/previously calledfnm exec -- <bin>directly, so the prefix override only applied when the user explicitly typedlerd npm. The shims now delegate to the lerd binary first viaexec "$LERD" <bin> "$@"whenever it is reachable, and fall back to the original direct-fnm path inside containers where the glibc lerd binary cannot exec. The friendly "No Node.js version available via lerd. Run: lerd node:install 22" hint surfaces through both paths. composer global requireleft binaries unreachable from PATH (#390). The composer flow had the same shape as the npm one:composer global require psy/psyshsucceeded but the resultingpsyshlived under~/.config/composer/vendor/bin/, which is not guaranteed on PATH (worst on macOS Homebrew). A newlerd composersubcommand now wraps the existinglerd php composer.pharflow and, after composer exits, syncs$COMPOSER_HOME/vendor/bin/to~/.local/share/lerd/bin/as small wrapper scripts that exec the real bin throughlerd phpso the#!/usr/bin/env phpshebang always resolves against the FPM container's PHP. Composer wrappers carry their own marker so they coexist with npm wrappers in the same directory without either sync removing the other's files, and the existing hand-writtenlaravelshim (which has no marker) is preserved as a foreign file so users who relied on the install-time shortcut keep working. The shellcomposershim delegates tolerd composerfirst when the lerd binary is reachable, falling back to the originallerd php composer.pharpath otherwise.RunPHPgrows a non-exiting sisterRunPHPCaptureso the sync can run on every exit path, including failedcomposer global removeruns that leave the bin dir half-cleaned.
[1.21.0] — 2026-05-19
The 1.21.0 line ships desktop notifications: a single in-process notifier inside lerd-ui fans every event out across the dashboard WebSocket and RFC 8291 Web Push, so captured mail, worker failures, finished service operations, registry update transitions, and ray/dump arrivals reach you even when no tab is open. The PHP-FPM image grows a real shell environment (zsh + starship + a handful of modern CLI tools, all isolated from the host) and then loses about 800 MB of build toolchain in a multi-stage split, dropping localhost/lerd-php85-fpm:local from 1.36 GB to 535 MB without losing any of the 68 PHP modules it ships. A new on-demand commands feature surfaces one-shot framework actions (cache clears, migrations, login URLs, anything you put in yaml) across the dashboard, the lerd run CLI, the command palette, and four new MCP tools, all backed by a generalised Dropdown component that replaces every native select in the UI. The site detail header gets a browser-style address bar with the favicon, TLS lock, LAN-share chip, and worktrees promoted from a dropdown to tabs, an Env tab joins Overview/Tinker/Dumps to show the project .env verbatim, and tray menu picks up Dump bridge and Notifications toggles that update live via a new KindDumpsStatus event. Postgres grows 17 and 18 alternates alongside a new MySQL 9.7 LTS line, all gated by a canonical-version pin so flipping the yaml's canonical no longer silently major-jumps existing installs. Plus Türkçe (tr) joins the dashboard languages, a public_dir override lands in .lerd.yaml for projects with a non-standard document root, every git invocation in the tree now flows through internal/git, and worker-failure pushes are batched so a systemd cascade no longer fires six near-identical notifications back to back.
The path to stable then folded in three transitive dependency bumps closing five Dependabot alerts (jwt-go to 5.2.2 for the header-parsing memory-allocation flaw, svelte to 5.55.8 for the DOM-clobbering and SSR-spread XSS paths plus hydratable promise serialization XSS, kysely to 0.28.17 for the JSON-path traversal injection), a LAN-exposure audit that closes three dashboard endpoints that were reachable on lan:expose installs (raw .env, push-test, and an unauthenticated mailpit webhook) and adds path-traversal validation for the new public_dir override, a catatonit PID 1 swap for mysql and mariadb so podman stop returns in a second instead of timing out at 30s, a host-worker stop-stays-stopped fix that also threads lerd's bin directory onto PATH for npm-spawned subprocesses so wayfinder and friends can find php, the restoration of git in the PHP-FPM runtime stage that the multi-stage split dropped, notification clicks that finally land on the right tab, and a container.target field in .lerd.yaml that lets multi-stage Containerfiles pick a development or production stage instead of always building the trailing one.
Added
- Desktop notifications via Web Push with per-category settings (#353). Five notification kinds ride a single in-process notifier inside
lerd-uithat fans every event out across/api/ws(open tabs) and Web Push (closed PWA / minimised tabs): captured mail from Mailpit, queue/horizon/reverb worker failures, finished service operations (install, migrate, reinstall, update, rollback) with success/failed variants, registry update-available transitions, andray()/dump()arrivals with the dumped content as a single-line preview in the body. Push is RFC 8291-encrypted, signed with a per-install VAPID key pair stored under~/.local/share/lerd, fans out from the centraldispatchNotificationchokepoint, and prunes subscriptions automatically on 410/404. The service-worker push handler is kind-agnostic, reading title, body, tag, url, and data straight off the payload so the SW never grows kind-specific branches. Settings live under System → Desktop notifications: master switch plus per-category toggles, a test-push button, and a subscribed-devices list with Forget actions. Per-kind preferences are stored both client-side in localStorage and server-side on the Subscription so closed-PWA Web Push honours the toggles too. Every visible string is translatable via Paraglide keys undernotify_*, with the English fallback always travelling in the payload so the SW (no DOM, no Paraglide) keeps rendering correctly. Worker-failure detection no longer gates on whether a dashboard tab is visible; only the eventbus publish does, so a closed-PWA developer still gets the push when a queue worker dies. - Notifications page polish and dashboard health row (#354). Denied state now expands into a banner with the page origin, per-browser unblock instructions picked from
navigator.userAgent(Chromium, Firefox, Safari, generic), and a recheck button so users who fix the permission in browser settings don't have to hard-reload to see the page transition out of denied. The panel follows theDetailPanel/DetailHeadershape used by DNS, Nginx, Watcher, and Dump-bridge, with a single Enable/Disable button in the header trailing slot next to aStatusPilland category-toggle rows that stretch to the full panel width. The sidebar dot, the System Health widget row on the dashboard, and the header pill now share onenotifyEffectiveOnderivation (permission granted, not auto-subscribe-disabled, master on) so the three indicators agree in every state. The label across the UI is now just "Notifications". - Turkish (
tr) locale for the dashboard (#355). Türkçe joins English, German, Spanish, French, Indonesian, Dutch, and Portuguese with 620 strings translated to full coverage. Glossary picked for technical accuracy and Türkçe convention: Dashboard → Pano, Service → Servis; Worker, Worktree, Horizon, Reverb, Stripe, FrankenPHP, and Tinker stay as-is.LOCALE_LABELS["tr"] = "Türkçe",LOCALE_CODES["tr"] = "TR", the inlang settings list, and the key-drift test all pick up the new locale. - Zsh + starship baked into the PHP-FPM image for
lerd shell(#358).lerd shelland the TUI shell action previously dropped into baresh, which made the in-container experience feel like a different planet from the host. The PHP-FPM image now ships zsh with a self-contained config (starship prompt,compinit -u, persistent history under~/.local/share/lerd/shell-state/php-<ver>/zsh/) and a handful of modern CLI tools (eza, bat, fzf, zoxide). The quadlet setsHostName=to the host's hostname so the prompt readsroot@your-machineinstead of an opaque container id, and the renderer mounts the per-version zsh history directory read-write so commands survive container rebuilds.InteractiveShellScript()picks zsh in the FPM image and falls back through bash to sh for other containers. The shell environment is deliberately isolated from the host's~/.zshrcand~/.config/fish: an earlier draft mounted those, but every customised host config introduced its own cascade of "no such file or directory" errors as it tried to source distro-specific paths the alpine container had never heard of. The container shell is its own world now, consistent across every machine and contributor. - Postgres 17 and 18 alternates, MySQL 9.7 LTS, canonical version pinning across multi-version presets (#361). Postgres is restructured into a multi-version preset with 16 canonical plus 17 and 18 alternates, MySQL gains the new 9.7 LTS line as an alternate, and the obsolete MySQL 8.0 alternate is removed because 8.4 LTS already covers that line. Multi-version presets now snapshot the canonical version tag at install time into
ServiceConfig.CanonicalVersion, so a future release flipping the yaml's canonical (e.g. postgres 16 to 18) will no longer silently major-jump existing installs. The pin survives reconciles, gets backfilled from the running image for pre-pin installs, and is updated bylerd service migratewhen the user moves the canonical on purpose. Postgres 18 neededPGDATApinned to the legacy/var/lib/postgresql/datapath because the upstream image moved its default into a version subdirectory and refuses to start otherwise; MySQL 9.x removedmysql_native_passwordentirely, which was named in the sharedlerd.cnf'sauthentication_policyline and aborted init, so that line is gone and the defaultcaching_sha2_passwordis used on every supported version. pgadmin now auto-discovers postgres family members the same way phpmyadmin discovers mysql and mariadb: adynamic_envdirective registers it as a family consumer, theservers.jsonandpgpassfile mounts are generated at materialise time fromServicesInFamily, andPGADMIN_REPLACE_SERVERS_ON_STARTUPmakes pgadmin re-import on every restart so installing a postgres alternate immediately shows up in the dashboard without a manual server-add step. To support that,FileMountgained an optionalContentFngenerator alongside the staticContentfield. Closes #360. - On-demand framework commands across UI, CLI, and MCP (#363). A new commands feature surfaces one-shot admin actions (cache clears, migrations, login URL generation, anything you put in yaml) for every registered site. The dashboard exposes a Commands dropdown in the site controls band, the CLI exposes
lerd run <name>, and the command palette aggregates commands across all sites with a single multi-term search. MCP gains four tools so AI assistants can list, run, add, and remove commands without hand-editing yaml. The Go side introduces aFrameworkCommandschema (yaml-defined or inline in a project's.lerd.yaml), aResolveCommandsmerge function that lets projects override or disable framework defaults by name, a streaming run endpoint that interleaves stdout and stderr over SSE with per-site mutual exclusion and loopback gating, and anoutput: terminalmode that spawns the user's terminal emulator instead of streaming.lerd checkvalidates the schema. Built-in Laravel and Symfony come with the same command set hardcoded so sites work before the framework store fetch lands. The frontend introduces a generalisedDropdowncomponent (object options, keyboard navigation with arrow keys and type-ahead, ARIA roles, mobile-aware popover sizing) that replaces every native select in the UI for visual consistency. The commands feature uses it for both the per-site Commands menu and the inline run modal, which renders streamed output with ANSI color support and distinguishes stderr from stdout. Run state is global so the palette and the dropdown share the same modal; finished runs are kept in localStorage so users can review previous output without re-running, and long runs that finish while the tab is hidden trigger a desktop notification when permitted. Docs land indocs/features/commands.mdand the MCP skill picks up updated tool descriptions. - Multi-stage PHP-FPM build cuts image size by ~60% (#364). The PHP-FPM image was carrying about 800 MB of build toolchain (autoconf, make, g++, linux-headers, and a long list of
-devheaders) that pecl anddocker-php-ext-installneed at compile time but nothing needs at runtime. Splitting the Containerfile into a builder stage that compiles every extension and a runtime stage that only ships musl runtime libs plus the compiled.sofiles bringslocalhost/lerd-php85-fpm:localfrom 1.36 GB to 535 MB on top of #358, with no functional regressions. A newbuildCustomExtRuntimeDepshelper emits a singleapk RUNline in the runtime stage that reinstalls the apk packages user-configured extensions need; without it, custom extensions like imap (which needs c-client at runtime, not just at build time) would compile in the builder fine but fail to dlopen at runtime. The well-known imap case verified the round-trip works.baseContainerfileHashstrips the new{{.CustomExtensionsRuntime}}placeholder so pre-built base image cache keys stay stable. Every PHP extension (68 modules including imagick, redis, mongodb, igbinary, pcov, xdebug, opcache, all base extensions), composer, node + npm, ghostscript, imagemagick, ffmpeg, mysql-client, and the zsh/starship/etc shell environment from #358 are preserved. The base-images workflow now strips the new placeholder when hashing the Containerfile, and after each per-arch push a smoke step pulls the just-built amd64 image, counts loaded PHP modules, fails if the count drops below 50, and verifies a handful of load-bearing extensions (curl, gd, intl, mbstring, pdo_mysql, redis, xdebug, zip) are present plus that PDO has its three drivers and composer runs, so silent extension drops get caught at publish time instead of on user rebuilds 24 hours later. - Browser-style address bar header for the site detail panel (#365). The site detail panel now renders its top region like browser chrome. The dominant element is a pill-shaped address bar with the favicon, scheme, and domain, flanked by a circular reload, pause/resume, open-in-browser, a LAN-share toggle, terminal (loopback only), and unlink. The TLS lock at the leading edge of the pill clicks through to flip HTTPS, with the gating logic from the old
SiteControlstoggle ported over so it stays static when DNS is off. Clicking the URL itself opens the Manage Domains modal, leaving the explicit Open button as the way to launch the site in a browser. When LAN sharing is on, the existing teal URL chip with its hover-QR slots into the bar next to the framework badge instead of living below the controls row. Worktrees are tabs above the bar rather than a dropdown: the first tab is the main checkout with a home glyph, every other tab carries the branch icon and an inline X. The X opens a newRemoveWorktreeModalscoped to that branch, and the trailing + opensAddWorktreeModal. The old combinedWorktreeModalis gone because the tab strip already shows every worktree and removal is one click away. Side effect:openSiteInBrowserno longer ignores TLS on worktrees, so worktree subdomains correctly open as HTTPS when the parent site has TLS enabled, matching what nginx and the cert manager have always done. - Env tab on the site detail panel (#366). A new Env tab joins Overview, Tinker, and Dumps that renders the site's project
.envfile as raw, selectable, copyable text. The file shows verbatim, comments, blank lines, and ordering preserved, with a one-click copy button mirroring the existing services-sideEnvBlock. The tab only appears when the site actually has a.envfile on disk, so non-Laravel or static sites that never had one stay clean. A newhas_envfield on the sites snapshot makes that gate cheap on the frontend, and a newGET /api/sites/{domain}/envendpoint serves the raw bytes, accepting the same?branch=parameter the rest of the site routes use so the tab follows the active worktree selector just like Tinker does.EnvBlocknow accepts either a parsed vars map (existing service callers) or a raw text prop (the new site tab), keeping the styling and copy logic in one place. public_diroverride in.lerd.yaml(#370). Projects with a non-standard document root (e.g. a Laravel skeleton usingpublic_html/instead ofpublic/) can now declare it directly in.lerd.yamlwithout having to clone the entire framework definition. TheSitestruct already had aPublicDirfield andresolvePublicDir()already preferred it over the framework default; what was missing was a route to populate it from the per-project file.ProjectConfiggains apublic_dirfield,lerd linkthreads it into the site, andlerd initpreserves it on re-runs. The existing nginx vhost templates already use the resolved value, so no template churn was needed. Also promotes the Configuration page from/reference/configurationto a top-level/configurationentry in the docs nav and documents the new field with a short Custom public folder example. Closes #369.- Dump bridge and Notifications toggles in the tray menu, live dump-status updates on the dashboard (#373). The tray picks up two new items, Dump bridge and Notifications, alongside a global
lerd notify on/off/statuscommand. Notifications gain a config-level mute vianotifications.disabledinconfig.yaml, which the centraldispatchNotificationchokepoint now consults so neither the WebSocket banner broadcast nor the Web Push fanout runs while the gate is closed. Per-device dashboard prefs still apply on top when the gate is open. The CLI subcommand and the tray click both flow through the same path so the surface stays consistent. The dump-bridge indicator on the dashboard now updates live:dumpsops.Applystill owns state, butlerd-uilearns about it three ways. The dashboard's/api/dumps/toggleand/api/dumps/passthroughare wrapped withpublishAfterso cross-tab updates fan out, a new loopback-only/api/dumps/notify-changedendpoint lets the CLI nudge the daemon afterlerd dump on/off, and a newKindDumpsStatuseventbus kind broadcastsbuildDumpsStatusJSONover the WS. The frontend's dumps store subscribes to that frame and writes incoming payloads straight into the existing status writable, so the antenna dot, the system sidebar indicator, and the dashboard health widget all flip without a refresh. Tray clicks stop waiting for the next 30s poll too: every toggle handler shares a single refresh callback that fetches a fresh snapshot the moment the underlyinglerdcommand exits, so the menu label reflects the post-command state right after the click instead of lagging by up to half a minute. container.targetfield in.lerd.yamlfor multi-stage Containerfile builds (#385). Projects whose Containerfile defines separate development and production stages can now declarecontainer.target: developmentin.lerd.yamland lerd threads--target <name>into both the synchronouslerd linkbuild and the streaming UI rebuild. A sharedbuildCustomImageArgshelper keeps the two code paths from drifting, and the cache key inhashContainerfilemixes the target value in with a NUL delimiter so flipping between stages on an unchanged Containerfile actually rebuilds instead of serving the previously-built stage. Docs and the MCP skill table pick up the new field. macOS needs no extra wiring since the flag rides on the standardpodman buildargv that already crosses into the podman-machine VM. Addresses #379.- MCP
service_addaccepts theinitflag (#386). Theinitfield landed onCustomServiceand the preset schema in #383 so the quadlet generator can wire podman's--initwhen an image's main process ignores SIGTERM as PID 1, but the MCPservice_addtool never picked up the corresponding argument. An agent registering a custom service through the wire couldn't turn the flag on without editing the YAML by hand. The tool's input schema now advertisesinitalongside the otherCustomServicefields, so MCP-driven workflows reach feature parity with the.lerd.yamlpath.
Changed
- Every git invocation in the tree now flows through
internal/git(#356). Seven production call sites outsideinternal/git/were spawning git viaexec.Commanddirectly, each with its own stdout/stderr wiring. They now route through four new helpers ininternal/git/exec.goso the package owns every git invocation in the codebase. The new surface:Output(dir, args...) (string, error)captures stdout,Run(dir, log, args...)sends both stdout and stderr to a single writer (the modal-log pattern),RunTTY(dir, args...)wires stdout/stderr straight to the user's terminal so shell redirects still work,RunCaptureStderr(dir, args...)mirrors stdout to the terminal while teeing stderr into a buffer the caller inspects for git's "use --force" hint, andBranchExists(dir, branch)wraps theshow-ref --verifypattern. Routed sites:internal/cli/worktree_ui.go(branch-exists, worktree add, worktree remove),internal/cli/worktree_add.go,internal/cli/worktree_remove.go(worktree remove with force-detection plus the genericrunGit), andinternal/ui/server.go(runGitOutputwrapsOutput). Each preserves the originalcmd.Dir, stream wiring, and exit-code semantics. The only behaviour delta is the wrap text on errors, and no caller pattern-matches on that text. - Batched worker-failure notifications (#372). A systemd cascade like start-limit-hit hitting half a dozen queue workers at once, or an OOM kill taking out every worker for a site in the same tick, used to fire one push per unit and spam the user with five or six near-identical "Worker failed on …" notifications back to back. The watcher detects new failures every five seconds, so the bursts were tightly clustered and unavoidable on the wearer side. The watcher now hands its new-failure delta to a small batcher that buffers by unit and arms a single five-second flush timer. Failures arriving later in the same window join the in-flight batch without resetting the timer, so the grouped push lands at most five seconds after the first failure even under a sustained cascade. A single isolated failure still goes out with the existing per-unit shape (deep link to the affected site, per-unit tag for browser dedupe), while two or more collapse into one "N workers failed" payload listing every
worker@siteand taggedlerd-workers-groupso a later grouped push supersedes the earlier one in the notification tray instead of stacking. Matchingnotify_worker_failed_group_titleand_bodykeys are added to all eight locale message files, with the Turkish strings translated and the rest falling back to English the same way the existingworker_failedentries do.
Fixed
- Worktree-manager button rendered on non-git sites (#357). The gear icon next to the worktree picker in the site header opens the worktree-management modal, but on a site without a git repo there's nothing for that modal to manage. The button gated only on
site.paused, so it appeared anyway on non-git linked projects. TheWorktreePickeron the same row already gates its passivegit:(branch)label onsite.branchbeing set; the button now reuses the same condition so the two affordances behave consistently. No git, no worktree UI. - Adding or removing a secondary domain on a secured site left the TLS cert untouched (#367). The five call sites that mutate a site's domain list (the CLI
domain addandremovesubcommands, the HTTPdomain:add/domain:edit/domain:removehandlers, and the MCP equivalents) were all callingcerts.IssueCert, which is a documented no-op when the cert file already exists. Since the primary domain's cert always exists for a secured site, the new SAN list was silently discarded and browsers kept rejecting the new hostname with "connection is not private" until the cert was nuked by hand. Every domain-mutating caller now goes throughcerts.ReissueCertForWorktree, which enumerates the site's worktrees viagitpkg.DetectWorktrees, combines them withsite.Domains, and callsIssueCertForceso the cert atomically picks up the new SAN list while keeping existing worktree subdomains in scope. A focused unit test in the certs package and two handler-level tests in the ui package pin both regression vectors so neither this bug nor the worktree-subdomain regression that an earlier draft of this fix introduced can come back. - Streamed worktree install and post-v1.20.2 audit follow-ups (#368). Composer and npm output during
lerd worktree addnow streams to the UI modal via a cross-process flock (internal/git/install_lock.go); the watcher's auto-install skips when the UI holds the lock, and a 60-second periodic rescan recovers any worktree the UI left un-provisioned.WorktreeCheckoutPathnow nests under the parent site (<site>/<base>-<slug>) and writes a/<base>-*/entry to.git/info/exclude. Other fixes in the same pass: the UI dropdown's scrollbar is visible again (droppedno-scrollbar) and wheel scrolling inside the menu no longer closes it, same fix toCommandsDropdown; the UI commands runner prependsBinDirtoPATHso php/composer/npm shims resolve under launchd's restrictedPATHon macOS, refuses to run when?branch=doesn't resolve to a real worktree, and defer-recover wraps the SSE pipe goroutines; macOSlaunchd_darwin.containerToPodmanArgsnow emits--hostnamesolerd shellshowsroot@<host>;service migratesyncsCanonicalVersioncorrectly for postgres/postgis-style image tags (18-3.6-alpine) viamatchVersionByImageTag; the worker health watcher now seeds health state before the tick loop so a clean start followed by a new failure dispatches (the previous fix silently swallowed the first failure on clean start); push gains a success log line andendpointShortusesurl.Parse;notify.tsswitches from a singlelastTagstring to per-kind plus 2s-window dedupe; plus a UTF-8-safedumpPreview, logged parse errors onnotifyOnServiceUpdates, base-images smoke that runs on arm64, and a platform-aware shell hint. - Tinker swallowed bare-expression results when the dump bridge was enabled (#371). When the dump bridge is on, its custom
dump()ships the value to the dashboard socket but stays silent on stdout unlessdumps.passthroughis also on. Tinker auto-wraps bare expressions likeUser::count()indump(), so the bridge was silently swallowing the result and the REPL returned an empty block unless the user reached forecho. SettingLERD_DUMP_PASSTHROUGH=1on every tinkerpodman execkeeps the dashboard capture intact while letting Symfony VarDumper also print to stdout, restoring the value in the tinker output regardless of the global passthrough setting. - Stopped host workers resurrected on the next fsnotify event or launchd heal tick (#375, #376). A host worker that the user stopped via the UI or
lerd worker stopwas getting silently revived. Two paths were doing it: every HEAD write inside a worktree (commit, checkout, rebase, branch rename) fired the fsnotify "changed" handler, which was unconditionally callingAutoStartOptedInWorktreeWorkersand re-bootstrapping every opted-in host worker; and on macOS the launchd heal loop additionally treated a missing plist as drift and recreated it, even thoughWorkerStopForSiteremoves the plist precisely to represent user intent. A newshouldAutoStartWorkersOnSynchelper gates the change handler toaction == "added"so HEAD movement leaves existing units alone, andshouldHealOnReasonskips"plist missing"and only heals genuine drift like"not loaded in launchd"or"loaded but no live process". The same report also flagged that wayfinder'sphp artisan wayfinder:generate --with-formfailed insidenpm run devwith/bin/sh: php: command not foundbecause the launchd guard script and the systemd unit for host workers never put lerd's bin directory on PATH; both paths now prependconfig.BinDir(), and the Linux unit re-adds~/.local/binso systemd-user's inherited search path doesn't quietly narrow. Closes #381. Also localizes four hardcoded English literals in the new address-bar header from #365 (TLS toggle, LAN toggle, mobile-overflow LAN entry, overflow menu aria-label) so non-English users (Turkish shipped in #355) stop seeing English on the most-touched UI of v1.21.0. - VCS-typed composer repositories broke after the multi-stage split (#377). The PHP-FPM rebuild in #364 cut image size by moving git into the builder stage, where it was only needed for the phpredis/imagick fallback clones. The runtime stage lost it without anyone noticing. Composer falls back to dist for most flows, but
"type": "vcs"repositories declared incomposer.jsonhard-require a git clone, and any plugin that shells out to git for tag detection or repo state failed the same way. Re-adding git to the runtimeapk addbrings the previous behaviour back at the cost of a few megabytes, and a smoke test on the embedded Containerfile catches a future regression at publish time. - Three dashboard endpoints were reachable from the LAN when
lan:exposewas on, andpublic_diraccepted path traversal (#382).GET /api/sites/{domain}/envhanded out the raw.env(APP_KEY, DB credentials, third-party tokens) once the user enabledlan:expose,POST /api/push/testlet a LAN attacker spam pushes onto subscribed devices, andPOST /api/webhooks/mailpitwas bypassing the remote-control gate unconditionally so any reachable host could fire fake mail notifications with attacker-controlledsubjectandfrom. The first two now ride the existingloopbackOnlyRoutesandloopbackOnlySiteSubactionslists inwithRemoteControlGateso they 403 from non-loopback callers regardless of LAN or remote-control config. The mailpit bypass keeps the pre-auth path mailpit needs but tightens the check tofromHost(r), which verifies the source IP belongs to one of the host's interfaces; pasta on Linux and gvproxy/vmnet on macOS source-NAT the container to that address, while a LAN attacker arrives from elsewhere and a spoofed SYN breaks the TCP handshake because SYN-ACK routes back into the host.fromHoststrips IPv6 zone suffixes, parses sources withnet.ParseIP, and usesIP.Equalso v4, v6, zoned link-local, and v4-mapped-v6 sources all compare correctly. Separately, thepublic_diroverride added in #370 was concatenated into the nginx vhost'srootdirective without validation, so cloning a hostile repo into a parked directory could pivot the document root via../../etc. A newValidatePublicDirrejects absolute paths, leading~, NUL bytes, and any..segment;LoadProjectConfigsilently falls back to the framework default with a warn when the value is bad, andresolvePublicDirdouble-checks at render time as defence in depth. - mysql and mariadb
podman stophung the full grace window (#383).mysqldin mysql 8.4 (and mariadb, which shares the same daemon entrypoint shape) does not install a SIGTERM handler when it runs as PID 1, sopodman stopwaited the full 30s before SIGKILL andsystemctl restartwedged around the 30-90s mark. A newinitflag onCustomServiceand the preset schema threads through toPodmanArgs=--initin the quadlet generator, and the mysql and mariadb presets turn it on. Podman injects catatonit as PID 1, mysqld lands at PID 2 with its normal signal-handling thread intact,podman stopcompletes in ~1s, andlerd service restartreturns instead of timing out. The flag is opt-in per preset since postgres, redis, and the rest of the bundled services already handle PID 1 signals correctly. Closes #380. - Notification clicks landed on a non-existent or wrong tab (#384). Two of the dashboard's push notifications carried URLs the router couldn't resolve: the dump notification used
#dumps(not a top-level tab, soparseHashsilently fell back to dashboard) and theworker_failednotification used#sites/<name>(Sites is keyed by primary domain, so on any site where name and domain differ —whitewaterswiththeregistry.test, for example — the click landed on an empty state). Both now route through a smallsiteDomainForRoutehelper that resolves a registered site name to its primary domain at notification build time, falling back to the input verbatim when no site matches so test fixtures and unlink races stay safe. The dump notification points at#sites/<domain>/dumps, theworker_failedURL becomes#sites/<domain>, andSiteDetailpicks up an optional sub-tab segment fromrouteRestvia a$effectthat overrides the localStorage-restored tab when the segment names a known sub-tab.
Security
- Three transitive dependency bumps close five Dependabot alerts on main (#387).
github.com/golang-jwt/jwt/v5moves from 5.2.1 to 5.2.2 to close the header-parsing memory-allocation flaw (high). Svelte goes from 5.55.6 to 5.55.8, covering the three open medium-severity advisories: the DOM-clobbering XSS path, the SSR spread-attribute XSS, and the hydratable promise serialization XSS. Kysely steps to 0.28.17 to clear the JSON-path traversal injection (high), pulled in transitively by@inlang/paraglide-jsat build time. The Go-side JWT is an indirect dep with no direct call sites in lerd, so runtime exposure was small even before the bump. Svelte is the real one since the dashboard renders user-controlled site names, env values, and dump payloads, so each of those XSS paths was a live concern for any LAN-shared or remote-controlled install. Kysely is only reachable through paraglide's build-time SDK so runtime impact was nil, but the lockfile drift was noisy.
[1.20.2] — 2026-05-14
A lerd lan:share follow-up that fixes the bugs uncovered while sharing a real Laravel + Vite + RustFS site over the LAN. The tunnel share proxy was passing gzipped responses through untouched, so on most frameworks the page loaded but every asset URL still pointed at the local .test domain and the share rendered white. The LAN share proxy was setting X-Forwarded-Host but not X-Forwarded-Port, so Symfony's Request::getSchemeAndHttpHost() and Ziggy's URL helper composed asset URLs out of the proxy's host plus nginx's $server_port=443, leaking http://<lan-ip>:443/... into the page. Behind that was an nginx-side bug: every site's vhost did fastcgi_param HTTP_X_FORWARDED_PORT $server_port and unconditionally overwrote whatever the upstream proxy sent. Vite dev-server URLs (http://[::1]:5173/...) and RustFS / S3 / other loopback URLs (http://localhost:9000/...) were also leaking into LAN-shared pages and failing on the client device, including from the JSON-escaped form Inertia.js emits in data-page attributes. Toggling TLS on or off left running share listeners pointing at the old backend port until a manual restart. And under the hood, lerd secure / lerd unsecure had three drifting copies (CLI, dashboard, MCP); MCP was missing both the Stripe listener restart and the LAN share refresh, and any new step added today was at risk of being forgotten in two of the three places. The consolidation lands siteops.SetSecured as the single source of truth for the TLS toggle flow: every entry-point now calls one function with no per-caller variation, and all post-toggle work (Stripe webhook restart, LAN share rebind) goes through HTTP notifications to the daemon so adding a new step happens in exactly one place.
Fixed
- White page on
lerd sharefor gzipped responses. The tunnel share proxy was readingContent-Encodingand bailing out of body rewriting whenever the upstream returnedgzip, which is essentially every framework. The proxy now sendsAccept-Encoding: identityto the upstream and transparently decodes gzip when the upstream sends it anyway, so absolute asset URLs in HTML/CSS/JS finally get rewritten to the tunnel host. Mirrors what the LAN share proxy was already doing. - Ziggy and Symfony emitting
http://<lan-ip>:443/...URLs on LAN-shared sites. Two-layer fix: the LAN share proxy now forwardsX-Forwarded-Portto the upstream, and the nginx vhost template stops unconditionally overriding it with$server_port. A new shared_forwarded.confmap ($real_forwarded_port) reads the upstream header when present and falls back to$server_portonly when the request was direct. Both the SSL and non-SSL vhost templates were updated, and the running per-site confs were patched in place so the fix took effect immediately for all 12 sites without needing to re-link every one. - Vite dev server unreachable from LAN devices.
http://[::1]:5173/...URLs that the laravel-vite-plugin emits are rewritten in the response body tohttp://<lan>/__lerd_vite__/5173/..., and the share proxy forwards those paths to the local Vite. Vite HMR's WebSocket upgrade routes to the same listener (no per-projectvite.config.jschange needed). Transitive imports (/node_modules/...,/@vite/...,/@id/...,/@fs/...,/@react-refresh,/__vite_ping, plus framework source roots/resources/,/src/,/vendor/) reach Vite via the most recently observed Vite port for the share. Files with dev-only extensions (.vue,.ts,.tsx,.jsx,.mjs,.cjs,.svelte,.scss,.sass,.less,.styl,.astro,.mdx) and Vite-stamped query hints (?import,?worker,?raw,?inline,?url) route to Vite when a port has been observed. - RustFS / S3 / other loopback services on LAN-shared pages. The same body rewriter that catches Vite also normalises
http://localhost:9000/.../http://127.0.0.1:9000/.../http://[::1]:9000/...references to flow through the share proxy. Avatar images, Mailpit links, and any other dev-time loopback URL now load from LAN devices without per-project config. The rewriter also handles the JSON-escaped form (http:\/\/localhost:9000\/...) that ends up in Inertia.jsdata-pagepayloads. The handler only marks a port as the active Vite when the stripped path actually looks Vite-served, so an avatar fetch via/__lerd_vite__/9000/...no longer poisons subsequent/node_modules/lookups (which would otherwise hit RustFS and return400 InvalidBucketName). - Toggling TLS no longer leaves the LAN share pointing at the old backend.
lerd secure/lerd unsecure, the dashboard padlock, and the MCPsite_tlstool now refresh any running LAN share proxy so it re-binds to the new backend port (80 vs 443). Previously the share kept proxying to the previous port until manually restarted. - MCP
site_tlsnow syncs Vite Reverb env vars and refreshes Stripe / LAN share. Brought up to parity with the CLI:APP_URLplusVITE_REVERB_HOST/SCHEME/PORTget updated on every toggle (so browser Echo doesn't stay wedged onwss://host:80after going to HTTP), the Stripe listener restarts with the new scheme so its--forward-tokeeps working, and any running LAN share proxy re-binds to the new backend. Tool description updated to reflect the actual surface.
Changed
siteops.SetSecuredis now the single source of truth for the TLS toggle flow. CLI, dashboard, and MCP previously each had inline copies of the eight-step toggle dance (cert, registry, .env, .lerd.yaml, nginx reload, Stripe restart, LAN share refresh). They drifted: MCP only updatedAPP_URL, the dashboard never restarted Stripe, and the LAN share refresh existed only on the CLI side. The new helper runs all the core steps and posts to the daemon'sstripe:refreshandlan:refreshHTTP endpoints so any caller, including the daemon itself, drives the dependent listeners through the same code path. Adding a new step now happens in one place instead of three. CLI'srestartStripeIfActiveis exported ascli.RestartStripeIfActiveso the newstripe:refreshdaemon action can call it.- Tunnel share proxy in
lerd sharenow matcheslan:share's gzip + body rewriting behaviour. The two were close-but-not-identical implementations; the host proxy now uses the sameAccept-Encoding: identityupstream hint and gzip-decoding fallback the LAN proxy already had. - Loopback-URL rewriter terminator class includes
)and;so CSSurl(http://[::1]:5173/foo.png)declarations get rewritten too. Previously the regex only stopped on quote/slash/space, so background-image URLs in Vue scoped styles leaked through unchanged. Referer-based Vite routing was deliberately dropped. The LAN share listens on0.0.0.0, so trusting a Referer for the target port would let any device on the network craft aReferer: http://host/__lerd_vite__/22/anythingand steer the proxy at SSH or the database. Routing now uses only the in-processactiveVitePortthat gets set when a real/__lerd_vite__/<port>/request comes in. Active-port fallback was already covering every legitimate case the Referer path was hitting.- Speculative path prefixes (
/app/) and query hints (?v=) removed from the Vite router./app/was added defensively but breaks Filament admin URLs which sit under/app/.?v=hashis the most common cache-busting query on the web; matching it routed unrelated Laravel/Symfony assets to Vite. Both removals are safe because the explicit Vite prefixes (/@vite/,/node_modules/, etc.) plus the dev-only extension list already cover everything Vite actually serves.
[1.20.1] — 2026-05-14
A 1.20.0 follow-up for the curl|bash / wget|bash install path. When the installer script is piped through bash the script's own stdin is the pipe, not the terminal, and that pipe was inherited by lerd install so its [Y/n] prompts hit EOF the moment they were issued. The Node-management and DNS questions flashed past without ever showing up to the user, both silently defaulted to yes, and the mkcert step then jumped straight to a sudo password request that looked like it came out of nowhere. lerd install now reopens /dev/tty for its prompts whenever stdin is not itself a TTY, and install.sh separately redirects lerd install's stdin from /dev/tty when one is available, so the prompts reach the user from either side of the pipe. If neither a TTY stdin nor /dev/tty is available (unattended CI, containers without a controlling terminal) the prompts now print (no terminal, defaulting to yes/no) instead of vanishing.
Fixed
- Install prompts no longer skipped when piping the installer through bash (Ubuntu and other distros).
lerd installfalls back to/dev/ttywhen stdin is a pipe, andinstall.shhands/dev/ttytolerd installso the Node-management and DNS questions actually reach the user duringcurl … | bash/wget … | bashinstalls.
[1.20.0] — 2026-05-14
The 1.20.0 line makes git worktrees a first-class concept throughout the stack. Per-worktree workers are surfaced in CLI, dashboard, and TUI; a built-in Vite dev server worker runs on the host with auto-start per worktree; wildcard cert SANs cover deep subdomains; env_overrides templating handles multi-tenant apps; per-worktree dump tagging keeps the live viewer accurate; and the dashboard ships a manage-worktrees modal alongside an upgrade to Tailwind CSS v4. The TUI catches up to the dashboard with service update and rollback keybinds, a failing-workers header pill, and per-worktree controls in the site detail pane. PHP 7.4 and 8.0 land as a frozen legacy tier, lerd php:ext add learns --apk-deps for extensions that need extra Alpine build packages, the dashboard ships in German, Indonesian, and Dutch with a wide i18n pass across all seven locales, and MCP gains workers_mode, bug_report, and a branch parameter across its per-worktree CLI wrappers. The post-beta fix queue covers macOS phantom launchd workers restarting healthy units every cooldown, dashboard update-banner glitches, and the worktree-add modal's "Automatic" resolver now announcing what it picked instead of going silent.
Added
- Manage git worktrees from the site dashboard (#341). The site detail panel's path line gains a worktrees icon next to the branch picker that opens a modal for adding and removing worktrees, so the picker is no longer read-only. The modal lists each worktree with a link to its URL (https when the parent site is secured) and a trash button; Remove expands an inline confirm with Discard uncommitted changes (
--force) and, for isolated worktrees, Also drop database (off by default, matchinglerd worktree remove). Add worktree presents the same choices as thelerd worktree addprompts: a new branch (with optional base ref) or an existing branch (the dropdown lists local branches not already checked out plus remote-tracking branches, which git dwims into a fresh local branch), the database (share parent / isolated empty / cloned from main / cloned from another worktree / reuse-or-reset a preserved DB), a Laravel migrations checkbox, and the frontend-asset choice (asset worker /npm run <script>/ skip), and it streams thegit worktree add+ dependency-install + build + DB-setup output live. The checkout directory is picked automatically (<project-path>-<branch>). Backed by extracted non-interactive helpers ininternal/cli(RunWorktreeAdd,RemoveWorktreeAndCleanup,ApplyWorktreeDBChoice,ApplyWorktreeBuildChoice,RunWorktreeMigrations) shared with the CLI prompts, new HTTP endpointsGET /api/sites/worktree-options,POST /api/sites/worktree-add(SSE), aworktree:removesite action, and new locale strings across all seven languages. The site detail header also got a mobile-layout pass: the path andgit:(branch)picker stack on separate rows on narrow screens, and the pause / unlink / open / terminal buttons move into a left-aligned full-width row instead of crowding the top-right corner. - Per-worktree worker lifecycle as a first-class concept, with dashboard surfaces (#319). Two new framework-yaml flags:
per_worktree(worker can run independently per git worktree underlerd-<wname>-<site>-<wt>) andreplaces_build(while running, the worker provides the asset manifest so the staticnpm run buildstep is unnecessary). The two flags answer independent questions ("where can this run?" vs "do we still need a build?") so future host-mode workers can mix and match. The watcher'ssyncWorktreenow only auto-starts host workers the user opted into via.lerd.yaml workers:AND that declareper_worktree: true, replacing the previous blanket auto-start of everyhost: trueworker. Dashboard surfaces:/api/servicesenumerates worktree workers per site with newworker_worktree/worker_worktree_domainfields,/api/sitesworktrees carryframework_workers,SiteControlsrenders per-worktree toggles next to the parent toggles, andServiceDetailbuilds/api/worker/<site-wt>/<name>/logsURLs for worktree-suffixed unit names.lerd setupusesGetFrameworkForDirso store-defined workers (vite) appear in the toggles and falls back to the parent site when run inside a worktree.runLinkrejects worktree paths instead of registering them as separate top-level sites, eliminating the "main" entries that bled into the dashboard.lerd installrunsrefreshStoreFrameworksto re-fetch every cached framework yaml so users pick up the new flags without waiting for the 24h staleness check. - Built-in Vite dev server worker with host execution and worktree auto-start (#310). New
viteworker for Laravel runsnpm run devon the host via fnm rather than inside the PHP-FPM container, so Vite has direct filesystem access for HMR without port publishing or a proxy. A newhost: truefield onFrameworkWorkerlets future host-mode workers (Tailwind watcher, Encore, etc.) ship with the same execution profile. Per-worktree unit naming (lerd-vite-<site>-<wt>) allows simultaneous Vite instances across worktrees with auto-incremented ports. The vite definition lives in thelerd-frameworksstore (laravel/11-13.yaml), not in the Go binary, so future framework workers don't require a lerd release. env_overridesin.lerd.yamlfor worktree env templating (#308). Projects can declare templated or static env variables that resolve when worktrees are created, layered on top of the existingAPP_URLrewrite. Values can use,, andplaceholders or be plain strings. WhenAPP_URLis present inenv_overridesit takes precedence over the default rewrite; declared keys override defaults and undeclared defaults still apply. Multi-tenant apps that depend on session domains, tenant routing, or signed-URL hosts no longer need a manual.envpatch per worktree.- Automatic wildcard cert SANs and nginx
server_namefor worktree subdomains (#309). When a worktree is created on a secured site, lerd now includes*.branch.domain.testin both the TLS certificate SANs and the nginxserver_namedirective. Deep subdomains likeapp.branch.domain.testwork out of the box without manual cert regeneration. The parent cert is reissued with worktree wildcard SANs on creation, and the daemon-startup sync reissues certs that picked up worktrees added before the feature shipped. NewWorktreeCertDomainshelper builds the domain list andReissueCertForWorktreeis exposed for the daemon sync flow. - Unified asset-worker / npm-build prompt for
lerd worktree add(#321). The previous auto-skip-or-prompt branch is replaced by one select that lists every eligible asset worker (replaces_build+per_worktree+ check passes), every npm production-build script (build/prod/build:prod/build-prod/production), and Skip. Default prefers an opted-in asset worker, then the first npm script, then skip. Asset workers appear even when not in the parent's.lerd.yaml workers:list; picking one starts it ad-hoc withpersist=falseso the parent yaml stays the source of truth and the choice is per-session. - Worktree-add modal's "Automatic" resolution now announces its choice (#344).
applyWorktreeBuildRequestcalls a newlogAutoBuildResolutionwhenever the build request is""or"auto", emitting one explicit line stating which branch the resolver took and why. The asset-worker case names the worker and explains the.lerd.yaml workers:opt-in plusreplaces_buildrule; the script case names thenpm runtarget it's about to invoke; the skip case lists the production-build script names it looked for and warns aboutViteManifestNotFoundException.ApplyWorktreeBuildChoicealso gains a"skip"case so the modal never goes silent after the dependency-install phase, and CLI users hitting the huh "Skip" prompt see the same line. Users no longer have to read the buried per-kind log line and guess what "Automatic" picked. - Worktree-add warnings surface in the dashboard (#343).
RunWorktreeAddnow wraps its log writer in awarningCapturingWriterthat scans for[WARN]lines and returns them alongsidebranchandpath. The SSE done payload forwards them;WorktreeModalkeeps the log view open with an amber "completed with warnings" banner and a "back to list" button instead of silently jumping back to the list on a half-finished setup (db skipped, build script error, install timeout, etc.). Two new i18n keys added across de/en/es/fr/id/nl/pt. - Per-worktree dump tagging in the live viewer (#343).
LERD_SITEandLERD_BRANCHare plumbed through nginxfastcgi_param(worktree and parent vhosts) and throughpodman exec --envfromRunTinker. The dump bridge reads$_SERVERfirst, thengetenv(), so both FPM and CLI (tinker) requests get the same identifiers. The dump payload gains abranchfield across Go (internal/dumps/event.go), PHP, and the TypeScript stream type, so events from a worktree are no longer mis-grouped under the worktree's directory basename. - TUI service lifecycle keybinds (
uupdate,brollback). Pressinguwhile focused on the Services pane runslerd service update <name>for the highlighted service (no tag, applies the safe in-strategy update). Pressingbrunslerd service rollback <name>. Both fire and refresh the snapshot, mirroring the dashboard's Update / Rollback buttons. No-op on worker rows (queue-alpha, etc.) since they have no upstream image. Help reference (?) lists both. Migrate, remove, and reinstall stay CLI/dashboard-only for now since they need tag pickers or destructive-action confirmation. - TUI header pill for failing workers. When any framework worker (built-in queue/schedule/horizon/reverb, custom worker, or per-worktree worker) is in the systemd
failedstate, the TUI header renders⚠ N workers failing · H to healin failing-style colour next to the existing DNS / nginx / FPM / watcher pills. Mirrors the dashboard's amber sticky banner from #319. HelpercountFailingWorkersaggregates state across every site and worktree. - TUI per-worktree controls in the site detail pane. Each worktree row now expands to show its per-worktree workers (one row per
FrameworkWorkerin the worktree, e.g.viteon a Laravel branch), an "Isolated DB" toggle when the parent uses a managed database service (mysql/mariadb/postgres), a "LAN share" toggle, and PHP / Node version pickers. Pressing space/enter on a worktree-worker row runslerd worker start/stop <name>from inside the worktree's checkout, so the worktree-suffixed unit (lerd-<worker>-<site>-<branch>) is targeted; the DB row runslerd db:isolate/lerd db:share, the LAN row runslerd lan share/lerd lan unshare, and the PHP / Node pickers runlerd isolate <ver>/lerd isolate:node <ver>, all with cwd inside the worktree so the choices persist there rather than at the parent. Version rows show "(inherited)" in dim style when the worktree hasn't overridden the parent. Help reference (?) gains a Worktrees section. NewLANPortfield onsiteinfo.WorktreeInfopopulated fromconfig.FindWorktreeLAN. - MCP
site_php/site_nodegain abranchparameter. When set, the version pin is written inside the worktree's checkout andphp_version/node_versionis persisted to that worktree's.lerd.yaml(so the override travels with the branch in git); only the worktree's nginx vhost is regenerated. Withoutbranch, the existing parent-site flow runs unchanged. Routes throughlerd isolate/lerd isolate:nodewhich already handle the worktree case correctly viaFindParentSiteForWorktree. - MCP
workers_modetool. Wrapslerd workers mode <get|set>so AI assistants can flip the macOS worker runtime betweenexec(default; onepodman execper worker, supervised by launchd, lower memory) andcontainer(one detached container per worker, 1:1 supervisor boundary). Linux always uses exec under systemd, so the tool is a no-op there. Setting the mode on macOS stops each active worker in its old shape and restarts it in the new shape, so no manuallerd stop/lerd startis needed. - MCP
bug_reporttool. Wrapslerd bug-reportso agents helping users file GitHub issues can generate the diagnostic bundle (lerd doctor + config + systemd / podman state + recent logs + curated env vars) without leaving the chat. Site names, domains and parked-directory paths are anonymised by default;show_real_names: truekeeps raw values for local debugging. Returns the file path so the user can attach it to the issue. - PHP 7.4 and 8.0 as a frozen legacy tier. Old projects (Laravel 6–8 on 7.4, Laravel 8–9 on 8.0) can now run under lerd. They build from the same Alpine-based recipe as the current versions, including ICU full locale data; the only version-conditional bit is the Xdebug release (pinned to
3.1.6for 7.4,3.3.2for 8.0, since current Xdebug needs PHP 8.1+), andmongodbis unavailable on those lines. The base image is Alpine 3.16, so the bundled Node.js is 16.x. These are end-of-life upstream and get no security patches; they exist for local work on legacy apps only.lerd use 7.4,lerd isolate 8.0,lerd fetch 7.4 8.0all work; both versions are inbase-images.ymlandSupportedPHPVersions. lerd php:ext add --apk-deps "pkg ..."for extensions that need extra Alpine build packages (follow-up to #334, which already coversimap). Some PECL extensions need-devpackages that aren't in the base FPM image (ssh2needslibssh2-dev,memcachedneedslibmemcached-dev, …).--apk-depslets you supply them; they're saved per extension in~/.config/lerd/config.yamlunderphp.ext_apk_deps, deduped with the built-in set, and reapplied on everylerd php:rebuild.lerd php:ext listshows them, and the MCPphp_extadd action takes anapk_depsargument.- Dashboard now ships in German (
de), Indonesian (id), and Dutch (nl), and dozens of previously-hardcoded screens are now translatable in every locale. The three new locales get a full set of translated strings, wired into the inlang project, the language switcher, andLOCALE_LABELS/LOCALE_CODES. As part of the same pass the Dumps viewer, the Tinker tab, the site Overview/detail tabs, the service detail + reinstall/remove modals, the worktree-DB-isolate modal, and a batch of smaller components/modals were migrated off hardcoded English into them.*()catalog and translated across all seven locales (en/es/pt/fr/de/id/nl), so es/pt/fr benefit too. A vitest check (src/lib/locales.test.ts) guards every locale file against key drift fromen.json. Native-speaker review/polish welcome, especially forid.
Changed
- Upgraded the dashboard to Tailwind CSS v4 (#341). CSS-first config (
@themeinapp.css, notailwind.config.js), the v4 PostCSS plugin (@tailwindcss/postcss,autoprefixerdropped), class-based dark mode preserved via@custom-variant, and the v3-compat border-color base layer. All utility renames applied across the component tree (outline-none→outline-hidden,rounded→rounded-sm,ring→ring-3,shadow-sm→shadow-xs, etc.). config.SiteSlugconsolidates DB-safe name conversion (#315). The repeatedstrings.ToLower+ReplaceAll("-","_")pattern acrossinternal/cli/env.go::projectDBName,internal/cli/worktree_db.go::WorktreeDBName,internal/serviceops/reprovision.go::resolveDBName, andinternal/mcp/server.go::db_setis now a single helper that also handles dots so domain names slugify through the same path. Pure refactor, no behaviour change.- MCP
worker/worker_listtools gain abranchparameter and route through the lerd CLI. Previously the MCPworkertool inlined its own systemd unit content (nohost: truesupport, hardcodedlerd-<worker>-<site>unit name) so callingworker(action: "start", worker: "vite")quietly wrote a non-functional unit and ignored worktrees entirely. The handlers now shell out tolerd worker start/stopwith the cwd set to the parent site path or the resolved worktree path whenbranchis given, inheriting the CLI's worktree-aware naming, host execution, conflict handling, and persistence.worker_listreports the newhost/per_worktree/replaces_buildflags and acceptsbranchto switch between parent and per-worktree unit state. The injected SKILL / Junie guidelines / Cursor rules describe the new param so AI assistants drive per-worktree workers correctly. MakefileUI deps gain a stamp file (#343).node_modules/.package-lock.jsonis now an explicit target depending onpackage-lock.jsonpluspackage.json, sonpm ciruns only when the lockfile orpackage.jsonchange instead of everymake build/make test-ui.
Fixed
- macOS phantom launchd workers restarted healthy workers every cooldown (#343).
workerNeedsHealingandsweepOrphanWorkerArtifactswere looking for~/Library/LaunchAgents/lerd.<unit>.plist, but the file on disk is<unit>.plist(thecom.lerd.prefix lives only on the launchd Label inside the plist, seeservices.plistPath/plistLabel). The heal loop therefore saw every unit as "plist missing" and restarted healthy workers each cooldown; the orphan sweep simultaneously failed to keep real orphan.sh/.pidfiles. Both now read the correct path.isExecModeUnitgets the same fix ininternal/tui/log_tail_darwin.go. - vite toggle and log tab vanished from parent site rows after the per-worktree worker pass (#346). The siteinfo guard skipped
IsPerWorktree()workers fromenrichWorkersunconditionally, removing the vite toggle and log tab from sites that still run the worker at the parent level (no worktrees, or worktree-using parents that want vite opted in for project-level control). The check rule on the worker (file: node_modules/vitefor the Laravel store yaml) is already a tight enough gate, so the per_worktree skip was just removing correct rows along with the perma-inactive ones. The gate is gone; the test file now pins both directions. lerd install(andlerd update, which runs install) briefly dropped.testresolution on every run. The installer unconditionallysystemctl restartedlerd-dns, which tears down and recreates the podman container, a few-second window where port 5300 isn't bound and*.testwon't resolve. It now diffs the dnsmasq config (lerd.conf) and thelerd-dnsquadlet against what's already on disk and only restarts when one of them actually changed (or the container isn't running); a no-op reinstall, the common case after a version bump, leaves the live container alone. NewfileChangedByhelper wraps the read-mutate-read diff. macOS (native dnsmasq, no container teardown) is unchanged.- Dashboard "Open terminal & update" button failed with
lerd: command not found. The handler shelled out viash -c "lerd update; …", but the spawned terminal inherits a non-login shell environment that doesn't source.bashrc/.zshrc, so~/.local/binis offPATH. The script now usesos.Executable()to resolve the absolute path of the runninglerd-uibinary (which is thelerdbinary itself) before passing it to the shell. Same fix pattern the TUI'srunLerdalready uses. - Dashboard update banner showed
Lerd vv1.19.2 is available. Two bugs compounded: the wire payload from/api/versionreturned the GitHub tag verbatim (with leadingv) while the Svelte templateLerd v{version} is availablealready prependsv, producing the double-v.handleVersionnow strips thevvialerdUpdate.StripVbefore sending so the wire data is bare and the template renders cleanly. internal/updatetest pollution rewrote the user's realupdate-check.json.withTempCachesetXDG_CONFIG_HOMEbutconfig.UpdateCheckFilereadsXDG_DATA_HOME, so test cache writes leaked to~/.local/share/lerd/update-check.jsonand surfaced bogus version tags (e.g.v1.19.2) in the dashboard banner for 24h. Test now sets the correct env var so writes stay in the temp dir.- Stable users got "update available" prompts when a beta was published. GitHub's
/releases/latestredirect normally excludes prereleases, but a brief redirect-cache window (or a release accidentally not marked prerelease) can land a beta tag incachedLatest(), whichCachedUpdateCheckthen surfaces to every stable user.CachedUpdateChecknow skips the notification when the cached latest carries a prerelease suffix and the current version doesn't. Beta-on-beta upgrades still surface, and stable-to-stable is untouched. NewIsPrereleasehelper plus four pinned tests cover the matrix. lerd framework updateonly refreshed the latest cached version (#318). The command iteratedListFrameworksDetailed(one entry per<name>@<version>) but always fetchedentry.Latestfrom the store. A user withlaravel@10/11/12/13cached only gotlaravel@13refreshed; older cached versions stayed stale until the 24h auto-refresh ran orlerd installtriggered a fetch. The loop now fetchesinfo.Versionso each cached file is refreshed individually.lerd secure/lerd unsecureleftVITE_REVERB_*stale, breaking Reverb WebSocket on secured sites. Toggling HTTPS only updatedAPP_URLviaenvfile.UpdateAppURL.VITE_REVERB_HOST/SCHEME/PORTkept their pre-flip values, so a site secured afterlerd envended up withVITE_REVERB_SCHEME=http/VITE_REVERB_PORT=80while the browser was on HTTPS. Vite bakes those into the built JS, so Echo triedwss://host:80(TLS WebSocket on the HTTP port) and the connection failed silently every page load. The same omission affected the dashboard's secure/unsecure handlers and the per-worktree paths insideSecureSite/UnsecureSite. All five call sites now useenvfile.SyncPrimaryDomain, which has been the canonical helper for this since #209 (April 10) and was already used bylerd domain add/removeand TLD migration.TestUpdateEnvAppURL_syncsViteReverbVarsandTestUpdateEnvAppURL_unsecureWalksViteReverbBackpin the regression. The bug predatesSyncPrimaryDomainitself (introduced in v0.3.0, 2026-03-18), so existing secured Laravel sites withBROADCAST_CONNECTION=reverbneed to rebuild Vite assets after this update.- Worktree wildcard cert SANs only refreshed on per-worktree sync, not on bulk regen.
syncWorktreereissued the cert when a single worktree was added, butscanWorktrees(daemon-startup discovery) andcleanupWorktreeVhosts(post-cleanup regen) wrote*.branch.domain.testinto the nginxserver_namewithout reissuing the cert, so deep subdomains likeapp.branch.domain.testfailed TLS after a daemon restart that picked up worktrees added before the wildcard-SAN feature shipped. Both bulk paths now reissue once per site; cleanup also shrinks the cert SAN list when worktrees are removed. - A transient mkcert failure during cert reissue could downgrade a secured site to plain HTTP.
issueCertWithWorktreesremoved the existing.crtand.keyfiles before invoking mkcert so the new SANs would take effect; if mkcert hiccuped (binary missing after an update, permission drift, anything), the site was left with no cert files and the next start trippedRepairVhostsinto flippingSecured=false. The reissue path now writes mkcert output to*.newtemp paths and renames atomically, so a failure leaves the previous cert intact. NewIssueCertForceexposes the atomic flow andTestIssueCertForce_failureLeavesExistingCertIntactregression-pins the safety property by simulating a failing mkcert binary. - Dashboard DNS dot stayed red after boot until manual refresh. When
lerd-uicame up beforelerd-dnsfinished starting, the initial/api/statussnapshot baked indns.ok=false. Once DNS naturally became reachable a few seconds later nothing pushed an update to the live WebSocket, so the DNS pill on the dashboard stayed red until the user reloaded. The first attempted fix added the publish toWatchDNSin the watcher binary, butlerd watchandlerd serve-uiare separate processes andeventbus.Defaultis per-process, so that publish never reached the broker. The actual fix lives ininternal/ui/dns_status_watcher.go: a small in-process probe runs insidelerd-ui, ticks every 30s while a tab is visible, compares against the last observation, and publisheseventbus.KindStatuson transition.runSnapshotInvalidatorthen rebuilds the status snapshot and the broker pushes it to every tab. Cost is one localnet.LookupHostper 30s when the dashboard is open, zero work when no tab is open. TheWatchDNSimmediate-probe-at-startup change still applies and shaves 30s off the first repair tick. Table-driven tests cover the boot-up-publish, boot-down, steady-up, steady-down, recovery, regression, and idle-skip paths.
[1.19.3] — 2026-05-11
A small patch release: lerd php:ext add no longer reports success when the extension's build actually failed.
Fixed
lerd php:ext add <ext>reported success even when the PECL build failed (#334). The custom-extension RUN block ends in|| trueso a broken build doesn't brick the whole image on laterlerd php:rebuildruns, but that also meantlerd php:ext add imapprintedExtension "imap" installedwhilephp -mshowed nothing.lerd php:ext add(and the MCPphp_extadd action) now runphp -min the rebuilt image to confirm the extension loaded; if it didn't, the command exits with an error and removes the extension from~/.config/lerd/config.yamlagain instead of leaving a phantom entry.- Some extensions failed to build because their Alpine build deps weren't present (#334).
imapin particular needsimap-dev krb5-dev openssl-dev c-clientor PECL aborts withutf8_mime2text() has new signature, but U8T_CANONICAL is missing. lerd nowapk adds the required packages beforepecl installfor extensions it knows about (currentlyimap); the map is easy to extend for others.
[1.19.2] — 2026-05-11
A one-fix patch release: locale-aware formatting now works inside the PHP-FPM containers.
Fixed
NumberFormatter/ locale-aware currency and number formatting produced English output (€ 13,943.20instead of€ 13.943,20) inside PHP-FPM containers (#332). The Alpine PHP image only bundledicu-data-en(Alpine splits ICU's locale database into a separate package), soext-intlsilently fell back to the root/en locale for every non-English locale, breakingNumberFormatter('nl_NL', …),IntlDateFormatter, Laravel'sNumber::currency()and money formatting, and any test that asserts localised output. The Containerfile now installsicu-data-full, which carries the full CLDR locale set. This bumps the Containerfile hash so existing images rebuild on next use (orlerd php:rebuild). Note this does not change the C-librarysetlocale()/localeconv()path: musl libc does not implement non-CLC_NUMERIC/LC_MONETARY, so apps relying on locale-aware formatting should useext-intl(NumberFormatter), which is locale-data driven and unaffected by the system locale.
[1.19.1] — 2026-05-07
A maintenance release rolling up the post-v1.19.0 fix queue: Podman 4.x compatibility on Ubuntu 24.04, MySQL 8.4 client + driver compatibility for Laravel sites, broader system-node detection so users on nvm/volta/mise/asdf/fnm aren't quietly shimmed, host-port conflict surfacing on stopped services, and a .lerd.yaml honour fix on Laravel lerd init.
Fixed
- Service quadlets failed to generate on Podman 4.x (#299, #302). v1.19.0 emitted
StopTimeout=5in every service's[Container]section, but that key was added in Podman 5.0 and is unrecognised by the 4.9.3 shipped on Ubuntu 24.04. systemd-quadlet aborted with exit 1 and produced no service units, solerd-mysql.service,lerd-redis.serviceetc. simply didn't exist. Lerd now probespodman --versiononce and falls back toPodmanArgs=--stop-timeout=5on Podman <5.0, which is universally supported and produces the same--stop-timeout=5on the underlyingpodman run. lerd installfailed creating the lerd podman network on Ubuntu 24.04 (#299, #304). After creating the bridge, lerd ranpodman network update --dns-add <ip> lerdfor each upstream DNS server (e.g. libvirt's192.168.122.1), but on Ubuntu Noble's netavark <1.11 that command needs the per-network aardvark-dns runtime file to already exist, which it doesn't until a container has connected. The result was a hard install failure ending innetavark: unable to modify network dns servers: IO error: No such file or directory. Lerd now passes--dns <ip>flags atpodman network createtime, so the DNS servers are written into the netavark JSON in the same atomic step as the subnet and MTU. The post-create drift sync still runs but is now a no-op on a fresh install.--no-ipv6is no longer needed as a workaround.- Dashboard could go green for a service with no unit (#299, #301, #302). When a quadlet generator rejected a
.containerfile but a container from an earlier valid load was still in the cgroup,systemctl --user list-unitsprintednot-found active runningand the dashboard read theactivecolumn verbatim. The unit-state cache now collapses any LOAD other thanloaded(not-found,masked,bad-setting,error) toinactiveso the UI matches reality. - MySQL 8.4 was unusable from Laravel CLI tools (#303). The PHP-FPM container ships Alpine's
mysql-client(which is the MariaDB client), and MySQL 8.4 broke it on two fronts. First, MySQL 8.4 auto-generates self-signed TLS certs that the MariaDB client refuses to validate, breaking everymysql/mysqldumpinvocation Laravel and packages likespatie/db-dumpershell out to. Second, MySQL 8.4 disablesmysql_native_passwordby default and the MariaDB client doesn't speakcaching_sha2_password, so the CLI couldn't authenticate even with TLS off. Lerd now ships/etc/my.cnf.d/lerd-no-ssl.cnfinside the PHP-FPM image (full and fast-path builds) so the MariaDB client connects in plaintext over the trusted lerd network, and the MySQL preset config addsloose-mysql_native_password=ONso the auth plugin is loaded on startup. Theloose-prefix keeps this config compatible across MySQL 5.6 / 5.7 / 8.0 / 8.4. Also disabledrestrict_fk_on_non_standard_key(new in 8.4, ON by default) which was rejecting existing dumps with foreign keys against non-unique columns, and addedffmpegto the PHP-FPM image for media-library packages that depend on it. lerd initon a fresh Laravel project ignored the.lerd.yamlDB pick (#305). Picking MySQL or Postgres at init time saved the choice to.lerd.yamlbut never updated.env, so Laravel keptDB_CONNECTION=sqliteand the user's selection silently dropped on the floor. Laravel definesEnv.Services, sorunEnvtook the framework path which only applied services it could detect already in the.env; the non-framework branch already honoured.lerd.yaml. The yaml-honour decision is now extracted intoshouldApplyService/userPickedDBFromYAMLhelpers and reused on both paths, with table tests covering the regression case (yaml says mysql, env doesn't have it, still apply mysql).- Stopped services with port conflicts had no visible cause (#300). When a service unit was installed but stopped and another process held its host port (a system-installed Postgres on 5432, a stray Docker container, etc.), Start failed with a generic bind error and the user couldn't tell why. Lerd now surfaces the conflict passively in three places before they click anything:
lerd doctorgains a[Stopped service ports]section that walks installed-but-inactive services and warns per bound port;/api/servicessnapshots includeport_conflictsso the dashboard renders an amber pill on the service detail and a small alert icon in the sidebar list; and the port-finding hints inlerd startpre-flight, doctor, dns:diagnose, and the docs now branch on GOOS so macOS users get anlsofcommand instead of the Linux-onlyss -tlnpstring. The cli port helpers (PortInUse,PortInUseIn,PortListOutput,PortCheck,CollectPortChecks) are now exported sointernal/uican reuse them without duplicating the ss-vs-lsof logic. - Lerd silently shimmed Node on hosts using nvm / volta / mise / asdf / fnm (#297).
detectSystemNodeonly probednodein PATH, so a developer with their own version manager (whose shim is loaded later in shell rc) wouldn't see the new "Let lerd manage Node.js?" prompt and would end up with~/.local/share/lerd/bin/{node,npm,npx}masking their tooling. Detection now also probesnpm/npxand the well-known version-manager dirs, so those users get prompted instead. After confirming managed Node, install also runsfnm install+fnm defaultso the user is left with a workingnpm(fixes the "can't find version: default" surprise on first run); the shim now probes the default alias before exec to catch the "versions installed but no default" case alongside an empty list.lerd node:install/node:use/node:uninstallwarn and require confirmation when lerd isn't currently managing Node, and write fresh shims on accept so CLI opt-in matches the install flow. The dashboard NodePage install input/button is disabled when Node is system-managed, with a hint pointing atlerd install, and the UI endpoints have matching server-side guards. - Opting out of lerd-managed Node left fnm shims masking system node (#306).
lerd installadds a "Let lerd manage Node.js?" prompt when a system node is detected; answering no on a host that previously had managed node skipped writing fresh fnm shims but didn't remove the existing ones, so~/.local/share/lerd/bin/node|npm|npxkept overriding system node in PATH. The opt-out path now deletes the three shims so the user's choice actually takes effect. - Podman version probe missed Homebrew under launchd (#306, macOS). The
StopTimeout=vsPodmanArgs=--stop-timeout=5selector ranexec.Command("podman", "--version"), which fails under launchd's restricted PATH wherepodmanis at/opt/homebrew/bin/podman. The probe now goes throughPodmanBin()so launchd-spawnedlerd-uipicks the modern key on Podman 5.x instead of always falling back.
[1.19.0] — 2026-05-04
A large feature release. Highlights: a brand-new Svelte 5 dashboard with a command palette, every default service moved to YAML presets, a first-class update / migrate / rollback / reinstall flow with linked-site reprovisioning, the in-browser Tinker REPL, optional disabled-DNS install mode, worker self-heal, full git-worktree support, and macOS parity for the Linux-only pieces of the v1.19 surface.
Added
Services and data lifecycle
- YAML-driven default service presets. The six built-in services (mysql, postgres, redis, meilisearch, rustfs, mailpit) moved out of hardcoded Go lists and embedded
.containertemplates intointernal/config/presets/*.yamlfiles markeddefault: true. Adding or replacing a default service is now a YAML edit; default and add-on presets share one quadlet writer, one env-var resolver, and one dependency engine. Six duplicated service-name lists collapsed intoconfig.DefaultPresetNames(); three duplicated env-var maps collapsed intoconfig.DefaultPresetEnvVars(name). - MySQL canonical bumped to 8.4 LTS (was 8.0). Existing users on saved 8.0 are untouched (viper merge wins;
migrateStaleServiceImagesskipped fortrack_latestpresets); fresh installs land on 8.4.x. The 5.6 alternate is gone; 5.7 and 8.0 remain pickable.mysql.cnfloose-prefixes added so the same config file works across mysql 5.6 / 5.7 / 8.0 / 8.4 (8.4 hard-rejectsinnodb_large_prefix/innodb_file_formatwithout the prefix). - Update, upgrade, migrate, and rollback flow. New
serviceops.UpdateServiceStreaming,serviceops.MigrateService,serviceops.RollbackService, plus aninternal/registrypackage that queries Docker Hub and GHCR for newer tags. Per-presetupdate_strategy(patch/minor/rolling/none),track_latest(fresh installs resolve current upstream), andallow_major_upgrade(gates cross-major NewestStable).- CLI:
lerd service update <name> [tag],lerd service migrate <name> <tag>,lerd service rollback <name>.lerd service listgains an Update column with green and amber badges showing pending updates. - Web UI: green Update, amber Upgrade, violet Migrate (mysql / postgres / mariadb only), grey Rollback buttons in the service detail panel. Streaming NDJSON phase events into the in-flight UI machinery.
- MCP:
service_check_updatesfor read-only status;service_controlaction enum widened toupdate,migrate,rollback,restart,remove,reinstall.service_removeandservice_updateremoved as standalone tools.
- CLI:
- Remove and reinstall any service, including default presets (#294).
lerd service remove <name> [--purge]and a newlerd service reinstall <name> [--reset-data]work on default presets too.--purgeand--reset-datarename the data dir aside (<dir>.pre-remove-<unix-nanos>) so the wipe stays recoverable. With--reset-data, reinstall auto-reprovisions per-site state on the fresh container: CREATE DATABASE for mysql/mariadb/postgres (resolving names via.lerd.yaml db.database, then.env DB_DATABASE, then a derived site name),mc mbfor rustfs (using.env AWS_BUCKETor the derived site name). New web UI modals for both flows; default-service deletes that affect linked sites require typing the service name to confirm. - Migration safety guards. Rolling-tag updates suppressed when local manifest digest matches remote (no phantom badges on
:latest). Cross-major upgrades hidden from the Upgrade button by default; opt-in via per-presetallow_major_upgrade. Cross-strategy upgrade button suppressed forupdate_strategy: patchpresets without a registered SQL migrator (Meilisearch, where clicking would brick the data dir). Alternates installed via preset (e.g.mysql-8-0) internally promote topatchstrategy so they don't get auto-suggested cross-LTS jumps. internal/registrypackage.ListTags,MaybeNewerTag,NewestStableagainst Docker Hub and GHCR with a 6-hour disk cache. Typed*UnreachableErrand*UnsupportedRegistryErrare swallowed by the high-level helpers so offline or unsupported-registry installs stay quiet rather than spamming errors./api/services/<name>/{updates,update,rollback,migrate,reinstall}. Read JSON plus streaming NDJSON endpoints mirroring the existing preset-install streaming flow.- Gotenberg API preset for PDF generation and document conversion (#268, #271).
Web UI and developer surfaces
- Dashboard rewritten in Svelte 5 + TypeScript (#260). The previous 4,800-line Alpine.js monolith (
internal/ui/index.html) is replaced by small composable components per tab, stores, and modals underinternal/ui/web/. Every feature from the Alpine version is preserved; bundle ships as a single hashed JS + CSS file embedded in the Go binary (~60 KB gzipped JS, 7 KB gzipped CSS). Vite build runs beforego buildviamake build-ui. No backend or API changes; the WebSocket snapshot protocol and/api/*routes are identical. - Dashboard root with live widgets and global command palette (#280). Replaces the empty "select a site" landing page with a Dashboard tab built around at-a-glance widgets backed by the existing WebSocket pipeline. Eight focused cards in a 3-col grid: HeroStatus alert strip, Sites list, Services bounded grid with update banner, Workers per-group active counts, System Health (PHP-FPM and Node usage), Resources (CPU + memory via a new
/api/statsendpoint that parsespodman stats --no-stream), Lerd info merged with a Recent activity timeline driven by a frontend diff over WS snapshots, and a dismissible Onboarding panel when no sites are linked. Plus a global command palette (Cmd+K, Ctrl+K, or/) for jumping to any site, service, page, or action from any tab. - Tinker tab, in-browser PHP REPL per site (#282). CodeMirror-backed editor with context-aware autocomplete (project models from PSR-4, composer helpers, PHP built-ins cached per version, framework hints, buffer variable completion), live
php -llinting (debounced 2.5s, symbol cache per domain to keep lerd-ui CPU flat under heavy typing), and an editor-styled output panel with collapsible Symfony VarDumper trees. Driven by the framework definition'stinker:block (command,execute_flag,execute_positional,requires_package,requires_file); Laravel ships with one bundled, sites without a framework REPL run a temp script under plainphpwith composer autoload included. Bare expressions auto-dump per statement. lerd bug-reportcommand for GitHub issue triage (#266). Collects logs only for lerd's own infra units (lerd-nginx,lerd-ui,lerd-watcher,lerd-dns,lerd-tray,lerd-autostart,lerd-fpm-init); preset services keep their state in the unit-state and container tables but the noisy logs no longer ride along. Anonymizes site names, domains, parked-directory paths, and the username by default (site-1,site1.<tld>,$PARK_1,$USER); pass--show-real-namesto keep raw values for local debugging.
Worker management
- Worker self-heal across CLI, dashboard, TUI, and MCP (#279). New
lerd worker heal [name]resets the failed state and restarts every worker unit systemd lists asfailed(or one named unit). Same recovery is reachable from an amber sticky banner in the dashboard, theHkeybind in the TUI, and theworkers_heal/workers_healthMCP tools.lerd statussurfaces a one-line hint when any worker is in failed state. Heal is intentionally narrow: it never writes.lerd.yamlor rewrites the unit file. The dashboard banner is push-driven over WebSocket via a 5-second cached-detector watcher that publishes only when the unhealthy set changes. lerd workers mode <exec|container>on macOS (#218). Framework workers (queue, schedule, horizon, reverb, custom) run via a pid-file-guardedpodman execinto the shared FPM container by default (execmode), matching the memory profile of the Linux systemd path. Each worker adds near-zero RAM compared to the previous per-worker-container model. Users who prefer the previous 1:1 supervisor boundary can opt back in withlerd workers mode container. Setting is also surfaced in the TUI settings overlay (S) and viaGET/POST /api/settings/worker-modeinlerd-ui. The dedup guard prevents duplicate workers when the podman-machine SSH bridge hiccups: if a previouspodman execis still alive, launchd's relaunch exits cleanly. No change on Linux.
Networking and DNS
- Optional disabled-DNS install mode for users who don't want lerd touching system DNS.
lerd installasksLet lerd manage DNS for local sites (No: use *.localhost, no dnsmasq, no HTTPS)?after the Node prompt and persists the answer underdns.enabled. When disabled, the installer skips lerd-dns, dnsmasq config, the sudoers rule, the mkcert root CA install, and the resolver tweak.lerd startno longer manages lerd-dns,lerd doctorreports DNS as managed externally, and the dashboard hides per-site HTTPS toggles. HTTPS is intentionally coupled because mkcert never installed a trusted CA in this mode. Re-runninglerd installwith the opposite answer detects the TLD change, lists affected sites, and offers a one-pass migration (registry,.lerd.yaml, project.env, git-worktree vhosts and per-worktree.env, stale primary vhost confs, TLS cert/key on disable). Suggested by #281. - Layered DNS diagnostic in
lerd doctorand andns_diagnoseMCP tool. Walks the chain end to end (lerd-dns container, dnsmasq config file, port 5300 listening, direct dig at 127.0.0.1:5300, resolver hookup script or drop-in installed, interface routing inresolvectl status, system-wide DNS lookup) and surfaces exactly which rung is broken with a one-line remediation hint per failure. Replaces the old single "not resolving to 127.0.0.1" error that conflated seven possible causes. The MCP variant returns the structured walk as JSON (steps[].status,first_failureindex) so AI assistants can drive troubleshooting without scraping doctor's output. Inspired by #285.
Git worktrees
- Worktree branch renames are picked up automatically (#264). The watcher monitors each
.git/worktrees/<name>/HEADfor writes, so agit branch -morgit checkout -binside a worktree re-syncs the nginx vhost and.envAPP_URLto the renamed branch without a manual restart. Stale subdomain vhosts are removed surgically (only the now-stale one), not by regenerating every vhost on the site. Thanks to @ropi-bc for the contribution. APP_URLrealigns to the worktree domain on every scan (#263). PreviouslyEnsureWorktreeDepsonly rewroteAPP_URLwhen it first created the.env; renames and external workflows that relied on a manualgit branch -mleft it pointing at the stale subdomain. The worktree scan now updatesAPP_URLon existing.envfiles too. Thanks to @ropi-bc for the contribution.lerd worktree addandlerd worktree removeinteractive wrappers mirroringgit worktree's subcommand layout, with every flag passing straight through to git.addpolls until the watcher's install pipeline (composer + npm) finishes, then prompts for the production-build script (skip /build/prod/build:prod/build-prod/production, whichever exist inpackage.json), and for the database setup (share parent / isolated empty / clone from main / clone from another isolated worktree). When the worktree's branch already has a preserved isolated DB the prompt prepends two extra options at the top: Reuse preserved isolated DB (reconnects without touching the schema) and Reset preserved DB to a fresh empty schema. Picking "isolated empty" then offers to runphp artisan migrate --force.removerunsgit worktree remove, recovers from "modified or untracked files" by offering a force-retry select prompt, then asks at the end whether to drop the worktree's isolated database (default Keep, so re-adds reconnect to the same data).- Opt-in per-worktree database isolation in the dashboard. The site controls expose an Isolated DB toggle whenever a worktree is active and the parent uses a lerd-managed mysql, mariadb, or postgres service. Flipping it on creates
<parent_db>_<sanitized_branch>in the same service container, rewritesDB_DATABASEin the worktree's.env, and persists the choice asdb_isolated: truein the worktree's.lerd.yaml(so it travels with the branch in git). On enable, lerd asks how the new schema should be seeded: empty, cloned from the parent (mysqldump --single-transaction | mysqlorpg_dump | psqlend-to-end inside the service container), or cloned from another already-isolated worktree. Cleanup ordering is vhost first, LAN-share second, isolated database last. - Per-worktree LAN-share proxy with a per-user registry at
~/.local/share/lerd/worktree-lan-shares.yaml. The dashboard's LAN toggle is worktree-aware;LANShareStartWorktree/LANShareStopWorktreeallocate ports across the same pool the parent uses, the proxy targets<branch>.<parent>.test, and the QR-code popover passes?branch=so the encoded URL is the worktree's. Cleanup ongit worktree removePOSTslan:unshare?branch=to lerd-ui so the listener closes in the daemon's process. - Worktree-scoped site detail view in the dashboard, with per-worktree PHP and Node versions. The path line carries an inline branch picker (
/path · git:(main) 3 ▾) that collapses to a single line regardless of worktree count and opens a dropdown listing the main checkout and every active worktree with its derived domain. Picking a branch re-scopes the rest of the panel to that worktree: the site title and the Open / Terminal buttons target the worktree's domain and checkout path, the App logs tab tailsstorage/logsfrom the worktree's directory (/api/app-logs/{domain}?branch=<sanitized>), and the PHP / Node version selectors show the worktree's effective version with a dashed violet border when inherited from the parent. Changing a selector while a worktree is active writes a worktree-only override to.lerd.yamlinside the worktree's checkout, so the choice travels with the branch in git. - Worktree inheritance for
lerd phpandlerd node:php.DetectVersionandnode.DetectVersionconsult a path-onlyconfig.ParentSiteForWorktreeDirlookup after.php-version/.node-versionand beforecomposer.json/package.jsonconstraints, so a worktree without an explicit override inherits the parent's pinned version instead of the highest installed satisfier. Composer's^8.2no longer silently picks PHP 8.5 on a worktree of a parent pinned to 8.4. - Watcher race fix for fresh worktrees:
handleNewEntrypollsHEADuntil it's a final ref or SHA before firingonAdded, closing a window where fsnotify Created the entry dir before git finalised HEAD. The daemon's startupscanWorktreespass also reconciles stale*.<site>.test.conffiles now, so a worktree removed while the watcher was offline gets its orphan vhost dropped on next start.
Changed
- Web UI cache poll backs off when the desktop session is idle or locked (#255). The
podman pscache that drives the dashboard already drops from 15s to 60s when no tab is visible. It now also drops to 60s while at least one tab is visible but systemd-logind reports the session as idle or locked, so a focused tab on an unattended laptop stops paying the per-15s subprocess cost. Recomputes on every transition via a 30s logind poll. Linux only; macOS keeps the visibility-only behavior via the existingSessionIsIdleOrLockedstub. lerd service restartrefreshes the quadlet before restarting, same path asstart, so config edits and preset file mounts (mysqllerd.cnf) land on disk before the unit picks them up. Previously a stale quadlet from an earlier release could keep running until an explicit stop+start.applyServicesin the Web UI refreshes every server-supplied field on each WS push (was onlystatus/pinned/site_count), so update / upgrade / version state propagates over the socket without a page reload. Client-only flags (loading,error,flash) still survive across pushes for in-flight UI state.internal/podman/quadlets/lerd-{mysql,redis,postgres,meilisearch,rustfs,mailpit}.containerdeleted; quadlets are now generated from preset YAML on demand.internal/cli/service_image_{darwin,linux}.godeleted; platform image overrides moved intoPreset.PlatformOverrideswith optionaltemplate substitution sotrack_latest-resolved tags survive the platform swap.
Fixed
Service lifecycle (the new update / migrate / rollback flow)
- Concurrent update / migrate / rollback now serialised per service (
internal/serviceops/locks.go). Double-clicking the Update button, or a CLI run racing the dashboard, can no longer interleave config writes, image pulls, and data-dir swaps. persistImageChoiceandswapImagePinare atomic with quadlet regeneration. If the on-disk quadlet write fails after the config write, the config is rolled back so~/.config/lerd/config.yamland the generated.containerfile can't disagree. Joined-error output if the rollback itself fails.- Migrate failures restore the pre-migrate data dir. A new
abortMigratehelper bubbles errors fromrestoreDataDirFromBackupand restarts the old unit when appropriate. - Rollback after a Migrate is refused. New
LastOpandPreMigrateBackupfields onServiceConfig/CustomService.RollbackServiceerrors out when the last op was a migrate (running the old binary against the upgraded data dir would corrupt it). The dashboard hides the Rollback button via a newcan_rollbackfield on/api/services. - SQL dump credentials no longer leak through argv.
mysqldumpandpg_dumpallreceiveMYSQL_PWD/PGPASSWORDviapodman exec --env, not on the command line. Dump files now open with mode0600; the backups directory is0700. - 30-minute timeout on every in-container migrate exec. A wedged container can no longer block migrate forever;
containerExec/dumpToHost/restoreFromHostuseexec.CommandContextwith a hard cap. allow_major_upgradeis enforced even when the CLI passes a tag explicitly. A newenforceMajorUpgradeGatecheck refuseslerd service update mysql 9.0for presets whereallow_major_upgrade: false. The registry-recommendation gate could previously be bypassed by direct tag invocation.- Server-side NDJSON streams stop on client disconnect. A new
startNDJSONStreamhelper short-circuits writes oncer.Context()is cancelled orw.Writereturns an error, replacing four copies of the closure in update / migrate / rollback / preset-install handlers. - Client surfaces stream errors instead of freezing the spinner.
streamServiceActioncallssetProgresswith phaseerrorbefore continuing, so a failed update shows the message in the UI instead of leaving the inline status stuck on the last visible phase. - Update-button gate uses strict equality.
migration_supported === false/=== trueandcan_rollback !== falseso a missing field doesn't accidentally show the wrong button. JSON tags loseomitemptyon those bool fields so the false case is always wire-visible. lerd service restartwarns instead of failing when quadlet regeneration trips. A transient template error no longer strands a healthy unit; the restart proceeds against the existing on-disk quadlet.lerd updateno longer silently bumps preset minors past the user's installed version.EnsureDefaultPresetQuadletreads the existing on-disk image whenupdate_strategyispatch/minor/noneand the user has no explicit pin, solerd update(which callsinstall --from-update) keeps users on their current minor instead of replacing it with whatever the new preset declared. Meilisearch v1.7 to v1.42 used to swap engines in one step and hard-fail because the older data dir won't load under a newer binary. Rolling-strategy services (mailpit, rustfs, gotenberg) still pick up the preset image as before via the existingtrack_latestblock.
Registry and update detection
- Docker Hub pagination followed.
dockerHubResponse.Nextwas previously read but never followed; long-tail repos (postgres, mysql) lost newer tags past page 1. Capped at 20 pages and 5000 tags so a pathological response can't drive unbounded traffic. - Distinct error classes. New
*AuthRequiredErrand*NotFoundErr. 401/403 means auth needed, 404 means repo doesn't exist, 429/5xx means unreachable. All four collapse to "no update info" viaisQuietRegistryErrbut stay distinguishable. - Token and tag-list calls have separate timeout budgets (15s each). A slow GHCR token endpoint can no longer starve the subsequent tag-list request.
- Concurrent cache writes safe. In-process
cacheMuplus an inline singleflight collapses concurrentListTagscalls; cache writes go to a temp file andos.Renameatomically. Cache write failures emit a rate-limitedlog.Printfinstead of disappearing into a discarded error. - Response body capped at 5 MB and tag list at 5000 entries so a malicious or misconfigured registry can't OOM the process.
- Digest comparisons normalised (lowercase + trim) in the
alreadyOnDigesthelper so registries returning differently-cased digests don't cause a permanent "update available" badge.
Watcher and worktree handling
cleanupWorktreeVhostsno longer triggerscomposer installand the JS install on every surviving worktree. When one worktree was removed, the cleanup pass that re-generated vhosts for the survivors also calledEnsureWorktreeDepson each one, which kicked off a fullcomposer installplus the JS package-manager install. The rename and add paths handle deps viasyncWorktree; doing it from cleanup burned cycles for no benefit..envAPP_URLrewrite is skipped when the value is already correct.rewriteAppURLcompares the new bytes against the file before writing, so a no-op worktree scan no longer bumps.env's mtime. Dev-side watchers (vite, IDE indexers, opcachefile_update_protection) used to react to every scan even when the URL hadn't changed.- lerd-watcher no longer re-runs
composer installand the JS package manager on every restart.scanWorktreesinvokesEnsureWorktreeDepsfor each worktree on startup, which previously calledInstallDependenciesunconditionally; composer's "Nothing to install" path still fires the post-autoload-dump scripts (Laravelpackage:discover, Filament asset publish, etc.), accumulating several seconds of churn per worktree per restart.InstallDependenciesnow consults each manager's install marker (vendor/composer/installed.jsonfor composer;.modules.yaml/.package-lock.json/.yarn/install-state.gz/.bun-tagdepending on the JS lockfile) and skips when the marker is at-or-newer than the lockfile. go test ./...no longer rewrites the user's PHP-FPM quadlets and daemon-reloads systemd, which used to cascade workers into start-limit-hit.internal/php/versions.go::ListInstalled()had a self-heal step that, when called from a test withXDG_CONFIG_HOMEredirected to a temp dir, looked at the user's real running FPM containers, decided their quadlets were "missing", and wrote fresh quadlets plus ransystemd --user daemon-reloadagainst the real session. Each reload triggered a quadlet-generator pass which restarted the FPM unit; with workersBindsTo=lerd-php8X-fpm, that cascade rapidly trippedStartLimitBurstand parked every worker infailed.ListInstalled()is now read-only.
macOS parity
- macOS parity for v1.19 features that were Linux-only. Worker self-heal, the dashboard's failed-worker banner, and the
workers_healthMCP tool see real failed-unit state on macOS now.siteinfo.AllUnitStateswas returning an empty map on darwin, soworkerheal.Detectnever fired. TheUnitLifecycleinterface gainedAllUnitStates(), thedarwinServiceManagerwalks~/Library/LaunchAgents/lerd-*.plistto populate it, andsiteinfo/unitcache_darwin.goplugs the launchd walker in via a newallUnitStatesFnhook. The "last error" excerpt for failed workers also works on macOS now via a platform-splitreadLastErrorthat tails~/Library/Logs/lerd/<unit>.loginstead of relying onjournalctl. lerd lan:exposeno longer fails on macOS when DNS is enabled. The DNS-forwarder install path calledsystemctldirectly to write the unit, enable it, anddaemon-reload, every one of those returned ENOENT on darwin. Routed throughservices.Mgr.WriteServiceUnit/Enable/DaemonReload/RemoveServiceUnitso the same systemd-format unit content renders to a launchd plist on macOS viaparseServiceUnit, and the Linux path is unchanged.- Worktree auto-install no longer skips pnpm / yarn / bun on Apple Silicon. The lerd-watcher daemon inherits launchd's restricted PATH (
/usr/bin:/bin:/usr/sbin:/sbin), so the bareexec.LookPath("pnpm")returned ENOENT and the install step failed for any non-npm project. NewlookJSBinhelper falls back to/opt/homebrew/binand/usr/local/binon darwin, mirroringpodman.PodmanBin(). - Container resource stats on macOS. The dashboard's Resources card was empty on darwin because the bare
exec.Command("podman", ...)couldn't find podman under launchd's restricted PATH. Now usespodman.PodmanBin()like the rest of the project. - Tinker tab works on Laravel projects with non-trivial vendor trees. PHP CLI's 128 M
memory_limitdefault was exhausted byClassAliasAutoloaderduring boot (it requires the full composer class map upfront). Tinker's podman exec now passes-d memory_limit=512M. Also setsPSYSH_TRUST_PROJECT=1to silence PsySH's non-interactive "Restricted Mode" warning that otherwise broke simple expressions likeecho env('APP_URL');. - PostGIS preset on Apple Silicon uses the upstream image instead of the imresamu fork. The official
postgis/postgismanifest publishes only amd64 entries; on darwin a newpodman.PlatformPodmanArgshook injectsPodmanArgs=--platform=linux/amd64into the rendered quadlet so podman pulls the amd64 manifest and Podman Machine runs the container under qemu user-mode (or Rosetta when the VM has it wired). Hooked centrally atWriteQuadletDiffso cli, UI, MCP, and install all emit byte-identical units. Existing data dirs initialised under the imresamu fork open cleanly on the upstream image; users may want to runALTER EXTENSION postgis UPDATEonce after the swap. Pinning a multi-arch fork explicitly in~/.config/lerd/config.yamlbypasses the platform pin entirely.
Other
lerd-tray.servicecaps its restart loop at 3 failures per 60 seconds. Two unrecoverable failure modes (missing GTK runtime, no graphical session under launchd's bootstrap context) used to spin the unit throughRestartSec=2 Restart=on-failureindefinitely; the cap stops the churn while leaving normal transient failures recoverable.- DNS sudoers rules use wildcards in command arguments, breaking install on strict-sudo distros (#269, #272). Already shipped in v1.18.1, included here for completeness across minor branches.
Security
- Bumped npm dev dependencies to clear five medium-severity Dependabot advisories (#277).
internal/ui/webupgraded vite 5 to 7.1,@sveltejs/vite-plugin-svelte4 to 6.2, and vitest 2 to 3.2, which resolves vite to 7.3.2 (path traversal in.maphandling) and esbuild to 0.27.7 (dev-server SOP).docs/package.jsonkeeps vitepress at 1.6.4 and uses npmoverridesto pull vite ^6.4.2, esbuild ^0.25.0, and postcss ^8.5.10 (XSS in CSS stringify) into vitepress's transitive tree. Both manifests now report zero vulnerabilities undernpm audit.
Removed
service_removeandservice_updateMCP tools removed as standalone entries; folded intoservice_controlaction enum (which now also acceptsmigrate,rollback,restart,reinstall).- MySQL 5.6 alternate removed from the preset list. 5.7 and 8.0 remain pickable; 8.4 is the new canonical install.
[1.18.1] — 2026-04-29
Fixed
- DNS sudoers rules use wildcards in command arguments, breaking install on strict-sudo distros (#269, #272). Ubuntu 26.04 LTS made
sudo-rs(the memory-safe Rust rewrite of sudo) the default; sudo-rs's parser rejects wildcards in command arguments outright. The same pattern is rejected by upstream C sudo from 1.9.16 onward, which ships on Fedora 41+, Arch / CachyOS, openSUSE Tumbleweed, and NixOS unstable. Thecp /tmp/lerd-sudo-* /etc/...andresolvectl <verb> *rules lerd wrote to/etc/sudoers.d/lerdnever matched on those parsers, so every DNS reconfigure fell through to the password-prompt path and emitted parse errors visibly duringlerd install("wildcards are not allowed in command arguments"). Fixed by piping content throughsudo tee <fully-qualified-path>instead of staging in/tmpand copying, and by dropping the trailing*from the resolvectl line. The Darwin path got the same treatment so future Apple-bundled sudo updates don't surface the same break. Existing installs heal automatically on the nextlerd install: one password prompt to migrate, then the new rules grant passwordless operation for every subsequent DNS reconfigure. Verified end-to-end on a fresh Ubuntu 26.04 LTS VM running sudo-rs 0.2.13.
[1.18.0] — 2026-04-25
Added
- FrankenPHP runtime (#229). Per-site
dunglas/frankenphpcontainer as an alternative to the shared PHP-FPM image. Laravel and Symfony adapters;lerd runtime frankenphpCLI andsite_runtimeMCP tool to switch; optional worker mode (Laravel Octane, Symfony's FrankenPHP adapter with--watch). Runtime badge shown in both the Web UI and TUI. Paused sites stop/start their per-site container alongside FPM. - Dual-stack IPv4 + IPv6 networking (#230, #247). The lerd podman bridge is created with both subnets (
fd00:1e7d::/64for v6). Nginx vhosts listen on[::], dnsmasq answers AAAA for.test, and every managedPublishPortgets paired with a[::1]bind. Existing v4-only networks auto-migrate on the nextlerd install: containers stop, the network is recreated, previous DNS servers are restored, and containers restart. Hosts without a usable IPv6 address (no non-loopback, non-link-local v6 on any interface) are detected via/proc/net/if_inet6+ thedisable_ipv6sysctl plus a throw-away aardvark probe, and the network is created (or recreated) v4-only. A marker file prevents re-entering the migration loop. See architecture and troubleshooting. --no-ipv6flag andLERD_DISABLE_IPV6=1onlerd install(#251). Force a v4-onlylerdnetwork on dual-stack-capable hosts without touching the host networking stack. Reuses the existing~/.local/share/lerd/ipv6-probe-failed-lerdmarker, soEnsureNetworkhonors the opt-out on every path. Re-enable by deleting the marker and rerunninglerd install.- Three new service presets:
memcached,rabbitmq,elasticsearch(#252). Standard preset convention (lerd service preset <name>). Therabbitmqpreset exposes the management UI athttp://localhost:15672;elasticsearchbinds127.0.0.1:9200so the bundledelasticvuepreset becomes a one-click install on top. - Streaming preset install in the Web UI (#257).
POST /api/services/presets/{name}returnsapplication/x-ndjsonwith events forinstalling_config,starting_deps,pulling_image,starting_unit,waiting_ready, anddone. The image pull is now explicit and happens beforeStartUnit, so the formerly invisible on-demand pull surfaces as liveCopying blob …feedback. The Add button's label tracks the active phase ("Pulling image…", "Starting elasticsearch…", "Waiting for ready…") instead of one opaque "Adding…". The CLI (lerd service preset <name>) and the MCPservice_preset_installtool keep their existing synchronous behavior. - Offline landing page for the installed PWA (#258). A service worker ships with the dashboard and falls back to a dedicated offline page whenever
lerd-uiis unreachable, including the whole-stacklerd quitcase. The page showslerd startwith a copy button and the lerd logo, probes/api/statusevery five seconds, and auto-reloads the dashboard the moment the backend returns./api/*is deliberately not intercepted so the WebSocket and every mutating call keep their normal error semantics. Cache name is versioned with the lerd build so every update invalidates the previous shell cache cleanly. - Service version label across every surface (#246).
lerd service list,lerd status, the Web UI service list and detail header, and the TUI services pane now show the version alongside each built-in, preset, and custom service (e.g.mysql v8.0,redis v7,postgres v16,meilisearch v1.7). Derived from the installed quadlet'sImage=tag viapodman.ServiceVersionLabel, which strips distro/variant suffixes (-alpine,-slim,-3.5), keeps leadingv, and passes rolling tags (latest,main) through verbatim. - Restart button in the Web UI service detail (#246). Built-in and custom services now expose a Restart action alongside Start/Stop, matching the site container row.
POST /api/services/{name}/restartwrapspodman.RestartUnitand clears the paused flag on success. Workers (queue, schedule, horizon, reverb, stripe, site-scoped custom workers) are intentionally excluded. setupMCP tool (#240). Runs the framework'sDefault: truebootstrap commands (Laravel:storage:link+migrate; Symfony:doctrine:migrations:migratewhendoctrine-migrations-bundleis installed). Agents call it afterenv_setupon new or cloned projects; idempotent, no prompts. The interactivelerd setupCLI is unchanged.- Uninstall teardown prompts (#235).
lerd uninstallnow prompts independently for:- Remove MCP integration (global skills + per-site
.claude/.cursor/.junie/.mcp.jsonentries, preserves other MCP servers in shared files). - Uninstall mkcert CA from system trust stores.
- Purge lerd-built container images (
lerd-php*-fpm:local,lerd-custom-*:local,lerd-dnsmasq:local; upstream pulls like mysql/redis are deliberately kept, data lives in host bind mounts not in the images).--forceanswers yes to all.
- Remove MCP integration (global skills + per-site
lerd installrefreshes MCP skills and heals Claude Code registration (#235, #240). Global skill files (~/.claude/skills/lerd/,~/.cursor/rules/lerd.mdc,~/.junie/guidelines.md) and every opted-in site's per-project copies are re-written on install to match the new binary; previously this only ran onlerd update. If Claude Code's user-scope MCP config has lost the lerd entry, install also re-adds it viaclaude mcp add.- Stale-site auto-cleanup covers non-parked sites (#239). The 30 second watcher sweep now removes any registered site whose directory has been deleted, not only those under
parked_directories. Publishes asiteseventbus event so the dashboard reflects the removal without a manual refresh.
Changed
- BREAKING — slimmer MCP tool manifest (#232). The
tools/listresponse merged action pairs (queue_start+queue_stop→queue(action: ...),service_start+service_stop→service_control(action: ...), and similar) and trimmed long descriptions. AI sessions started against the old tool names must be restarted. The new names are reflected in the injected SKILL.md and indocs/features/mcp.md. project_newrunscomposer installafter scaffolding (#240). Thecreate-project --no-installscaffold is chased bycomposer installinside the FPM container, so the returned project has a populatedvendor/ready forenv_setup+setup.env_setupauto-createsdatabase/database.sqlitenon-interactively (#240). Laravel's defaultDB_CONNECTION=sqlitetriggered an interactive prompt inlerd envthat MCP/script callers silently skipped, leaving the sqlite file uncreated and the first request 500'ing. Non-interactive callers now default to sqlite, persist the choice to.lerd.yaml, and run the existing file-creation block. Calldb_setto switch to mysql/postgres afterwards.- Per-session MCP token cost reduced (#236, #237).
tools/listtrimmed ~14% (20 KB → 17 KB), injectedSKILL.mdtrimmed from 44 KB → 40 KB by collapsing redundant single-tool workflow recipes. Descriptions are preserved where weaker local LLMs rely on them (sitefields,pathdefaulting, enum-valued descriptions). - SKILL.md bootstrap workflows rewritten (#240). Replaced the per-framework
artisan migrate/console doctrine:migrations:migratefork with a framework-agnostic sequence: new project =project_new → site_link → env_setup → setup, cloned project =site_link → composer install → env_setup → setup. Debug-500 flow callssetup()for pending migrations. - Install flow starts per-site containers and stripe workers in the correct phase (#234).
lerd installnow starts per-site custom containers and FrankenPHP runtimes after service containers, and stripe listeners fire in the worker phase instead of duringrestoreSiteInfrastructure(no more "stripe starts before FPM" out-of-order).
Fixed
- Aardvark-dns drift after network recreation (#234, #240). When a network is rm'd and recreated with the same name, netavark can preserve the old listen-ips header in aardvark's runtime config, stalling every container DNS lookup ~5 seconds while glibc waits for the non-listening gateway to time out.
EnsureNetworknow detects the drift (viaAardvarkNetworkDrifted) and triggers a recreate; both the dual-stack migration path andlerd uninstall's network teardown wipe$XDG_RUNTIME_DIR/containers/networks/aardvark-dns/<name>betweenrmandcreateso the condition can't re-occur. - Custom container sites not started after
lerd install(#234).install.gonow callsstartPerSiteContainersafterstartRestoredServices, solerd-custom-<site>units come up alongside FPM and global services. Previously they sat enabled-but-stopped until the user ranlerd start. - Stripe listeners started before FPM and nginx were up (#234).
restoreSiteInfrastructurecalledStripeStartForSitesynchronously, unlike other workers which write their unit file and deferStartto the worker phase. NewwriteStripeUnit/StripeRestoreUnitsplit so the start fires instartRestoredServices's worker phase, matching queue/schedule/reverb ordering. lerd sharecollapsed https asset URLs on LAN (#231). HTTPS sites sharing on LAN had asset URLs stripped back to HTTP by the nginx rewrite; the collapse now only fires for http assets on https pages.
Docs
- New "Runtime" section in the commands reference covers
lerd runtime fpm|frankenphpand its--worker/--no-workerflags. - Laravel and Symfony getting-started pages mention FrankenPHP / Octane / Symfony Runtime as optional alternatives to the shared PHP-FPM stack.
- Herd comparison table gains a FrankenPHP / Octane row (Lerd: built-in, free; Herd: Pro-only).
- Architecture dual-stack section and the remote-development "Security caveats" list note v4-only firewall rules bypass and globally-routable v6 SLAAC LAN reach.
- Landing page: two-column hero for MCP + Rootless Podman, new Framework store and Polyglot sites cards, trimmed copy to a 4-row max; hero text scaled down to fit "Local PHP development for Linux" on one line.
- README feature list mentions FrankenPHP; MCP example updated to the post-1.18
site_link → composer install → env_setup → setupsequence; tool count corrected to ~50 after the #232 manifest slim. - New troubleshooting entry for the aardvark-dns drift case (symptoms, cause, manual verification via the aardvark config file).
- Uninstall instructions now cover the three new teardown prompts and
--forcesemantics. - Lifecycle reference documents the stale-site auto-cleanup (fsnotify fast path + 30s sweep + eventbus refresh).
docs/features/mcp.mdtool table addssetupand updates example interactions to the four-step bootstrap sequence.- Getting-started guides (laravel / symfony / wordpress) mention
setupin the AI-assistant tip.
CI
- Skip docs deploy and brew tap upload on pre-release tags (#233). The docs site and the Homebrew tap now track stable tags only; beta and rc tags still build binaries but don't publish.
- Scheduled
check-upstream-phpworkflow now actually triggers a base-image rebuild (#256). The dispatch step ran with the default restrictedGITHUB_TOKENsocreateWorkflowDispatchreturned403 Resource not accessible by integration, and the digest cache was saved before the failing trigger job, advancing the cached digests without an actual rebuild. Jobs are merged,permissions: actions: writeis declared on the job, and the cache save is gated on dispatch success. On failure the prior cache is preserved so the next cron retries.
[1.17.1] — 2026-04-20
Fixed
- Services not started after fresh macOS install.
ensureServiceQuadleton macOS calledWriteContainerUnitFnwhich only writes the launchd plist, not a.containerfile inQuadletDir.startRestoredServiceslater reads that directory viaquadletImage()to decide what to pre-pull; with no file it returns empty and skips the pull, leavingpodman runto auto-pull mysql/postgres/etc. on a brand-new Podman Machine where those pulls often fail or time out. Switched toWriteQuadletDiff, which writes both the.containerfile and the launchd plist viaAfterQuadletWriteFn, so pre-pull works on first install. - PHP FPM images silently missed on first install.
ensureFPMQuadletTowrote the launchd plist only after a successful image build; a failed build left the PHP version unregistered, invisible toensureImages(), and never retried. The plist is now written before the build so the version shows up inlerd status(asimage missing) andlerd startrebuilds it on the next run.lerd install's autostart block also re-runsensureImages()before starting FPM containers so transient build failures heal automatically. - Image pulls routed through lerd-dns on macOS install.
ensureImages()ran afterConfigureResolver(), so every registry pull (nginx, DNS, services, FPM) went through the.testoverride. Moved the call before the DNS block so pulls use the clean system resolver. .containerfile missing for service and custom-container units on macOS. All call sites (FPM, custom services, custom containers, UI server, MCP server) usedWriteContainerUnitFn, which on macOS writes only the launchd plist.quadletImage()then had no file to read, so the pre-pull step was skipped and containers failed on first start. Every site now callsWriteQuadletDiff, which writes both artifacts.- Certificates reissued on every
lerd setup.IssueCertre-ran mkcert even when the cert and key were already present. Now skips when both files exist. - Shims broken under Homebrew installs. php/composer/laravel shims hardcoded
~/.local/bin/lerdas the target binary, so they failed for Homebrew installs at/opt/homebrew/bin/lerd. Shims now resolve the running binary withos.Executable()and use whichever path ranlerd install. - PHP commands failing on fresh installs because
.envservices weren't running.ensureServicesForCwdonly acted on paused sites, so mysql/postgres/etc. referenced in a site's.envstayed down and migrations failed with connection errors. It now starts any referenced service that isn't running, silently, regardless of pause state. - Dashboard preset install left services stopped.
InstallPresetByNameonly wrote the quadlet without starting the container, so services added from the Web UI (mongodb and others) sat idle after install. The UI now starts dependencies and then the service itself, matching the dashboard's Start button. lerd shareURL unreachable while a VPN is active.detectPrimaryLANIPreturned the VPN tunnel address (utun*/tun*) instead of the physical LAN interface, so the shared URL worked on the host but nothing else on the LAN could reach it. The detector now validates which interface the routing-table probe selected and falls back to scanning physical interfaces, skippingutun,tun,tap,wg, and container bridges.- fnm install fails on ARM Linux. The installer only fetched
fnm-linux.zip, which is x86_64-only; arm64 machines needfnm-arm64.zip. Arch detection now picks the correct archive, matching the existing logic used for mkcert. - Go test runs hang for the full timeout.
installCompletioncalledos.Executable()during tests; the resulting path pointed at the test binary, which then re-invoked itself withcompletion bashand hung until CI's 10-minute timeout. The installer now skips when the executable name ends in.test.
[1.17.0] — 2026-04-20
Added
- Nginx per-site overrides (#225). User snippets dropped in
~/.local/share/lerd/nginx/custom.d/{domain}.confnow survive every vhost regeneration and everylerd update. Each generated server block ends with anincludethat pulls that file in, and lerd never writes intocustom.d/so your edits stay put. Fixes #223. - X-Forwarded- propagation into PHP* (#225). Generated vhosts now set
HTTP_HOST,SERVER_NAME,HTTP_X_FORWARDED_HOST,HTTP_X_FORWARDED_PROTO,HTTP_X_FORWARDED_PORT,HTTP_X_REAL_IP, andHTTP_X_FORWARDED_FORvia two http-levelmapblocks ($real_forwarded_host,$real_forwarded_proto) declared once in a newconf.d/_forwarded.conf. Direct browser access is unchanged because the maps fall back to$hostand$scheme; tunnels likelerd share, ngrok, and cloudflared now produce correct absolute URLs out of the box, without any app-sidetrust_proxiesconfig. Fixes #224. - Global AI skill docs refreshed on
lerd update(#222).mcp:enable-globalnow also writes user-scopeSKILL.md, cursor rules, and junie guidelines so AI assistants know about the current lerd MCP tools.lerd updaterewrites those three files from the new binary whenever global MCP is enabled, keeping them aligned with any added or renamed tools. The gate detects both Claude user-scope registration and the lerd-owned marker files, so users without Claude Code installed are still covered. - TUI responsive layout, scrollbars, and color refresh (#217). Below 100 columns the dashboard stacks into a narrow layout: list pane (40%) above detail (60%),
vtoggles between sites and services,tabcycles only through the active list and detail. Sites, services, and the site detail pane gained a scrollbar; the log pane is scrollable with{and}and its header shows the current offset. Colors were rebalanced to match the web UI palette: emerald for running, violet for accents, amber for paused, red for failing.
Fixed
- Country-code TLDs incorrectly encoded in auto-generated site names (#221).
SiteNameAndDomainused a curated TLD list that missed most ccTLDs, so a directory namedastrolov.roproducedastrolov-ro.testinstead ofastrolov.test. Replaced with a regex matching any trailing two-letter suffix, covering every ISO 3166 code without a maintenance list. Multi-letter gTLDs (.com,.net,.info,.dev,.app,.ltd, and friends) stay on the curated list soapp.v2andbackup.oldsurvive unchanged. - Invalid
AWS_BUCKETnames on rustfs sites (#220). The framework template wrote the underscored database handle, which rustfs rejects.envMap["AWS_BUCKET"]now flows throughs3BucketNameon every run so stale invalid values auto-heal, and a newtemplate placeholder resolves to the S3-safe form. Existing sites with a broken bucket name are repaired on the nextlerd env. - Auto-stop skipped Podman services on macOS after all sites were paused (#216). Services using Podman's
--restart=alwayspolicy sit with a launchd plist in the "not running, never exited" state;UnitStatusfell through toContainerRunning, but a transientpodman inspectfailure (common under VM socket contention) returned "failed" and auto-stop silently skipped the service.ContainerRunningis now the authoritative check, withUnitStatuskept as a fallback when the container is not found. Postgres and meilisearch now stop as expected.
Changed
- Docs workflow deploys only on tag release (#219). The GitHub Pages deploy now triggers on
v*tag pushes instead of every push to main, so the published site tracks tagged versions and doesn't republish on internal-only merges.
[1.16.0] — 2026-04-17
Added
lerd tui— terminal dashboard. A btop-style, full-screen dashboard for sites, services, and workers, with near parity to the Web UI and System Tray. Built on the same bubbletea / lipgloss stack already used forlerd manand the samesiteinfo/podman.Cache/ eventbus plumbing that driveslerd-ui, so both surfaces see identical live state.- Layout: Sites + Services stacked in the left column, a full-height Site detail pane on the right, and a toggleable log tail below (
l). Header shows DNS / nginx / FPM status plus anupdate: vX.Y.Zbanner when a newer release is cached. - Site detail: primary domain header, internal name, disk path, full domains list (add with
a, rename withe, remove withx), services-used with live state, workers (toggle withspace), git worktrees, HTTPS toggle, LAN share toggle (showshttp://<lan-ip>:<port>when on), PHP and Node version pickers (open withspace, commit withenter, backed bylerd isolate/lerd isolate:node). - Services pane includes site-owned workers (
queue-<site>,schedule-<site>,horizon-<site>,reverb-<site>, custom framework workers), routed throughlerd queue start/stop,lerd worker start/stop <name>, etc.topens an interactive shell in the focused container (FPM or custom for sites, the service container for services, the owning site's FPM for workers). - Filter + sort:
/to filter sites / services by name (sites also match domains and framework label),oto cycle sort (sites: name · status · framework; services: name · status · usage).vhides the services pane. - Log sources:
[/]cycle through FPM / custom container, every worker journal (journalctl --user -u lerd-<kind>-<site>), and every file matched by the framework'sfw.Logsglobs (Laravel:storage/logs/*.log). Logs pane takes at least half the window and has a right-edge scrollbar. - In-pane overlays:
Sswaps the detail pane for global Settings (LAN expose, autostart on login, Xdebug per PHP version) and moves focus into it;?swaps it for a scrollable Keybindings reference.escreturns to Site detail. - Updates live by subscribing to the in-process eventbus and re-querying every 2 s so changes made from another terminal surface within a couple of seconds.
- Layout: Sites + Services stacked in the left column, a full-height Site detail pane on the right, and a toggleable log tail below (
Selectable Xdebug mode per PHP version (#205).
lerd xdebug onnow accepts--mode(debug,coverage,profile,trace,develop,gcstats, or comma combos likedebug,coverage); previously mode was hardcoded todebug. The dashboard gains a mode dropdown next to the Xdebug toggle and a clickable Xdebug chip on each site row. The MCPxdebug_ontool accepts amodeargument. Toggle orchestration (validate → persist → write ini → restart FPM) is extracted into a newxdebugopspackage; the three surfaces (CLI, UI, MCP) are now thin wrappers. Legacy configs with no saved mode resolve todebugso existing setups are unaffected.Adaptive Podman Machine memory on macOS (#206). The VM memory target now scales with host RAM instead of a fixed 4 GB floor: 3 GB on machines with ≤8 GB, 4 GB on 9–31 GB, 6 GB on 32 GB+. Detection uses
sysctl hw.memsize; falls back to 4 GB when detection fails. On 8 GB MacBooks lerd prints a note with the manual override command (podman machine set --memory 4096) so the tradeoff is visible.lerd quitstops the Podman Machine VM on macOS. After all containers, workers, the Web UI, watcher, and tray are shut down,lerd quitcallspodman machine stopon any running machine.lerd startalready starts the machine, so quit and start are now fully symmetric. No change on Linux where Podman runs natively without a VM.
Fixed
Worker log tabs stuck on "connecting..." in the dashboard (#210). Two separate bugs combined. First, silent units (no output until an event fires) never triggered the first body write, so Go's
http.ResponseWriternever sent the HTTP 200 +text/event-streamheaders and the browser'sEventSourcestayed inCONNECTING. Fixed by flushing a: connectedSSE comment immediately after writing headers. Second, switching tabs opened a newEventSourcewithout closing the previous one; after a few clicks all six browser HTTP/1.1 connections were consumed and new streams queued indefinitely. Fixed by closing every non-active worker log stream before opening the new one.Git worktrees running stale code from the main checkout (#209).
vendor/andnode_modules/in a new worktree were symlinks back to the main repo. PHP resolves__DIR__through symlinks, sovendor/autoload.phpreported the main repo path and Composer'sClassLoaderloaded every class frommain's source tree, silently ignoring any divergedapp/orsrc/files in the worktree. The symlinks are now replaced with real copies seeded from the main repo using reflink-aware helpers (cp -a --reflink=autoon Linux btrfs/xfs,cp -Rcon macOS APFS, plain Go walk elsewhere), followed bycomposer installandnpm cito reconcile against the worktree's own lockfiles.
[1.15.1] — 2026-04-16
Fixed
lerd.localhost504 on rootless Linux. The dashboard vhost reverse-proxied tohost.containers.internal:7073, which on rootless podman setups where netavark resolves that name to169.254.1.2but doesn't wire up a bridge alias or DNAT for it routed packets into a dead end, and the proxy hop timed out after 60 seconds.lerd-uinow also binds a unix domain socket at~/.local/share/lerd/run/lerd-ui.sock, thelerd-nginxquadlet bind-mounts that path read-write, and the Linux vhostproxy_passes throughhttp://unix:...instead of TCP. Unix sockets depend on filesystem access, not container networking, so the dashboard no longer breaks when your netavark/pasta/rootless stack shifts between versions or your host changes networks. macOS keeps the TCP path viahost.containers.internal:7073because unix sockets don't traverse the podman-machine virtio-fs boundary as functional sockets, and gvproxy reliably forwards that upstream there.- Xdebug times out silently on rootless Linux (same class of bug as #186). The 1.13.1 fix replaced a hardcoded
169.254.1.2with a dynamicgetent hosts host.containers.internalprobe but still trusted whatever netavark returned without checking it actually routed. On setups where netavark gives the same 169.254.1.2 back, the fix is a no-op and Xdebug fails withTime-out connecting to debugging clientexactly as before.DetectHostGatewayIPnow runs a real reachability probe: from insidelerd-nginx, TCP-connect to lerd-ui:7073 for each candidate (getent's answer, the host's primary LAN IP, slirp4netns's10.0.2.2) and use the first that opens. If nothing works, fall back to the legacy constant and surface the failure inlerd doctorunder a new[Container → Host connectivity]section so users get a concrete diagnosis instead of silent retries. - Xdebug breaks when the laptop changes networks. A probe at
lerd startpins a LAN IP into/etc/hosts, which goes stale the moment you move from home wifi to a coffee shop or rotate DHCP. Newlerd-watchergoroutine reprobes the host gateway on LAN change and rewrites the shared/etc/hostsin place, so PHP-FPM containers pick up the new address on the nextgetaddrinfocall without a container restart. Steady-state cost is near zero: a singlenet.Dial("udp4", "1.1.1.1:80")routing-table lookup per 30 s tick (never sends a packet, just reads the kernel's source IP for the default route). The expensive probe only runs when the primary LAN IP actually changes. Matters most on macOS where eachpodman execthrough gvproxy costs 300 ms to 1 s, so a naive probe-every-tick design would burn 1-3 % of a core continuously on battery. - Podman auto-creates a directory at missing bind-mount source paths. When the FPM container starts before an ini file has been written, podman satisfies the
Volume=clause by creating the source as a directory, and the next write against that path either silently no-ops (EnsureUserInireturned early on theos.Statsuccess without checkingIsDir) or fails withis a directory(WriteXdebugIni, the inline hosts-file pre-create). Fix:EnsureXdebugIniandWriteXdebugInidetect a stale directory and remove it before writing;EnsureUserInigot the same self-heal; the inline hosts pre-create was extracted intoensureFPMHostsFilewhich normalises stale-directory, missing, and regular-file states into "regular file present";WriteContainerHostsandwriteBrowserHostsnowMkdirAlltheir parent instead of assuming the data dir already exists. Scanned everyVolume=source on the embedded FPM, nginx, and service quadlets to confirm these three file sources were the remaining ones needing pre-creation (directory-typed mounts likedata/*andconf.dare safe because podman creating them is the right behaviour). - Dashboard shows containers as still running after
lerd stop. The in-processAfterUnitChangehook refreshedpodman.Cachebefore broadcasting, but the/api/internal/notifyendpoint that CLI and MCP processes use to signal unit lifecycle changes only invalidated thesiteinfocache and published events without refreshing the container cache. Site/FPM running flags read frompodman.Cache, so afterlerd stopthe browser kept reporting everything as up until the 15-60 s background poller next ticked. The notify handler now also callspodman.Cache.PollNow()in a goroutine so state flips within a second of the CLI exiting while the handler still returns under the 500 ms POST timeout.
[1.15.0] — 2026-04-16
Added
- Per-project custom container support (#198). Non-PHP sites (Node.js, Python, Go, Ruby, etc.) can define a
Containerfile.lerdand acontainer:section in.lerd.yaml. Lerd builds a dedicated image, runs it as a named container, and nginx reverse-proxies to it. Full lifecycle:lerd linkbuilds and starts,lerd unlinkstops and cleans up (prompts to remove the image),lerd secure/lerd unsecuretoggle HTTPS,lerd pause/lerd unpausestop and start the container,lerd restartrestarts without rebuilding,lerd rebuildforces a fresh image build. Workers defined incustom_workersexec into the container. Services are reachable by name (lerd-mysql,lerd-redis, etc.) on the shared Podman network. lerd restartcommand. Restarts the container for any site type: the per-project custom container for custom sites, or the shared PHP-FPM container for PHP sites. Also available assite_restartMCP tool and in the dashboard (restart icon in the site header).lerd rebuildcommand. Rebuilds the custom container image from the Containerfile and restarts the container. Also available assite_rebuildMCP tool andPOST /api/sites/{domain}/rebuildin the dashboard.lerd initcustom container wizard. When no PHP project is detected (nocomposer.json, no framework) and aContainerfile.lerdexists, the wizard switches to custom container mode and asks for the container port, containerfile path, HTTPS, and services.- Containerfile MD5 caching.
lerd linkskips the image build when the Containerfile hasn't changed since the last build. The hash is stored in~/.local/share/lerd/container-hashes/.lerd rebuildalways forces a fresh build. - Dashboard: custom container UI. Container icon (cube) in the sidebar, base image badge (e.g.
node:22-alpine :3000) instead of the PHP dropdown, "Container" logs tab, restart button, worker toggles forcustom_workers, running/stopped status reflecting the custom container. site_restartandsite_rebuildMCP tools. Skill content updated with custom container architecture,.lerd.yamlreference includingcontainerandcustom_workersfields, setup workflow, and env var configuration guidance.
Fixed
- Watcher overwriting custom container sites. The site file watcher and
siteinfo.enrichVersionsno longer re-detect PHP/Node versions for custom container sites, preventing the empty values from being overwritten with defaults. - Parked watcher re-registering custom containers.
RegisterProjectnow skips sites already registered as custom containers. - Service auto-stop ignoring
.lerd.yaml.CountSitesUsingServiceandsitesUsingServicenow check.lerd.yamlservices list in addition to.envscanning, preventing auto-stop of services used only by custom container sites. - Domain change producing 502.
RegenerateSiteVhostnow uses custom container vhost templates for custom sites instead of PHP templates. lerd install/lerd updateoverwriting custom vhosts. The vhost regeneration during install now branches for custom container sites.lerd start/lerd stoptrying to start/stop workers for ignored sites.registeredFrameworkWorkerUnitsnow skips ignored and paused sites.lerd pause/lerd unpausenot stopping/starting custom containers. Pause now stops the custom container, unpause starts it and restores the proxy vhost.
[1.14.1] — 2026-04-16
Fixed
- Node version dropdown missing from site rows in the dashboard. The 1.14.0
node_managed_by_lerdgate was implemented as an outer<template x-if>wrapping two inner templates (empty-list placeholder and populated<select>). Alpine.js'sx-ifdirective only renders a single child element, so the outer template silently rendered nothing and the Node dropdown disappeared for every site, even on machines where lerd manages Node. Flattened into two sibling templates that each include thenode_managed_by_lerdcondition inline, matching the existing PHP dropdown pattern.
[1.14.0] — 2026-04-16
Added
- Node version management (#191). Lerd now detects whether Node is managed by the system (distro package, nvm, fnm, mise, asdf, volta) or by lerd itself, and adapts the UI and init wizard accordingly. On machines where Node is system-managed, the dashboard shows a "system" badge next to the Node.js sidebar section, hides the per-site Node version dropdown, and the
lerd initwizard omits the Node version input (an existingnode_versionin.lerd.yamlis preserved). The status API gainsnode_managed_by_lerd. Also fixes a UI regression where installing Node from the dashboard could emit a spurious "unknown version" error. - Decoupled
lerd db:*commands (#192).lerd db:import,db:export,db:create, anddb:shellnow work in any project type (NestJS, Next.js, Go, Rails, etc.) without requiring a linked site or PHP-style.env. Resolution chain (first match wins):--serviceflag,.lerd.yaml db:block, framework detect rules, then generic.envinference (DB_CONNECTION/DB_TYPE/TYPEORM_CONNECTION/DATABASE_URL/DB_PORT). Credentials from.envare intentionally ignored, because lerd always connects viapodman execusing the container's fixed admin credentials (postgres/lerdorroot/lerd), so a mismatchedDB_USERNAME=rootagainst a pgsql container no longer fails withrole "root" does not exist.db:shellnow checks whether the target database exists and prompts to create it before opening the shell, instead of dumping a raw psql error.
Changed
- Skip
.envbackup when lerd has already written the file (#193).lerd envused to unconditionally copy.envto.env.before_lerdon first run, which could overwrite a legitimate user backup if lerd had previously rewritten the file. The backup is now skipped when lerd has already written.envin this project, so.env.before_lerdalways reflects the user's pre-lerd state. - Tray improvements (#194). The tray "Open Dashboard" entry now opens the dashboard in the default browser, the update prompt wording is clearer, and "Quit" now stops the full lerd-ui + daemon stack instead of just dismissing the tray.
[1.13.1] — 2026-04-14
Fixed
- Xdebug and inter-site HTTP inside PHP-FPM containers (closes #186). The shared
/etc/hostsbind-mounted into every PHP-FPM container used to hardcode169.254.1.2both forhost.containers.internaland for every linked.testdomain. That address is only a valid host gateway on rootless podman with pasta/netavark/slirp4netns, so Xdebug timed out connecting back to the IDE on other podman configurations. It also routed inter-site HTTP through a fragileFPM → pasta host-loopback → host 127.0.0.1:80 (rootlessport) → lerd-nginxchain that failed on some podman versions and surfaced as 504s during debugging.WriteContainerHostsnow probes the real host gateway by exec-inggetent hosts host.containers.internalinsidelerd-nginx, with a throwaway alpine container on the lerd network as fallback, and the old constant as a final fallback. Two distinct IPs are written:host.containers.internalpoints at the detected host gateway for Xdebug and any host-side tooling, while every.testdomain resolves straight tolerd-nginx's bridge IP so inter-site HTTP travels container-to-container over the lerd network without any pasta hop. Rendering was extracted into a purerenderContainerHostshelper with table-driven unit tests covering empty registries, nginx IP wiring, IP separation regressions, and loopback preservation.
[1.13.0] — 2026-04-14
Added
lerd lan:share/lerd lan:unshare— expose a single site to other devices on the local network at a stablehttp://IP:PORTURL with no client-side DNS setup. A host-level reverse proxy runs in the lerd-ui daemon, rewriting theHostheader so nginx routes to the correct vhost and rewriting absolute URLs (https://domain→http://LAN-IP:port) in HTML, CSS, and JS bodies so assets and redirects work without DNS.Accept-Encoding: identityis forced upstream and gzip is decoded inModifyResponseto keep body rewriting reliable, andLocationheaders are rewritten on redirects. Each site gets a stable port from 9100 onwards (saved insites.yaml, restored on daemon start) that avoids conflicts with Reverb and other services. The CLI prints a compact half-block Unicode QR code after sharing; the dashboard UI adds a LAN toggle next to HTTPS with the URL inline and a fixed-positioned QR tooltip on hover (fixed positioning escapesoverflow-x-autoclipping on parents). QR PNGs are served from/api/lan-qr/{domain}. Closes #179.lerd import sail(aliaslerd sail import) — migrate an existing Laravel Sail project into lerd without manual dump/restore. Detects Docker or Podman Compose, remaps conflicting ports and strips non-data service ports so Sail starts cleanly alongside lerd, waits for the database, auto-detects the Sail DB name (handles the case wherelerd envalready overwroteDB_DATABASE), dumps the DB from the Sail container into lerd's MySQL/PostgreSQL, reads MinIO credentials from the composeenvironmentblock, and mirrors the bucket into RustFS viamc. Tears Sail back down when done (--no-stopkeeps it running).lerd envnow backs up.env→.env.before_lerdon first run andlerd env:restorebrings it back.lerd linkdetectslaravel/sailincomposer.jsonand prompts to run the import before setup, passingDB_DATABASEthrough automatically.- Hardcoded bundled preset files (
preset_files.go) — preset file mounts (phpmyadmin config, etc.) ship inside the Go binary instead of being copied into~/.config/lerd/services/*.yaml, solerdupdates roll out new preset contents on the next service start withoutremove + reinstall. Legacyfiles:entries in user yaml are auto-stripped and re-saved on load. Newlines and NUL bytes in custom service env values are now rejected to close a quadletEnvironment=injection vector. - URL hash routing in the dashboard —
#sites/<domain>,#services/<name>,#system/<section>,#service/<name>(dashboard iframe), and#docsare now deep-linkable with working back/forward navigation.loadSitesauto-select only fires when thesitestab is active and the hash doesn't already claim an iframe view, so refreshing on a sub-page stays put. repeat_familydynamic_env directive — produces N copies of a value aligned with the host list fromdiscover_family, used forPMA_USERS/PMA_PASSWORDSso phpmyadmin can pre-auth against every database in a family.- GitHub star nudge — low-key prompt added to the installer and dashboard.
Fixed
- Service dashboards rendered broken inside the iframe overlay — phpmyadmin lost session cookies across the cross-origin iframe and pgadmin's list-databases XHR dropped cookies after the initial connect. The phpmyadmin preset now rebuilds
cfg['Servers']fromPMA_HOSTS/PMA_USERS/PMA_PASSWORDSwithauth_type=configfor multi-host auto-login and setsCookieSameSite=Noneplus forced HTTPS env so cookies flow inside the iframe. The pgadmin preset setsSESSION_COOKIE_SAMESITE=NoneandSESSION_COOKIE_SECURE=Truefor the same reason. Rustfs now starts with--console-enableand the dashboard URL points at/rustfs/console/so the iframe lands on the web UI instead of the raw S3 XML. The preset picker also hides presets with unmet dependencies (mongo-express disappears until mongo is installed) instead of just disabling the install button. - Lerd-ui spawned terminals died silently when started at boot — when lerd-ui runs as a lingering systemd user service it starts before the compositor exists and inherits an empty graphical environment (no
WAYLAND_DISPLAY/DISPLAY), so any GUI terminal it forked exited immediately. Clicking site Terminal or "Open terminal & update" did nothing with no visible error.graphicalEnv()now pulls the graphical vars fromsystemctl --user show-environmentand probesXDG_RUNTIME_DIRfor awayland-*socket as a last resort, so spawned terminals can always reach the compositor regardless of how lerd-ui itself was launched. Darwin is skipped becauseopen -a Terminalreattaches to the Aqua session on its own. - PHP-FPM subdomain detection —
SERVER_NAMEis now set to$hostin PHP-FPM vhosts so subdomain routing works correctly under nginx. - Mobile dashboard layout broken by the iframe overlay — the dashboard iframe assumed the desktop left rail was always visible and extended full-height, hiding the mobile bottom nav, and dashboard service icons only existed in the desktop rail so mobile users had no way to reach them. The overlay now spans full width on mobile and stops above the nav, the mobile bottom nav gains a scrollable dashboards group with a separator, the nav is pinned to
h-16to match the iframe's reserved offset, the docs sidebar link reuses the iframe trigger, and the bottom nav is flattened so built-in tabs and dashboard services share equal width instead of each group claiming half the bar. lerd manindexednode_moduleswhen walking docs — the docs FS walker now skipsnode_modulesso man-page generation no longer drags vendor directories into the index.- VitePress build broken on
{{.Resources.Memory}}— Vue's compiler parses interpolations even inside backtick code spans, and the leading dot fails JS expression parsing. The token is now wrapped in an explicit<code v-pre>element so Vue skips it.
[1.12.6] — 2026-04-13
Added
- Background container state cache — a single goroutine polls
podman ps -a --filter name=lerd-on a 15 s (focused tab) / 60 s (idle) cadence and serves every hot path (buildStatus, siteinfo,IsActive,/api/sites,/api/services,/api/status) from an in-memory snapshot instead of spawning apodman inspectsubprocess per container per request. Snapshot rebuilds are serialised with aTryLockso concurrent requests share the in-flight build rather than each triggering their own batch. Browser Page Visibility is piped over the WebSocket so the server downshifts cache polling when every tab is hidden. The tray poller drops from 5 s to 30 s. Net effect on macOS: the idle VM no longer burns 30–80 % host CPU from repeatedpodman machine sshround-trips.
Fixed
- UI Stop button hung for a minute on slow-stopping services — stopping Selenium (or any custom service using
supervisord/Chromium) from the web UI would leave the button spinning for 30–60 s while systemctl waited on the container's graceful shutdown. Custom service quadlets now emitStopTimeout=5so podmanSIGKILLs after 5 s, matching the existing--stop-timeout=5behaviour on macOS. The UItoggleServicehandler also wraps the POST in an 8 sAbortControllertimeout and shows "Stopping in background…" on abort, so the button always releases promptly and the WebSocket snapshot push backfills the final state. Existing installs of affected services can pick up the new timeout withlerd service remove <name> && lerd service preset <name>. - Worker entries clobbered on
lerd uninstall→lerd install—WorkerStartForSitecalledSetProjectWorkers(CollectRunningWorkerNames(...)), which fully replaced the.lerd.yamlworkers list on every invocation. Workers started sequentially duringlerd setupoverwrote each other's entries, so after an uninstall/install cycle only the last-started worker survived. Now uses a new additiveAddProjectWorkerhelper.StripeStartForSitealso persists"stripe"after a successful start so it survives the same cycle, andlerd initnow listsstripein the Workers multi-select whenSTRIPE_SECRETis present in the site's.env. - UI worker toggles visually reverted —
AfterUnitChangepublished the snapshot broadcast before the container cache had re-polled, so the first frame after toggling a worker carried the old state and the button appeared to flip back. The hook now callsCache.PollNow()from a goroutine before publishing, so the broadcast always carries fresh data. The activating state is also now treated as running (not failing) for queue/schedule/reverb/horizon and generic framework workers, so the brief startup window no longer flashes a red error indicator. - WebSocket initial snapshot could be stale —
handleWScalled the asyncCache.Refresh()before assembling the first frame, meaning a freshly-opened browser could see container states from beforelerd startran. Replaced with the synchronousCache.PollNow()so the first frame on every new connection reflects current reality, even when multiple tabs reconnect at the same time. - Rustfs bucket not created for Laravel projects —
lerd envonly ran the rustfs bucket creation logic inside the fallbackknownServicesloop. Laravel projects go throughfw.Env.Services, which skipped the branch entirely, so Dusk/Panther sites hit "bucket does not exist" errors on first upload. The bucket create/mc anonymous set publiclogic now runs on the framework service path too, honours an existingAWS_BUCKETvalue in.envinstead of always overwriting with the project slug, and retries up to 3 times (2 s apart) to bridge the window between the host TCP port becoming reachable and themccontainer being able to connect over thelerdnetwork. - Default PHP FPM was auto-stopped when unused —
autoStopUnusedFPMsdidn't exemptcfg.PHP.DefaultVersion, so setting a default (e.g. 8.5) with no site explicitly referencing it would stop the container immediately after start, breakingphp,composer, andlaravel newshims. The helper now mirrorscoreUnits()and always keeps the default version running. - macOS Podman Machine memory resize fired on every start — the
{{.Resources.Memory}}inspect template returns MiB, not bytes, but the comparison assumed bytes. Machines already at 4096 MiB tripped the condition and the CLI stopped + resized the VM on everylerd start. Fixed the unit handling and, while there, lengthened the readiness timeout from 90 s to 120 s with a 3 s grace period, so the post-resize restart no longer races the API socket.ensureDefaultPHPInstalledalso now auto-builds the FPM image + writes the quadlet on the firstlerd startafter switching the configured default PHP version, so users don't have to runlerd php installmanually.
[1.12.5] — 2026-04-13
Fixed
- macOS ARM64 postgres pulled an image with no ARM64 manifest —
platformImageOverridewas applied beforesvcCfg.Imagefrom global config, so the macOS substitute (imresamu/postgis) was silently overwritten bypostgis/postgis:16-3.5-alpineon everyensureServiceQuadletandlerd installrun. The override now runs last and only when the resolved image is the known-bad upstreampostgis/postgis+alpinesuffix, leaving user-pinned custom images untouched. The embedded quadlet fallback also moves frompostgis:16-3.5-alpinetopostgis:16-3.5so fresh Linux installs get an image with an ARM64 manifest. (#175)
[1.12.4] — 2026-04-13
Fixed
pgpassrewrite failed with permission denied — file mounts declared withchown: true(e.g. pgAdmin's/pgpass) get re-owned to a userns-mapped uid by podman's:Uflag. On the next materialize the host process could no longer open the file for writing and surfacedopen …/pgpass: permission denied.MaterializeServiceFilesnow unlinks the existing entry before writing, so a stale userns-owned file is replaced cleanly.
[1.12.3] — 2026-04-13
Fixed
- pgAdmin crash loop and iframe embedding — pgAdmin now ships a mounted
config_local.pythat disablesX-Frame-Options,ENHANCED_COOKIE_PROTECTION, andWTF_CSRF_CHECK_DEFAULT, so it renders inside the inline dashboard overlay with working sessions and preferences. Also fixes a launchd plist XML escaping bug where'and"in env var values were emitted as numeric character references that Apple's plist parser passed through literally, corrupting container env and crash-looping pgAdmin on macOS. Adds a Slonik (elephant) icon for pgAdmin in the dashboard rail. (#171) - UI service remove left family consumers stale — the web UI's remove handler did not call
RegenerateFamilyConsumers, so removing mariadb from the UI left phpMyAdmin'sPMA_HOSTSpointing at the gone host. The UI now matches the CLI behaviour. (#172) - Workers showed as off on macOS while running —
unitStatusFndefaulted to asystemctl-based path that does not exist on macOS, so the UI never reflected running workers. Darwin now overrides it to usepodman.UnitStatus, the same pathlerd statususes. (#170)
[1.12.2] — 2026-04-13
Added
- Inline dashboard iframes — service dashboards (phpMyAdmin, pgAdmin, Mailpit, RustFS, Meilisearch, Mongo Express, Selenium…) now open as a full-width overlay inside the lerd UI instead of a new browser tab. The left icon rail grows a separator followed by one stroke icon per running dashboard-exposing service, and the service detail Dashboard / Open phpMyAdmin / Open pgAdmin buttons route through the same overlay. Clicking any of the main nav icons closes it. An Open-in-new-tab escape hatch remains for dashboards that refuse framing or lose session cookies under third-party partitioning. (#168)
- phpMyAdmin ships
AllowThirdPartyFraming— the phpmyadmin preset now materialises/etc/phpmyadmin/config.user.inc.phpwith$cfg['AllowThirdPartyFraming'] = true;so it renders inside the inline overlay. Existing installs mustlerd service remove phpmyadmin && lerd service preset phpmyadminafter upgrading to pick up the new file mount.
[1.12.1] — 2026-04-13
Added
- macOS release pipeline and Homebrew tap — tagged releases now build darwin amd64/arm64 binaries and publish to the
geodro/homebrew-lerdtap. Install on macOS withbrew tap geodro/lerd && brew install lerd && lerd install.
Fixed
- Short-name pulls failed on Ubuntu — built-in service images (
mysql,redis,postgis,meilisearch,rustfs,mailpit) were stored as short names and failed on distros whose/etc/containers/registries.confhas no unqualified-search registries. All defaults are now fully qualified withdocker.io/, and existing configs are auto-migrated on next load. - Installing a preset from the UI did nothing visible — the
/api/services/presets/endpoint did not publish an eventbus event after a successful install, so the 2-second snapshot cache kept returning the stale services list. The frontend's immediateloadServices()then failed to find the new service, leaving the modal open and the dashboard unchanged. The endpoint now invalidates the cache and broadcasts over WebSocket, so the phpMyAdmin (and any other preset) install flow closes the modal, switches to the Services tab, selects the new service, and starts it.
[1.12.0] — 2026-04-13
Added
- macOS platform support — first-class macOS alongside Linux. Installation, DNS, autostart, start/stop (with Podman Machine), PHP version detection, UI log streaming, and service management are all platform-split. Dedicated macOS CI build and test job.
- macOS service management — workers UI, tray fix, parallel service start, and LAN support on macOS.
- WebSocket live dashboard updates — the dashboard now receives push updates over WebSocket instead of polling, cutting idle request traffic and reflecting site/worker state changes immediately. (#161)
- Scheduled (timer-driven) framework workers — frameworks can declare workers that run on a systemd timer instead of as long-lived processes. Timers are included in
lerd start/lerd stop, surfaced in the UI, and detected via sibling.timerunits in worker status. (#160) - Platform-split UI log streaming — log tailing routes through platform-specific backends (journald on Linux, log(1) on macOS).
- Cross-platform service management, DNS split, and CLI utilities — foundation for multi-OS support: abstracted service layer, platform-specific DNS handling, and shared CLI helpers.
Fixed
- Input validation and credential handling hardened — security audit sweep across CLI entry points and credential handling paths.
- Scheduled-worker lifecycle — orphan
.timerfiles are now skipped and cleaned up, timer-driven workers report as active in the UI, and stopping/tearing down timer-backed workers collapses and cleans up cleanly. - Per-version framework resolution —
schedule,queue,horizon, andreverbshortcuts now resolve the framework definition per-version instead of falling back to a single global definition. - Perpetual service quadlet rewrite on install —
lerd installno longer rewrites service quadlets on every run, which previously dropped local edits and triggered needless restarts. - Skip Laravel installer prompt when already installed —
lerd setupno longer asks to install Laravel when the project is already initialised. - macOS terminal integration —
lerd openand the UI terminal button open Terminal.app silently at the project directory;lerd updatedefers tobrew upgrade lerdon macOS. - macOS DNS sleep/wake repair, tray startup, and install ordering — DNS survives sleep/wake cycles, the tray starts reliably on login, and install ordering avoids race conditions. Sequential install image pulls keep the sudo prompt visible.
- Launchctl kickstart hang on tray restart —
lerd-trayrestart no longer hangs insidelaunchctl kickstarton macOS. - RunParallel keypress goroutine swallowed sudo input — the parallel-run keypress watcher no longer competes with sudo for stdin, so password prompts work again.
- Install linger and sudo prompt UX —
lerd installenables systemd user linger automatically, and the linger sudo prompt renders on its own line for readability. - Default PHP-FPM always starts in
lerd start— the default PHP-FPM unit is always brought up, preventing "no PHP handler" errors on fresh boots. - Linux worker restore, PostGIS migration, and UI request pile-up — hardened worker restoration on Linux, fixed PostGIS database migration, and stopped the UI from piling up in-flight requests.
- Remote CA installed into isolated CAROOT —
lerd setup --remoteinstalls its CA into a dedicated CAROOT so it no longer overwrites the local mkcert root.
Changed
- Platform-split installation — binaries, DNS, autostart, and cleanup routines are now dispatched through a platform interface rather than hardcoded to Linux.
- Platform-split start/stop —
lerd start/lerd stoprun through per-platform implementations, including Podman Machine orchestration on macOS. - Platform-split PHP version detection — PHP version discovery runs through platform-specific probes.
Docs
- macOS in the tagline and install docs — the tagline, install instructions, and per-platform update steps now include macOS. Beta wording and "coming soon" / "Linux-only" phrasing removed throughout.
[1.11.0] — 2026-04-11
Added
- Ptyxis terminal support —
lerd openand the tray menu now detect and launch Ptyxis, the GNOME 47+ terminal emulator. - Link → init → setup flow — after
lerd link, the CLI guides the user throughlerd initandlerd setupwhen the project hasn't been initialised yet. - PHP version suggestion during link — when the project requires a PHP version that isn't installed,
lerd linksuggests installing it. - Favicon field in framework definitions — frameworks can now declare a custom favicon path (e.g.
core/misc/favicon.icofor Drupal) so the dashboard shows the correct icon.
Fixed
- Framework detection for custom frameworks — detection rules now read
composer.jsondirectly and support customdetectrules, fixing detection for frameworks like Drupal, CakePHP, and WordPress. - Worker checks and env setup for custom frameworks — worker
checkrules and env variable setup now work correctly for non-Laravel frameworks. - Favicon detection uses framework public_dir — custom frameworks with non-standard public directories (e.g.
web/for Symfony/Drupal) now have their favicons detected correctly. - 0-byte favicon files skipped — empty favicon placeholder files no longer show as having a favicon in the dashboard.
- Link only writes .lerd.yaml when it already exists — avoids creating an unnecessary config file for projects that don't use one.
Changed
- Site enrichment consolidated into
internal/siteinfo— CLI, MCP, and UI no longer duplicate site enrichment logic. A singleLoadAll(flags)function with flag-based enrichment replaces ~340 lines of duplicated code across three packages. - Link/unlink core logic extracted into
internal/siteops— shared site operations (vhost generation, site naming, linking, unlinking) moved out of the CLI package for reuse by MCP and UI. - Framework detection centralised —
DetectFrameworkForDirand.lerd.yamloperations moved into the config package, eliminating scattered detection logic.
[1.10.1] — 2026-04-10
Fixed
- phpMyAdmin (and other
dynamic_envpresets) connected to wrong host — the Web UI and MCPservice_start/service_addcode paths generated custom service quadlets without resolvingdynamic_envdirectives, soPMA_HOSTSwas never set and phpMyAdmin fell back to its default hostdb. All three paths now delegate toserviceops.EnsureCustomServiceQuadletwhich handlesdynamic_envresolution and file materialisation.
[1.10.0] — 2026-04-10
Added
- Framework definition store — community framework store backed by
geodro/lerd-frameworkswithlerd framework search,lerd framework install, andlerd framework updatecommands. Definitions auto-fetch when linking a project and auto-refresh after 24 hours. MCP toolsframework_searchandframework_installexpose the store to AI assistants. (#103) - Framework-agnostic worker system — all hardcoded Laravel worker logic replaced with a generic system driven by framework YAML definitions. Dedicated commands (
queue,schedule,reverb,horizon) are now aliases that read from the framework definition. Workers supportconflicts_with, proxy config with auto port assignment, and port collision prevention across sites. - Worker add/remove CLI and MCP tools —
lerd worker addandlerd worker removemanage custom workers in.lerd.yaml(project-level) or the global framework overlay (--global). Orphaned workers (running units with no framework definition) are detected and surfaced inworker list,worker stop, and setup. - PHP version ranges — framework definitions declare supported PHP min/max ranges.
lerd linkandlerd initclamp the PHP version to the framework's supported range.lerd sitesand the UI show the framework version (e.g. "Laravel 11"). andtemplate vars — framework env var templates can reference the site's primary domain and TLS scheme..envkeys likeAPP_URL,VITE_REVERB_HOST, andVITE_REVERB_SCHEMEsync automatically when the primary domain changes.- Selenium service preset — bundled
seleniumpreset (selenium/standalone-chromium) for browser testing with Laravel Dusk. Auto-detected viacomposer_detectonlaravel/dusk, patchesDuskTestCase.php, and includes noVNC on port 7900 for watching tests live. Newshare_hostsfield on custom services maps.testdomains to the nginx container IP. - Cursor MCP support —
mcp:injectandmcp:enable-globalnow write Cursor configuration (.cursor/mcp.jsonand.cursor/rules/lerd.mdc). (#132) - Ghostscript in PHP-FPM —
ghostscriptadded to the base PHP-FPM image for PDF manipulation with libraries like Spatie MediaLibrary. (#138) - mysql-client in PHP-FPM —
mysql-clientadded to the PHP-FPM image somysqldumpworks insidelerd phpsessions. (#142)
Changed
- MCP tool responses optimised for AI agents — ANSI escape codes stripped from all CLI output.
doctor,check, andenv_checkreturn structured JSON instead of raw text.env:checkno longer exits non-zero. - CI auto-rebuilds PHP images — a scheduled workflow checks Docker Hub daily for upstream
php:X.Y-fpm-alpinesecurity patches and triggers a force rebuild when new digests appear.
Fixed
php:rebuildreused stale base images —lerd php rebuildnow always pulls fresh base images instead of building on top of potentially outdated cached layers. (#140)npm run buildfailed whennode_modulesmissing — build step is now guarded so it skips gracefully when dependencies haven't been installed. (#133)
[1.9.4] — 2026-04-10
Fixed
- Extra volume mounts lost after install/update —
lerd installrewrote nginx and service quadlets from raw templates, dropping extra volume mounts for projects outside$HOME. Mounts now survive install and update cycles.
[1.9.3] — 2026-04-10
Fixed
- Projects outside
$HOMEfailed with "chdir: No such file or directory" — the PHP-FPM and nginx containers only bind-mount$HOME, so projects in/var/www,/opt/projects, or similar paths could not be served or exec'd into. Lerd now automatically injects extra volume mounts into both containers when it detects a project outside the home directory. Mounts are added transparently duringlerd link,lerd park, or any exec command (lerd php,composer,laravel new) and cleaned up onlerd unlink/lerd unpark. (#120) - Env file keys appended instead of uncommented — when a
.envkey existed but was commented out (#DB_HOST=...),lerd envappended a duplicate instead of uncommenting the existing line in place.
Added
lerd doctorchecks for crun — warns whencrunis not installed, since it is the recommended OCI runtime for rootless Podman.
[1.9.2] — 2026-04-10
Fixed
- Site service badges missed .env-detected services — badges on the site detail panel only showed services declared in
.lerd.yaml. Now also scans the site's.envforlerd-{name}references (both built-in and custom services), matching the same auto-detection logic the Services tab already uses.
[1.9.1] — 2026-04-09
Fixed
- Queue workers silently lost on uninstall+reinstall —
queueStartExplicitran a Redis preflight that returned an error before the unit file was written. Install-timerestoreSiteInfrastructureruns before any services are started, so for sites withQUEUE_CONNECTION=redisthe write step always failed and the worker units stayed missing on disk while systemd remembered them asnot-found failed. The preflight is gone; the dependency now lives in the systemd unit itself.lerd-queue-<site>.servicedeclaresAfter=/Wants=for whatever the queue backend needs (lerd-redis.servicewhenQUEUE_CONNECTION=redis,lerd-mysql.service/lerd-postgres.servicefor database-backed queues) on top of the FPM container, andlerd-horizon-<site>.servicealways declareslerd-redis.service. systemd handles the activation order andRestart=alwayscovers the small ready-window between activation and the backing container accepting connections. - Preset-installed services not regenerated on reinstall —
restoreSiteInfrastructureonly handled inline custom services and built-in named refs. Preset references likemariadb-11(declared in.lerd.yamlasmariadb-11: {preset: mariadb, version: "11"}) fell through toensureServiceQuadlet, which only knows about built-ins, so the silently-swallowedunknown serviceerror left sites with no quadlet for any preset-installed service after an uninstall+reinstall cycle. The restore path now goes throughProjectService.Resolve()which already knows how to render both inline and preset references back into a concreteCustomService.
Changed
lerd statusshows[preset]for preset-installed services instead of grouping them under[custom]. Hand-rolled custom services keep the[custom]label.- Tagline reworded —
lerd --help, theinstall.shbanner, and the goreleaser GitHub release notes header now readLerd — Podman-powered local PHP dev environment for Linuxinstead ofLaravel Herd for Linux — …. - Services walkthrough (
docs/getting-started/services.md) updated to lead with the bundled preset flow for MongoDB, phpMyAdmin, and pgAdmin (lerd service preset <name>) instead of the hand-rolled YAML each one used to require. Adminer, Elasticsearch, and RabbitMQ stay as full YAML recipes since there's no preset for them yet. Adminer's port bumped to 8083 to avoid colliding with themongo-expresspreset on 8082.
[1.9.0] — 2026-04-09
Added
- Service presets — opt-in bundled service definitions surfaced via
lerd service preset(list / install) and a+picker on the Web UI's Services tab. First batch shipsphpmyadmin,pgadmin,mongo,mongo-express, andstripe-mockas embedded YAML that becomes a normal custom service once installed, so every existinglerd servicesubcommand (start/stop/remove/expose/pin) keeps working unchanged. Installed presets are filtered out of the picker; after install the user lands on the new service detail panel and the service auto-starts. - Multi-version preset families — presets can declare multiple versions in a single YAML (e.g.
mysql8.0/8.4/9.0,mariadb10.11/11.4) andlerd service presetshows version pills onlist, prompts for a version on install, and persists the chosen tag in.lerd.yaml. Family discovery groups versions by base name in both the CLI list and the Web UI picker. - Preset MCP tools —
service_preset_listandservice_preset_installexpose the preset catalog and install flow to AI assistants, sharing the install path with the CLI throughserviceops.InstallPresetByName. Re-runlerd mcp:injectin existing projects to pick up the new tool descriptions. - Custom service
files:field — declare inline-rendered config files materialised on the host and bind-mounted into the container, with optionalmode(octal perms) andchown: true(adds:Uso podman re-chowns to the container's non-root uid). Used by thepgadminpreset to ship aservers.json+pgpassthat autoconnects tolerd-postgres. Files re-render on everylerd service startso editing the YAML and restarting picks up changes. - Custom service
connection_url:field — non-built-in databases now get the same "Open connection URL" link surface as the built-in mysql/postgres services. The detail panel renders a real<a>element pointing atmysql://,postgresql://, ormongodb://so right-click "Copy link" works and left-click hands the URL to the user's registered DB client (DBeaver, TablePlus, Compass, etc.). - Recursive
service start—lerd service start <svc>now ensures every entry independs_onis up first, recursively, in both the CLI and the Web UI. Pairs with the existing recursive stop that takes dependents down before the parent. Starting any preset that depends on a built-in (phpmyadmin,pgadmin) auto-starts the database. - Preset dependency gating at install time — installing a preset whose dependency is another custom service (e.g.
mongo-expressonmongo) is rejected with a clear error until the dependency is installed first. Built-in deps (mysql, postgres) are auto-satisfied. The Web UI's Add button is disabled with a matching amber "install mongo first" hint. - Database service quality-of-life suggestions — the detail panel of every database service (mysql, postgres, and an installed
mongo) now shows a sky-blue suggestion banner offering to install its paired admin UI when missing. The banner is dismissable per-preset and the dismissal persists inlocalStorage. When the admin UI is installed, the header gains an Open phpMyAdmin / pgAdmin / Mongo Express button that auto-starts the admin service if needed. - Lerd health dot in the Web UI — the Lerd entry in the System list now reflects overall core health (green when DNS / nginx / watcher are all running, red when any is down, yellow when an update is available) instead of only the update flag. The lerd logo in the left rail gains a small yellow badge when an update is available and is clickable, jumping straight to the Lerd entry.
- One-click update terminal — when an update is available, the Lerd entry exposes an "Open terminal & update" button that POSTs to the new loopback-only
/api/lerd/update-terminalendpoint, which spawns the user's preferred terminal emulator (kitty / foot / alacritty / wezterm / ghostty / ptyxis / konsole / gnome-terminal / xfce4-terminal / tilix / terminator / xterm) runninglerd updateso the host can prompt for sudo and stream download progress. - Getting-started walkthroughs — new
docs/getting-started/laravel.md,symfony.md,wordpress.md, andservices.mdpages plus adocs/usage/lifecycle.mdreference covering how Lerd's units come up at boot and howstart/stop/autostartinteract.
Changed
autostartis now a single coherent switch —cfg.Autostart.Disabledis the canonical source of truth for whether lerd comes up at login. Toggling it enables/disables everylerd-*.containerquadlet (by adding/stripping the[Install]section so the podman generator stops emitting thedefault.target.wantssymlink) and everylerd-*.serviceunit (UI, watcher, per-site worker/queue/schedule/horizon/reverb/stripe) together. Toggling does not stop or start anything currently running — the user is in the middle of working and a session-level switch should not yank infrastructure out from under them. Uselerd start/lerd stopfor live state.lerd autostart trayremoved — the tray is now governed by the same single autostart switch as everything else. The standaloneautostart traysubcommand and thelerd-autostart.serviceunit file are gone.- Service display labels — the Web UI now shows phpMyAdmin, pgAdmin, MySQL, PostgreSQL, Meilisearch, Mailpit, RustFS, MongoDB, Mongo Express, and Stripe Mock with their proper casing.
Fixed
- Tray autostart was broken — the tray autostart path went through the now-removed
lerd-autostart.serviceshim and stopped enabling on fresh installs. The unified autostart toggle now covers the tray too, the per-unit autostart toggle is wired up correctly, andlerd installhonours the persisted autostart state.
[1.8.0] — 2026-04-09
Added
lerd lan:expose/lan:unexpose/lan:status— unified switch to share a lerd dev environment with another machine on the local network. Off by default; every container port now binds127.0.0.1(was0.0.0.0since v0.1.0), so untrusted wifi is safe out of the box. Service containers (mysql, postgres, redis, meilisearch, rustfs, mailpit) stay loopback-only even when LAN exposure is on; only nginx flips to0.0.0.0, since Laravel apps inlerd-php-fpmreach services through the podman bridge regardless of host bind. Quadlets are rewritten centrally viapodman.WriteQuadletDiffso flipping the switch only restarts units whose on-disk content actually changed.- Remote dashboard access — the dashboard at port 7073 is gated by two independent flags:
cfg.LAN.Exposedis the top-level kill switch andcfg.UI.PasswordHashadds HTTP Basic auth on top. LAN clients only reach the dashboard when both are set; loopback always bypasses both. Stale credentials cannot survivelan:unexpose. The dashboard's "Remote dashboard access" card distinguishes active / inert / disabled states so the user sees when credentials are stored but blocked bylan:unexpose. UI feedback during a toggle streams NDJSON progress events fromPOST /api/lan/status; the card polls every 5s while on the System tab so CLI toggles are reflected without a page reload. http://lerd.localhostas a usable bookmark —lerd-nginxserves the static dashboard HTML, icons, and PWA manifest from thelerd.localhostvhost, with/api/*explicitly returning 444 so a LAN curl forging the Host header cannot reachlerd-uithrough the proxy. The dashboard JS detects when it was loaded fromlerd.localhostand rewrites all fetch, EventSource, and favicon img srcs to absolutehttp://localhost:7073URLs so they hitlerd-uidirectly over loopback.lerd remote-setup— generates a one-shot 15-minute code and prints a curl one-liner the remote machine runs to install mkcert, trust the lerd root CA, and configure its resolver (NetworkManager+dnsmasq, systemd-resolved 254+, standalone dnsmasq, or macOS/etc/resolver). The endpoint is gated by token presence + RFC 1918 source IP + brute-force lockout. The bootstrap script's epilogue warns that the server IP is hardcoded into the resolver dropin and explains how to re-bootstrap if the server moves networks.app_urlfield in.lerd.yamlandsites.yaml— new precedence chain forAPP_URL:.lerd.yamlapp_url(committed, shared across machines) >sites.yamlapp_url(per-machine override) > the default<scheme>://<primary-domain>generator.lerd setupno longer overwrites a customAPP_URLon every run — set it once in.lerd.yamland lerd respects it. The.lerd.yamlapp_urlis silently suppressed when its host points at a domain that the conflict filter dropped, so.envnever ends up writing a hostname owned by another site.- Soft-fallback domain conflict handling — when
lerd linkor the parked-directory watcher tries to register a domain another site already owns, the conflicting domain is now filtered out (instead of failing the whole link) and a clear WARN line is printed naming the owning site. Surviving domains still register; if every domain conflicts, lerd falls back to a freshly generated<dirname>.<tld>with a numeric suffix..lerd.yamlis never modified on disk — the originaldomains:list stays so the conflict is visible to the UI and self-heals on the next link if the owning site is removed. - Domain conflict UI surface — the site detail header's "+N more" pill now counts conflicted domains and shows an amber warning icon when present (hover reveals each conflicted entry with the owning site name). The Manage Domains modal renders conflicted entries at the top with a warning icon, the domain struck-through, a "used by <site>" pill, and a small trash button that removes the entry from
.lerd.yamlonly (no registry, vhost, or cert touched). Thedomain:removeserver action detects conflict-filtered entries and routes them to a.lerd.yaml-only delete path. [Remote Access]section inlerd status— new block showing LAN exposure state and dashboard remote-access state, with hints when off. Refactored into a testableprintRemoteAccessStatushelper.- Tray "Expose to LAN" toggle — new menu item that shells out to
lerd lan expose / unexpose, mirroring the autostart toggle. - Dynamic colour tray icon — white L when lerd is running, red L when stopped. The default flag flipped from
--mono=trueto--mono=falseso the colour icon is what users see by default; mono mode is still available for OS-recoloured template icons. The icon's dark background was stripped so it's transparent on the panel.
Changed
- Tray "Open Dashboard" opens
http://lerd.localhostinstead of the bare127.0.0.1:7073loopback URL. Tray API polling stays on loopback so the tray works before nginx is up. - Tray paused services render with a yellow dot instead of red, so user-initiated stops are visually distinct from broken services.
lerd doctor"linger enabled" check renamed to "systemd linger" so the WARN row no longer reads as if linger is in fact enabled.
Fixed
lerd uninstallleft the tray running — the uninstall flow stopped and disabled all systemd units but never killed standalone tray processes (launched from the desktop file orlerd tray). The tray kept running after the binary was gone, with no way to dismiss it short ofpkill. Uninstall now calls the existingkillTrayhelper after the unit teardown.lerd installhang when installing the Laravel installer — the installer prompted for the Laravel installer on every run and then shelled out through the composer shim, which routes throughlerd phpand depends on cwd-based PHP detection. When the install command runs from$HOMEwith no project metadata, detection fell back tocfg.PHP.DefaultVersionand handed composer to a possibly-missing container. Worse,composer global requiretriggers symfony/flex / plugin trust prompts which sat invisibly insidepodman exec -t -i, making the whole step look stuck with no output. Fixed by skipping the prompt entirely when no PHP version is installed, and when it does run, bypassing the shim — picking a known-installed PHP (preferring the configured default), ensuring its FPM container is running, andpodman exec'ingcomposer global require --no-interaction laravel/installerdirectly.
[1.7.1] — 2026-04-08
Added
- Database picker in
lerd init— the wizard's services step is now split into a single-choice Database select (sqlite / mysql / postgres) and a multi-select for everything else. The default is seeded from any database already in.lerd.yaml, thenDB_CONNECTIONin.env(or.env.examplefor fresh clones), falling back to SQLite. After the wizard completes,lerd envruns automatically so the choice immediately lands in.env— picking MySQL/PostgreSQL writes the connection vars and creates the project database (plus_testing), picking SQLite writesDB_CONNECTION=sqliteand createsdatabase/database.sqliteif it's missing. - Runtime database prompt in
lerd env— when run interactively on a Laravel project whose.envsaysDB_CONNECTION=sqliteand whose.lerd.yamldoesn't yet pick a database,lerd envnow prompts for a deliberate choice (Keep SQLite / MySQL / PostgreSQL) and persists it so subsequent runs don't re-ask. Skipped automatically when stdin isn't a TTY (CI, MCP, scripted runs) and for frameworks with explicit env service rules (Symfony, WordPress, etc.) that don't useDB_CONNECTION. db_setMCP tool — pick the database for a Laravel project from an AI assistant:db_set(database: "sqlite" | "mysql" | "postgres"). Persists the choice to.lerd.yaml(replacing any prior database — the choice is exclusive), rewrites theDB_keys in.env, starts the service if needed, and creates the database (or the SQLite file). The companionenv_setuptool's description now points atdb_setso AI assistants know to call it beforeenv_setupon fresh Laravel clones —env_setupalone leavesDB_CONNECTION=sqliteuntouched.- SQLite as a first-class env-time choice —
serviceEnvVars["sqlite"]now appliesDB_CONNECTION=sqliteandDB_DATABASE=database/database.sqlite. Thelerd envflow special-cases sqlite so it isn't treated as a podman service: no quadlet, noservice_start, just the env vars and the file creation. The user's database choice in.lerd.yamlis authoritative — switching from mysql → sqlite (or vice versa) skips the auto-detection of the previous database in.env.
Fixed
vendor_bins/vendor_runmissing from injected MCP skills — the new vendor/bin tooling shipped in v1.7.0 was registered with the MCP server but absent from the skill content thatlerd mcp:injectwrites into.claude/skills/lerd/SKILL.mdand.junie/guidelines.md, so AI assistants weren't told the tools existed. Both files now describe the tools with examples for pest, phpunit, pint, phpstan, and rector. Re-runlerd mcp:injectin existing projects to pick up the updated skill content.
[1.7.0] — 2026-04-08
Added
- Application log viewer in the UI — site detail view now has an App Logs tab that parses application log files into a structured table with level, date, and message columns, expandable to show full stacktraces. Frameworks declare log file locations and parser format via a new
logsfield in their YAML; Laravel defaults tostorage/logs/*.logwith Monolog parsing. Auto-selects the site with the most recent log activity on page load, refreshes every 5 seconds, and supports search filtering plus a Latest/All toggle. Entries display oldest-first (newest at the bottom), pinned to the bottom on every refresh, matching the streaming container/queue/worker log panes. vendor/binshortcuts andlerd test/lerd aaliases — any composer-installed binary in the project'svendor/binis now callable directly aslerd <name>(e.g.lerd pest,lerd pint,lerd phpstan), routed through the project's PHP-FPM container withvendor/binprepended toPATH. Built-in lerd commands always win on name collisions. Two new shortcuts:lerd a(alias forartisan) andlerd test(shortcut forartisan test). The same surface is exposed to MCP clients viavendor_bins(list) andvendor_run(execute). Closes #101.- Laravel installer shipped globally —
lerd installnow offers to installlaravel/installeras a global composer package and creates alaravelshim inBinDirrouted throughlerd php, so thelaravelcommand works directly in the terminal the way Herd ships it. The prompt defaults to yes and runs before the parallel TUI to avoid stdin conflicts. Closes #98. - Site favicons in the UI — the UI detects
favicon.ico/svg/pngin each site's public directory and serves them viaGET /api/sites/{domain}/favicon. The sites list and detail header now display the favicon when available, falling back to the status dot.
Changed
- PHP and Node version selects deferred until loaded — the version dropdowns in the site detail view now show static placeholders while the version lists are still loading, preventing the browser from resetting
selectedSite.php_version/node_versionto an empty string and causing spurious change events.
Fixed
- Dark mode dropdown readability — the PHP and Node version selectors now apply explicit option background and text colors so the dropdown menu is readable in dark mode.
[1.6.3] — 2026-04-06
Changed
- Tray switched to libayatana-appindicator — the system tray now uses the actively maintained ayatana fork instead of the legacy libappindicator3. No behavior change; ayatana is the default backend in getlantern/systray and is already present on Ubuntu desktops.
lerd updatedefaults to yes — pressing Enter now confirms the update instead of cancelling.
Fixed
- DNS broken on systems without NetworkManager — the resolved drop-in file was written with 0600 permissions (unreadable by systemd-resolved), breaking
.testdomain resolution on omarchy and similar systems. Fixed by setting correct permissions (0644) viasudoWriteFile. - Sudoers missing resolved paths — extended the sudoers drop-in to cover systemd-resolved config paths for passwordless install/start on resolved-only systems.
[1.6.2] — 2026-04-06
Fixed
- MissingAppKeyException on fresh project —
lerd envnow generatesAPP_KEYdirectly in.envwhenvendor/does not exist yet, instead of failing silently onartisan key:generate. This prevents Laravel'sMissingAppKeyExceptionduringcomposer installpost-install scripts in thelerd new→lerd link→lerd setupflow. composer installusing wrong PHP version in setup —lerd setupnow runscomposer installinside the project's PHP-FPM container, matching thecomposer.jsonPHP constraint. Previously it used the host composer shim which could resolve to the global default PHP version.- PHP version detection from
composer.jsonignores installed versions — the constraint resolver now picks the highest installed PHP version satisfying thecomposer.jsonrequire.phpconstraint (e.g.^8.3with 8.3 and 8.4 installed → 8.4). Supports^,~,>=,<,||,*, and AND constraints. Falls back to the literal minimum when no installed version matches.
[1.6.1] — 2026-04-06
Fixed
- Fresh install missing default PHP-FPM —
lerd installnow always builds and starts the default PHP version, even with no registered sites. Previouslylerd newwould fail on a fresh install because no PHP-FPM container existed. - Install not restoring services —
lerd installnow restores service quadlets (mysql, redis, custom services) from.lerd.yaml, pulls missing images, and starts them. Workers no longer fail on reinstall because their dependencies are running. - Install not restoring workers —
lerd installnow callsrestoreSiteInfrastructureto recreate worker units from.lerd.yamlafter services are started. - FPM not restored for sites using default PHP — both
lerd installandlerd startnow fall back to the configured default PHP version when a site has no explicitPHPVersion, instead of skipping it. - UI stripe toggle not syncing
.lerd.yaml— toggling the Stripe listener from the web UI now writes the workers list to.lerd.yaml, matching the behaviour of all other worker toggles. - Uninstall spinner with no expandable output — replaced the StepRunner spinner (Ctrl+O did nothing) with the same
step()/ok()output style used by install.
[1.6.0] — 2026-04-06
Added
- Framework setup commands — framework definitions now support a
setupfield with one-off bootstrap commands (migrations, storage links, fixtures) shown inlerd setup. Laravel's hardcoded storage:link/migrate/db:seed steps are now part of the built-in framework definition. Custom frameworks define their own via YAML. - Conditional checks on workers and setup commands — both
workersandsetupentries support an optionalcheckfield (fileorcomposer) to conditionally show them based on project dependencies (e.g. messenger worker only shown whensymfony/messengeris installed). - Service version placeholders — framework env vars support
,,, andplaceholders, resolved from the running service image tag atlerd envtime. --setupflag forlerd framework add— define setup commands via CLI flags in addition to YAML.- Link modal streaming logs — the web UI link modal now streams
lerd linkandlerd envoutput line-by-line instead of showing only a spinner. - Domain modal success feedback — add/edit/remove domain operations in the web UI now show a flash message on success.
- omarchy OS support — systems with systemd-resolved but no NetworkManager can now install and run lerd. The installer accepts either resolver.
- Reverb prerequisite check —
lerd reverb:startandlerd reverb:stopnow check forlaravel/reverbin composer.json before proceeding, with install instructions and a link to the Laravel Broadcasting docs.
Changed
- Worker state synced to
.lerd.yaml— all worker start/stop commands (queue,schedule,reverb,horizon,stripe:listen,worker start/stop) now persist the active workers list in.lerd.yamlwhen the file exists. Previouslyworker start/stopandstripe:listendid not update the file. lerd startrestores site infrastructure — after an uninstall/reinstall cycle,lerd startreads.lerd.yamlfrom each active site and recreates missing FPM quadlets, service quadlets, and worker units automatically.lerd installrestores FPM quadlets — reinstalling now restores PHP-FPM quadlets for all PHP versions used by registered sites, not just the default version.- Improved
lerd uninstall— stops alllerd-*systemd units (workers, stripe listeners, etc.) instead of only the hardcoded watcher and UI services. DNS teardown and the data-removal prompt now run before the step runner to avoid stdin conflicts.
Fixed
- DNS teardown leaves stale DNS on virtual interfaces —
lerd uninstallnow reverts all network interfaces that have lerd DNS configured (e.g.virbr0,vnet*), not just the default interface. - Internet DNS broken after uninstall — after reverting interfaces and restarting NetworkManager, lerd now explicitly pushes the DHCP-assigned upstream DNS servers so name resolution works immediately.
- Domain modal stale state — the web UI domain modal now properly updates the domain list after add/edit/remove operations. The site list merge was matching by domain (which changes) instead of name (stable).
lerd envruns automatically in setup —lerd envnow runs at the start oflerd setupinstead of being a selectable step, ensuring.envis configured beforecomposer installtriggers post-install scripts.- Definition conflict resolution — when
.lerd.yamland the local framework/service definition differ, lerd now offers a three-way choice: use .lerd.yaml version, use local definition, or skip. Both sync directions persist immediately. - Improved horizon/reverb error messages — error messages now include install commands and docs links instead of generic text.
- Dynamic DNS resolver hints —
lerd doctorandlerd statusnow show the correct restart command based on the active resolver instead of always suggesting "restart NetworkManager".
Docs
- Added contributing section to nav bar, stripe page to usage sidebar, troubleshooting to reference sidebar
- Fixed
placeholders being swallowed by VitePress (Vue template interpolation) - Replaced non-rendering mermaid chart with ASCII diagram on architecture page
- Added reverb prerequisite note to commands reference
- Updated requirements, architecture, and troubleshooting for systemd-resolved support
[1.5.1] — 2026-04-04
Fixed
- Nginx fails to start when TLS certificates are missing —
lerd startnow detects SSL vhosts referencing missing cert files before starting nginx, switches affected sites back to HTTP, and removes orphan SSL configs. Previously a single missing certificate would prevent all sites from loading. - Paused sites bypass landing page after update —
lerd install(called bylerd update) was regenerating vhosts for all sites, overwriting paused landing pages with the full site config. Paused and ignored sites are now skipped during vhost regeneration. - Paused landing page redesigned — the paused page now matches the branded "Site Not Found" page with the Lerd logo, red accent, and Resume + Dashboard buttons. Uses a single shared HTML file instead of generating one per site.
[1.5.0] — 2026-04-04
Added
- Multi-domain support — sites can now respond to multiple
.testdomains. Uselerd domain add,lerd domain remove, andlerd domain listto manage them. Domains are stored in.lerd.yamland the certificate is reissued automatically when a domain is added to a secured site. lerd env:checkcommand — compare all.envfiles against.env.exampleand flag missing or extra keys. Exits non-zero when required keys are missing.lerd checkcommand — validate.lerd.yamlsyntax, PHP version, Node version, services, frameworks, and workers before running setup. Reports OK/WARN/FAIL per field.lerd whichcommand — show the resolved PHP version, Node version, document root, and nginx config paths for the current site.- Port conflict detection —
lerd startchecks for port conflicts before starting containers and warns if another process is already using a required port. lerd update --beta— update to the latest pre-release build from GitHub.lerd update --rollback— revert to the previously installed version using the automatic backup.- Automatic PHP/Node version switching — the watcher monitors
.lerd.yaml,.php-version,.node-version, and.nvmrcand automatically re-links the site when versions change. - Workers in
lerd init— the wizard includes a workers step that pre-selects workers based on the framework and installed packages. Horizon is auto-detected fromcomposer.json. - Setup prompt on link — when linking a site with workers configured in
.lerd.yaml, lerd prompts to runlerd setupto install dependencies and start workers. - Branded error pages — requests to unlinked
.testdomains show a styled "Site Not Found" page with links to the dashboard instead of a generic browser error. - Failing worker visibility —
lerd statusshows failing and restarting workers across all sites. The web UI shows a pulsing red toggle and a "!" indicator on the log tab for failing workers.
Fixed
- Crash-looping workers left running after unlink —
lerd unlinknow detects and stops crash-looping workers for the site. - Paused sites counted in status workers section — paused sites are now excluded from the workers list in
lerd status. - Paused sites counted in TLS check —
lerd statusno longer flags TLS issues for paused or ignored sites. - Service container left behind on remove —
lerd service removenow properly cleans up the Podman container.
[1.4.2] — 2026-04-03
Fixed
- Paused sites counted in service badges and auto-stop logic — paused sites were included when counting how many sites use a service, so services stayed active and their site-count badges inflated even after all active sites were paused. Paused sites are now excluded from
CountSitesUsingServiceand the badge tooltip list.
[1.4.1] — 2026-04-03
Fixed
- 3-pane dashboard layout missing from v1.4.0 — the new icon rail, list panel, and full-height detail panel were lost during a merge conflict resolution. The correct UI is now restored.
[1.4.0] — 2026-04-03
Added
- 3-pane dashboard layout — the UI is redesigned around a persistent icon rail (Sites, Services, System), a scrollable list panel, and a full-height detail panel. Logs fill remaining height rather than being capped at a fixed box. Works at any scale from 1 to 50+ sites. Mobile gets a full-screen list/detail with a bottom tab bar and a back button.
- PHP-FPM auto-lifecycle — FPM containers for unused PHP versions are stopped automatically on
lerd unlinkandlerd start. Paused sites keep their FPM running. Onlerd start, only versions referenced by at least one site are started. When a site is unpaused, its FPM container is guaranteed running before nginx is restored. - Manual FPM start/stop from the dashboard — unused PHP versions (no active sites) show a Stop button in the dashboard when running. Stopped unused versions are shown with a neutral badge rather than an error.
lerd startparallel spinner UI — start and stop operations now show a live per-unit progress display. All images required by units are checked and rebuilt or pulled before containers start.- Site pills on services — core services (MySQL, Mailpit, etc.) and worker-type services (Queue, Horizon, Reverb, etc.) show clickable site pills. Clicking a pill navigates directly to that site's settings.
- Clickable PHP-FPM site pills — site pills on the PHP-FPM detail panel now navigate to the site's settings panel instead of opening the browser.
- Instant system theme switching — when the theme is set to Auto, the dashboard switches between light and dark immediately as the OS preference changes, without a page reload.
Fixed
lerd statusfalse errors for stopped unused PHP-FPM — stopped FPM containers for versions not referenced by any site are now reported as warnings, not errors.- MinIO migration prompt shown after already migrating to RustFS — the
lerd updatemigration prompt now also checks whether thelerd-miniocontainer is running, so users who have already migrated are not prompted again. - Pre-built PHP base images required ghcr.io login — lerd now always pulls base images anonymously to avoid authentication errors from expired or unrelated ghcr.io credentials.
[1.3.3] — 2026-04-02
Fixed
- Broadcasting jobs fail when
lerd envwas run on a Reverb site —REVERB_HOSTwas set to the site domain (e.g.my-app.test), which resolves inside the PHP-FPM container tohost.containers.internal(169.254.1.2). That address — the nginx proxy on the host — is not reachable from inside the container's network namespace, so every broadcast job failed with cURL error 7.REVERB_HOST,REVERB_PORT, andREVERB_SCHEMEare now always written aslocalhost,REVERB_SERVER_PORT, andhttpso the queue worker connects to Reverb directly inside the same container.VITE_REVERB_HOST/PORT/SCHEMEcontinue to use the site domain and external port for browser connections through nginx. Sites affected can be fixed by re-runninglerd env. - Log lines repeating on SSE reconnect — when the browser reconnected to a log stream (network blip, tab restore) the entire history was replayed from the start. For systemd units the stream now emits the journalctl cursor as the SSE event id and resumes with
--after-cursoron reconnect; for Podman containers a monotonic line counter is used and--tail 0skips history on reconnect.
[1.3.2] — 2026-04-01
Fixed
- Queue log streaming was a stale duplicate of the shared implementation — the
/api/queue/<site>/logsSSE handler had its own inline copy of the log streaming logic instead of calling the sharedstreamUnitLogshelper used by every other worker (horizon, schedule, reverb, stripe). The duplicate is removed.
[1.3.1] — 2026-04-01
Fixed
- PHP FPM fails to start on fresh installs — the shared hosts file (
~/.local/share/lerd/hosts) is bind-mounted into every PHP-FPM container. If no site had ever been linked, the file did not exist and podman refused to start the container withstatfs: no such file or directory.WriteFPMQuadletnow ensures the file is created before the container is started.
[1.3.0] — 2026-04-01
Added
- Multiple Reverb sites without port collisions — when
lerd envdetectsBROADCAST_CONNECTION=reverb, it auto-assigns a uniqueREVERB_SERVER_PORTper site starting at 8080 and incrementing for each additional site.reverb:start(including the UI toggle) also assigns and persists the port on first start if still missing, so the fix applies even whenlerd envhas not been re-run. The nginx WebSocket proxy uses the per-site port instead of the old hardcoded 8080. Fixes #47. - New MCP tools:
db_import,db_create,php_list,php_ext,park,unpark— six new tools for AI agents covering database import from a SQL file, on-demand database creation, listing installed PHP versions, managing PHP extensions, and parking/unparking directories. lerd whatsnew— new command that prints the changelog for the currently installed version. The changelog excerpt has been removed fromlerd statusandlerd doctoroutput.- Portable
.lerd.yaml—.lerd.yamlcan now describe a site's full local environment (PHP version, Node version, framework, services, custom workers). Runninglerd linkin a project that has a.lerd.yamlapplies all settings automatically, so cloning a project and runninglerd link && lerd envis enough to reproduce the full environment. Closes #33. - Pre-built PHP base images — PHP images are now built on top of pre-built base images pulled from
ghcr.ioinstead of compiling all extensions from source. First-install time drops from ~5 minutes to ~30 seconds. Closes #43.
[1.2.4] — 2026-03-31
Added
lerd php:rebuildaccepts a version argument — pass a version (e.g.lerd php:rebuild 8.3) to rebuild only that PHP image instead of all installed versions.
Fixed
- Inter-application
.testdomain resolution inside containers — HTTP/HTTPS requests from one site to another (e.g.booking.testcallingstaffing.test) were failing because.testdomains resolved to127.0.0.1inside containers, which points to the container itself rather than the host Nginx. A shared hosts file (~/.local/share/lerd/hosts) is now bind-mounted into every PHP-FPM container at/etc/hostswith a169.254.1.2entry per linked site. Since it is a bind mount,lerd linkandlerd unlinkupdate all running containers instantly without a restart. Fixes #39. - Reverb proxy returns 502 after container restart — the Nginx
location /appblock used a bare hostname inproxy_pass, which Nginx resolves once at config load time. If the PHP-FPM container restarted and received a new IP, subsequent WebSocket and broadcast requests failed with 502. The proxy now uses a variable (set $reverb) to force per-request DNS resolution, matching how the FastCGI location already handles the FPM upstream.
[1.2.3] — 2026-03-31
Added
- Horizon appears in the Services panel — when Laravel Horizon is running for a site it now shows up as its own entry in the Services panel (grouped under "Horizon"), with a stop button, live log stream, and a subtitle showing the site domain. Previously Horizon was only visible in the site detail view.
- Starting Horizon stops the queue worker —
horizon:start(CLI, UI, MCP) now automatically stops any running queue worker for the same site before starting Horizon, since the two must not run simultaneously. lerd unlinkstops all workers for the site — queue workers, Horizon, schedule workers, Reverb, Stripe listeners, and custom framework workers are all stopped before the site is unlinked.
Fixed
- Tray no longer shows per-site workers — Reverb, Horizon, queue workers, schedule workers, Stripe listeners, and custom framework workers are filtered out of the tray menu. Only real infrastructure services (MySQL, Redis, Mailpit, etc.) are listed there.
lerd phpcan now run scripts outside$HOME— IDEs like PhpStorm write their validation scripts to/tmpand callphp -d... /tmp/ide-phpinfo.php. The container only mounts$HOME, so those scripts were unreachable and produced an empty output ("Failed to parse validation script output").runPhpnow detects any argument that is an absolute path to a host file outside$HOME, reads it, and streams it to the container viastdin//dev/stdin.- Horizon logs in the Services panel now stream the correct site — the logs URL for a Horizon service entry now routes to
/api/horizon/{site}/logs(systemd journal) instead of the generic/api/logs/lerd-horizon-{site}endpoint that tried to usepodman logson a non-existent container. - Horizon log tab on the Sites panel no longer shows stale logs from a previous site — switching sites now properly closes and clears the Horizon log stream; clicking the Horizon tab reconnects to the correct site's stream.
[1.2.2] — 2026-03-31
Added
lerd initvalidates PHP version input — the PHP version prompt now rejects invalid input such as8,5or plain strings; onlyMAJOR.MINORnumeric format (e.g.8.3) is accepted.lerd initandlerd envdetect services from.env.example— when.envis absent, service detection falls back to.env.exampleso a freshly cloned project is configured correctly before.envis created.lerd envwaits for services to be ready before creating databases and buckets — after starting MySQL, PostgreSQL, or RustFS, lerd now polls for readiness (mysqladmin ping/pg_isready/ TCP dial) before attempting to create the database or bucket. Previously the create step could silently fail if the container had not finished initialising.- Automatic quadlet restoration for orphaned PHP FPM containers —
lerd php:list(and any command that callsListInstalled) now scanspodman ps -aforlerd-php*-fpmcontainers whose quadlet file is missing and restores it automatically, so users who lost their quadlet files do not need to reinstall PHP.
Fixed
lerd initinstalls PHP FPM with a progress indicator — when the required PHP FPM version is not yet installed,lerd initnow shows a spinner rather than silently blocking. (PR #34)
[1.2.1] — 2026-03-31
Fixed
mcp:injectandmcp:enable-globalfail on empty JSON config files —mergeMCPServersJSONnow skipsjson.Unmarshalwhen the target file exists but is empty, preventing a spurious "unexpected end of JSON input" error. Affects~/.ai/mcp/mcp.json,~/.junie/mcp/mcp.json, and.mcp.json. (PR #31)lerd newrunscomposer installwith the wrong PHP version —composer create-projectfor Laravel now passes--no-install --no-plugins --no-scriptsso dependency installation is deferred tolerd setup, where the correct PHP version is already active. (PR #28 by @voronkovich)- Duplicate
export PATHentries written to.zshrcon repeatedlerd install—appendShellRCnow checks whether the PATH line already exists before appending. (PR #30 by @voronkovich) - Redundant
appendShellRCcall writes a brokenexport PATH=":$PATH"line to.zshrc— the call with an emptybinDirhas been removed;ensureZshFpathalready handles the fpath setup. (PR #29 by @voronkovich)
[1.2.0] — 2026-03-30
Added
lerd init— interactive wizard that writes PHP version, HTTPS preference, and required services to.lerd.yamlfor project portability. On a machine with an existing.lerd.yaml,lerd initapplies the saved config non-interactively, making new-machine setup a single command.lerd setupnow runs the wizard as its first step,lerd linkauto-secures whensecured: trueis set, andlerd env/lerd isolate/lerd secureall keep the file in sync.lerd console— run a framework's interactive console (e.g.php artisan tinkerfor Laravel, or theconsolefield from the framework YAML) inside the project container. Arguments are forwarded as-is.consoleMCP tool — execute framework console commands from an AI assistant session. Resolves the correct binary viaconfig.GetConsoleCommandso it works for any framework that defines aconsolefield.- Cloudflare Tunnel backend for
lerd share— pass--cloudflareto tunnel a site viacloudflared. Without the flag, lerd auto-detects between ngrok and Expose as before. The tunnel is routed through the host proxy to fix Host header and TLS SNI for secured sites. - pcov bundled in PHP-FPM images — pcov is now pre-installed via PECL in all lerd PHP-FPM images;
lerd php:ext add pcovis no longer needed to runpest --coverage. - WebP support in PHP-FPM images — gd and imagick now include WebP support out of the box (PR #15 by @ReyArlena).
- Connection URLs and hostname note in the dashboard — database service cards now show ready-to-use connection URLs alongside a note about the internal container hostname.
Fixed
- Paused site vhosts overwritten on watcher restart —
scanWorktrees()now skips paused sites on startup; worktree vhost generation and nginx reloads triggered by.php-versionchanges are also skipped while a site is paused (registry is still updated for when the site is unpaused). lerd consolefalls back toartisanfor Laravel — when a Laravel project's framework YAML has no explicitconsolefield,lerd consolenow correctly usesphp artisan.
Internal
- Unit tests for
config,php,distro, andenvfilepackages.
[1.1.2] — 2026-03-30
Fixed
lerd installno longer hangs after "Adding shell PATH configuration" — the interactive MCP registration prompt has been removed. Runlerd mcp:enable-globalmanually after install to register the MCP server.- Dashboard URL in install completion message — now shows
http://lerd.localhostinstead of the rawhttp://127.0.0.1:7073address.
[1.1.1] — 2026-03-30
Added
- CI badge on README — the README now shows a live CI status badge linked to the
ci.ymlworkflow.
Fixed
- MCP registration prompt unresponsive when installing via pipe —
lerd installreads the "Register lerd MCP globally?" prompt answer from/dev/ttyinstead of stdin. When the installer is run via a pipe (curl ... | sh), stdin is the pipe andfmt.Scanreturns immediately with no input; opening/dev/ttydirectly reads from the actual terminal regardless of how the process was started.
Internal
- Release workflow now gates on CI — the
release.ymlworkflow runs build, test, vet, and format checks before invoking GoReleaser. A tag push on a broken commit will now fail before any artifacts are published.
[1.1.0] — 2026-03-30
Added
lerd new <name-or-path>— scaffold a new PHP project using the framework'screatecommand. Defaults to Laravel (composer create-project laravel/laravel). Pass--framework=<name>to use any framework that defines acreatefield. Extra args can be forwarded to the scaffold command after--. Theproject_newMCP tool provides the same functionality for AI assistants.createfield in framework definitions — framework YAML files now support acreateproperty (e.g.create: composer create-project symfony/skeleton). The target directory is appended automatically bylerd new. The--createflag was also added tolerd framework add.project_newMCP tool — scaffold a new project from an AI assistant session. Acceptspath(required),framework(default:laravel), andargs(extra scaffold flags). Follow withsite_linkandenv_setupto register and configure the new site.lerd mcp:enable-global— registers the lerd MCP server at Claude Code user scope (and Windsurf / JetBrains Junie global configs) so lerd tools are available in every AI session without per-project configuration. Duringlerd install, if Claude Code is detected and lerd is not yet registered, the installer prompts to run this automatically.site_phpMCP tool — change the PHP version for a registered site from your AI assistant. Writes.php-version, updates the site registry, regenerates the nginx vhost, and reloads nginx in one call. The target FPM container must be running.site_nodeMCP tool — change the Node.js version for a registered site. Writes.node-versionand installs the version via fnm if not already present.- CWD fallback for MCP path resolution — the MCP server now falls back to the working directory Claude was opened in when
LERD_SITE_PATHis not set. This meanspathcan be omitted fromartisan,composer,env_setup,site_link,db_export, and other tools when running in a global MCP session — just open Claude in the project directory.
Fixed
lerd setupnpm step fails without a lockfile — the npm install step now runsnpm ciwhenpackage-lock.jsonoryarn.lockis present, and falls back tonpm installotherwise. Previouslynpm ciwas always used, causing the step to fail on projects without a lockfile. (PR #5 by @voronkovich)- Duplicate
PATHentry onlerd install—add_to_pathininstall.shnow checks the live$PATHbefore modifying shell rc files. If the install directory is already present, the function returns early and skips rc modification. (PR #7 by @voronkovich) - zsh completions moved to XDG directory — zsh completions are written to
~/.local/share/zsh/site-functions/_lerdinstead of~/.zfunc/_lerd, aligning with the XDG base directory convention. (PR #8 by @voronkovich) .php-versionchanges not reflected in nginx — writing a.php-versionfile (vialerd isolateor directly) updated the queue worker but left the nginx vhost pointing at the old FPM socket. The watcher daemon now detects when the resolved PHP version changes, updates the site registry, regenerates the vhost, and reloads nginx automatically (debounced to 2 seconds).- PHP version resolution order —
.php-versionnow takes priority overcomposer.json'srequire.phpconstraint, matching the documented and intuitive precedence (explicit pin beats inferred constraint).
[1.0.4] — 2026-03-26
Fixed
.testdomains unavailable from PHP-FPM containers — v1.0.3 fixed internet access by setting real upstream DNS servers (e.g.192.168.0.x) on thelerdPodman network, but this caused aardvark-dns to skip systemd-resolved, breaking.testresolution from inside containers.lerd startandlerd installnow use pasta's built-in DNS proxy at169.254.1.1(read from the rootless-netnsinfo.json) as the aardvark-dns upstream. This address chains through systemd-resolved, which routes.testqueries to lerd-dns and forwards all other queries to real upstream servers — giving containers both.testresolution and full internet access.- HTTPS to
.testsites fails from inside PHP-FPM containers (cURL error 60) — PHP code making outbound HTTPS requests to local.testdomains (e.g. Reverb broadcasting, internal API calls) received SSL certificate errors because the mkcert root CA was not trusted inside the container. The PHP-FPM image build now copies the mkcert root CA into the Alpine trust store (update-ca-certificates), so all.testHTTPS certificates are trusted. Existing images are automatically rebuilt onlerd update. - Reverb / queue / schedule workers not restarted after
php:rebuild— whenphp:rebuildreplaced and restarted the PHP-FPM containers, workers running inside those containers viapodman exec(Reverb, queue, schedule) were killed by theBindsTosystemd dependency but not brought back up automatically.php:rebuildnow explicitly restarts all such workers after the containers are back online.
[1.0.3] — 2026-03-26
Fixed
- No internet access from PHP-FPM containers — on systems where
/etc/resolv.confpoints to a stub resolver (127.0.0.53via systemd-resolved), aardvark-dns could not forward external DNS queries because the stub address is only reachable on the host's loopback, not from inside the container network namespace.lerd startandlerd installnow detect the real upstream DNS servers (reading/run/systemd/resolve/resolv.conffirst) and set them on thelerdPodman network so aardvark-dns forwards correctly.
[1.0.2] — 2026-03-25
Added
- RustFS replaces MinIO — MinIO OSS is no longer maintained; lerd now ships RustFS as its built-in S3-compatible object storage service. RustFS exposes the same API and credentials (
lerd/lerdpassword) so no application changes are needed. Closes #3. lerd minio:migrate— one-command migration from an existing MinIO installation to RustFS. Stops the MinIO container, copies data to the RustFS data directory, removes the MinIO quadlet, updatesconfig.yaml, and starts RustFS. The original MinIO data directory is preserved for manual cleanup.- Auto-migration prompt during
lerd update— if a MinIO data directory is detected at update time, lerd offers to run the migration automatically before continuing. lerd.localhostcustom domain — the Lerd dashboard is now accessible athttp://lerd.localhost(nginx proxies the domain to the UI service).lerd dashboardopens the new URL..localhostresolves to127.0.0.1natively on all modern systems with no DNS configuration.- Installable PWA — the dashboard ships a web app manifest (
/manifest.webmanifest) and SVG icons so it can be installed as a standalone app from Chrome or other PWA-capable browsers.
Fixed
- 502 Bad Gateway on Inertia.js full-page refreshes — nginx vhost templates now include
fastcgi_buffers 16 16kandfastcgi_buffer_size 32k, preventingupstream sent too big headererrors caused by large FastCGI response headers (common on routes with heavy session/flash data).
[1.0.1] — 2026-03-25
Added
lerd shell— opens an interactiveshsession inside the project's PHP-FPM container. The PHP version is resolved the same way as every other lerd command (.php-version,composer.json, global default). The working directory is set to the site root. If the site is paused, any services referenced in.envare started automatically before the shell opens.- Shell completions auto-installed on
lerd install— fish completions are written to~/.config/fish/completions/lerd.fish; zsh completions to~/.zfunc/_lerdwith the requiredfpathandcompinitlines appended to.zshrc; bash completions to~/.local/share/bash-completion/completions/lerd. - Pause/unpause propagates to git worktrees — when a site is paused, all its worktree checkouts also receive a paused nginx vhost with a Resume button. The button targets the parent site so clicking it unpauses both the parent and all worktrees at once. Unpausing restores all worktree vhosts and removes the paused HTML files.
Fixed
lerd parkrefuses to park a framework project root — if the target directory is itself a Laravel/framework project, lerd now prints a helpful message and suggestslerd linkinstead of silently misbehaving.lerd parkno longer registers framework subdirectories as sites — when a project root is accidentally used as a park directory, subdirectories likeapp/,vendor/, andpublic/are now skipped with a warning rather than being registered as phantom sites.
[1.0.0] — 2026-03-25
Added
Laravel Horizon support — lerd auto-detects
laravel/horizonincomposer.jsonand provides dedicatedlerd horizon:start/lerd horizon:stopcommands that runphp artisan horizonas a persistent systemd user service (lerd-horizon-{site}). When Horizon is detected, the Queue toggle in the web UI is replaced by a Horizon toggle, and a Horizon log tab appears in the site detail panel while Horizon is running. Pause/unpause correctly stops and resumes the Horizon service alongside other workers. MCP toolshorizon_startandhorizon_stopprovide the same control to AI assistants.Service dependencies (
depends_on) — custom services can now declare which services they depend on. Starting a service with dependencies starts those dependencies first; starting a dependency automatically starts any services that depend on it; stopping a dependency cascade-stops its dependents first. Declare via thedepends_onYAML field, the--depends-onflag onlerd service add, or thedepends_onparameter in theservice_addMCP tool.lerd man— terminal documentation browser — browse and search the built-in docs without leaving the terminal. Opens an interactive TUI with arrow-key navigation, live filtering by title or content, and a scrollable markdown pager. Pass a page name to jump directly (e.g.lerd man sites). SetGLAMOUR_STYLE=lightto override the default dark theme. Works in non-TTY mode too:lerd man | catprints a table of contents andlerd man sites | catprints raw markdown.lerd about— new command that prints the version, build info, project URL, and copyright.CLI commands auto-start services on paused sites — running
php artisan,composer,lerd db:export,lerd db:import, orlerd db:shellin a paused site's directory automatically starts any services the site needs (MySQL, Redis, etc.) before executing. A notice is printed only when a service actually needs starting; if services are already running the command executes silently. The site stays paused — no vhost restore or worker restart.lerd pause/lerd unpause— pause a site without unlinking it.lerd pausestops all running workers (queue, schedule, reverb, stripe, and any custom workers), replaces the nginx vhost with a static landing page, and auto-stops any services no longer needed by other active sites. The paused state persists acrosslerd start/lerd stopcycles.lerd unpauserestores the vhost, restarts any services the site's.envreferences, and resumes all workers that were running before the pause. The landing page includes a Resume button that calls the lerd API directly so you can unpause from the browser.lerd service pin/lerd service unpin— pin a service so it is never auto-stopped, even when no active sites reference it in their.env. Pinning immediately starts the service if it isn't already running. Unpin to restore normal auto-stop behaviour.MCP
site_pause/site_unpausetools — AI agents can pause and resume sites directly, enabling workflows like "pause all sites except the one I'm working on".MCP
service_pin/service_unpintools — AI agents can pin services to keep them always available.Extra ports on built-in services —
lerd service expose <service> <host:container>publishes an additional host port on any built-in service (mysql, redis, postgres, meilisearch, minio, mailpit). Mappings are persisted in~/.config/lerd/config.yamlunderservices.<name>.extra_portsand applied on every start. The service is restarted automatically if running. Use--removeto delete a mapping. MCP toolservice_exposeprovides the same capability.Reverb nginx WebSocket proxy — when a site uses Laravel Reverb (detected via
composer.jsonorBROADCAST_CONNECTION=reverbin.env), lerd now adds a/applocation block to the nginx vhost that proxies WebSocket upgrade requests to the Reverb server running on port 8080 inside the PHP-FPM container. The block is added automatically onlerd linkand onreverb:start.Framework definitions — user-defined PHP framework YAML files at
~/.config/lerd/frameworks/<name>.yaml. Each definition describes detection rules, the document root, env file format, per-service env detection/variable injection, and background workers.lerd framework list/add/removemanage definitions from the CLI.Framework workers — frameworks can define named background workers (e.g.
messengerfor Symfony,horizonorpulsefor Laravel) that run as systemd user services inside the PHP-FPM container.lerd worker start <name>/lerd worker stop <name>/lerd worker listmanage them.Custom workers for Laravel — the built-in Laravel definition now has built-in
queue,schedule, andreverbworkers. Additional workers (e.g. Horizon, Pulse) can be added vialerd framework add laravel --from-file ...; they are merged on top of the built-in definition.Generic
lerd workercommand —lerd worker start/stop/listworks for any framework-defined worker.lerd queue:start,lerd schedule:start, andlerd reverb:startare now aliases forlerd worker start queue/schedule/reverband work on any framework with those workers, not just Laravel.Web UI: framework worker toggles — custom framework workers appear as indigo toggles in the Sites panel alongside queue/schedule/reverb. Each running worker shows a log tab in the site detail drawer and an indicator dot in the site list.
MCP
worker_start/worker_stop/worker_list— start, stop, or list framework-defined workers for a site via the MCP server.MCP
framework_list/framework_add/framework_remove— manage framework definitions from an AI assistant.framework_addwithname: "laravel"adds custom workers to the built-in Laravel definition.MCP
sitesnow includes framework and workers — each site entry now includes itsframeworkname and aworkersarray with running status per worker.Docs:
Frameworks & Workerspage — full documentation of the YAML schema, detection rules, worker definitions, and complete Symfony and WordPress examples.Web UI: docs link — a "Docs" link in the dashboard navbar opens the documentation site.
Changed
lerd service listuses a compact two-column format — theTypecolumn has been removed. Custom services show[custom]inline after their status. Inactive reason anddepends on:info now appear as indented sub-lines, keeping the output narrow on small terminals.lerd service list/lerd service statusshows inactive reason — when a service is inactive, the output now includes a short note explaining why:(no sites using this service)for auto-stopped services, or(start with: lerd service start <name>)for manually stopped ones.lerd logsaccepts a site name as target — pass a registered site name to get logs for that site's PHP-FPM container (e.g.lerd logs my-project). Previously only nginx, service names, and PHP version strings were accepted.lerd unlinkauto-stops unused services — after unlinking a site, any services that were only needed by that site are automatically stopped (respecting pin and manually-started flags).db:importanddb:exportaccept a-d/--databaseflag — both commands now accept an optional--database/-dflag to target a specific database. When omitted the database name falls back toDB_DATABASEfrom the project's.envas before. The MCPdb_exporttool gains the same optionaldatabaseargument.lerd secure/lerd unsecurerestart the Stripe listener — if alerd stripe:listenservice is active when HTTPS is toggled, it is automatically restarted with the updated forwarding URL so--forward-tostays in sync with the site's scheme.MinIO: per-site bucket created by
lerd env— when MinIO is detected,lerd envnow creates a bucket named after the site handle (e.g.my_project), sets it to public access, and writesAWS_BUCKET=<site>andAWS_URL=http://localhost:9000/<site>into.env. PreviouslyAWS_BUCKETwas hardcoded tolerdandAWS_URLhad no bucket path.reverb:startregenerates the nginx vhost — runninglerd reverb:start(or toggling Reverb in the web UI) now regenerates the site's nginx config and reloads nginx, ensuring the/appWebSocket proxy block is added to existing sites without requiringlerd linkto be re-run.lerd envsets correct Reverb connection values —REVERB_HOST,REVERB_PORT, andREVERB_SCHEMEare now derived from the site's domain and TLS state instead of hardcodedlocalhost:8080.VITE_REVERB_*vars are also written to match.queue_start/schedule_start/reverb_startare no longer Laravel-only — these CLI commands and MCP tools now work for any framework that defines a worker with that name.lerd envrespects framework env configuration — uses the framework's configured env file, example file, format,url_key, and per-service detection rules instead of hardcoded Laravel paths.lerd link/lerd parkdetect and record the framework — the detected framework name is stored in the site registry and shown inlerd sites.
Fixed
lerd phpandlerd artisanno longer break MCP stdio transport — both commands now allocate a TTY (-t) only when stdin is a real terminal. When invoked by MCP or any other pipe-based tool, the TTY flag is omitted so stdin/stdout remain clean byte streams.Reverb toggle no longer appears on projects that don't use Reverb — the UI previously showed the Reverb toggle for all Laravel sites because the built-in worker map always included
reverb. It now gates oncli.SiteUsesReverb()(checks forlaravel/reverbin composer.json orBROADCAST_CONNECTION=reverbin.env).
Removed
internal/laravel/detector.go— replaced by the genericconfig.DetectFramework/config.GetFrameworksystem.
[0.9.1] — 2026-03-22
Added
- MCP
service_envtool — returns the recommended Laravel.envconnection variables for any service (built-in or custom) as a key/value map. Agents can callservice_env(name: "mysql")to inspect connection settings without runningenv_setupor modifying.env. Works for all six built-in services and any custom service registered viaservice_add.
Changed
lerd updatedoes a fresh version check — bypasses the 24-hour update cache and always fetches the latest release tag from GitHub directly. After a successful update the cache is refreshed solerd statusandlerd doctorstop showing a stale "update available" notice.lerd updateignores git-describe suffixes — dev/dirty builds (e.g.v0.9.0-dirty) are now treated as equal to the corresponding release when comparing versions, so locally-built binaries no longer trigger a spurious update prompt.
[0.9.0] — 2026-03-22
Added
lerd doctorcommand — full environment diagnostic. Checks podman, systemd user session, linger, quadlet/data dir writability, config validity, DNS resolution, port 80/443/5300 conflicts, PHP-FPM image presence, and update availability. Reports OK/FAIL/WARN per check with a hint for every failure and a summary line at the end.lerd statusshows watcher and update notice —lerd-watcheris now included in the status output alongside DNS, nginx, and PHP-FPM. A highlighted banner is printed when a newer version is cached.- Background update checker — checks GitHub for a new release once per 24 hours; result is cached to
~/.local/share/lerd/update-check.json. Fetches relevant CHANGELOG sections between the current and latest version. Used bylerd status,lerd doctor, the web UI, and the system tray. - MCP
statustool — returns structured JSON with DNS (ok + tld), nginx (running), PHP-FPM per version (running), and watcher (running). Recommended first call when a site isn't loading. - MCP
doctortool — runs the fulllerd doctordiagnostic and returns the text report. Use when the user reports setup issues or unexpected behaviour. - Watcher structured logging — the watcher package now uses
slogthroughout. SetLERD_DEBUG=1in the environment to enable debug-level output at runtime; watcher is otherwise silent except for WARN/ERROR events. - Web UI: Watcher card — the System tab now shows whether
lerd-watcheris running. When stopped, a Start button appears to restart it without opening a terminal. The card also streams live watcher logs (DNS repair events, fsnotify errors, worktree timeouts) directly in the browser. - Web UI: grouped worker accordions — queue workers, schedule workers, Stripe listeners, and Reverb servers are now grouped into collapsible accordions on the Services tab. Click a group header to expand it; only one group is open at a time. Mobile pill navigation is split into core services + group toggle pills with expandable sub-rows.
- Tray: update badge — the "Check for update..." menu item shows "⬆ Update to vX.Y.Z" when a new version is cached. Per-site workers (queue, schedule, Stripe, Reverb) are no longer listed in the tray services section.
Changed
lerd updateshows changelog and asks for confirmation — before downloading anything,lerd updatenow fetches and prints the CHANGELOG sections for every version between the current and latest release, then promptsUpdate to vX.Y.Z? [y/N]. The update only proceeds on an explicity/yes; pressing Enter or anything else cancels.
Fixed
lerd startnow startslerd-watcher— the watcher service was missing from the start sequence and could only be stopped bylerd quit, never started.lerd startnow includes it alongsidelerd-ui.
[0.8.2] — 2026-03-21
Fixed
- 413 Request Entity Too Large on file uploads — nginx now sets
client_max_body_size 0(unlimited) in thehttpblock, applied to all vhosts.lerd startalso rewritesnginx.confon every start so future config changes take effect without runninglerd install. - MCP
logstarget accepts site domains — site names containing dots (e.g.astrolov.com) were incorrectly matched as PHP version strings, producing invalid container names. The PHP version check now requires the strict pattern\d+\.\d+. - MinIO
AWS_URLset to public endpoint —AWS_URLis nowhttp://localhost:9000(browser-reachable) instead ofhttp://lerd-minio:9000(internal container hostname).AWS_ENDPOINTis unchanged and remains the internal address used by PHP. - Services page no longer blinks — the services list was polling every 5 seconds regardless of which tab was active, and showed a loading spinner on each poll. Polling now only runs while the services tab is visible, and the spinner only shows on the initial load.
Added
- DNS health watcher — the
lerd-watcherdaemon now polls.testDNS resolution every 30 seconds. When resolution breaks, it waits forlerd-dnsto be ready and re-applies the resolver configuration, replicating the repair performed bylerd start. Uses the configured TLD (dns.tldin global config, defaulttest). - MCP
logstarget is optional — whentargetis omitted, logs for the current site's PHP-FPM container are returned (resolved fromLERD_SITE_PATH). Specifytargetonly to view a different service or site.
Changed
make installrespects manually-stopped services —lerd-ui,lerd-watcher, andlerd-trayare only restarted after install if they were already running. Services stopped vialerd quitare left stopped.
[0.8.1] — 2026-03-21
Fixed
- MCP
service_start/service_stopaccept custom services — the MCP tool schema previously restricted thenamefield to an enum of built-in services, causing AI assistants to refuse to call these tools for custom services added viaservice_add. The enum constraint has been removed; any registered service name is now valid.
Changed
- MCP SKILL and guidelines updated —
soketiremoved from the built-in service list (dropped in v0.8.0);service_start/service_stopdescriptions clarified to explicitly mention custom service support.
[0.8.0] — 2026-03-21
Added
lerd reverb:start/reverb:stop— runs the Laravel Reverb WebSocket server as a persistent systemd user service (lerd-reverb-<site>.service), executingphp artisan reverb:startinside the PHP-FPM container. Survives terminal sessions and restarts on failure. Also available aslerd reverb start/lerd reverb stop.lerd schedule:start/schedule:stop— runs the Laravel task scheduler as a persistent systemd user service (lerd-schedule-<site>.service), executingphp artisan schedule:work. Also available aslerd schedule start/lerd schedule stop.lerd dashboard— opens the Lerd dashboard (http://127.0.0.1:7073) in the default browser viaxdg-open.- Auto-configure
REVERB_*env vars —lerd envnow generatesREVERB_APP_ID,REVERB_APP_KEY,REVERB_APP_SECRET, andREVERB_HOST/PORT/SCHEMEvalues whenBROADCAST_CONNECTION=reverbis detected, using random secure values for secrets. lerd setuprunsstorage:link— setup now runsphp artisan storage:linkwhen the site'sstorage/app/publicdirectory is not yet symlinked.lerd setupstarts the queue worker — setup now startsqueue:startas a final step whenQUEUE_CONNECTION=redisis set in.envor.env.example.- Watcher triggers
queue:restarton config changes — the watcher daemon monitors.env,composer.json,composer.lock, and.php-versionin every registered site and signalsphp artisan queue:restartwhen any of those files change (debounced). This ensures queue workers reload after deploys or PHP version changes. lerd start/stopmanage schedule and reverb —lerd startandlerd stopnow include alllerd-schedule-*andlerd-reverb-*service units in their start/stop sequences alongside queue workers and stripe listeners.- MCP tools for reverb, schedule, stripe — new
reverb_start,reverb_stop,schedule_start,schedule_stop, andstripe_listentools exposed via the MCP server. - Web UI: schedule and reverb per-site — the site detail panel shows whether the schedule worker and Reverb server are running, with start/stop buttons and live log streaming.
- Web UI:
stripe:stopaction — the dashboard now supports stopping a stripe listener from the site action menu (was start-only). WriteServiceIfChanged— internal helper that skips writing and runningdaemon-reloadwhen a service unit's content is unchanged, preventing unnecessary Podman quadlet regeneration.QueueRestartForSite— internal function that signals a graceful queue worker restart viaphp artisan queue:restartinside the PHP-FPM container.
Changed
- Queue worker uses
Restart=always— thelerd-queue-*service unit now restarts unconditionally (wasRestart=on-failure), matching the behaviour of schedule and reverb services. lerd.testdashboard vhost removed —lerd installno longer generates an nginx proxy vhost forlerd.test. The dashboard is only accessible athttp://127.0.0.1:7073. Thelerd.testdomain is no longer reserved and may be used for a regular site.- Web UI queue/stripe start is non-blocking —
queue:startandstripe:listensite actions now run in a background goroutine so the HTTP response returns immediately rather than waiting for the service to start.
Removed
- Soketi service removed — Soketi has been removed from Lerd's service list, config defaults, and env suggestions. Laravel Reverb (
lerd reverb:start) is the recommended WebSocket solution.
[0.7.0] — 2026-03-21
Added
lerd quitcommand — fully shuts down Lerd: stops all containers and services (likelerd stop), then also stops thelerd-uiandlerd-watcherprocess units, and kills the system tray.- Start/Stop from the web UI — the dashboard now has Start and Stop buttons that call
lerd start/lerd stopvia new/api/lerd/start,/api/lerd/stop, and/api/lerd/quitAPI endpoints. The Start button is only shown when one or more core services (DNS, nginx, PHP-FPM) are not running. lerd startresumes stripe listeners —lerd-stripe-*services are now included in the start sequence alongside queue workers and the UI service.
Changed
- Tray quit uses
lerd quit— the tray's quit action now calls the newquitcommand instead ofstop, ensuring a full shutdown including the UI and watcher processes. The menu item is renamed from "Stop Lerd & Quit" to "Quit Lerd". lerd stopstops all services regardless of pause state — stop now shuts down all installed services including paused ones and stripe listeners, ensuring a clean shutdown every time.
Fixed
- Log panel guards — clicking to open logs for FPM, nginx, DNS, or queue services no longer attempts to open a log stream when the service is not running.
0.6.0 — 2026-03-21
Added
- Git worktree support — each
git worktreecheckout automatically gets its own subdomain (<branch>.<site>.test) with a dedicated nginx vhost. No manual steps required.- The watcher daemon detects
git worktree add/git worktree removein real time via fsnotify and generates or removes vhosts accordingly. It watches.git/itself so it correctly re-attaches when.git/worktrees/is deleted (last worktree removed) and re-created (new worktree added). - Startup scan generates vhosts for all existing worktrees across all registered sites.
EnsureWorktreeDeps— symlinksvendor/andnode_modules/from the main repo into each worktree checkout, and copies.envwithAPP_URLrewritten to the worktree subdomain.lerd sitesshows worktrees indented under their parent site.- The web UI shows worktrees in the site detail panel with clickable domain links and an open-in-browser button.
- A git-branch icon appears on the site button in the sidebar whenever the site has active worktrees.
- The watcher daemon detects
- HTTPS for worktrees — when a site is secured with
lerd secure, all its worktrees automatically receive an SSL vhost that reuses the parent site's wildcard mkcert certificate (*.domain.test). No separate certificate is needed per worktree. Securing and unsecuring a site also updatesAPP_URLin each worktree's.env. - Catch-all default vhost (
_default.conf) — any.testhostname that does not match a registered site returns HTTP 444 / rejects the TLS handshake, instead of falling through to the first alphabetical vhost. stripe:listenas a background service —lerd stripe:listennow runs the Stripe CLI in a persistent systemd user service (lerd-stripe-<site>.service) rather than a foreground process. It survives terminal sessions and restarts on failure.lerd stripe:listen stoptears it down.- Service pause state —
lerd service stopnow records the service as manually paused.lerd startand autostart on login skip paused services.lerd stop+lerd startrestore the previous state: running services restart, manually stopped services stay stopped. - Queue worker Redis pre-flight —
lerd queue:startchecks thatlerd-redisis running whenQUEUE_CONNECTION=redisis set in.env, and returns a friendly error with instructions rather than failing with a cryptic DNS error from PHP.
Fixed
- Park watcher depth — the filesystem watcher no longer registers projects found in subdirectories of parked directories. Only direct children of a parked directory are eligible for auto-registration.
- Nginx reload ordering for secure/unsecure —
lerd secure/lerd unsecure(and their UI/MCP equivalents) now save the updatedsecuredflag tosites.yamlbefore reloading nginx. Previously a failed nginx reload would leavesites.yamlwith a stalesecuredstate, causing the watcher to regenerate the wrong vhost type on restart. - Tray always restarts on
lerd start— any existing tray process is killed before relaunching, preventing duplicate tray instances after repeatedlerd startcalls. - FPM quadlet skip-write optimisation —
WriteFPMQuadletskips writing and daemon-reloading when the quadlet content is unchanged. Unnecessary daemon-reloads caused Podman's quadlet generator to regenerate all service files, which could briefly disruptlerd-dnsand cause.testresolution failures.
[0.5.16] — 2026-03-20
Fixed
- PHP-FPM image build on restricted Podman — fully qualify all base image names in the Containerfile (
docker.io/library/composer:latest,docker.io/library/php:X.Y-fpm-alpine). Systems without unqualified-search registries configured in/etc/containers/registries.confwould fail with "short-name did not resolve to an alias".
[0.5.15] — 2026-03-20
Fixed
- PHP-FPM image build on Podman — the Containerfile now declares
FROM composer:latest AS composer-binas an explicit stage before copying the composer binary. Podman (unlike Docker) does not auto-pull images referenced only inCOPY --from, causing builds to fail with "no stage or image found with that name". This also affectedlerd updateandlerd php:rebuildin v0.5.14, leaving containers stopped if the build failed after the old image was removed. - Zero-downtime PHP-FPM rebuild —
lerd php:rebuildno longer removes the existing image before building. The running container stays up during the build; only the finalsystemctl restartcauses a brief interruption. Force rebuilds now use--no-cacheinstead ofrmi -f. - UI logs panel — clicking logs for a site whose PHP-FPM container is not running now shows a clean "container is not running" message instead of the raw podman error.
lerd php/lerd artisan— running these when the PHP-FPM container is stopped now returns a friendly error with thesystemctl --user startcommand instead of a raw podman error.lerd updateensures PHP-FPM is running — after applying infrastructure changes,lerd updatenow starts any installed PHP-FPM containers that are not running. Also fixed a cosmetic bug where "skipping rebuild" was printed even when a rebuild had just run.
[0.5.14] — 2026-03-20
Added
LERD_SITE_PATHin MCP config —mcp:injectnow embeds the project path asLERD_SITE_PATHin the injected MCP server config. The MCP server reads this at startup and uses it as the defaultpathforartisan,composer,env_setup,db_export, andsite_link, so AI assistants no longer need to pass an explicit path on every call..ai/mcp/mcp.jsoninjection —mcp:injectnow also writes into.ai/mcp/mcp.json(used by Windsurf and other MCP-compatible tools), in addition to.mcp.jsonand.junie/mcp/mcp.json.
[0.5.10] — 2026-03-20
Fixed
- DNS race on install/update —
lerd install(and by extensionlerd update) now waits up to 15 seconds for thelerd-dnscontainer to be ready before callingConfigureResolver(). Previously,resolvectlwas called immediately after the container restart, causing systemd-resolved to mark127.0.0.1:5300as failed and fall back to the DHCP DNS server, breaking.testresolution untillerd installwas run again manually.
[0.5.8] — 2026-03-20
Fixed
- GoReleaser archive — split amd64 and arm64 into separate archive definitions so
lerd-tray(amd64-only) doesn't cause a binary count mismatch error
[0.5.7] — 2026-03-20
Fixed
- Cross-distro tray compatibility — the main
lerdbinary is now fully static (CGO_ENABLED=0) and carries no shared library dependencies. A separatelerd-traybinary (built with CGO + libappindicator3) is shipped alongside it in the release tarball. At runtimelerd trayexecslerd-tray; if the helper is absent orlibappindicator3.so.1is missing the tray is silently skipped and everything else keeps working. Fixes startup failure on Fedora and other distros where libappindicator3 is not installed by default.
[0.5.6] — 2026-03-19
Added
- Parallel build TUI —
lerd fetchandlerd php:rebuildnow build PHP-FPM images in parallel with a compact spinner UI; press Ctrl+O to toggle per-job output - Service image pull TUI —
lerd service startshows a spinner while pulling the container image if it is not already present - Condensed uninstall output —
lerd uninstalluses the same spinner UI for a cleaner experience
Changed
- Install output —
lerd installuses plain sequential output with a spinner only for the slow image pull and dnsmasq build steps; interactive sudo prompts (mkcert CA, DNS sudoers) are no longer affected by raw terminal mode - mkcert output indented — output from
mkcert -installis indented to align with the surrounding install step lines - Spinner timer hidden when zero — the elapsed timer is omitted from spinner rows that complete in under one second
Fixed
- PHP Containerfile — removed
pdo_sqliteandsqlite3fromdocker-php-ext-install; both are bundled in the PHP Alpine base image and including them caused aCannot find config.m4build error
[0.5.5] — 2026-03-19
Added
lerd php:ext add/remove/list— manage custom PHP extensions per version; extensions are persisted in config and included in every image rebuild- Expanded default FPM image — added
bz2,calendar,dba,ldap,mysqli,pdo_sqlite,sqlite3,soap,shmop,sysvmsg,sysvsem,sysvshm,xsl(viadocker-php-ext-install) plusigbinaryandmongodb(via PECL); the default bundle now covers ~30 extensions for Herd-parity - Composer extension detection —
lerd park/lerd linkreadsext-*keys fromcomposer.jsonand warns if any required extensions are missing from the image, with an actionable hint lerd php:ini [version]— opens the per-version user php.ini in$EDITOR; the file is mounted into the FPM container at/usr/local/etc/php/conf.d/98-lerd-user.iniand created automatically with commented examples on first use
[0.5.4] — 2026-03-19
Added
- Custom services: users can now define arbitrary OCI-based services without recompiling. Config lives at
~/.config/lerd/services/<name>.yaml.lerd service add [file.yaml]— add from a YAML file or inline flags (--name,--image,--port,--env,--env-var,--data-dir,--detect-key,--detect-prefix,--init-exec,--init-container,--dashboard,--description)lerd service remove <name>— stop (if running), remove quadlet and config; data directory preservedlerd service list— shows built-in and custom services with a[custom]type columnlerd service start/stop— works for custom serviceslerd start/lerd stop— includes installed custom serviceslerd env— auto-detects custom services viaenv_detect, appliesenv_vars, runssite_init.execlerd status— includes custom services in the[Services]section- Web UI services tab — shows custom services with start/stop and dashboard link
- System tray — shows custom services (slot pool expanded from 7 to 20)
/placeholders inenv_varsandsite_init.exec— substituted with the project site handle atlerd envtimesite_initYAML block — runs ash -ccommand inside the service container once per project whenlerd envdetects the service (for DB/collection creation, user setup, etc.)dashboardfield on custom services and built-in service responses — shows an "Open" button in the web UI when the service is active; dashboard URLs for built-ins (Mailpit, MinIO, Meilisearch) moved from hardcoded JS to the API response- README simplified — now a slim landing page pointing to the docs site; full documentation at
geodro.github.io/lerd - Docs updated —
docs/usage/services.mdextended with full custom services reference
Fixed
- Custom service data directory is now created automatically before starting (
podmanrefused to mount a non-existent host path) lerd service removenow checks unit status before stopping — skips stop if not running, and aborts removal if stop fails (prevents orphaned running containers)
0.5.3 — 2026-03-19
Fixed
- Tray not restarting after
lerd update:lerd installwas killing the tray withpkillbut only relaunching it whenlerd-tray.servicewas enabled. If the tray was started directly (lerd tray), it was killed and never restarted. Now tracks whether the tray was running before the kill and relaunches it directly when systemd is not managing it.
0.5.2 — 2026-03-19
Fixed
lerd db:createandlerd db:shellwere missing from the binary —cmd/lerd/main.gowas not staged in the v0.5.1 commit
0.5.1 — 2026-03-19
Added
lerd db:create [name]/lerd db create [name]: creates a database and a<name>_testingdatabase in one command. Name resolution: explicit argument →DB_DATABASEfrom.env→ project name (site registry or directory). Reports "already exists" instead of failing when a database is present. Available for both MySQL and PostgreSQL.lerd db:shell/lerd db shell: opens an interactive MySQL (mysql -uroot -plerd) or PostgreSQL (psql -U postgres) shell inside the service container, connecting to the project's database automatically. Replaces the need to runpodman exec --tty lerd-mysql mysql …manually.
Changed
lerd envnow creates a<name>_testingdatabase alongside the main project database when setting up MySQL or PostgreSQL. Both databases report "already exists" if they were previously created.
0.5.0 — 2026-03-19
Added
- System tray applet (
lerd tray): a desktop tray icon for KDE, GNOME (with AppIndicator extension), waybar, and other SNI-compatible environments. The applet detaches from the terminal automatically and pollshttp://127.0.0.1:7073every 5 seconds. Menu includes:- 🟢/🔴 overall running status with per-component nginx and DNS indicators
- Open Dashboard — opens the web UI
- Start / Stop Lerd toggle
- Services section — lists all active services with 🟢/🔴 status; clicking a service starts or stops it
- PHP section — lists all installed PHP versions; current global default is marked ✔; clicking switches the global default via
lerd use - Autostart at login toggle — enables or disables
lerd-autostart.service - Check for update — polls GitHub; if a newer version is found the item changes to "⬆ Update to vX.Y.Z" and clicking opens a terminal with a confirmation prompt before running
lerd update - Stop Lerd & Quit — runs
lerd stopthen exits the tray
--monoflag forlerd tray: defaults totrue(white monochrome icon); pass--mono=falsefor the red colour iconlerd autostart tray enable/disable: registers/removeslerd-tray.serviceas a user systemd unit that starts the tray on graphical loginlerd startstarts the tray: iflerd-tray.serviceis enabled it is started via systemd; otherwise, if no tray process is already running,lerd trayis launched directlymake build-nogui: headless build (CGO_ENABLED=0 -tags nogui) for CI or servers;lerd trayreturns a clear error instead of failing to link
Changed
- Build now requires CGO and
libappindicator3(libappindicator-gtk3on Arch,libappindicator3-devon Debian/Ubuntu,libappindicator-gtk3-develon Fedora). Themake buildtarget setsCGO_ENABLED=1 -tags legacy_appindicatorautomatically. lerd-autostart.servicenow declaresAfter=graphical-session.targetso the tray (which needs a display) is available whenlerd startruns at login.- Web UI update flow: the "Update" button has been removed. When an update is available the UI now shows
vX.Y.Z available — run lerd update in a terminal. The/api/updateendpoint has been removed. This avoids silent failures caused bysudosteps inlerd installthat require a TTY. /api/statusnow includes aphp_defaultfield with the global default PHP version, used by the tray to mark the active version with ✔.
[0.4.3] — 2026-03-19
Fixed
- DNS broken after install on Fedora (and other NM + systemd-resolved systems): the NetworkManager dispatcher script and
ConfigureResolver()were callingresolvectl domain $IFACE ~test, which caused systemd-resolved to mark the interface asDefault Route: no. This meant queries for anything outside.test(i.e. all internet DNS) had no route and were refused. Fixed by also passing~.as a routing domain in both places — the interface now handles.testspecifically via lerd's dnsmasq and remains the default route for all other queries. .testDNS fails after reboot/restart:lerd startwas callingresolvectl dnsto point systemd-resolved at lerd-dns (port 5300) immediately after the container unit became active — but dnsmasq inside the container wasn't ready to accept connections yet. systemd-resolved would try port 5300, fail, mark it as a bad server, and fall back to the upstream DNS for the rest of the session. Fixed by waiting up to 10 seconds for port 5300 to accept TCP connections before callingConfigureResolver().- Clicking a site URL after disabling HTTPS still opened the HTTPS version: the nginx HTTP→HTTPS redirect was a
301(permanent), which browsers cache indefinitely. After disabling HTTPS, the browser would serve the cached redirect instead of hitting the server. Changed to302(temporary) so browsers always check the server, and disabling HTTPS takes effect immediately.
[0.4.2] — 2026-03-19
Changed
lerd setupdetects the correct asset build command frompackage.json: instead of always suggestingnpm run build, the setup step now readsscriptsfrompackage.jsonand picks the first available candidate in priority order:build(Vite / default),production(Laravel Mix),prod. The step label reflects the detected command (e.g.npm run production). If none of the candidates exist, the build step is omitted from the selector.
[0.4.1] — 2026-03-19
Fixed
lerd statusTLS certificate check:certExpirywas passing raw PEM bytes directly tox509.ParseCertificate, which expects DER-encoded bytes. The fix decodes the PEM block first, so certificate expiry is read correctly and sites no longer show "cannot read cert" when the cert file exists and is valid.
[0.4.0] — 2026-03-19
Added
- Xdebug toggle (
lerd xdebug on/off [version]): enables or disables Xdebug per PHP version by rebuilding the FPM image with Xdebug installed and configured (mode=debug,start_with_request=yes,client_host=host.containers.internal, port 9003). The FPM container is restarted automatically.lerd xdebug statusshows enabled/disabled for all installed versions. lerd fetch [version...]: pre-builds PHP FPM images for the specified versions (or all supported: 8.1–8.5) so the firstlerd use <version>is instant. Skips versions whose images already exist.lerd db:import <file.sql>/lerd db:export [-o file]: import or export a SQL dump using the project's.envDB settings. Supports MySQL/MariaDB (lerd-mysql) and PostgreSQL (lerd-postgres). Also available aslerd db import/lerd db export.lerd share [site]: exposes the current site publicly via ngrok or Expose. Auto-detects which tunnel tool is installed; use--ngrokor--exposeto force one. Forwards to the local nginx port with the correctHostheader so nginx routes to the right vhost.lerd setup: interactive project bootstrap command — presents a checkbox list of steps (composer install, npm ci, lerd env, lerd mcp:inject, php artisan migrate, php artisan db:seed, npm run build, lerd secure, lerd open) with smart defaults based on project state.lerd linkalways runs first (mandatory, not in the list) to ensure the site is registered with the correct PHP version before any subsequent step.--all/-aruns everything without prompting (CI-friendly);--skip-openskips opening the browser.
Fixed
- PHP version detection order:
composer.jsonrequire.phpnow takes priority over.php-version, so projects declaring"php": "^8.4"incomposer.jsonautomatically use PHP 8.4 even if a stale.php-versionfile says otherwise. Explicit.lerd.yamloverrides still take top priority. lerd linkpreserves HTTPS: re-linking a site that was already secured now regenerates the SSL vhost (not an HTTP vhost), sohttps://continues to work after a re-link.lerd linkpreservessecuredflag: re-linking no longer resets a secured site tosecured: false.lerd secure/lerd unsecuredirectory name resolution: sites in directories with real TLDs (e.g.astrolov.com) are now resolved correctly by path lookup, so the commands no longer error with "site not found" when the directory name differs from the registered site name.
[0.3.0] — 2026-03-18
Added
lerd envcommand: copies.env.example→.envif missing, detects which services the project uses, applies lerd connection values, starts required services, generatesAPP_KEYif missing, and setsAPP_URLto the registered.testdomainlerd unsecure [name]command: removes the mkcert TLS cert and reverts the site to HTTPlerd secureandlerd unsecurenow automatically updateAPP_URLin the project's.envtohttps://orhttp://respectivelylerd installnow installs a/etc/sudoers.d/lerdrule granting passwordlessresolvectl dns/domain/revert— required for the autostart service which cannot prompt for a sudo password- PHP FPM images now include the
gmpextension - MCP server (
lerd mcp): JSON-RPC 2.0 stdio server exposing lerd as a Model Context Protocol tool provider for AI assistants (Claude Code, JetBrains Junie, and any MCP-compatible client). Tools:artisan,sites,service_start,service_stop,queue_start,queue_stop,logs lerd mcp:inject: writes.mcp.json,.claude/skills/lerd/SKILL.md, and.junie/mcp/mcp.jsoninto a project directory. Merges into existingmcpServersconfigs — other servers (e.g.laravel-boost,herd) are preserved unchanged- UI: queue worker toggle in the Sites tab — amber toggle to start/stop the queue worker per site; spinner while toggling; error text on failure; logs link opens the live log drawer for that worker when running
- UI: Unlink button in the Sites tab — small red-bordered button that confirms, calls
POST /api/sites/{domain}/unlink, and removes the site from the table client-side immediately lerd unlinkparked-site behaviour: unlinking a site under a parked directory now marks it asignoredin the registry instead of removing it, preventing the watcher from re-registering it on next scan. Runninglerd linkin the same directory clears the flag. Non-parked sites are still removed from the registry entirelyGET /api/sitesfilters out ignored sites so they are invisible in the UIqueue:startandqueue:stopare now also available as API actions viaPOST /api/sites/{domain}/queue:startandPOST /api/sites/{domain}/queue:stop, enabling UI and MCP control
Fixed
- DNS
.testrouting now works correctly after autostart:resolvectl revertis called before re-applying per-interface DNS settings so systemd-resolved resets the current server to127.0.0.1:5300; previously, resolved would mark lerd-dns as failed during boot (before it started) then fall back to the upstream DNS for all queries including.test, causing NXDOMAIN on every.testlookup fnm installno longer prints noise to the terminal when a Node version is already installed
Changed
lerd startandlerd stopnow start/stop containers in parallel — startup is noticeably faster on multi-container setupslerd startnow re-applies DNS resolver config on every invocation, ensuring.testrouting is always correct after reboot or network changeslerd parknow skips already-registered sites instead of overwriting them, preserving settings such as TLS status and custom PHP versionlerd installcompletion message now shows bothhttp://lerd.testandhttp://127.0.0.1:7073as fallback- Composer is now stored as
composer.phar; thecomposershim runs it vialerd php - Autostart service now declares
After=network-online.targetand runs at elevated priority (Nice=-10)
[0.2.0] — 2026-03-17
Changed
- UI completely redesigned: dark theme inspired by Laravel.com with near-black background, red accents, and top navbar replacing the sidebar
- Light / Auto / Dark theme toggle added to the navbar; preference persists in localStorage
[0.1.66] — 2026-03-17
Fixed
lerd startnow detects missing PHP FPM images (e.g. afterpodman rmi) and automatically rebuilds them before starting unitslerd statusnow reportsimage missingwith alerd php:rebuild <version>hint instead of just showing the container as not running
[0.1.65] — 2026-03-17
Fixed
- PHP 8.5 FPM image now builds successfully:
opcacheis already compiled into PHP 8.5 sodocker-php-ext-enable opcacheis now a no-op (|| true);apk updateis run beforeapk addto avoid stale index warnings;redisfalls back to building from GitHub source when PECL fails
[0.1.64] — 2026-03-17
Fixed
redisandimagickPHP extensions now fall back to building from GitHub source when the PECL stable release doesn't compile against the current PHP API version (e.g. PHP 8.5) — redis is required so the build fails if both methods fail; imagick remains optional
[0.1.63] — 2026-03-17
Fixed
pecl install redisis now also non-fatal during PHP FPM image builds — theredisextension (likeimagick) doesn't yet compile against PHP 8.5's new API; both extensions are best-effort and the build succeeds regardless
[0.1.62] — 2026-03-17
Fixed
- PHP 8.5 image build no longer fails when the
imagickPECL extension can't compile against the new PHP API — imagick is installed if available, silently skipped otherwise (redis is unaffected)
[0.1.61] — 2026-03-17
Fixed
- Domains are now always lowercased — directory names like
MyAppor custom--domain MyApp.testnow consistently producemyapp.test
[0.1.60] — 2026-03-17
Fixed
- All container volume mounts now include the
:zSELinux relabeling option — on Fedora (and other SELinux-enforcing systems) dnsmasq and nginx containers were unable to read their config files, causing DNS and nginx to fail immediately after install - Home-directory volume mounts (nginx, PHP-FPM) use
--security-opt=label=disableinstead of:zto avoid recursively relabeling the user's home directory
0.1.53 — 2026-03-17
Fixed
lerd installnow configures the system DNS resolver (writes NM dispatcher / appliesresolvectl) only afterlerd-dnsis running — previously applyingresolvectl dns <iface> 127.0.0.1:5300before the dnsmasq container started routed all DNS through a non-existent server, breaking image pulls with "no such host" / "server misbehaving"
0.1.52 — 2026-03-17
Fixed
- DNS resolution on Ubuntu (systemd-resolved + NetworkManager): NM overrides global
resolved.confdrop-ins via DBUS so theDNS=127.0.0.1:5300drop-in had no effect; now installs an NM dispatcher script (/etc/NetworkManager/dispatcher.d/99-lerd-dns) that callsresolvectl dns/domainper-interface on "up", and applies it immediately to the default interface - Upstream DNS servers in the dnsmasq config are now detected from the running system (
/run/systemd/resolve/resolv.conf→/etc/resolv.conf, skipping loopback/stub addresses) — no hardcoded IPs lerd-dns.containernow mounts~/.local/share/lerd/dnsmasqinto the container and uses--conf-dirinstead of embedding all options in theExecline
0.1.51 — 2026-03-17
Fixed
- DNS resolution now works on systems using systemd-resolved (Ubuntu, etc.) —
lerd installdetects whether systemd-resolved is the active resolver and writes/etc/systemd/resolved.conf.d/lerd.confwithDNS=127.0.0.1:5300andDomains=~testinstead of configuring NetworkManager's embedded dnsmasq lerd statusPHP version hint no longer shows "8.5" — corrected to "8.4"
0.1.50 — 2026-03-17
Fixed
install.sh--localbinary path is now validated beforecheck_prerequisitesruns — previously podman not being installed would causedie "podman is required"before the file-exists check, making bats test 23 fail in CI
0.1.49 — 2026-03-17
Fixed
install.shask()no longer causes CI test failures underset -euo pipefailwhen/dev/ttyis unavailable —read </dev/ttynow has2>/dev/null || trueso a missing tty is silently treated as "no"
0.1.48 — 2026-03-17
Fixed
- All container images now use fully qualified names (
docker.io/library/nginx:alpine, etc.) — Ubuntu's/etc/containers/registries.confhas no unqualified-search registries, causing short names to fail with exit code 125 lerd installnow writes thelerd.testUI vhost before starting nginx so the dashboard is available on the very first start
0.1.47 — 2026-03-17
Fixed
lerd installnow runspodman system migrateafter installing podman on a fresh system to initialise Podman's storage before the first rootless container operation
0.1.46 — 2026-03-17
Fixed
- Container images are now pre-pulled before
daemon-reload/ service start so the systemd 90 s default timeout is not exceeded on a fresh install pulling large images;TimeoutStartSec=300added to bothlerd-nginx.containerandlerd-dns.containeras an additional safeguard lerd installno longer prints a spurious nginx reload[WARN]— the separate reload step was removed;RestartUnitalready loads the latest config
0.1.45 — 2026-03-17
Fixed
install.shask()now reads from/dev/ttyso prompts work correctly when the script is piped to bash (curl | bash); a missing tty falls back gracefullyinstall.shnow aborts with a clear error ifpodmanis not found after the prerequisite install step
0.1.44 — 2026-03-17
Fixed
- HTTP→HTTPS redirect in SSL vhosts changed from
301(permanent, browser-cached) to302(temporary) so disabling HTTPS is not cached by the browser - Site domain links in the dashboard now use
https://when TLS is enabled andhttp://otherwise
0.1.43 — 2026-03-17
Fixed
lerd install(andlerd update) no longer overwrites SSL vhosts with plain HTTP configs — sites withsecured: trueinsites.yamlnow have their SSL vhost regenerated in-place during the vhost regeneration step- Sites table in the dashboard no longer flickers on background poll — the 5 s interval now updates existing row properties in-place instead of replacing the entire array; new/removed sites are still added/removed correctly
0.1.42 — 2026-03-17
Added
- Sites tab now auto-refreshes every 5 seconds — PHP version, Node version, TLS status, and FPM running state stay current without a manual reload
- Install Node version UI added to the Services tab — enter a version number and click Install to run
fnm installin the background
0.1.41 — 2026-03-17
Fixed
lerd installnow usesRestartUnit(instead ofStartUnit) for all services so a re-run afterlerd updatepicks up the new binary and any changed quadlet files- Installer bats tests updated:
latest_versionmocks updated for the redirect-based version check,certutiladded to the--checkprerequisite mock
0.1.40 — 2026-03-17
Fixed
- Sites tab now shows the live PHP/Node version detected from disk (
.php-version,.lerd.yaml,composer.json) instead of the stale value stored insites.yaml; if the detected version differs,sites.yamlis updated automatically
0.1.39 — 2026-03-17
Added
- PHP and Node columns in the Sites tab are now dropdowns — selecting a version writes
.php-version/.node-versionto the project directory, updatessites.yaml, regenerates the nginx vhost, and reloads nginx; available PHP versions come from installed FPM quadlets, Node versions fromfnm list
0.1.38 — 2026-03-17
Fixed
- HTTPS sites no longer return "File not found" —
SecureSitewas constructing a bareconfig.Sitewith onlyDomainandPHPVersion, leavingPathempty so the generated SSL vhost hadroot /public; it now receives the full site struct fetchLatestVersiontests updated to use the redirect-based approach (fixes broken test suite after v0.1.34 change)
0.1.37 — 2026-03-17
Fixed
- HTTPS toggle in Sites tab no longer returns "site not found" — the API was looking up sites by name but receiving the full domain; added
FindSiteByDomainand switched the handler to use it - HTTPS column now shows a proper toggle switch instead of "On / Off" text buttons
0.1.36 — 2026-03-17
Fixed
lerd statusno longer warns about all 7 services being inactive — it now only shows services that have a quadlet file on disk (i.e. were intentionally installed); uninstalled services are silently skipped with a single "No services installed" message if none are present
0.1.35 — 2026-03-17
Added
install.shnow checks forcertutil(nss-tools) as a prerequisite and offers to install it automatically — without it mkcert cannot register the CA in Chrome/Firefox, causingERR_CERT_AUTHORITY_INVALIDon HTTPS sites- README documents
certutil/nss-toolsas a requirement with per-distro package names
0.1.34 — 2026-03-17
Fixed
- Version detection in both
lerd updateandinstall.shno longer uses the GitHub REST API — it now follows thehttps://github.com/{repo}/releases/latestHTML redirect to extract the tag from the URL; this endpoint is not rate-limited (60 req/hour limit on the API was causing "No releases found" / HTTP 403 for anyone who ran the installer more than a few times)
0.1.33 — 2026-03-17
Fixed
install.shlatest_version()now sendsUser-Agent: lerd-installerandAccept: application/vnd.github+jsonheaders — GitHub's API returns 403 for unauthenticated requests without a User-Agent, which the script was silently treating as "no releases found"install.shcmd_uninstallnow dynamically discovers units from quadlet files on disk (same fix aslerd uninstall)
0.1.32 — 2026-03-17
Fixed
lerd uninstallnow stops and disables all services that were enabled at runtime (e.g. mailpit, soketi started from the UI dashboard) — the unit list is now derived dynamically from the quadlet files on disk instead of a hardcoded list, so nothing is left behindlerd uninstallnow also removeslerd-ui.servicealongsidelerd-watcher.service
0.1.31 — 2026-03-17
Fixed
lerd updateno longer fails with "GitHub API returned HTTP 403" — the version check now sends aUser-Agent: lerd-cliheader, which GitHub requires for unauthenticated API requests
0.1.30 — 2026-03-17
Fixed
lerd updatenow restarts thelerd-uisystemd service after applying changes so the new binary is immediately picked up without manual intervention
0.1.29 — 2026-03-17
Added
- HTTPS toggle in Sites tab — the TLS column is now a clickable button; clicking it calls
POST /api/sites/{domain}/secureorunsecure, issues/removes the mkcert certificate, regenerates the nginx vhost, and reloads nginx inline without leaving the UI
Fixed
lerd secureno longer fails with "renaming SSL config: no such file or directory" —RemoveVhostwas deleting both the HTTP and SSL config files before the rename; the command now only removes the HTTP config, then renames the SSL one into place.envCopy button now works on plain HTTP (lerd.test) —navigator.clipboard.writeTextrequires HTTPS; added adocument.execCommand('copy')fallback via a temporary off-screen textarea
0.1.28 — 2026-03-17
Added
- Live logs drawer — click any site row in the dashboard to open a live streaming log panel at the bottom of the screen showing that site's PHP-FPM container output (
podman logs -f); lines are colour-coded (red for errors/fatals, yellow for warnings/notices); auto-scrolls with a 500-line buffer; Clear and Close controls in the header - Env vars preview in Services tab — each service card now has a "Show .env / Hide .env" toggle that expands a syntax-highlighted code block with all the
.envvariables for that service, with a one-click Copy button in the header
Fixed
- Service start from UI no longer fails with "Unit not found" after the first time a service quadlet is written —
handleServiceActionnow retriesStartUnitup to 5 times with increasing delays (300 ms each) to give the systemd Quadlet generator time to register the new.serviceunit afterdaemon-reload - Removed stale "Copied to clipboard!" feedback element that was previously separate from the env preview Copy button
0.1.27 — 2026-03-17
Fixed
lerd update(andlerd install) no longer prompts for sudo if DNS is already configured —dns.Setup()now checks whether/etc/NetworkManager/conf.d/lerd.confand/etc/NetworkManager/dnsmasq.d/lerd.confalready contain the correct content and skips all sudo steps if so; this makes updating from the UI dashboard work without any password prompt in the common case
0.1.26 — 2026-03-17
Fixed
lerd.testproxy vhost no longer usesresolver+set $upstream— nginx's resolver directive only works with DNS, buthost.containers.internalis resolved via/etc/hostsinside the container; using a staticproxy_pass http://host.containers.internal:7073lets nginx resolve it correctly at startup
0.1.25 — 2026-03-17
Changed
lerd updateno longer unconditionally rebuilds PHP-FPM images — it now computes a SHA-256 hash of the embedded Containerfile and only rebuilds if the hash differs from the one stored after the last successful build- Hash is stored to
~/.local/share/lerd/php-image-hashafterlerd php:rebuild,lerd use <version>, andlerd park(first build)
0.1.24 — 2026-03-17
Fixed
lerd.testproxy vhost now useshost.containers.internalinstead of the Podman network gateway IP — the gateway IP is typically blocked by the host firewall for connections from containers, whilehost.containers.internalis a Podman built-in that always routes to the host correctly
0.1.23 — 2026-03-17
Fixed
- Dashboard service start now writes the Quadlet file and reloads systemd before calling
systemctl start, fixing "Unit not found" error on first use - Service action errors are now returned as JSON with the error message and last 20 lines of
journalctllogs - Frontend shows a loading spinner while toggling, "Started successfully" / "Stopped" flash on success, and an inline error with expandable logs on failure
0.1.22 — 2026-03-17
Fixed
lerd.testdashboard now reachable: UI server changed to listen on0.0.0.0:7073so nginx (running inside the Podman container) can reach it via the network gateway IPlerd installnow reloads nginx after writing thelerd.testproxy vhost so it takes effect immediately without a manual restartlerd.testis now a reserved domain —lerd parksilently skips any directory that would resolve to it,lerd linkreturns an error if the resolved domain is reserved
0.1.21 — 2026-03-17
Added
- Lerd dashboard — browser UI available at
http://lerd.test, served bylerd serve-uias a persistent systemd user service (lerd-ui.service) - Dashboard shows three tabs: Sites (table with domain links, PHP/Node version, TLS badge, FPM status), Services (start/stop toggles, copy
.envbutton per service), System (DNS, nginx, PHP-FPM health, auto-refreshes every 10 seconds) - Update flow built into the UI: "Check for update" button in sidebar checks GitHub releases; if an update is available shows the version and an "Update" button that runs
lerd update lerd installnow writes and startslerd-ui.serviceand generates thelerd.testnginx reverse proxy vhost; printsDashboard: http://lerd.teston completionlerd start/lerd stopincludelerd-uialongside DNS, nginx, and PHP-FPM
0.1.20 — 2026-03-17
Changed
lerd stopnow also stops all installed services (those with a quadlet file) in addition to DNS, nginx, and PHP-FPMlerd startnow also starts all installed services
0.1.19 — 2026-03-17
Added
lerd php:rebuild— force-removes and rebuilds all installed PHP-FPM images; useful after a Containerfile changelerd updatenow automatically runslerd php:rebuildafterlerd installso PHP-FPM image changes (new extensions, config tweaks) are applied on every update
0.1.18 — 2026-03-17
Added
lerd logs— show PHP-FPM container logs for the current project (auto-detects version)lerd logs -f/--follow— tail logs in real timelerd logs nginx— show nginx container logslerd logs <service>— show logs for any service (e.g.lerd logs mailpit)lerd logs <version>— show logs for a specific PHP-FPM container (e.g.lerd logs 8.5)- PHP-FPM containers now route all PHP errors to stderr (
catch_workers_output,log_errors,error_log=/proc/self/fd/2) so they appear inpodman logs/lerd logs
0.1.17 — 2026-03-17
Added
mailpitservice — local SMTP server with web UI athttp://127.0.0.1:8025; catches all outgoing mail from Laravel appssoketiservice — self-hosted Pusher-compatible WebSocket server for Laravel Echo / broadcasting- PHP 8.5 support —
lerd use 8.5builds and starts the PHP 8.5 FPM container; default PHP version updated to 8.5
0.1.16 — 2026-03-17
Added
lerd php [args...]— runs PHP inside the correct versioned FPM container, detecting version from.php-version/composer.json/ global defaultlerd artisan [args...]— shortcut forlerd php artisan [args]lerd node [args...]— runs Node via fnm with auto-detected versionlerd npm [args...]— runs npm via fnm with auto-detected versionlerd npx [args...]— runs npx via fnm with auto-detected versionlerd installnow writesphp,composer,node,npm,npxshims to~/.local/share/lerd/bin/so commands work directly from the terminal
0.1.15 — 2026-03-17
Fixed
- Service
.envvariables now use container hostnames (lerd-mysql,lerd-redis, etc.) instead of127.0.0.1— PHP-FPM runs inside thelerdPodman network so127.0.0.1resolves to the container's own loopback, not the host
0.1.14 — 2026-03-17
Fixed
- nginx
resolverdirective added tonginx.confusing the Podman network gateway so upstream container hostnames are re-resolved dynamically after FPM restarts (previously nginx cached the old IP and returned 502) fastcgi_passin vhost templates now uses a$fpmvariable to force use of the resolverlerd installnow regenerates all registered site vhosts so template changes are applied immediately- PHP-FPM containers now use a locally built image (
lerd-php{version}-fpm:local) with all Laravel-required extensions pre-installed:pdo_mysql,pdo_pgsql,bcmath,mbstring,xml,zip,gd,intl,opcache,pcntl,exif,sockets,redis,imagick - PHP-FPM images are built automatically on first
lerd use <version>— subsequent runs reuse the cached image
0.1.13 — 2026-03-17
Changed
lerd service start/lerd service restart—.envoutput is printed without leading whitespace for direct copy-paste
0.1.12 — 2026-03-17
Fixed
lerd service start <service>— automatically writes the quadlet file and reloads systemd before starting, so services work on first use without needing a priorlerd install
Changed
lerd service startandlerd service restartnow print the recommended.envvariables to add to your Laravel project after the service starts
0.1.11 — 2026-03-17
Added
lerd start— start DNS, nginx, and all installed PHP-FPM containerslerd stop— stop DNS, nginx, and all installed PHP-FPM containers
0.1.10 — 2026-03-17
Fixed
- Nginx and PHP-FPM containers now mount the user's home directory so project files are accessible inside the containers
nginx.conf— addeduser root;and changed pid/error_log to writable paths (/tmp/nginx.pid, stderr) so nginx starts correctly in rootless Podman withoutUserNS=keep-id- PHP-FPM pool now runs workers as root (
-Rflag +zz-lerd.confoverride) so it can read project files in the home directory ensureFPMQuadlet— always overwrites the quadlet file (previously skipped if it existed, leaving stale configs in place)lerd install— now regenerates all existing PHP-FPM quadlets so config changes are applied without manual deletionEnsureNginxConfig— always overwritesnginx.conf(previously skipped if file existed)
0.1.9 — 2026-03-17
Fixed
lerd-dns.containerquadlet template was embedded from the wrong source directory (internal/podman/quadlets/) — the file still referencedandyshinn/dnsmasqwithNetwork=host, causing the DNS container to fail with "Permission denied on port 53"; updated to the Alpine-based dnsmasq on port 5300 via published portdns.Setup()andensureUnprivilegedPorts()—sudosubprocesses now haveStdin/Stdout/Stderrconnected to the process terminal so password prompts display correctly instead of failing with "a terminal is required"
Added
lerd unpark [directory]— removes a parked directory and unlinks all sites registered from it
Changed
lerd parkandlerd link— directory names with real TLDs (.com,.net,.org,.io,.ltd, etc.) now have the TLD stripped and remaining dots replaced with dashes before appending.test(e.g.admin.astrolov.com→admin-astrolov.test)lerd use <version>/lerd status— PHP version detection now tracks FPM quadlet files instead of static CLI binaries, solerd use 8.4is immediately reflected inlerd status
0.1.8 — 2026-03-17
Fixed
lerd updatenow automatically runslerd installafter swapping the binary, so quadlet files, DNS config, sysctl settings and any other infrastructure changes are applied without the user having to run a second command
0.1.7 — 2026-03-17
Fixed
lerd-dns.container— removedNetwork=hostandAddCapability=NET_ADMINwhich both fail under rootless Podman; container now runs dnsmasq on port 5300 via a published port (127.0.0.1:5300:5300)lerd install— now checksnet.ipv4.ip_unprivileged_port_startand automatically sets it to 80 (with sudo) so rootless Podman can bind nginx to ports 80 and 443; also writes/etc/sysctl.d/99-lerd-ports.confto persist across reboots
Changed
lerd status— every FAIL entry now shows an actionable hint (e.g.systemctl --user start lerd-nginx,lerd service start mysql,lerd use 8.4)
0.1.6 — 2026-03-17
Fixed
lerd installwas callingdns.WriteDnsmasqConfig(writes only the container's local config) instead ofdns.Setup(), which means/etc/NetworkManager/conf.d/lerd.confand/etc/NetworkManager/dnsmasq.d/lerd.confwere never written and NetworkManager was never restarted — causing*.testDNS resolution to silently faildns.Setup()now prints a clear message before invokingsudoso users know why a password prompt appears
0.1.5 — 2026-03-17
Fixed
install.sh— definitively fixed theinstall: cannot stat '...\033[0m...'error by refactoringdownload_binaryto accept a caller-supplied directory instead of returning a path via stdout; all output now goes directly to the terminal (stderr) and is never captured by command substitution
0.1.4 — 2026-03-17
Fixed
install.sh—install: cannot stat '...\033[0m...'error:download_binarywas called inside$()command substitution so itsinfooutput was captured into thebinaryvariable along with the path; all UI output indownload_binarynow goes to stderr, leaving only the path on stdoutinstall.sh— tar extraction errors insidedownload_binarynow also go to stderr and produce a clean error message instead of polluting the captured path
0.1.3 — 2026-03-17
Fixed
install.sh—BASH_SOURCE[0]: unbound variablestill occurred on bash versions where${array[0]:-default}triggersset -uwhen the array itself is unset (not just empty); fixed by suspendingnounsetbriefly withset +ubefore readingBASH_SOURCE
0.1.2 — 2026-03-17
Fixed
install.sh—BASH_SOURCE[0]: unbound variablecrash when the script is piped to bash (curl|bash/wget|bash);BASH_SOURCEis unset in that execution context so it now defaults to$0
0.1.1 — 2026-03-17
Fixed
install.sh— replaced[[ ... ]] && main "$@"guard withif/fiso the script sources cleanly underset -euo pipefail(the&&idiom exits with code 1 when the condition is false, whichset -etreated as fatal)install.sh—latest_versionno longer exits non-zero when the GitHub API returns notag_name(e.g. curl failure or no releases yet)
0.1.0 — 2026-03-17
Initial release.
Added
Core
- Single static Go binary built with Cobra
- XDG-compliant config (
~/.config/lerd/) and data (~/.local/share/lerd/) directories - Global config at
~/.config/lerd/config.yamlwith sensible defaults - Per-project
.lerd.yamloverride support - Linux distro detection (Arch, Debian/Ubuntu, Fedora, openSUSE)
- Build metadata injected at compile time: version, commit SHA, build date
Site management
lerd park [dir]— auto-discover and register all Laravel projects in a directorylerd link [name]— register the current directory as a named sitelerd unlink— remove a site and clean up its vhostlerd sites— tabular view of all registered sites
PHP
lerd install— one-time setup: directories, Podman network, binary downloads, DNS, nginxlerd use <version>— set the global PHP versionlerd isolate <version>— pin PHP version per-project via.php-versionlerd php:list— list installed static PHP binaries- PHP version resolution order:
.php-version→.lerd.yaml→composer.json→ global default
Node
lerd isolate:node <version>— pin Node version per-project via.node-version- Node version resolution order:
.nvmrc→.node-version→package.json engines.node→ global default - fnm bundled for Node version management
TLS
lerd secure [name]— issue a locally-trusted mkcert certificate for a site- Automatic HTTPS vhost generation
- mkcert CA installed into system trust store on
lerd install
Services
lerd service start|stop|restart|status|list— manage optional services- Bundled services: MySQL 8.0, Redis 7, PostgreSQL 16, Meilisearch v1.7, MinIO
Infrastructure
- All containers run rootless on a dedicated
lerdPodman network - Nginx and PHP-FPM as Podman Quadlet containers (auto-managed by systemd)
- dnsmasq container for
.testTLD resolution via NetworkManager - fsnotify-based watcher daemon (
lerd-watcher.service) for auto-discovery of new projects
Diagnostics
lerd status— health overview: DNS, nginx, PHP-FPM containers, services, cert expirylerd dns:check— verify.testresolution
Lifecycle
lerd update— self-update from latest GitHub release (atomic binary swap)lerd uninstall— stop all containers, remove units, binary, PATH entry, optionally data- Shell completion via
lerd completion bash|zsh|fish
Installer (install.sh)
- curl and wget support
- Prerequisite checking with per-distro install prompts (pacman / apt / dnf / zypper)
- Automatic
lerd installinvocation post-download --update,--uninstall,--checkflags- Installs as
lerd-installerfor later use