Changelog
All notable changes to Lerd will be documented here.
The format follows Keep a Changelog. Lerd uses Semantic Versioning.
[Unreleased]
[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. <tld>placeholder rendered as broken HTML in the docs (#296). Vitepress was parsing the bare<tld>literal as an HTML tag and dropping it from the rendered page. Now backtick-wrapped everywhere it appears.
[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