Service ports become something you manage rather than fight: a containerised
database coexists with a host-installed server of the same engine by auto-shifting its published port,
and published and extra ports are editable from the dashboard, the CLI, and MCP. The Laravel-only doctor is
rebuilt framework-agnostic, lerd auth ssh lets composer reach private git repos, and
lerd cleanup reclaims the podman disk rebuilds leave behind. Underneath, lerd now
self-heals rootless podman after a major upgrade, recovers ghost containers and a lost nginx port after
suspend, and closes the host-execution boundary against untrusted projects. This digest also folds in the
1.26.1 and 1.26.2 patch releases.
When a DB engine's default published port (3306, 5432) is already owned by a host-installed server, lerd's container can't bind it, and the collision is worst at boot, where systemd autostarts the quadlet with no lerd process around to step aside. The port-ownership guard runs at quadlet-write time: if a host server owns the port and no override is set, lerd publishes on the next free port, persists the choice, and rewrites the connection URL. The container-internal port never changes, so containerised apps that reach the service by name are unaffected. Detection is liveness-independent, a populated data dir or a live socket counts, so a cleanly stopped host DB still reclaims its port on boot.
.env.lerd_override + LERD_EXTERNAL_SERVICES couldn't cover, now handled at write time so even boot autostart never fights the host server.lerd service port mysql 3307 # free 3306 for a host MySQL; pre-flights, restarts, reports lerd service port mysql --reset # back to the preset default
Coexist with a host MySQL/MariaDB/Postgres. Reserves preset ports in the free-port search, fails closed if the override can't persist, and checks dual-stack bindability.
internal/serviceopsA cog on each service page opens a Ports modal for the published host port and extra ports, through one shared serviceops layer the CLI, dashboard and MCP all call.
The gate now keys off preset ownership, not default-stack membership, so optional presets like gotenberg autofill their host port and can add extra ports.
The site doctor was Laravel only. It is rebuilt as a universal baseline that runs for
every site, the env file, env drift against the example, the application key, composer and node install
and lockfile state, composer and npm security audits, a missing or empty SQLite database, and the PHP version
range, alongside each framework's own checks declared in the lerd-frameworks store. Store checks are
fully declarative through four typed evaluators, so a framework gains coverage by editing the store
rather than Go, and Laravel's app_debug, storage_link and migration checks move into
that model unchanged.
lerd site:doctor # the site in the current dir lerd site:doctor app.test # another site by domain lerd site:doctor --json # machine-readable; also over MCP: diag site_doctor
Env, application key, dependency install and lockfile state, security audits, database presence and PHP range run for every site regardless of framework.
internal/sitedoctorFour typed evaluators (env_key_set, env_combo, symlink, command) mean a framework adds coverage by editing lerd-frameworks, not the binary.
Web button, TUI tab, lerd site:doctor --json, and MCP diag site_doctor. Dependency and audit findings expose an allowlisted, loopback-only Fix button.
Mirroring ddev auth ssh, a shared lerd-ssh-agent sidecar holds
unlocked keys in memory so lerd composer can authenticate to private git repositories,
including passphrase-protected keys. Keys without a passphrase already work via the bind-mounted
~/.ssh once the image has an SSH client; the agent covers the passphrase case, which needs an
agent the FPM container can reach. On macOS the host agent socket can't cross the podman-machine VM
boundary, so the agent runs inside podman and its socket lives on a named volume mounted into every FPM
container.
ssh-agent/ssh-add; keys clear when the agent stops or the machine restarts.lerd auth ssh # default ~/.ssh/id_* lerd auth ssh ~/.ssh/id_work # a specific key; prompts for the passphrase once lerd auth ssh --list # show loaded keys · --remove to clear + stop
One lerd-ssh-agent container holds the unlocked keys; its socket on a named volume is reachable from every FPM container, macOS included.
lerd composer/lerd php inject SSH_AUTH_SOCK into the exec only when the agent is running, falling back to on-disk keys otherwise.
The FPM and FrankenPHP runtime images gain git and openssh-client, so on-disk keys work and the agent has the binaries it needs.
Every PHP rebuild orphans the previous :local image, every base-image hash bump
strands the old base, and every service upgrade leaves the old version behind, gigabytes nothing reaps today.
Unlike a blunt podman system prune -a, lerd cleanup only ever touches images that are
provably lerd's, enforced structurally by a dev.lerd.* label or the base repo name, so it
can never remove a database, a named volume, or another tool's images. Reported sizes count an image's
unique layers only, so the figure reflects disk that is actually freed.
| Tier | What it reclaims | How it's bounded |
|---|---|---|
| Safe default + automatic | Orphaned PHP/FrankenPHP build images, and pre-built base images nothing live is built on | In-use vs orphaned decided by layer ancestry, so the current base is always kept |
| Deep --deep, on demand | Service images no installed service references any more (e.g. an old mysql:8.0) | Keeps each service's current image and its one-back rollback target |
| Never | Databases, named volumes, other tools' images, any image carrying a tag you added | Ownership matched against lerd's own preset catalogue |
lerd cleanup --dry-run # show what would go and the size lerd cleanup # preview, confirm, reclaim the safe tier lerd cleanup --deep # also unused service images lerd cleanup auto off # disable the daily watcher sweep (on by default)
The safe tier requires a dev.lerd.* label or the lerd-php*-fpm-base repo name; the deep tier matches lerd's preset catalogue.
lerd-watcher runs the safe tier about once a day (persisted timestamp), and a rebuild or service update reaps the exact image it just superseded.
lerd doctor shows the reclaimable total as a read-only line when there's disk to reclaim; the destructive command stays CLI-only.
A podman major-version or network-backend change silently reshuffles rootless storage and
networking, and containers on lerd's bridge then fail with an opaque failed to mount runtime directory
for rootless netns. lerd now fingerprints the host podman version and backend, and on the next
run after a change runs the remediation before bringing containers up. The fingerprint is recorded up
front so a mid-heal failure can't re-run the destructive steps every invocation.
Compare the recorded podman version + backend in podman-env.json against the live one (server version via podman info, so a client-only brew upgrade can't false-trigger). #658
Recreate the lerd network in the new backend's format (CNI is removed and not auto-migrated in 5.0); stopped containers are handed back for the caller to restart.
podman system migrate, then clear the stale rootless-netns. lerd doctor also checks for the netavark, aardvark-dns and pasta/slirp4netns helpers a fresh host can lack.
An unclean Machine shutdown leaves a container stuck Created with no storage layer; lerd purges the stuck lerd-* ones inside the VM and retries once (darwin-only, status=created).
The DNS watcher dials 443 and, after two consecutive down reads on a server it has seen healthy, restarts lerd-nginx so 443 binds again after a long suspend.
php, artisan, tinker and shell start a stopped FPM container, or offer to install a missing version; over MCP they return a clean error instead of a raw podman failure.
Roughly 145 call sites funnel through Cmd/Run/CmdContext in internal/podman, with a guard against building a podman exec outside the package.
Custom-service install state resolves from the definition YAML; a lerd start pass regenerates a missing unit and reaps an orphan quadlet with no backing YAML.
A wave of fixes to shipped behaviour: a PHP site keeps serving while idle-suspended because FPM stays up, but a host-proxy site has no FPM, so stopping its dev server leaves nginx pointing at a dead port. Suspend now swaps the vhost to an auto-refreshing waking page and restores it once the dev server is back, and the rest of the wave sharpens idle-suspend, macOS and LAN-share.
A suspended host-proxy site serves an auto-refreshing landing page; resume waits (bounded 60s) for the dev-server port before restoring the proxy vhost.
Stopping a host-proxy site's dev-server worker pauses the site rather than leaving nginx to 502.
Suspended workers wake on web access on macOS #681, and the in-container worker is reaped on stop so idle-suspend actually frees CPU #662.
Postgres runs native on Apple Silicon via an imresamu/postgis rewrite instead of emulation.
An untrusted .lerd.yaml could still run commands on the host with no consent,
through host: true workers, an embedded framework_def, and commands run via
lerd run, the dashboard, and the commands_run MCP tool. An imported
framework_def is now stripped of its host workers and its whole commands list, so the store never
holds project-origin host execution, and a project's own host extensions still work, with consent.
framework_def; detection rewrites the sanitised def on the next run. host_commands.disabled / skip_confirmation mirror the host-proxy knobs.An embedded framework_def loses its host workers and commands on import, the same way its command doctor checks were already stripped.
Starting a host custom worker or running a project command shows it and asks once; --yes, the confirm modal, and force: true carry that consent.
Trusted store, built-in and user-overlay definitions, in-container workers, and framework-provided commands are unaffected.
Between digests, two patch releases went out. Both centred on a silent fallback that made most Linux hosts rebuild every PHP image from source instead of pulling the published prebuilt base, plus cherry-picked macOS and feedback fixes.
1.26.1 gated the pull's --policy=always behind podman 5.0, but --policy landed later still; the flag is dropped entirely (pull already defaults to always) and the real pull error surfaces.
podman pull --policy was rejected on podman below 5.0, its stderr discarded, so Ubuntu/Debian hosts quietly built from source; the failure now surfaces.
lerd setup returns npm/bun build failures through the feedback step rather than calling os.Exit, so the failure shows in context.
Bind-all IPv6 publish lines are rewritten to 0.0.0.0 so lan.exposed containers keep their ports #618, and the LAN DNS forwarder is skipped where dnsmasq already binds the LAN address #619.
Host workers whose dependencies aren't installed surface a remedy instead of dropping silently, and lerd update's service restarts warn rather than error.
NixOS gets a first-class path: its declarative model doesn't fit the one-line installer's
imperative DNS and self-install steps, so lerd ships a flake that packages the binary and the
configuration.nix blocks the stack needs. The MCP reference is also brought back in line with the
current tool surface.
nix run github:lerd-env/lerd-nixos -- --help # the flake packages the binary + config blocks
A documented flake and a NixOS guide for the declarative stack, since the imperative installer's DNS and self-install steps don't fit NixOS.
The MCP reference is brought back in step with the current tool surface, including the new diag site_doctor and service-port actions.