Skip to content

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=5 in 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, so lerd-mysql.service, lerd-redis.service etc. simply didn't exist. Lerd now probes podman --version once and falls back to PodmanArgs=--stop-timeout=5 on Podman <5.0, which is universally supported and produces the same --stop-timeout=5 on the underlying podman run.
  • lerd install failed creating the lerd podman network on Ubuntu 24.04 (#299, #304). After creating the bridge, lerd ran podman network update --dns-add <ip> lerd for each upstream DNS server (e.g. libvirt's 192.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 in netavark: unable to modify network dns servers: IO error: No such file or directory. Lerd now passes --dns <ip> flags at podman network create time, 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-ipv6 is 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 .container file but a container from an earlier valid load was still in the cgroup, systemctl --user list-units printed not-found active running and the dashboard read the active column verbatim. The unit-state cache now collapses any LOAD other than loaded (not-found, masked, bad-setting, error) to inactive so 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 every mysql / mysqldump invocation Laravel and packages like spatie/db-dumper shell out to. Second, MySQL 8.4 disables mysql_native_password by default and the MariaDB client doesn't speak caching_sha2_password, so the CLI couldn't authenticate even with TLS off. Lerd now ships /etc/my.cnf.d/lerd-no-ssl.cnf inside 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 adds loose-mysql_native_password=ON so the auth plugin is loaded on startup. The loose- prefix keeps this config compatible across MySQL 5.6 / 5.7 / 8.0 / 8.4. Also disabled restrict_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 added ffmpeg to the PHP-FPM image for media-library packages that depend on it.
  • lerd init on a fresh Laravel project ignored the .lerd.yaml DB pick (#305). Picking MySQL or Postgres at init time saved the choice to .lerd.yaml but never updated .env, so Laravel kept DB_CONNECTION=sqlite and the user's selection silently dropped on the floor. Laravel defines Env.Services, so runEnv took 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 into shouldApplyService / userPickedDBFromYAML helpers 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 doctor gains a [Stopped service ports] section that walks installed-but-inactive services and warns per bound port; /api/services snapshots include port_conflicts so the dashboard renders an amber pill on the service detail and a small alert icon in the sidebar list; and the port-finding hints in lerd start pre-flight, doctor, dns:diagnose, and the docs now branch on GOOS so macOS users get an lsof command instead of the Linux-only ss -tlnp string. The cli port helpers (PortInUse, PortInUseIn, PortListOutput, PortCheck, CollectPortChecks) are now exported so internal/ui can reuse them without duplicating the ss-vs-lsof logic.
  • Lerd silently shimmed Node on hosts using nvm / volta / mise / asdf / fnm (#297). detectSystemNode only probed node in 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 probes npm / npx and the well-known version-manager dirs, so those users get prompted instead. After confirming managed Node, install also runs fnm install + fnm default so the user is left with a working npm (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:uninstall warn 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 at lerd install, and the UI endpoints have matching server-side guards.
  • Opting out of lerd-managed Node left fnm shims masking system node (#306). lerd install adds 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|npx kept 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= vs PodmanArgs=--stop-timeout=5 selector ran exec.Command("podman", "--version"), which fails under launchd's restricted PATH where podman is at /opt/homebrew/bin/podman. The probe now goes through PodmanBin() so launchd-spawned lerd-ui picks 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 .container templates into internal/config/presets/*.yaml files marked default: 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 into config.DefaultPresetNames(); three duplicated env-var maps collapsed into config.DefaultPresetEnvVars(name).
  • MySQL canonical bumped to 8.4 LTS (was 8.0). Existing users on saved 8.0 are untouched (viper merge wins; migrateStaleServiceImages skipped for track_latest presets); fresh installs land on 8.4.x. The 5.6 alternate is gone; 5.7 and 8.0 remain pickable. mysql.cnf loose- prefixes added so the same config file works across mysql 5.6 / 5.7 / 8.0 / 8.4 (8.4 hard-rejects innodb_large_prefix / innodb_file_format without the prefix).
  • Update, upgrade, migrate, and rollback flow. New serviceops.UpdateServiceStreaming, serviceops.MigrateService, serviceops.RollbackService, plus an internal/registry package that queries Docker Hub and GHCR for newer tags. Per-preset update_strategy (patch / minor / rolling / none), track_latest (fresh installs resolve current upstream), and allow_major_upgrade (gates cross-major NewestStable).
    • CLI: lerd service update <name> [tag], lerd service migrate <name> <tag>, lerd service rollback <name>. lerd service list gains 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_updates for read-only status; service_control action enum widened to update, migrate, rollback, restart, remove, reinstall. service_remove and service_update removed as standalone tools.
  • Remove and reinstall any service, including default presets (#294). lerd service remove <name> [--purge] and a new lerd service reinstall <name> [--reset-data] work on default presets too. --purge and --reset-data rename 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 mb for rustfs (using .env AWS_BUCKET or 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-preset allow_major_upgrade. Cross-strategy upgrade button suppressed for update_strategy: patch presets without a registered SQL migrator (Meilisearch, where clicking would brick the data dir). Alternates installed via preset (e.g. mysql-8-0) internally promote to patch strategy so they don't get auto-suggested cross-LTS jumps.
  • internal/registry package. ListTags, MaybeNewerTag, NewestStable against Docker Hub and GHCR with a 6-hour disk cache. Typed *UnreachableErr and *UnsupportedRegistryErr are 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 under internal/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 before go build via make 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/stats endpoint that parses podman 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 -l linting (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's tinker: 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 plain php with composer autoload included. Bare expressions auto-dump per statement.
  • lerd bug-report command 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-names to 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 as failed (or one named unit). Same recovery is reachable from an amber sticky banner in the dashboard, the H keybind in the TUI, and the workers_heal / workers_health MCP tools. lerd status surfaces a one-line hint when any worker is in failed state. Heal is intentionally narrow: it never writes .lerd.yaml or 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-guarded podman exec into the shared FPM container by default (exec mode), 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 with lerd workers mode container. Setting is also surfaced in the TUI settings overlay (S) and via GET/POST /api/settings/worker-mode in lerd-ui. The dedup guard prevents duplicate workers when the podman-machine SSH bridge hiccups: if a previous podman exec is 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 install asks Let lerd manage DNS for local sites (No: use *.localhost, no dnsmasq, no HTTPS)? after the Node prompt and persists the answer under dns.enabled. When disabled, the installer skips lerd-dns, dnsmasq config, the sudoers rule, the mkcert root CA install, and the resolver tweak. lerd start no longer manages lerd-dns, lerd doctor reports 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-running lerd install with 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 doctor and an dns_diagnose MCP 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 in resolvectl 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_failure index) 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>/HEAD for writes, so a git branch -m or git checkout -b inside a worktree re-syncs the nginx vhost and .env APP_URL to 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_URL realigns to the worktree domain on every scan (#263). Previously EnsureWorktreeDeps only rewrote APP_URL when it first created the .env; renames and external workflows that relied on a manual git branch -m left it pointing at the stale subdomain. The worktree scan now updates APP_URL on existing .env files too. Thanks to @ropi-bc for the contribution.
  • lerd worktree add and lerd worktree remove interactive wrappers mirroring git worktree's subcommand layout, with every flag passing straight through to git. add polls 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 in package.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 run php artisan migrate --force. remove runs git 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, rewrites DB_DATABASE in the worktree's .env, and persists the choice as db_isolated: true in 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 | mysql or pg_dump | psql end-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 / LANShareStopWorktree allocate 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 on git worktree remove POSTs lan: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 tails storage/logs from 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.yaml inside the worktree's checkout, so the choice travels with the branch in git.
  • Worktree inheritance for lerd php and lerd node: php.DetectVersion and node.DetectVersion consult a path-only config.ParentSiteForWorktreeDir lookup after .php-version / .node-version and before composer.json / package.json constraints, so a worktree without an explicit override inherits the parent's pinned version instead of the highest installed satisfier. Composer's ^8.2 no longer silently picks PHP 8.5 on a worktree of a parent pinned to 8.4.
  • Watcher race fix for fresh worktrees: handleNewEntry polls HEAD until it's a final ref or SHA before firing onAdded, closing a window where fsnotify Created the entry dir before git finalised HEAD. The daemon's startup scanWorktrees pass also reconciles stale *.<site>.test.conf files 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 ps cache 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 existing SessionIsIdleOrLocked stub.
  • lerd service restart refreshes the quadlet before restarting, same path as start, so config edits and preset file mounts (mysql lerd.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.
  • applyServices in the Web UI refreshes every server-supplied field on each WS push (was only status / 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}.container deleted; quadlets are now generated from preset YAML on demand. internal/cli/service_image_{darwin,linux}.go deleted; platform image overrides moved into Preset.PlatformOverrides with optional template substitution so track_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.
  • persistImageChoice and swapImagePin are atomic with quadlet regeneration. If the on-disk quadlet write fails after the config write, the config is rolled back so ~/.config/lerd/config.yaml and the generated .container file can't disagree. Joined-error output if the rollback itself fails.
  • Migrate failures restore the pre-migrate data dir. A new abortMigrate helper bubbles errors from restoreDataDirFromBackup and restarts the old unit when appropriate.
  • Rollback after a Migrate is refused. New LastOp and PreMigrateBackup fields on ServiceConfig / CustomService. RollbackService errors 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 new can_rollback field on /api/services.
  • SQL dump credentials no longer leak through argv. mysqldump and pg_dumpall receive MYSQL_PWD / PGPASSWORD via podman exec --env, not on the command line. Dump files now open with mode 0600; the backups directory is 0700.
  • 30-minute timeout on every in-container migrate exec. A wedged container can no longer block migrate forever; containerExec / dumpToHost / restoreFromHost use exec.CommandContext with a hard cap.
  • allow_major_upgrade is enforced even when the CLI passes a tag explicitly. A new enforceMajorUpgradeGate check refuses lerd service update mysql 9.0 for presets where allow_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 startNDJSONStream helper short-circuits writes once r.Context() is cancelled or w.Write returns an error, replacing four copies of the closure in update / migrate / rollback / preset-install handlers.
  • Client surfaces stream errors instead of freezing the spinner. streamServiceAction calls setProgress with phase error before 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 / === true and can_rollback !== false so a missing field doesn't accidentally show the wrong button. JSON tags lose omitempty on those bool fields so the false case is always wire-visible.
  • lerd service restart warns 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 update no longer silently bumps preset minors past the user's installed version. EnsureDefaultPresetQuadlet reads the existing on-disk image when update_strategy is patch / minor / none and the user has no explicit pin, so lerd update (which calls install --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 existing track_latest block.

Registry and update detection

  • Docker Hub pagination followed. dockerHubResponse.Next was 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 *AuthRequiredErr and *NotFoundErr. 401/403 means auth needed, 404 means repo doesn't exist, 429/5xx means unreachable. All four collapse to "no update info" via isQuietRegistryErr but 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 cacheMu plus an inline singleflight collapses concurrent ListTags calls; cache writes go to a temp file and os.Rename atomically. Cache write failures emit a rate-limited log.Printf instead 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 alreadyOnDigest helper so registries returning differently-cased digests don't cause a permanent "update available" badge.

Watcher and worktree handling

  • cleanupWorktreeVhosts no longer triggers composer install and the JS install on every surviving worktree. When one worktree was removed, the cleanup pass that re-generated vhosts for the survivors also called EnsureWorktreeDeps on each one, which kicked off a full composer install plus the JS package-manager install. The rename and add paths handle deps via syncWorktree; doing it from cleanup burned cycles for no benefit.
  • .env APP_URL rewrite is skipped when the value is already correct. rewriteAppURL compares 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, opcache file_update_protection) used to react to every scan even when the URL hadn't changed.
  • lerd-watcher no longer re-runs composer install and the JS package manager on every restart. scanWorktrees invokes EnsureWorktreeDeps for each worktree on startup, which previously called InstallDependencies unconditionally; composer's "Nothing to install" path still fires the post-autoload-dump scripts (Laravel package:discover, Filament asset publish, etc.), accumulating several seconds of churn per worktree per restart. InstallDependencies now consults each manager's install marker (vendor/composer/installed.json for composer; .modules.yaml / .package-lock.json / .yarn/install-state.gz / .bun-tag depending 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 with XDG_CONFIG_HOME redirected to a temp dir, looked at the user's real running FPM containers, decided their quadlets were "missing", and wrote fresh quadlets plus ran systemd --user daemon-reload against the real session. Each reload triggered a quadlet-generator pass which restarted the FPM unit; with workers BindsTo=lerd-php8X-fpm, that cascade rapidly tripped StartLimitBurst and parked every worker in failed. 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_health MCP tool see real failed-unit state on macOS now. siteinfo.AllUnitStates was returning an empty map on darwin, so workerheal.Detect never fired. The UnitLifecycle interface gained AllUnitStates(), the darwinServiceManager walks ~/Library/LaunchAgents/lerd-*.plist to populate it, and siteinfo/unitcache_darwin.go plugs the launchd walker in via a new allUnitStatesFn hook. The "last error" excerpt for failed workers also works on macOS now via a platform-split readLastError that tails ~/Library/Logs/lerd/<unit>.log instead of relying on journalctl.
  • lerd lan:expose no longer fails on macOS when DNS is enabled. The DNS-forwarder install path called systemctl directly to write the unit, enable it, and daemon-reload, every one of those returned ENOENT on darwin. Routed through services.Mgr.WriteServiceUnit / Enable / DaemonReload / RemoveServiceUnit so the same systemd-format unit content renders to a launchd plist on macOS via parseServiceUnit, 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 bare exec.LookPath("pnpm") returned ENOENT and the install step failed for any non-npm project. New lookJSBin helper falls back to /opt/homebrew/bin and /usr/local/bin on darwin, mirroring podman.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 uses podman.PodmanBin() like the rest of the project.
  • Tinker tab works on Laravel projects with non-trivial vendor trees. PHP CLI's 128 M memory_limit default was exhausted by ClassAliasAutoloader during boot (it requires the full composer class map upfront). Tinker's podman exec now passes -d memory_limit=512M. Also sets PSYSH_TRUST_PROJECT=1 to silence PsySH's non-interactive "Restricted Mode" warning that otherwise broke simple expressions like echo env('APP_URL');.
  • PostGIS preset on Apple Silicon uses the upstream image instead of the imresamu fork. The official postgis/postgis manifest publishes only amd64 entries; on darwin a new podman.PlatformPodmanArgs hook injects PodmanArgs=--platform=linux/amd64 into 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 at WriteQuadletDiff so 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 run ALTER EXTENSION postgis UPDATE once after the swap. Pinning a multi-arch fork explicitly in ~/.config/lerd/config.yaml bypasses the platform pin entirely.

Other

  • lerd-tray.service caps 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 through RestartSec=2 Restart=on-failure indefinitely; 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/web upgraded vite 5 to 7.1, @sveltejs/vite-plugin-svelte 4 to 6.2, and vitest 2 to 3.2, which resolves vite to 7.3.2 (path traversal in .map handling) and esbuild to 0.27.7 (dev-server SOP). docs/package.json keeps vitepress at 1.6.4 and uses npm overrides to 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 under npm audit.

Removed

  • service_remove and service_update MCP tools removed as standalone entries; folded into service_control action enum (which now also accepts migrate, 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. The cp /tmp/lerd-sudo-* /etc/... and resolvectl <verb> * rules lerd wrote to /etc/sudoers.d/lerd never matched on those parsers, so every DNS reconfigure fell through to the password-prompt path and emitted parse errors visibly during lerd install ("wildcards are not allowed in command arguments"). Fixed by piping content through sudo tee <fully-qualified-path> instead of staging in /tmp and 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 next lerd 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/frankenphp container as an alternative to the shared PHP-FPM image. Laravel and Symfony adapters; lerd runtime frankenphp CLI and site_runtime MCP 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::/64 for v6). Nginx vhosts listen on [::], dnsmasq answers AAAA for .test, and every managed PublishPort gets paired with a [::1] bind. Existing v4-only networks auto-migrate on the next lerd 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 + the disable_ipv6 sysctl 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-ipv6 flag and LERD_DISABLE_IPV6=1 on lerd install (#251). Force a v4-only lerd network on dual-stack-capable hosts without touching the host networking stack. Reuses the existing ~/.local/share/lerd/ipv6-probe-failed-lerd marker, so EnsureNetwork honors the opt-out on every path. Re-enable by deleting the marker and rerunning lerd install.
  • Three new service presets: memcached, rabbitmq, elasticsearch (#252). Standard preset convention (lerd service preset <name>). The rabbitmq preset exposes the management UI at http://localhost:15672; elasticsearch binds 127.0.0.1:9200 so the bundled elasticvue preset becomes a one-click install on top.
  • Streaming preset install in the Web UI (#257). POST /api/services/presets/{name} returns application/x-ndjson with events for installing_config, starting_deps, pulling_image, starting_unit, waiting_ready, and done. The image pull is now explicit and happens before StartUnit, so the formerly invisible on-demand pull surfaces as live Copying 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 MCP service_preset_install tool 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-ui is unreachable, including the whole-stack lerd quit case. The page shows lerd start with a copy button and the lerd logo, probes /api/status every 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's Image= tag via podman.ServiceVersionLabel, which strips distro/variant suffixes (-alpine, -slim, -3.5), keeps leading v, 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}/restart wraps podman.RestartUnit and clears the paused flag on success. Workers (queue, schedule, horizon, reverb, stripe, site-scoped custom workers) are intentionally excluded.
  • setup MCP tool (#240). Runs the framework's Default: true bootstrap commands (Laravel: storage:link + migrate; Symfony: doctrine:migrations:migrate when doctrine-migrations-bundle is installed). Agents call it after env_setup on new or cloned projects; idempotent, no prompts. The interactive lerd setup CLI is unchanged.
  • Uninstall teardown prompts (#235). lerd uninstall now prompts independently for:
    • Remove MCP integration (global skills + per-site .claude/.cursor/.junie/.mcp.json entries, 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). --force answers yes to all.
  • lerd install refreshes 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 on lerd update. If Claude Code's user-scope MCP config has lost the lerd entry, install also re-adds it via claude 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 a sites eventbus event so the dashboard reflects the removal without a manual refresh.

Changed

  • BREAKING — slimmer MCP tool manifest (#232). The tools/list response merged action pairs (queue_start + queue_stopqueue(action: ...), service_start + service_stopservice_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 in docs/features/mcp.md.
  • project_new runs composer install after scaffolding (#240). The create-project --no-install scaffold is chased by composer install inside the FPM container, so the returned project has a populated vendor/ ready for env_setup + setup.
  • env_setup auto-creates database/database.sqlite non-interactively (#240). Laravel's default DB_CONNECTION=sqlite triggered an interactive prompt in lerd env that 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. Call db_set to switch to mysql/postgres afterwards.
  • Per-session MCP token cost reduced (#236, #237). tools/list trimmed ~14% (20 KB → 17 KB), injected SKILL.md trimmed from 44 KB → 40 KB by collapsing redundant single-tool workflow recipes. Descriptions are preserved where weaker local LLMs rely on them (site fields, path defaulting, enum-valued descriptions).
  • SKILL.md bootstrap workflows rewritten (#240). Replaced the per-framework artisan migrate / console doctrine:migrations:migrate fork 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 calls setup() for pending migrations.
  • Install flow starts per-site containers and stripe workers in the correct phase (#234). lerd install now starts per-site custom containers and FrankenPHP runtimes after service containers, and stripe listeners fire in the worker phase instead of during restoreSiteInfrastructure (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. EnsureNetwork now detects the drift (via AardvarkNetworkDrifted) and triggers a recreate; both the dual-stack migration path and lerd uninstall's network teardown wipe $XDG_RUNTIME_DIR/containers/networks/aardvark-dns/<name> between rm and create so the condition can't re-occur.
  • Custom container sites not started after lerd install (#234). install.go now calls startPerSiteContainers after startRestoredServices, so lerd-custom-<site> units come up alongside FPM and global services. Previously they sat enabled-but-stopped until the user ran lerd start.
  • Stripe listeners started before FPM and nginx were up (#234). restoreSiteInfrastructure called StripeStartForSite synchronously, unlike other workers which write their unit file and defer Start to the worker phase. New writeStripeUnit / StripeRestoreUnit split so the start fires in startRestoredServices's worker phase, matching queue/schedule/reverb ordering.
  • lerd share collapsed 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|frankenphp and its --worker / --no-worker flags.
  • 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 → setup sequence; 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 --force semantics.
  • Lifecycle reference documents the stale-site auto-cleanup (fsnotify fast path + 30s sweep + eventbus refresh).
  • docs/features/mcp.md tool table adds setup and updates example interactions to the four-step bootstrap sequence.
  • Getting-started guides (laravel / symfony / wordpress) mention setup in 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-php workflow now actually triggers a base-image rebuild (#256). The dispatch step ran with the default restricted GITHUB_TOKEN so createWorkflowDispatch returned 403 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: write is 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. ensureServiceQuadlet on macOS called WriteContainerUnitFn which only writes the launchd plist, not a .container file in QuadletDir. startRestoredServices later reads that directory via quadletImage() to decide what to pre-pull; with no file it returns empty and skips the pull, leaving podman run to auto-pull mysql/postgres/etc. on a brand-new Podman Machine where those pulls often fail or time out. Switched to WriteQuadletDiff, which writes both the .container file and the launchd plist via AfterQuadletWriteFn, so pre-pull works on first install.
  • PHP FPM images silently missed on first install. ensureFPMQuadletTo wrote the launchd plist only after a successful image build; a failed build left the PHP version unregistered, invisible to ensureImages(), and never retried. The plist is now written before the build so the version shows up in lerd status (as image missing) and lerd start rebuilds it on the next run. lerd install's autostart block also re-runs ensureImages() before starting FPM containers so transient build failures heal automatically.
  • Image pulls routed through lerd-dns on macOS install. ensureImages() ran after ConfigureResolver(), so every registry pull (nginx, DNS, services, FPM) went through the .test override. Moved the call before the DNS block so pulls use the clean system resolver.
  • .container file missing for service and custom-container units on macOS. All call sites (FPM, custom services, custom containers, UI server, MCP server) used WriteContainerUnitFn, 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 calls WriteQuadletDiff, which writes both artifacts.
  • Certificates reissued on every lerd setup. IssueCert re-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/lerd as the target binary, so they failed for Homebrew installs at /opt/homebrew/bin/lerd. Shims now resolve the running binary with os.Executable() and use whichever path ran lerd install.
  • PHP commands failing on fresh installs because .env services weren't running. ensureServicesForCwd only acted on paused sites, so mysql/postgres/etc. referenced in a site's .env stayed 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. InstallPresetByName only 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 share URL unreachable while a VPN is active. detectPrimaryLANIP returned 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, skipping utun, 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 need fnm-arm64.zip. Arch detection now picks the correct archive, matching the existing logic used for mkcert.
  • Go test runs hang for the full timeout. installCompletion called os.Executable() during tests; the resulting path pointed at the test binary, which then re-invoked itself with completion bash and 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}.conf now survive every vhost regeneration and every lerd update. Each generated server block ends with an include that pulls that file in, and lerd never writes into custom.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, and HTTP_X_FORWARDED_FOR via two http-level map blocks ($real_forwarded_host, $real_forwarded_proto) declared once in a new conf.d/_forwarded.conf. Direct browser access is unchanged because the maps fall back to $host and $scheme; tunnels like lerd share, ngrok, and cloudflared now produce correct absolute URLs out of the box, without any app-side trust_proxies config. Fixes #224.
  • Global AI skill docs refreshed on lerd update (#222). mcp:enable-global now also writes user-scope SKILL.md, cursor rules, and junie guidelines so AI assistants know about the current lerd MCP tools. lerd update rewrites 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%), v toggles between sites and services, tab cycles 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). SiteNameAndDomain used a curated TLD list that missed most ccTLDs, so a directory named astrolov.ro produced astrolov-ro.test instead of astrolov.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 so app.v2 and backup.old survive unchanged.
  • Invalid AWS_BUCKET names on rustfs sites (#220). The framework template wrote the underscored database handle, which rustfs rejects. envMap["AWS_BUCKET"] now flows through s3BucketName on every run so stale invalid values auto-heal, and a new template placeholder resolves to the S3-safe form. Existing sites with a broken bucket name are repaired on the next lerd env.
  • Auto-stop skipped Podman services on macOS after all sites were paused (#216). Services using Podman's --restart=always policy sit with a launchd plist in the "not running, never exited" state; UnitStatus fell through to ContainerRunning, but a transient podman inspect failure (common under VM socket contention) returned "failed" and auto-stop silently skipped the service. ContainerRunning is now the authoritative check, with UnitStatus kept 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 for lerd man and the same siteinfo / podman.Cache / eventbus plumbing that drives lerd-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 an update: vX.Y.Z banner when a newer release is cached.
    • Site detail: primary domain header, internal name, disk path, full domains list (add with a, rename with e, remove with x), services-used with live state, workers (toggle with space), git worktrees, HTTPS toggle, LAN share toggle (shows http://<lan-ip>:<port> when on), PHP and Node version pickers (open with space, commit with enter, backed by lerd isolate / lerd isolate:node).
    • Services pane includes site-owned workers (queue-<site>, schedule-<site>, horizon-<site>, reverb-<site>, custom framework workers), routed through lerd queue start/stop, lerd worker start/stop <name>, etc. t opens 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), o to cycle sort (sites: name · status · framework; services: name · status · usage). v hides 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's fw.Logs globs (Laravel: storage/logs/*.log). Logs pane takes at least half the window and has a right-edge scrollbar.
    • In-pane overlays: S swaps 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. esc returns 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.
  • Selectable Xdebug mode per PHP version (#205). lerd xdebug on now accepts --mode (debug, coverage, profile, trace, develop, gcstats, or comma combos like debug,coverage); previously mode was hardcoded to debug. The dashboard gains a mode dropdown next to the Xdebug toggle and a clickable Xdebug chip on each site row. The MCP xdebug_on tool accepts a mode argument. Toggle orchestration (validate → persist → write ini → restart FPM) is extracted into a new xdebugops package; the three surfaces (CLI, UI, MCP) are now thin wrappers. Legacy configs with no saved mode resolve to debug so 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 quit stops the Podman Machine VM on macOS. After all containers, workers, the Web UI, watcher, and tray are shut down, lerd quit calls podman machine stop on any running machine. lerd start already 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.ResponseWriter never sent the HTTP 200 + text/event-stream headers and the browser's EventSource stayed in CONNECTING. Fixed by flushing a : connected SSE comment immediately after writing headers. Second, switching tabs opened a new EventSource without 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/ and node_modules/ in a new worktree were symlinks back to the main repo. PHP resolves __DIR__ through symlinks, so vendor/autoload.php reported the main repo path and Composer's ClassLoader loaded every class from main's source tree, silently ignoring any diverged app/ or src/ files in the worktree. The symlinks are now replaced with real copies seeded from the main repo using reflink-aware helpers (cp -a --reflink=auto on Linux btrfs/xfs, cp -Rc on macOS APFS, plain Go walk elsewhere), followed by composer install and npm ci to reconcile against the worktree's own lockfiles.


[1.15.1] — 2026-04-16

Fixed

  • lerd.localhost 504 on rootless Linux. The dashboard vhost reverse-proxied to host.containers.internal:7073, which on rootless podman setups where netavark resolves that name to 169.254.1.2 but 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-ui now also binds a unix domain socket at ~/.local/share/lerd/run/lerd-ui.sock, the lerd-nginx quadlet bind-mounts that path read-write, and the Linux vhost proxy_passes through http://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 via host.containers.internal:7073 because 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.2 with a dynamic getent hosts host.containers.internal probe 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 with Time-out connecting to debugging client exactly as before. DetectHostGatewayIP now runs a real reachability probe: from inside lerd-nginx, TCP-connect to lerd-ui:7073 for each candidate (getent's answer, the host's primary LAN IP, slirp4netns's 10.0.2.2) and use the first that opens. If nothing works, fall back to the legacy constant and surface the failure in lerd doctor under 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 start pins a LAN IP into /etc/hosts, which goes stale the moment you move from home wifi to a coffee shop or rotate DHCP. New lerd-watcher goroutine reprobes the host gateway on LAN change and rewrites the shared /etc/hosts in place, so PHP-FPM containers pick up the new address on the next getaddrinfo call without a container restart. Steady-state cost is near zero: a single net.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 each podman exec through 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 (EnsureUserIni returned early on the os.Stat success without checking IsDir) or fails with is a directory (WriteXdebugIni, the inline hosts-file pre-create). Fix: EnsureXdebugIni and WriteXdebugIni detect a stale directory and remove it before writing; EnsureUserIni got the same self-heal; the inline hosts pre-create was extracted into ensureFPMHostsFile which normalises stale-directory, missing, and regular-file states into "regular file present"; WriteContainerHosts and writeBrowserHosts now MkdirAll their parent instead of assuming the data dir already exists. Scanned every Volume= 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 like data/* and conf.d are safe because podman creating them is the right behaviour).
  • Dashboard shows containers as still running after lerd stop. The in-process AfterUnitChange hook refreshed podman.Cache before broadcasting, but the /api/internal/notify endpoint that CLI and MCP processes use to signal unit lifecycle changes only invalidated the siteinfo cache and published events without refreshing the container cache. Site/FPM running flags read from podman.Cache, so after lerd stop the browser kept reporting everything as up until the 15-60 s background poller next ticked. The notify handler now also calls podman.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.lerd and a container: section in .lerd.yaml. Lerd builds a dedicated image, runs it as a named container, and nginx reverse-proxies to it. Full lifecycle: lerd link builds and starts, lerd unlink stops and cleans up (prompts to remove the image), lerd secure/lerd unsecure toggle HTTPS, lerd pause/lerd unpause stop and start the container, lerd restart restarts without rebuilding, lerd rebuild forces a fresh image build. Workers defined in custom_workers exec into the container. Services are reachable by name (lerd-mysql, lerd-redis, etc.) on the shared Podman network.
  • lerd restart command. 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 as site_restart MCP tool and in the dashboard (restart icon in the site header).
  • lerd rebuild command. Rebuilds the custom container image from the Containerfile and restarts the container. Also available as site_rebuild MCP tool and POST /api/sites/{domain}/rebuild in the dashboard.
  • lerd init custom container wizard. When no PHP project is detected (no composer.json, no framework) and a Containerfile.lerd exists, the wizard switches to custom container mode and asks for the container port, containerfile path, HTTPS, and services.
  • Containerfile MD5 caching. lerd link skips the image build when the Containerfile hasn't changed since the last build. The hash is stored in ~/.local/share/lerd/container-hashes/. lerd rebuild always 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 for custom_workers, running/stopped status reflecting the custom container.
  • site_restart and site_rebuild MCP tools. Skill content updated with custom container architecture, .lerd.yaml reference including container and custom_workers fields, setup workflow, and env var configuration guidance.

Fixed

  • Watcher overwriting custom container sites. The site file watcher and siteinfo.enrichVersions no 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. RegisterProject now skips sites already registered as custom containers.
  • Service auto-stop ignoring .lerd.yaml. CountSitesUsingService and sitesUsingService now check .lerd.yaml services list in addition to .env scanning, preventing auto-stop of services used only by custom container sites.
  • Domain change producing 502. RegenerateSiteVhost now uses custom container vhost templates for custom sites instead of PHP templates.
  • lerd install/lerd update overwriting custom vhosts. The vhost regeneration during install now branches for custom container sites.
  • lerd start/lerd stop trying to start/stop workers for ignored sites. registeredFrameworkWorkerUnits now skips ignored and paused sites.
  • lerd pause/lerd unpause not 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_lerd gate was implemented as an outer <template x-if> wrapping two inner templates (empty-list placeholder and populated <select>). Alpine.js's x-if directive 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 the node_managed_by_lerd condition 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 init wizard omits the Node version input (an existing node_version in .lerd.yaml is preserved). The status API gains node_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, and db:shell now 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): --service flag, .lerd.yaml db: block, framework detect rules, then generic .env inference (DB_CONNECTION / DB_TYPE / TYPEORM_CONNECTION / DATABASE_URL / DB_PORT). Credentials from .env are intentionally ignored, because lerd always connects via podman exec using the container's fixed admin credentials (postgres/lerd or root/lerd), so a mismatched DB_USERNAME=root against a pgsql container no longer fails with role "root" does not exist. db:shell now checks whether the target database exists and prompts to create it before opening the shell, instead of dumping a raw psql error.

Changed

  • Skip .env backup when lerd has already written the file (#193). lerd env used to unconditionally copy .env to .env.before_lerd on 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 .env in this project, so .env.before_lerd always 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/hosts bind-mounted into every PHP-FPM container used to hardcode 169.254.1.2 both for host.containers.internal and for every linked .test domain. 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 fragile FPM → pasta host-loopback → host 127.0.0.1:80 (rootlessport) → lerd-nginx chain that failed on some podman versions and surfaced as 504s during debugging. WriteContainerHosts now probes the real host gateway by exec-ing getent hosts host.containers.internal inside lerd-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.internal points at the detected host gateway for Xdebug and any host-side tooling, while every .test domain resolves straight to lerd-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 pure renderContainerHosts helper 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 stable http://IP:PORT URL with no client-side DNS setup. A host-level reverse proxy runs in the lerd-ui daemon, rewriting the Host header so nginx routes to the correct vhost and rewriting absolute URLs (https://domainhttp://LAN-IP:port) in HTML, CSS, and JS bodies so assets and redirects work without DNS. Accept-Encoding: identity is forced upstream and gzip is decoded in ModifyResponse to keep body rewriting reliable, and Location headers are rewritten on redirects. Each site gets a stable port from 9100 onwards (saved in sites.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 escapes overflow-x-auto clipping on parents). QR PNGs are served from /api/lan-qr/{domain}. Closes #179.
  • lerd import sail (alias lerd 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 where lerd env already overwrote DB_DATABASE), dumps the DB from the Sail container into lerd's MySQL/PostgreSQL, reads MinIO credentials from the compose environment block, and mirrors the bucket into RustFS via mc. Tears Sail back down when done (--no-stop keeps it running). lerd env now backs up .env.env.before_lerd on first run and lerd env:restore brings it back. lerd link detects laravel/sail in composer.json and prompts to run the import before setup, passing DB_DATABASE through 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, so lerd updates roll out new preset contents on the next service start without remove + reinstall. Legacy files: 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 quadlet Environment= injection vector.
  • URL hash routing in the dashboard#sites/<domain>, #services/<name>, #system/<section>, #service/<name> (dashboard iframe), and #docs are now deep-linkable with working back/forward navigation. loadSites auto-select only fires when the sites tab is active and the hash doesn't already claim an iframe view, so refreshing on a sub-page stays put.
  • repeat_family dynamic_env directive — produces N copies of a value aligned with the host list from discover_family, used for PMA_USERS / PMA_PASSWORDS so 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'] from PMA_HOSTS / PMA_USERS / PMA_PASSWORDS with auth_type=config for multi-host auto-login and sets CookieSameSite=None plus forced HTTPS env so cookies flow inside the iframe. The pgadmin preset sets SESSION_COOKIE_SAMESITE=None and SESSION_COOKIE_SECURE=True for the same reason. Rustfs now starts with --console-enable and 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 from systemctl --user show-environment and probes XDG_RUNTIME_DIR for a wayland-* socket as a last resort, so spawned terminals can always reach the compositor regardless of how lerd-ui itself was launched. Darwin is skipped because open -a Terminal reattaches to the Aqua session on its own.
  • PHP-FPM subdomain detectionSERVER_NAME is now set to $host in 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-16 to 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 man indexed node_modules when walking docs — the docs FS walker now skips node_modules so 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 a podman inspect subprocess per container per request. Snapshot rebuilds are serialised with a TryLock so 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 repeated podman machine ssh round-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 emit StopTimeout=5 so podman SIGKILLs after 5 s, matching the existing --stop-timeout=5 behaviour on macOS. The UI toggleService handler also wraps the POST in an 8 s AbortController timeout 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 with lerd service remove <name> && lerd service preset <name>.
  • Worker entries clobbered on lerd uninstalllerd installWorkerStartForSite called SetProjectWorkers(CollectRunningWorkerNames(...)), which fully replaced the .lerd.yaml workers list on every invocation. Workers started sequentially during lerd setup overwrote each other's entries, so after an uninstall/install cycle only the last-started worker survived. Now uses a new additive AddProjectWorker helper. StripeStartForSite also persists "stripe" after a successful start so it survives the same cycle, and lerd init now lists stripe in the Workers multi-select when STRIPE_SECRET is present in the site's .env.
  • UI worker toggles visually revertedAfterUnitChange published 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 calls Cache.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 stalehandleWS called the async Cache.Refresh() before assembling the first frame, meaning a freshly-opened browser could see container states from before lerd start ran. Replaced with the synchronous Cache.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 projectslerd env only ran the rustfs bucket creation logic inside the fallback knownServices loop. Laravel projects go through fw.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 public logic now runs on the framework service path too, honours an existing AWS_BUCKET value in .env instead 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 the mc container being able to connect over the lerd network.
  • Default PHP FPM was auto-stopped when unusedautoStopUnusedFPMs didn't exempt cfg.PHP.DefaultVersion, so setting a default (e.g. 8.5) with no site explicitly referencing it would stop the container immediately after start, breaking php, composer, and laravel new shims. The helper now mirrors coreUnits() 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 every lerd 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. ensureDefaultPHPInstalled also now auto-builds the FPM image + writes the quadlet on the first lerd start after switching the configured default PHP version, so users don't have to run lerd php install manually.

[1.12.5] — 2026-04-13

Fixed

  • macOS ARM64 postgres pulled an image with no ARM64 manifestplatformImageOverride was applied before svcCfg.Image from global config, so the macOS substitute (imresamu/postgis) was silently overwritten by postgis/postgis:16-3.5-alpine on every ensureServiceQuadlet and lerd install run. The override now runs last and only when the resolved image is the known-bad upstream postgis/postgis + alpine suffix, leaving user-pinned custom images untouched. The embedded quadlet fallback also moves from postgis:16-3.5-alpine to postgis:16-3.5 so fresh Linux installs get an image with an ARM64 manifest. (#175)

[1.12.4] — 2026-04-13

Fixed

  • pgpass rewrite failed with permission denied — file mounts declared with chown: true (e.g. pgAdmin's /pgpass) get re-owned to a userns-mapped uid by podman's :U flag. On the next materialize the host process could no longer open the file for writing and surfaced open …/pgpass: permission denied. MaterializeServiceFiles now 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.py that disables X-Frame-Options, ENHANCED_COOKIE_PROTECTION, and WTF_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's PMA_HOSTS pointing at the gone host. The UI now matches the CLI behaviour. (#172)
  • Workers showed as off on macOS while runningunitStatusFn defaulted to a systemctl-based path that does not exist on macOS, so the UI never reflected running workers. Darwin now overrides it to use podman.UnitStatus, the same path lerd status uses. (#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.php with $cfg['AllowThirdPartyFraming'] = true; so it renders inside the inline overlay. Existing installs must lerd service remove phpmyadmin && lerd service preset phpmyadmin after 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-lerd tap. Install on macOS with brew 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.conf has no unqualified-search registries. All defaults are now fully qualified with docker.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 immediate loadServices() 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 .timer units 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 .timer files 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 resolutionschedule, queue, horizon, and reverb shortcuts now resolve the framework definition per-version instead of falling back to a single global definition.
  • Perpetual service quadlet rewrite on installlerd install no longer rewrites service quadlets on every run, which previously dropped local edits and triggered needless restarts.
  • Skip Laravel installer prompt when already installedlerd setup no longer asks to install Laravel when the project is already initialised.
  • macOS terminal integrationlerd open and the UI terminal button open Terminal.app silently at the project directory; lerd update defers to brew upgrade lerd on 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 restartlerd-tray restart no longer hangs inside launchctl kickstart on 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 UXlerd install enables 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 CAROOTlerd setup --remote installs 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/stoplerd start/lerd stop run 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 supportlerd open and 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 through lerd init and lerd setup when the project hasn't been initialised yet.
  • PHP version suggestion during link — when the project requires a PHP version that isn't installed, lerd link suggests installing it.
  • Favicon field in framework definitions — frameworks can now declare a custom favicon path (e.g. core/misc/favicon.ico for Drupal) so the dashboard shows the correct icon.

Fixed

  • Framework detection for custom frameworks — detection rules now read composer.json directly and support custom detect rules, fixing detection for frameworks like Drupal, CakePHP, and WordPress.
  • Worker checks and env setup for custom frameworks — worker check rules 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 single LoadAll(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 centralisedDetectFrameworkForDir and .lerd.yaml operations moved into the config package, eliminating scattered detection logic.

[1.10.1] — 2026-04-10

Fixed

  • phpMyAdmin (and other dynamic_env presets) connected to wrong host — the Web UI and MCP service_start/service_add code paths generated custom service quadlets without resolving dynamic_env directives, so PMA_HOSTS was never set and phpMyAdmin fell back to its default host db. All three paths now delegate to serviceops.EnsureCustomServiceQuadlet which handles dynamic_env resolution and file materialisation.

[1.10.0] — 2026-04-10

Added

  • Framework definition store — community framework store backed by geodro/lerd-frameworks with lerd framework search, lerd framework install, and lerd framework update commands. Definitions auto-fetch when linking a project and auto-refresh after 24 hours. MCP tools framework_search and framework_install expose 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 support conflicts_with, proxy config with auto port assignment, and port collision prevention across sites.
  • Worker add/remove CLI and MCP toolslerd worker add and lerd worker remove manage 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 in worker list, worker stop, and setup.
  • PHP version ranges — framework definitions declare supported PHP min/max ranges. lerd link and lerd init clamp the PHP version to the framework's supported range. lerd sites and the UI show the framework version (e.g. "Laravel 11").
  • and template vars — framework env var templates can reference the site's primary domain and TLS scheme. .env keys like APP_URL, VITE_REVERB_HOST, and VITE_REVERB_SCHEME sync automatically when the primary domain changes.
  • Selenium service preset — bundled selenium preset (selenium/standalone-chromium) for browser testing with Laravel Dusk. Auto-detected via composer_detect on laravel/dusk, patches DuskTestCase.php, and includes noVNC on port 7900 for watching tests live. New share_hosts field on custom services maps .test domains to the nginx container IP.
  • Cursor MCP supportmcp:inject and mcp:enable-global now write Cursor configuration (.cursor/mcp.json and .cursor/rules/lerd.mdc). (#132)
  • Ghostscript in PHP-FPMghostscript added to the base PHP-FPM image for PDF manipulation with libraries like Spatie MediaLibrary. (#138)
  • mysql-client in PHP-FPMmysql-client added to the PHP-FPM image so mysqldump works inside lerd php sessions. (#142)

Changed

  • MCP tool responses optimised for AI agents — ANSI escape codes stripped from all CLI output. doctor, check, and env_check return structured JSON instead of raw text. env:check no longer exits non-zero.
  • CI auto-rebuilds PHP images — a scheduled workflow checks Docker Hub daily for upstream php:X.Y-fpm-alpine security patches and triggers a force rebuild when new digests appear.

Fixed

  • php:rebuild reused stale base imageslerd php rebuild now always pulls fresh base images instead of building on top of potentially outdated cached layers. (#140)
  • npm run build failed when node_modules missing — 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/updatelerd install rewrote 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 $HOME failed 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 during lerd link, lerd park, or any exec command (lerd php, composer, laravel new) and cleaned up on lerd unlink / lerd unpark. (#120)
  • Env file keys appended instead of uncommented — when a .env key existed but was commented out (#DB_HOST=...), lerd env appended a duplicate instead of uncommenting the existing line in place.

Added

  • lerd doctor checks for crun — warns when crun is 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 .env for lerd-{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+reinstallqueueStartExplicit ran a Redis preflight that returned an error before the unit file was written. Install-time restoreSiteInfrastructure runs before any services are started, so for sites with QUEUE_CONNECTION=redis the write step always failed and the worker units stayed missing on disk while systemd remembered them as not-found failed. The preflight is gone; the dependency now lives in the systemd unit itself. lerd-queue-<site>.service declares After=/Wants= for whatever the queue backend needs (lerd-redis.service when QUEUE_CONNECTION=redis, lerd-mysql.service / lerd-postgres.service for database-backed queues) on top of the FPM container, and lerd-horizon-<site>.service always declares lerd-redis.service. systemd handles the activation order and Restart=always covers the small ready-window between activation and the backing container accepting connections.
  • Preset-installed services not regenerated on reinstallrestoreSiteInfrastructure only handled inline custom services and built-in named refs. Preset references like mariadb-11 (declared in .lerd.yaml as mariadb-11: {preset: mariadb, version: "11"}) fell through to ensureServiceQuadlet, which only knows about built-ins, so the silently-swallowed unknown service error left sites with no quadlet for any preset-installed service after an uninstall+reinstall cycle. The restore path now goes through ProjectService.Resolve() which already knows how to render both inline and preset references back into a concrete CustomService.

Changed

  • lerd status shows [preset] for preset-installed services instead of grouping them under [custom]. Hand-rolled custom services keep the [custom] label.
  • Tagline rewordedlerd --help, the install.sh banner, and the goreleaser GitHub release notes header now read Lerd — Podman-powered local PHP dev environment for Linux instead of Laravel 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 the mongo-express preset 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 ships phpmyadmin, pgadmin, mongo, mongo-express, and stripe-mock as embedded YAML that becomes a normal custom service once installed, so every existing lerd service subcommand (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. mysql 8.0/8.4/9.0, mariadb 10.11/11.4) and lerd service preset shows version pills on list, 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 toolsservice_preset_list and service_preset_install expose the preset catalog and install flow to AI assistants, sharing the install path with the CLI through serviceops.InstallPresetByName. Re-run lerd mcp:inject in 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 optional mode (octal perms) and chown: true (adds :U so podman re-chowns to the container's non-root uid). Used by the pgadmin preset to ship a servers.json + pgpass that autoconnects to lerd-postgres. Files re-render on every lerd service start so 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 at mysql://, postgresql://, or mongodb:// 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 startlerd service start <svc> now ensures every entry in depends_on is 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-express on mongo) 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 in localStorage. 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-terminal endpoint, which spawns the user's preferred terminal emulator (kitty / foot / alacritty / wezterm / ghostty / ptyxis / konsole / gnome-terminal / xfce4-terminal / tilix / terminator / xterm) running lerd update so the host can prompt for sudo and stream download progress.
  • Getting-started walkthroughs — new docs/getting-started/laravel.md, symfony.md, wordpress.md, and services.md pages plus a docs/usage/lifecycle.md reference covering how Lerd's units come up at boot and how start / stop / autostart interact.

Changed

  • autostart is now a single coherent switchcfg.Autostart.Disabled is the canonical source of truth for whether lerd comes up at login. Toggling it enables/disables every lerd-*.container quadlet (by adding/stripping the [Install] section so the podman generator stops emitting the default.target.wants symlink) and every lerd-*.service unit (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. Use lerd start / lerd stop for live state.
  • lerd autostart tray removed — the tray is now governed by the same single autostart switch as everything else. The standalone autostart tray subcommand and the lerd-autostart.service unit 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.service shim and stopped enabling on fresh installs. The unified autostart toggle now covers the tray too, the per-unit autostart toggle is wired up correctly, and lerd install honours 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 binds 127.0.0.1 (was 0.0.0.0 since 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 to 0.0.0.0, since Laravel apps in lerd-php-fpm reach services through the podman bridge regardless of host bind. Quadlets are rewritten centrally via podman.WriteQuadletDiff so 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.Exposed is the top-level kill switch and cfg.UI.PasswordHash adds HTTP Basic auth on top. LAN clients only reach the dashboard when both are set; loopback always bypasses both. Stale credentials cannot survive lan:unexpose. The dashboard's "Remote dashboard access" card distinguishes active / inert / disabled states so the user sees when credentials are stored but blocked by lan:unexpose. UI feedback during a toggle streams NDJSON progress events from POST /api/lan/status; the card polls every 5s while on the System tab so CLI toggles are reflected without a page reload.
  • http://lerd.localhost as a usable bookmarklerd-nginx serves the static dashboard HTML, icons, and PWA manifest from the lerd.localhost vhost, with /api/* explicitly returning 444 so a LAN curl forging the Host header cannot reach lerd-ui through the proxy. The dashboard JS detects when it was loaded from lerd.localhost and rewrites all fetch, EventSource, and favicon img srcs to absolute http://localhost:7073 URLs so they hit lerd-ui directly 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_url field in .lerd.yaml and sites.yaml — new precedence chain for APP_URL: .lerd.yaml app_url (committed, shared across machines) > sites.yaml app_url (per-machine override) > the default <scheme>://<primary-domain> generator. lerd setup no longer overwrites a custom APP_URL on every run — set it once in .lerd.yaml and lerd respects it. The .lerd.yaml app_url is silently suppressed when its host points at a domain that the conflict filter dropped, so .env never ends up writing a hostname owned by another site.
  • Soft-fallback domain conflict handling — when lerd link or 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.yaml is never modified on disk — the original domains: 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.yaml only (no registry, vhost, or cert touched). The domain:remove server action detects conflict-filtered entries and routes them to a .lerd.yaml-only delete path.
  • [Remote Access] section in lerd status — new block showing LAN exposure state and dashboard remote-access state, with hints when off. Refactored into a testable printRemoteAccessStatus helper.
  • 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=true to --mono=false so 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.localhost instead of the bare 127.0.0.1:7073 loopback 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 uninstall left the tray running — the uninstall flow stopped and disabled all systemd units but never killed standalone tray processes (launched from the desktop file or lerd tray). The tray kept running after the binary was gone, with no way to dismiss it short of pkill. Uninstall now calls the existing killTray helper after the unit teardown.
  • lerd install hang 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 through lerd php and depends on cwd-based PHP detection. When the install command runs from $HOME with no project metadata, detection fell back to cfg.PHP.DefaultVersion and handed composer to a possibly-missing container. Worse, composer global require triggers symfony/flex / plugin trust prompts which sat invisibly inside podman 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, and podman exec'ing composer global require --no-interaction laravel/installer directly.

[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, then DB_CONNECTION in .env (or .env.example for fresh clones), falling back to SQLite. After the wizard completes, lerd env runs automatically so the choice immediately lands in .env — picking MySQL/PostgreSQL writes the connection vars and creates the project database (plus _testing), picking SQLite writes DB_CONNECTION=sqlite and creates database/database.sqlite if it's missing.
  • Runtime database prompt in lerd env — when run interactively on a Laravel project whose .env says DB_CONNECTION=sqlite and whose .lerd.yaml doesn't yet pick a database, lerd env now 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 use DB_CONNECTION.
  • db_set MCP 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 the DB_ keys in .env, starts the service if needed, and creates the database (or the SQLite file). The companion env_setup tool's description now points at db_set so AI assistants know to call it before env_setup on fresh Laravel clones — env_setup alone leaves DB_CONNECTION=sqlite untouched.
  • SQLite as a first-class env-time choiceserviceEnvVars["sqlite"] now applies DB_CONNECTION=sqlite and DB_DATABASE=database/database.sqlite. The lerd env flow special-cases sqlite so it isn't treated as a podman service: no quadlet, no service_start, just the env vars and the file creation. The user's database choice in .lerd.yaml is authoritative — switching from mysql → sqlite (or vice versa) skips the auto-detection of the previous database in .env.

Fixed

  • vendor_bins / vendor_run missing 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 that lerd mcp:inject writes into .claude/skills/lerd/SKILL.md and .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-run lerd mcp:inject in 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 logs field in their YAML; Laravel defaults to storage/logs/*.log with 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/bin shortcuts and lerd test / lerd a aliases — any composer-installed binary in the project's vendor/bin is now callable directly as lerd <name> (e.g. lerd pest, lerd pint, lerd phpstan), routed through the project's PHP-FPM container with vendor/bin prepended to PATH. Built-in lerd commands always win on name collisions. Two new shortcuts: lerd a (alias for artisan) and lerd test (shortcut for artisan test). The same surface is exposed to MCP clients via vendor_bins (list) and vendor_run (execute). Closes #101.
  • Laravel installer shipped globallylerd install now offers to install laravel/installer as a global composer package and creates a laravel shim in BinDir routed through lerd php, so the laravel command 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/png in each site's public directory and serves them via GET /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_version to 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 update defaults 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 .test domain resolution on omarchy and similar systems. Fixed by setting correct permissions (0644) via sudoWriteFile.
  • 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 projectlerd env now generates APP_KEY directly in .env when vendor/ does not exist yet, instead of failing silently on artisan key:generate. This prevents Laravel's MissingAppKeyException during composer install post-install scripts in the lerd newlerd linklerd setup flow.
  • composer install using wrong PHP version in setuplerd setup now runs composer install inside the project's PHP-FPM container, matching the composer.json PHP constraint. Previously it used the host composer shim which could resolve to the global default PHP version.
  • PHP version detection from composer.json ignores installed versions — the constraint resolver now picks the highest installed PHP version satisfying the composer.json require.php constraint (e.g. ^8.3 with 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-FPMlerd install now always builds and starts the default PHP version, even with no registered sites. Previously lerd new would fail on a fresh install because no PHP-FPM container existed.
  • Install not restoring serviceslerd install now 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 workerslerd install now calls restoreSiteInfrastructure to recreate worker units from .lerd.yaml after services are started.
  • FPM not restored for sites using default PHP — both lerd install and lerd start now fall back to the configured default PHP version when a site has no explicit PHPVersion, 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 setup field with one-off bootstrap commands (migrations, storage links, fixtures) shown in lerd 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 workers and setup entries support an optional check field (file or composer) to conditionally show them based on project dependencies (e.g. messenger worker only shown when symfony/messenger is installed).
  • Service version placeholders — framework env vars support , , , and placeholders, resolved from the running service image tag at lerd env time.
  • --setup flag for lerd framework add — define setup commands via CLI flags in addition to YAML.
  • Link modal streaming logs — the web UI link modal now streams lerd link and lerd env output 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 checklerd reverb:start and lerd reverb:stop now check for laravel/reverb in 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.yaml when the file exists. Previously worker start/stop and stripe:listen did not update the file.
  • lerd start restores site infrastructure — after an uninstall/reinstall cycle, lerd start reads .lerd.yaml from each active site and recreates missing FPM quadlets, service quadlets, and worker units automatically.
  • lerd install restores 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 all lerd-* 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 interfaceslerd uninstall now 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 env runs automatically in setuplerd env now runs at the start of lerd setup instead of being a selectable step, ensuring .env is configured before composer install triggers post-install scripts.
  • Definition conflict resolution — when .lerd.yaml and 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 hintslerd doctor and lerd status now 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 missinglerd start now 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 updatelerd install (called by lerd 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 .test domains. Use lerd domain add, lerd domain remove, and lerd domain list to manage them. Domains are stored in .lerd.yaml and the certificate is reissued automatically when a domain is added to a secured site.
  • lerd env:check command — compare all .env files against .env.example and flag missing or extra keys. Exits non-zero when required keys are missing.
  • lerd check command — validate .lerd.yaml syntax, PHP version, Node version, services, frameworks, and workers before running setup. Reports OK/WARN/FAIL per field.
  • lerd which command — show the resolved PHP version, Node version, document root, and nginx config paths for the current site.
  • Port conflict detectionlerd start checks 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 .nvmrc and 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 from composer.json.
  • Setup prompt on link — when linking a site with workers configured in .lerd.yaml, lerd prompts to run lerd setup to install dependencies and start workers.
  • Branded error pages — requests to unlinked .test domains show a styled "Site Not Found" page with links to the dashboard instead of a generic browser error.
  • Failing worker visibilitylerd status shows 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 unlinklerd unlink now 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 checklerd status no longer flags TLS issues for paused or ignored sites.
  • Service container left behind on removelerd service remove now 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 CountSitesUsingService and 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 unlink and lerd start. Paused sites keep their FPM running. On lerd 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 start parallel 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 status false 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 update migration prompt now also checks whether the lerd-minio container 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 env was run on a Reverb siteREVERB_HOST was set to the site domain (e.g. my-app.test), which resolves inside the PHP-FPM container to host.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, and REVERB_SCHEME are now always written as localhost, REVERB_SERVER_PORT, and http so the queue worker connects to Reverb directly inside the same container. VITE_REVERB_HOST/PORT/SCHEME continue to use the site domain and external port for browser connections through nginx. Sites affected can be fixed by re-running lerd 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-cursor on reconnect; for Podman containers a monotonic line counter is used and --tail 0 skips 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>/logs SSE handler had its own inline copy of the log streaming logic instead of calling the shared streamUnitLogs helper 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 with statfs: no such file or directory. WriteFPMQuadlet now 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 env detects BROADCAST_CONNECTION=reverb, it auto-assigns a unique REVERB_SERVER_PORT per 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 when lerd env has 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 from lerd status and lerd doctor output.
  • Portable .lerd.yaml.lerd.yaml can now describe a site's full local environment (PHP version, Node version, framework, services, custom workers). Running lerd link in a project that has a .lerd.yaml applies all settings automatically, so cloning a project and running lerd link && lerd env is 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.io instead 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:rebuild accepts 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 .test domain resolution inside containers — HTTP/HTTPS requests from one site to another (e.g. booking.test calling staffing.test) were failing because .test domains resolved to 127.0.0.1 inside 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/hosts with a 169.254.1.2 entry per linked site. Since it is a bind mount, lerd link and lerd unlink update all running containers instantly without a restart. Fixes #39.
  • Reverb proxy returns 502 after container restart — the Nginx location /app block used a bare hostname in proxy_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 workerhorizon: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 unlink stops 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 php can now run scripts outside $HOME — IDEs like PhpStorm write their validation scripts to /tmp and call php -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"). runPhp now detects any argument that is an absolute path to a host file outside $HOME, reads it, and streams it to the container via stdin / /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 use podman logs on 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 init validates PHP version input — the PHP version prompt now rejects invalid input such as 8,5 or plain strings; only MAJOR.MINOR numeric format (e.g. 8.3) is accepted.
  • lerd init and lerd env detect services from .env.example — when .env is absent, service detection falls back to .env.example so a freshly cloned project is configured correctly before .env is created.
  • lerd env waits 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 containerslerd php:list (and any command that calls ListInstalled) now scans podman ps -a for lerd-php*-fpm containers whose quadlet file is missing and restores it automatically, so users who lost their quadlet files do not need to reinstall PHP.

Fixed

  • lerd init installs PHP FPM with a progress indicator — when the required PHP FPM version is not yet installed, lerd init now shows a spinner rather than silently blocking. (PR #34)

[1.2.1] — 2026-03-31

Fixed

  • mcp:inject and mcp:enable-global fail on empty JSON config filesmergeMCPServersJSON now skips json.Unmarshal when 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 new runs composer install with the wrong PHP versioncomposer create-project for Laravel now passes --no-install --no-plugins --no-scripts so dependency installation is deferred to lerd setup, where the correct PHP version is already active. (PR #28 by @voronkovich)
  • Duplicate export PATH entries written to .zshrc on repeated lerd installappendShellRC now checks whether the PATH line already exists before appending. (PR #30 by @voronkovich)
  • Redundant appendShellRC call writes a broken export PATH=":$PATH" line to .zshrc — the call with an empty binDir has been removed; ensureZshFpath already 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.yaml for project portability. On a machine with an existing .lerd.yaml, lerd init applies the saved config non-interactively, making new-machine setup a single command. lerd setup now runs the wizard as its first step, lerd link auto-secures when secured: true is set, and lerd env / lerd isolate / lerd secure all keep the file in sync.
  • lerd console — run a framework's interactive console (e.g. php artisan tinker for Laravel, or the console field from the framework YAML) inside the project container. Arguments are forwarded as-is.
  • console MCP tool — execute framework console commands from an AI assistant session. Resolves the correct binary via config.GetConsoleCommand so it works for any framework that defines a console field.
  • Cloudflare Tunnel backend for lerd share — pass --cloudflare to tunnel a site via cloudflared. 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 pcov is no longer needed to run pest --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 restartscanWorktrees() now skips paused sites on startup; worktree vhost generation and nginx reloads triggered by .php-version changes are also skipped while a site is paused (registry is still updated for when the site is unpaused).
  • lerd console falls back to artisan for Laravel — when a Laravel project's framework YAML has no explicit console field, lerd console now correctly uses php artisan.

Internal

  • Unit tests for config, php, distro, and envfile packages.

[1.1.2] — 2026-03-30

Fixed

  • lerd install no longer hangs after "Adding shell PATH configuration" — the interactive MCP registration prompt has been removed. Run lerd mcp:enable-global manually after install to register the MCP server.
  • Dashboard URL in install completion message — now shows http://lerd.localhost instead of the raw http://127.0.0.1:7073 address.

[1.1.1] — 2026-03-30

Added

  • CI badge on README — the README now shows a live CI status badge linked to the ci.yml workflow.

Fixed

  • MCP registration prompt unresponsive when installing via pipelerd install reads the "Register lerd MCP globally?" prompt answer from /dev/tty instead of stdin. When the installer is run via a pipe (curl ... | sh), stdin is the pipe and fmt.Scan returns immediately with no input; opening /dev/tty directly reads from the actual terminal regardless of how the process was started.

Internal

  • Release workflow now gates on CI — the release.yml workflow 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's create command. Defaults to Laravel (composer create-project laravel/laravel). Pass --framework=<name> to use any framework that defines a create field. Extra args can be forwarded to the scaffold command after --. The project_new MCP tool provides the same functionality for AI assistants.
  • create field in framework definitions — framework YAML files now support a create property (e.g. create: composer create-project symfony/skeleton). The target directory is appended automatically by lerd new. The --create flag was also added to lerd framework add.
  • project_new MCP tool — scaffold a new project from an AI assistant session. Accepts path (required), framework (default: laravel), and args (extra scaffold flags). Follow with site_link and env_setup to 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. During lerd install, if Claude Code is detected and lerd is not yet registered, the installer prompts to run this automatically.
  • site_php MCP 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_node MCP tool — change the Node.js version for a registered site. Writes .node-version and 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_PATH is not set. This means path can be omitted from artisan, 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 setup npm step fails without a lockfile — the npm install step now runs npm ci when package-lock.json or yarn.lock is present, and falls back to npm install otherwise. Previously npm ci was always used, causing the step to fail on projects without a lockfile. (PR #5 by @voronkovich)
  • Duplicate PATH entry on lerd installadd_to_path in install.sh now checks the live $PATH before 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/_lerd instead of ~/.zfunc/_lerd, aligning with the XDG base directory convention. (PR #8 by @voronkovich)
  • .php-version changes not reflected in nginx — writing a .php-version file (via lerd isolate or 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-version now takes priority over composer.json's require.php constraint, matching the documented and intuitive precedence (explicit pin beats inferred constraint).

[1.0.4] — 2026-03-26

Fixed

  • .test domains unavailable from PHP-FPM containers — v1.0.3 fixed internet access by setting real upstream DNS servers (e.g. 192.168.0.x) on the lerd Podman network, but this caused aardvark-dns to skip systemd-resolved, breaking .test resolution from inside containers. lerd start and lerd install now use pasta's built-in DNS proxy at 169.254.1.1 (read from the rootless-netns info.json) as the aardvark-dns upstream. This address chains through systemd-resolved, which routes .test queries to lerd-dns and forwards all other queries to real upstream servers — giving containers both .test resolution and full internet access.
  • HTTPS to .test sites fails from inside PHP-FPM containers (cURL error 60) — PHP code making outbound HTTPS requests to local .test domains (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 .test HTTPS certificates are trusted. Existing images are automatically rebuilt on lerd update.
  • Reverb / queue / schedule workers not restarted after php:rebuild — when php:rebuild replaced and restarted the PHP-FPM containers, workers running inside those containers via podman exec (Reverb, queue, schedule) were killed by the BindsTo systemd dependency but not brought back up automatically. php:rebuild now 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.conf points to a stub resolver (127.0.0.53 via 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 start and lerd install now detect the real upstream DNS servers (reading /run/systemd/resolve/resolv.conf first) and set them on the lerd Podman 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, updates config.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.localhost custom domain — the Lerd dashboard is now accessible at http://lerd.localhost (nginx proxies the domain to the UI service). lerd dashboard opens the new URL. .localhost resolves to 127.0.0.1 natively 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 16k and fastcgi_buffer_size 32k, preventing upstream sent too big header errors 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 interactive sh session 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 .env are 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/_lerd with the required fpath and compinit lines 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 park refuses to park a framework project root — if the target directory is itself a Laravel/framework project, lerd now prints a helpful message and suggests lerd link instead of silently misbehaving.
  • lerd park no longer registers framework subdirectories as sites — when a project root is accidentally used as a park directory, subdirectories like app/, vendor/, and public/ 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/horizon in composer.json and provides dedicated lerd horizon:start / lerd horizon:stop commands that run php artisan horizon as 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 tools horizon_start and horizon_stop provide 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 the depends_on YAML field, the --depends-on flag on lerd service add, or the depends_on parameter in the service_add MCP 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). Set GLAMOUR_STYLE=light to override the default dark theme. Works in non-TTY mode too: lerd man | cat prints a table of contents and lerd man sites | cat prints 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, or lerd db:shell in 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 pause stops 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 across lerd start / lerd stop cycles. lerd unpause restores the vhost, restarts any services the site's .env references, 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_unpause tools — 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_unpin tools — AI agents can pin services to keep them always available.

  • Extra ports on built-in serviceslerd 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.yaml under services.<name>.extra_ports and applied on every start. The service is restarted automatically if running. Use --remove to delete a mapping. MCP tool service_expose provides the same capability.

  • Reverb nginx WebSocket proxy — when a site uses Laravel Reverb (detected via composer.json or BROADCAST_CONNECTION=reverb in .env), lerd now adds a /app location 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 on lerd link and on reverb: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/remove manage definitions from the CLI.

  • Framework workers — frameworks can define named background workers (e.g. messenger for Symfony, horizon or pulse for Laravel) that run as systemd user services inside the PHP-FPM container. lerd worker start <name> / lerd worker stop <name> / lerd worker list manage them.

  • Custom workers for Laravel — the built-in Laravel definition now has built-in queue, schedule, and reverb workers. Additional workers (e.g. Horizon, Pulse) can be added via lerd framework add laravel --from-file ...; they are merged on top of the built-in definition.

  • Generic lerd worker commandlerd worker start/stop/list works for any framework-defined worker. lerd queue:start, lerd schedule:start, and lerd reverb:start are now aliases for lerd worker start queue/schedule/reverb and 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_add with name: "laravel" adds custom workers to the built-in Laravel definition.

  • MCP sites now includes framework and workers — each site entry now includes its framework name and a workers array with running status per worker.

  • Docs: Frameworks & Workers page — 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 list uses a compact two-column format — the Type column has been removed. Custom services show [custom] inline after their status. Inactive reason and depends on: info now appear as indented sub-lines, keeping the output narrow on small terminals.

  • lerd service list / lerd service status shows 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 logs accepts 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 unlink auto-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:import and db:export accept a -d/--database flag — both commands now accept an optional --database / -d flag to target a specific database. When omitted the database name falls back to DB_DATABASE from the project's .env as before. The MCP db_export tool gains the same optional database argument.

  • lerd secure / lerd unsecure restart the Stripe listener — if a lerd stripe:listen service is active when HTTPS is toggled, it is automatically restarted with the updated forwarding URL so --forward-to stays in sync with the site's scheme.

  • MinIO: per-site bucket created by lerd env — when MinIO is detected, lerd env now creates a bucket named after the site handle (e.g. my_project), sets it to public access, and writes AWS_BUCKET=<site> and AWS_URL=http://localhost:9000/<site> into .env. Previously AWS_BUCKET was hardcoded to lerd and AWS_URL had no bucket path.

  • reverb:start regenerates the nginx vhost — running lerd reverb:start (or toggling Reverb in the web UI) now regenerates the site's nginx config and reloads nginx, ensuring the /app WebSocket proxy block is added to existing sites without requiring lerd link to be re-run.

  • lerd env sets correct Reverb connection valuesREVERB_HOST, REVERB_PORT, and REVERB_SCHEME are now derived from the site's domain and TLS state instead of hardcoded localhost:8080. VITE_REVERB_* vars are also written to match.

  • queue_start / schedule_start / reverb_start are no longer Laravel-only — these CLI commands and MCP tools now work for any framework that defines a worker with that name.

  • lerd env respects 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 park detect and record the framework — the detected framework name is stored in the site registry and shown in lerd sites.

Fixed

  • lerd php and lerd artisan no 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 on cli.SiteUsesReverb() (checks for laravel/reverb in composer.json or BROADCAST_CONNECTION=reverb in .env).

Removed

  • internal/laravel/detector.go — replaced by the generic config.DetectFramework / config.GetFramework system.

[0.9.1] — 2026-03-22

Added

  • MCP service_env tool — returns the recommended Laravel .env connection variables for any service (built-in or custom) as a key/value map. Agents can call service_env(name: "mysql") to inspect connection settings without running env_setup or modifying .env. Works for all six built-in services and any custom service registered via service_add.

Changed

  • lerd update does 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 so lerd status and lerd doctor stop showing a stale "update available" notice.
  • lerd update ignores 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 doctor command — 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 status shows watcher and update noticelerd-watcher is 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 by lerd status, lerd doctor, the web UI, and the system tray.
  • MCP status tool — 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 doctor tool — runs the full lerd doctor diagnostic and returns the text report. Use when the user reports setup issues or unexpected behaviour.
  • Watcher structured logging — the watcher package now uses slog throughout. Set LERD_DEBUG=1 in 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-watcher is 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 update shows changelog and asks for confirmation — before downloading anything, lerd update now fetches and prints the CHANGELOG sections for every version between the current and latest release, then prompts Update to vX.Y.Z? [y/N]. The update only proceeds on an explicit y/yes; pressing Enter or anything else cancels.

Fixed

  • lerd start now starts lerd-watcher — the watcher service was missing from the start sequence and could only be stopped by lerd quit, never started. lerd start now includes it alongside lerd-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 the http block, applied to all vhosts. lerd start also rewrites nginx.conf on every start so future config changes take effect without running lerd install.
  • MCP logs target 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_URL set to public endpointAWS_URL is now http://localhost:9000 (browser-reachable) instead of http://lerd-minio:9000 (internal container hostname). AWS_ENDPOINT is 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-watcher daemon now polls .test DNS resolution every 30 seconds. When resolution breaks, it waits for lerd-dns to be ready and re-applies the resolver configuration, replicating the repair performed by lerd start. Uses the configured TLD (dns.tld in global config, default test).
  • MCP logs target is optional — when target is omitted, logs for the current site's PHP-FPM container are returned (resolved from LERD_SITE_PATH). Specify target only to view a different service or site.

Changed

  • make install respects manually-stopped serviceslerd-ui, lerd-watcher, and lerd-tray are only restarted after install if they were already running. Services stopped via lerd quit are left stopped.

[0.8.1] — 2026-03-21

Fixed

  • MCP service_start / service_stop accept custom services — the MCP tool schema previously restricted the name field to an enum of built-in services, causing AI assistants to refuse to call these tools for custom services added via service_add. The enum constraint has been removed; any registered service name is now valid.

Changed

  • MCP SKILL and guidelines updatedsoketi removed from the built-in service list (dropped in v0.8.0); service_start/service_stop descriptions 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), executing php artisan reverb:start inside the PHP-FPM container. Survives terminal sessions and restarts on failure. Also available as lerd 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), executing php artisan schedule:work. Also available as lerd schedule start / lerd schedule stop.
  • lerd dashboard — opens the Lerd dashboard (http://127.0.0.1:7073) in the default browser via xdg-open.
  • Auto-configure REVERB_* env varslerd env now generates REVERB_APP_ID, REVERB_APP_KEY, REVERB_APP_SECRET, and REVERB_HOST/PORT/SCHEME values when BROADCAST_CONNECTION=reverb is detected, using random secure values for secrets.
  • lerd setup runs storage:link — setup now runs php artisan storage:link when the site's storage/app/public directory is not yet symlinked.
  • lerd setup starts the queue worker — setup now starts queue:start as a final step when QUEUE_CONNECTION=redis is set in .env or .env.example.
  • Watcher triggers queue:restart on config changes — the watcher daemon monitors .env, composer.json, composer.lock, and .php-version in every registered site and signals php artisan queue:restart when any of those files change (debounced). This ensures queue workers reload after deploys or PHP version changes.
  • lerd start / stop manage schedule and reverblerd start and lerd stop now include all lerd-schedule-* and lerd-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, and stripe_listen tools 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:stop action — the dashboard now supports stopping a stripe listener from the site action menu (was start-only).
  • WriteServiceIfChanged — internal helper that skips writing and running daemon-reload when a service unit's content is unchanged, preventing unnecessary Podman quadlet regeneration.
  • QueueRestartForSite — internal function that signals a graceful queue worker restart via php artisan queue:restart inside the PHP-FPM container.

Changed

  • Queue worker uses Restart=always — the lerd-queue-* service unit now restarts unconditionally (was Restart=on-failure), matching the behaviour of schedule and reverb services.
  • lerd.test dashboard vhost removedlerd install no longer generates an nginx proxy vhost for lerd.test. The dashboard is only accessible at http://127.0.0.1:7073. The lerd.test domain is no longer reserved and may be used for a regular site.
  • Web UI queue/stripe start is non-blockingqueue:start and stripe:listen site 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 quit command — fully shuts down Lerd: stops all containers and services (like lerd stop), then also stops the lerd-ui and lerd-watcher process 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 stop via new /api/lerd/start, /api/lerd/stop, and /api/lerd/quit API endpoints. The Start button is only shown when one or more core services (DNS, nginx, PHP-FPM) are not running.
  • lerd start resumes stripe listenerslerd-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 new quit command instead of stop, ensuring a full shutdown including the UI and watcher processes. The menu item is renamed from "Stop Lerd & Quit" to "Quit Lerd".
  • lerd stop stops 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 worktree checkout 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 remove in 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 — symlinks vendor/ and node_modules/ from the main repo into each worktree checkout, and copies .env with APP_URL rewritten to the worktree subdomain.
    • lerd sites shows 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.
  • 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 updates APP_URL in each worktree's .env.
  • Catch-all default vhost (_default.conf) — any .test hostname that does not match a registered site returns HTTP 444 / rejects the TLS handshake, instead of falling through to the first alphabetical vhost.
  • stripe:listen as a background servicelerd stripe:listen now 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 stop tears it down.
  • Service pause statelerd service stop now records the service as manually paused. lerd start and autostart on login skip paused services. lerd stop + lerd start restore the previous state: running services restart, manually stopped services stay stopped.
  • Queue worker Redis pre-flightlerd queue:start checks that lerd-redis is running when QUEUE_CONNECTION=redis is 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/unsecurelerd secure / lerd unsecure (and their UI/MCP equivalents) now save the updated secured flag to sites.yaml before reloading nginx. Previously a failed nginx reload would leave sites.yaml with a stale secured state, 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 repeated lerd start calls.
  • FPM quadlet skip-write optimisationWriteFPMQuadlet skips 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 disrupt lerd-dns and cause .test resolution 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.conf would 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-bin as an explicit stage before copying the composer binary. Podman (unlike Docker) does not auto-pull images referenced only in COPY --from, causing builds to fail with "no stage or image found with that name". This also affected lerd update and lerd php:rebuild in v0.5.14, leaving containers stopped if the build failed after the old image was removed.
  • Zero-downtime PHP-FPM rebuildlerd php:rebuild no longer removes the existing image before building. The running container stays up during the build; only the final systemctl restart causes a brief interruption. Force rebuilds now use --no-cache instead of rmi -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 the systemctl --user start command instead of a raw podman error.
  • lerd update ensures PHP-FPM is running — after applying infrastructure changes, lerd update now 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_PATH in MCP configmcp:inject now embeds the project path as LERD_SITE_PATH in the injected MCP server config. The MCP server reads this at startup and uses it as the default path for artisan, composer, env_setup, db_export, and site_link, so AI assistants no longer need to pass an explicit path on every call.
  • .ai/mcp/mcp.json injectionmcp:inject now also writes into .ai/mcp/mcp.json (used by Windsurf and other MCP-compatible tools), in addition to .mcp.json and .junie/mcp/mcp.json.

[0.5.10] — 2026-03-20

Fixed

  • DNS race on install/updatelerd install (and by extension lerd update) now waits up to 15 seconds for the lerd-dns container to be ready before calling ConfigureResolver(). Previously, resolvectl was called immediately after the container restart, causing systemd-resolved to mark 127.0.0.1:5300 as failed and fall back to the DHCP DNS server, breaking .test resolution until lerd install was 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 lerd binary is now fully static (CGO_ENABLED=0) and carries no shared library dependencies. A separate lerd-tray binary (built with CGO + libappindicator3) is shipped alongside it in the release tarball. At runtime lerd tray execs lerd-tray; if the helper is absent or libappindicator3.so.1 is 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 TUIlerd fetch and lerd php:rebuild now build PHP-FPM images in parallel with a compact spinner UI; press Ctrl+O to toggle per-job output
  • Service image pull TUIlerd service start shows a spinner while pulling the container image if it is not already present
  • Condensed uninstall outputlerd uninstall uses the same spinner UI for a cleaner experience

Changed

  • Install outputlerd install uses 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 -install is 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_sqlite and sqlite3 from docker-php-ext-install; both are bundled in the PHP Alpine base image and including them caused a Cannot find config.m4 build 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 (via docker-php-ext-install) plus igbinary and mongodb (via PECL); the default bundle now covers ~30 extensions for Herd-parity
  • Composer extension detectionlerd park / lerd link reads ext-* keys from composer.json and 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.ini and 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 preserved
    • lerd service list — shows built-in and custom services with a [custom] type column
    • lerd service start/stop — works for custom services
    • lerd start / lerd stop — includes installed custom services
    • lerd env — auto-detects custom services via env_detect, applies env_vars, runs site_init.exec
    • lerd 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 in env_vars and site_init.exec — substituted with the project site handle at lerd env time
  • site_init YAML block — runs a sh -c command inside the service container once per project when lerd env detects the service (for DB/collection creation, user setup, etc.)
  • dashboard field 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 updateddocs/usage/services.md extended with full custom services reference

Fixed

  • Custom service data directory is now created automatically before starting (podman refused to mount a non-existent host path)
  • lerd service remove now 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 install was killing the tray with pkill but only relaunching it when lerd-tray.service was 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:create and lerd db:shell were missing from the binary — cmd/lerd/main.go was 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>_testing database in one command. Name resolution: explicit argument → DB_DATABASE from .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 run podman exec --tty lerd-mysql mysql … manually.

Changed

  • lerd env now creates a <name>_testing database 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 polls http://127.0.0.1:7073 every 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 stop then exits the tray
  • --mono flag for lerd tray: defaults to true (white monochrome icon); pass --mono=false for the red colour icon
  • lerd autostart tray enable/disable: registers/removes lerd-tray.service as a user systemd unit that starts the tray on graphical login
  • lerd start starts the tray: if lerd-tray.service is enabled it is started via systemd; otherwise, if no tray process is already running, lerd tray is launched directly
  • make build-nogui: headless build (CGO_ENABLED=0 -tags nogui) for CI or servers; lerd tray returns a clear error instead of failing to link

Changed

  • Build now requires CGO and libappindicator3 (libappindicator-gtk3 on Arch, libappindicator3-dev on Debian/Ubuntu, libappindicator-gtk3-devel on Fedora). The make build target sets CGO_ENABLED=1 -tags legacy_appindicator automatically.
  • lerd-autostart.service now declares After=graphical-session.target so the tray (which needs a display) is available when lerd start runs 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/update endpoint has been removed. This avoids silent failures caused by sudo steps in lerd install that require a TTY.
  • /api/status now includes a php_default field 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 calling resolvectl domain $IFACE ~test, which caused systemd-resolved to mark the interface as Default 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 .test specifically via lerd's dnsmasq and remains the default route for all other queries.
  • .test DNS fails after reboot/restart: lerd start was calling resolvectl dns to 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 calling ConfigureResolver().
  • 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 to 302 (temporary) so browsers always check the server, and disabling HTTPS takes effect immediately.

[0.4.2] — 2026-03-19

Changed

  • lerd setup detects the correct asset build command from package.json: instead of always suggesting npm run build, the setup step now reads scripts from package.json and 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 status TLS certificate check: certExpiry was passing raw PEM bytes directly to x509.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 status shows 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 first lerd 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 .env DB settings. Supports MySQL/MariaDB (lerd-mysql) and PostgreSQL (lerd-postgres). Also available as lerd 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 --ngrok or --expose to force one. Forwards to the local nginx port with the correct Host header 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 link always runs first (mandatory, not in the list) to ensure the site is registered with the correct PHP version before any subsequent step. --all / -a runs everything without prompting (CI-friendly); --skip-open skips opening the browser.

Fixed

  • PHP version detection order: composer.json require.php now takes priority over .php-version, so projects declaring "php": "^8.4" in composer.json automatically use PHP 8.4 even if a stale .php-version file says otherwise. Explicit .lerd.yaml overrides still take top priority.
  • lerd link preserves HTTPS: re-linking a site that was already secured now regenerates the SSL vhost (not an HTTP vhost), so https:// continues to work after a re-link.
  • lerd link preserves secured flag: re-linking no longer resets a secured site to secured: false.
  • lerd secure / lerd unsecure directory 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 env command: copies .env.example.env if missing, detects which services the project uses, applies lerd connection values, starts required services, generates APP_KEY if missing, and sets APP_URL to the registered .test domain
  • lerd unsecure [name] command: removes the mkcert TLS cert and reverts the site to HTTP
  • lerd secure and lerd unsecure now automatically update APP_URL in the project's .env to https:// or http:// respectively
  • lerd install now installs a /etc/sudoers.d/lerd rule granting passwordless resolvectl dns/domain/revert — required for the autostart service which cannot prompt for a sudo password
  • PHP FPM images now include the gmp extension
  • 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.json into a project directory. Merges into existing mcpServers configs — 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 unlink parked-site behaviour: unlinking a site under a parked directory now marks it as ignored in the registry instead of removing it, preventing the watcher from re-registering it on next scan. Running lerd link in the same directory clears the flag. Non-parked sites are still removed from the registry entirely
  • GET /api/sites filters out ignored sites so they are invisible in the UI
  • queue:start and queue:stop are now also available as API actions via POST /api/sites/{domain}/queue:start and POST /api/sites/{domain}/queue:stop, enabling UI and MCP control

Fixed

  • DNS .test routing now works correctly after autostart: resolvectl revert is called before re-applying per-interface DNS settings so systemd-resolved resets the current server to 127.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 .test lookup
  • fnm install no longer prints noise to the terminal when a Node version is already installed

Changed

  • lerd start and lerd stop now start/stop containers in parallel — startup is noticeably faster on multi-container setups
  • lerd start now re-applies DNS resolver config on every invocation, ensuring .test routing is always correct after reboot or network changes
  • lerd park now skips already-registered sites instead of overwriting them, preserving settings such as TLS status and custom PHP version
  • lerd install completion message now shows both http://lerd.test and http://127.0.0.1:7073 as fallback
  • Composer is now stored as composer.phar; the composer shim runs it via lerd php
  • Autostart service now declares After=network-online.target and 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 start now detects missing PHP FPM images (e.g. after podman rmi) and automatically rebuilds them before starting units
  • lerd status now reports image missing with a lerd 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: opcache is already compiled into PHP 8.5 so docker-php-ext-enable opcache is now a no-op (|| true); apk update is run before apk add to avoid stale index warnings; redis falls back to building from GitHub source when PECL fails

[0.1.64] — 2026-03-17

Fixed

  • redis and imagick PHP 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 redis is now also non-fatal during PHP FPM image builds — the redis extension (like imagick) 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 imagick PECL 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 MyApp or custom --domain MyApp.test now consistently produce myapp.test

[0.1.60] — 2026-03-17

Fixed

  • All container volume mounts now include the :z SELinux 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=disable instead of :z to avoid recursively relabeling the user's home directory

0.1.53 — 2026-03-17

Fixed

  • lerd install now configures the system DNS resolver (writes NM dispatcher / applies resolvectl) only after lerd-dns is running — previously applying resolvectl dns <iface> 127.0.0.1:5300 before 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.conf drop-ins via DBUS so the DNS=127.0.0.1:5300 drop-in had no effect; now installs an NM dispatcher script (/etc/NetworkManager/dispatcher.d/99-lerd-dns) that calls resolvectl dns/domain per-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.container now mounts ~/.local/share/lerd/dnsmasq into the container and uses --conf-dir instead of embedding all options in the Exec line

0.1.51 — 2026-03-17

Fixed

  • DNS resolution now works on systems using systemd-resolved (Ubuntu, etc.) — lerd install detects whether systemd-resolved is the active resolver and writes /etc/systemd/resolved.conf.d/lerd.conf with DNS=127.0.0.1:5300 and Domains=~test instead of configuring NetworkManager's embedded dnsmasq
  • lerd status PHP version hint no longer shows "8.5" — corrected to "8.4"

0.1.50 — 2026-03-17

Fixed

  • install.sh --local binary path is now validated before check_prerequisites runs — previously podman not being installed would cause die "podman is required" before the file-exists check, making bats test 23 fail in CI

0.1.49 — 2026-03-17

Fixed

  • install.sh ask() no longer causes CI test failures under set -euo pipefail when /dev/tty is unavailable — read </dev/tty now has 2>/dev/null || true so 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.conf has no unqualified-search registries, causing short names to fail with exit code 125
  • lerd install now writes the lerd.test UI vhost before starting nginx so the dashboard is available on the very first start

0.1.47 — 2026-03-17

Fixed

  • lerd install now runs podman system migrate after 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=300 added to both lerd-nginx.container and lerd-dns.container as an additional safeguard
  • lerd install no longer prints a spurious nginx reload [WARN] — the separate reload step was removed; RestartUnit already loads the latest config

0.1.45 — 2026-03-17

Fixed

  • install.sh ask() now reads from /dev/tty so prompts work correctly when the script is piped to bash (curl | bash); a missing tty falls back gracefully
  • install.sh now aborts with a clear error if podman is 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) to 302 (temporary) so disabling HTTPS is not cached by the browser
  • Site domain links in the dashboard now use https:// when TLS is enabled and http:// otherwise

0.1.43 — 2026-03-17

Fixed

  • lerd install (and lerd update) no longer overwrites SSL vhosts with plain HTTP configs — sites with secured: true in sites.yaml now 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 install in the background

0.1.41 — 2026-03-17

Fixed

  • lerd install now uses RestartUnit (instead of StartUnit) for all services so a re-run after lerd update picks up the new binary and any changed quadlet files
  • Installer bats tests updated: latest_version mocks updated for the redirect-based version check, certutil added to the --check prerequisite 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 in sites.yaml; if the detected version differs, sites.yaml is 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-version to the project directory, updates sites.yaml, regenerates the nginx vhost, and reloads nginx; available PHP versions come from installed FPM quadlets, Node versions from fnm list

0.1.38 — 2026-03-17

Fixed

  • HTTPS sites no longer return "File not found" — SecureSite was constructing a bare config.Site with only Domain and PHPVersion, leaving Path empty so the generated SSL vhost had root /public; it now receives the full site struct
  • fetchLatestVersion tests 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 FindSiteByDomain and 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 status no 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.sh now checks for certutil (nss-tools) as a prerequisite and offers to install it automatically — without it mkcert cannot register the CA in Chrome/Firefox, causing ERR_CERT_AUTHORITY_INVALID on HTTPS sites
  • README documents certutil/nss-tools as a requirement with per-distro package names

0.1.34 — 2026-03-17

Fixed

  • Version detection in both lerd update and install.sh no longer uses the GitHub REST API — it now follows the https://github.com/{repo}/releases/latest HTML 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.sh latest_version() now sends User-Agent: lerd-installer and Accept: application/vnd.github+json headers — GitHub's API returns 403 for unauthenticated requests without a User-Agent, which the script was silently treating as "no releases found"
  • install.sh cmd_uninstall now dynamically discovers units from quadlet files on disk (same fix as lerd uninstall)

0.1.32 — 2026-03-17

Fixed

  • lerd uninstall now 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 behind
  • lerd uninstall now also removes lerd-ui.service alongside lerd-watcher.service

0.1.31 — 2026-03-17

Fixed

  • lerd update no longer fails with "GitHub API returned HTTP 403" — the version check now sends a User-Agent: lerd-cli header, which GitHub requires for unauthenticated API requests

0.1.30 — 2026-03-17

Fixed

  • lerd update now restarts the lerd-ui systemd 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}/secure or unsecure, issues/removes the mkcert certificate, regenerates the nginx vhost, and reloads nginx inline without leaving the UI

Fixed

  • lerd secure no longer fails with "renaming SSL config: no such file or directory" — RemoveVhost was 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
  • .env Copy button now works on plain HTTP (lerd.test) — navigator.clipboard.writeText requires HTTPS; added a document.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 .env variables 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 — handleServiceAction now retries StartUnit up to 5 times with increasing delays (300 ms each) to give the systemd Quadlet generator time to register the new .service unit after daemon-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 (and lerd install) no longer prompts for sudo if DNS is already configured — dns.Setup() now checks whether /etc/NetworkManager/conf.d/lerd.conf and /etc/NetworkManager/dnsmasq.d/lerd.conf already 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.test proxy vhost no longer uses resolver + set $upstream — nginx's resolver directive only works with DNS, but host.containers.internal is resolved via /etc/hosts inside the container; using a static proxy_pass http://host.containers.internal:7073 lets nginx resolve it correctly at startup

0.1.25 — 2026-03-17

Changed

  • lerd update no 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-hash after lerd php:rebuild, lerd use <version>, and lerd park (first build)

0.1.24 — 2026-03-17

Fixed

  • lerd.test proxy vhost now uses host.containers.internal instead of the Podman network gateway IP — the gateway IP is typically blocked by the host firewall for connections from containers, while host.containers.internal is 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 journalctl logs
  • 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.test dashboard now reachable: UI server changed to listen on 0.0.0.0:7073 so nginx (running inside the Podman container) can reach it via the network gateway IP
  • lerd install now reloads nginx after writing the lerd.test proxy vhost so it takes effect immediately without a manual restart
  • lerd.test is now a reserved domain — lerd park silently skips any directory that would resolve to it, lerd link returns 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 by lerd serve-ui as 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 .env button 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 install now writes and starts lerd-ui.service and generates the lerd.test nginx reverse proxy vhost; prints Dashboard: http://lerd.test on completion
  • lerd start / lerd stop include lerd-ui alongside DNS, nginx, and PHP-FPM

0.1.20 — 2026-03-17

Changed

  • lerd stop now also stops all installed services (those with a quadlet file) in addition to DNS, nginx, and PHP-FPM
  • lerd start now 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 change
  • lerd update now automatically runs lerd php:rebuild after lerd install so 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 time
  • lerd logs nginx — show nginx container logs
  • lerd 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 in podman logs / lerd logs

0.1.17 — 2026-03-17

Added

  • mailpit service — local SMTP server with web UI at http://127.0.0.1:8025; catches all outgoing mail from Laravel apps
  • soketi service — self-hosted Pusher-compatible WebSocket server for Laravel Echo / broadcasting
  • PHP 8.5 support — lerd use 8.5 builds 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 default
  • lerd artisan [args...] — shortcut for lerd php artisan [args]
  • lerd node [args...] — runs Node via fnm with auto-detected version
  • lerd npm [args...] — runs npm via fnm with auto-detected version
  • lerd npx [args...] — runs npx via fnm with auto-detected version
  • lerd install now writes php, composer, node, npm, npx shims to ~/.local/share/lerd/bin/ so commands work directly from the terminal

0.1.15 — 2026-03-17

Fixed

  • Service .env variables now use container hostnames (lerd-mysql, lerd-redis, etc.) instead of 127.0.0.1 — PHP-FPM runs inside the lerd Podman network so 127.0.0.1 resolves to the container's own loopback, not the host

0.1.14 — 2026-03-17

Fixed

  • nginx resolver directive added to nginx.conf using 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_pass in vhost templates now uses a $fpm variable to force use of the resolver
  • lerd install now 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.env output 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 prior lerd install

Changed

  • lerd service start and lerd service restart now print the recommended .env variables 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 containers
  • lerd 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 — added user root; and changed pid/error_log to writable paths (/tmp/nginx.pid, stderr) so nginx starts correctly in rootless Podman without UserNS=keep-id
  • PHP-FPM pool now runs workers as root (-R flag + zz-lerd.conf override) 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 deletion
  • EnsureNginxConfig — always overwrites nginx.conf (previously skipped if file existed)

0.1.9 — 2026-03-17

Fixed

  • lerd-dns.container quadlet template was embedded from the wrong source directory (internal/podman/quadlets/) — the file still referenced andyshinn/dnsmasq with Network=host, causing the DNS container to fail with "Permission denied on port 53"; updated to the Alpine-based dnsmasq on port 5300 via published port
  • dns.Setup() and ensureUnprivilegedPorts()sudo subprocesses now have Stdin/Stdout/Stderr connected 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 park and lerd 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.comadmin-astrolov.test)
  • lerd use <version> / lerd status — PHP version detection now tracks FPM quadlet files instead of static CLI binaries, so lerd use 8.4 is immediately reflected in lerd status

0.1.8 — 2026-03-17

Fixed

  • lerd update now automatically runs lerd install after 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 — removed Network=host and AddCapability=NET_ADMIN which 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 checks net.ipv4.ip_unprivileged_port_start and 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.conf to 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 install was calling dns.WriteDnsmasqConfig (writes only the container's local config) instead of dns.Setup(), which means /etc/NetworkManager/conf.d/lerd.conf and /etc/NetworkManager/dnsmasq.d/lerd.conf were never written and NetworkManager was never restarted — causing *.test DNS resolution to silently fail
  • dns.Setup() now prints a clear message before invoking sudo so users know why a password prompt appears

0.1.5 — 2026-03-17

Fixed

  • install.sh — definitively fixed the install: cannot stat '...\033[0m...' error by refactoring download_binary to 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.shinstall: cannot stat '...\033[0m...' error: download_binary was called inside $() command substitution so its info output was captured into the binary variable along with the path; all UI output in download_binary now goes to stderr, leaving only the path on stdout
  • install.sh — tar extraction errors inside download_binary now also go to stderr and produce a clean error message instead of polluting the captured path

0.1.3 — 2026-03-17

Fixed

  • install.shBASH_SOURCE[0]: unbound variable still occurred on bash versions where ${array[0]:-default} triggers set -u when the array itself is unset (not just empty); fixed by suspending nounset briefly with set +u before reading BASH_SOURCE

0.1.2 — 2026-03-17

Fixed

  • install.shBASH_SOURCE[0]: unbound variable crash when the script is piped to bash (curl|bash / wget|bash); BASH_SOURCE is unset in that execution context so it now defaults to $0

0.1.1 — 2026-03-17

Fixed

  • install.sh — replaced [[ ... ]] && main "$@" guard with if/fi so the script sources cleanly under set -euo pipefail (the && idiom exits with code 1 when the condition is false, which set -e treated as fatal)
  • install.shlatest_version no longer exits non-zero when the GitHub API returns no tag_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.yaml with sensible defaults
  • Per-project .lerd.yaml override 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 directory
  • lerd link [name] — register the current directory as a named site
  • lerd unlink — remove a site and clean up its vhost
  • lerd sites — tabular view of all registered sites

PHP

  • lerd install — one-time setup: directories, Podman network, binary downloads, DNS, nginx
  • lerd use <version> — set the global PHP version
  • lerd isolate <version> — pin PHP version per-project via .php-version
  • lerd php:list — list installed static PHP binaries
  • PHP version resolution order: .php-version.lerd.yamlcomposer.json → global default

Node

  • lerd isolate:node <version> — pin Node version per-project via .node-version
  • Node version resolution order: .nvmrc.node-versionpackage.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 lerd Podman network
  • Nginx and PHP-FPM as Podman Quadlet containers (auto-managed by systemd)
  • dnsmasq container for .test TLD 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 expiry
  • lerd dns:check — verify .test resolution

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 install invocation post-download
  • --update, --uninstall, --check flags
  • Installs as lerd-installer for later use

Released under the MIT License.