Architecture
All containers join the rootless Podman network lerd. Communication between Nginx and PHP-FPM uses container names as hostnames.
Platform requirement
On Linux, lerd requires systemd. Every container runs as a Podman Quadlet (systemd unit), every worker as a systemd user service, and the autostart flow uses systemd user linger. Non-systemd distros (OpenRC, runit, s6, sysvinit) are not supported. On macOS the same responsibilities are handled by launchd, which lerd manages automatically.
Request flow
*.test DNS
│
┌──────────┴──────────┐
│ DNS resolver │
│ (NM or resolved) │
└──────────┬──────────┘
│ forwards .test queries
┌──────────┴──────────┐
│ lerd-dns │
│ (dnsmasq, :5300) │
└──────────┬──────────┘
│ resolves to 127.0.0.1
▼
Browser ──── port 80/443 ──▶ lerd-nginx
(nginx:alpine)
│
fastcgi_pass :9000
│
▼
lerd-php84-fpm
(locally built image)
│
reads (bind mount)
│
▼
~/Lerd/my-app (or any path)Components
| Component | Technology |
|---|---|
| CLI | Go + Cobra, single static binary |
| Web server | Podman Quadlet (nginx:alpine) |
| PHP-FPM | Podman Quadlet per version (locally built image with all Laravel extensions) |
| PHP CLI | php binary inside the FPM container (podman exec) |
| Composer | composer.phar via bundled PHP CLI |
| Node | fnm binary, version per project |
| Services | Podman Quadlet containers |
| DNS | dnsmasq container + NetworkManager or systemd-resolved integration |
| TLS | mkcert, locally trusted CA |
| Debug bridge | Always-mounted auto_prepend_file PHP file in each FPM container; runtime sentinel toggles capture without a container restart. NDJSON to an in-process receiver inside lerd-ui, per-user Unix socket on Linux (~/.local/share/lerd/run/lerd-dumps.sock, via the %h:%h mount); TCP loopback 127.0.0.1:9913 on macOS (FPM reaches it via gvproxy's host.containers.internal:9913 because unix sockets don't traverse the podman-machine virtio-fs boundary). See features/dumps.md. |
| Notifications | Single in-process notifier inside lerd-ui dispatches every kind (mail, worker failures, finished service ops, service updates, dumps) through both WebSocket (open tabs at /api/ws) and Web Push (closed PWA / minimised tabs). Per-install VAPID keys at ~/.local/share/lerd/vapid-{private,public}.key; subscription store at push-subscriptions.json with per-category preferences. See features/notifications.md. |
Key design decisions
Rootless Podman: all containers run without root privileges. The only operations requiring sudo are DNS setup (configures NetworkManager or systemd-resolved to route .test queries) and the initial net.ipv4.ip_unprivileged_port_start=80 sysctl. Both are applied by lerd bootstrap, which the install re-executes once under sudo on Linux and a package maintainer script calls directly as root.
Dual-stack networking: the lerd podman bridge is created with both an IPv4 and an IPv6 ULA subnet (fd00:1e7d::/64) when the host has a usable IPv6 address (anything outside ::1 and fe80::/10). On hosts that advertise IPv6 in the kernel but have no routable v6 on any interface, typical in headless QEMU/KVM VMs, containers, and networks without v6 DHCP, netavark can't reliably hold the ULA gateway on the rootless bridge, so aardvark-dns fails to bind [fd00:1e7d::1]:53 and service containers exit on start. Lerd detects this by reading /proc/net/if_inet6 and /proc/sys/net/ipv6/conf/all/disable_ipv6, and when no usable v6 is present the lerd network is created v4-only instead. Existing networks whose schema doesn't match the current host (dual-stack on a v6-less host, or v4-only on a host that now has v6) are recreated in place on the next lerd install: attached containers stop, the network is recreated with the right schema, the previous network_dns_servers list is restored, and the containers restart. When dual-stack is in use, nginx vhosts listen on 0.0.0.0 and [::], lerd-dns answers AAAA records for *.test (::1 locally, the host's primary global v6 when lerd lan:expose on), and every managed PublishPort in service quadlets is paired (a 127.0.0.1:5432 bind also gets a [::1]:5432). To opt out, set an explicit subnet via podman network create before lerd install runs, or override the lerd-* quadlets to remove the [::] / [::1] lines before they're written.
Binding symmetry is preserved across stacks: 127.0.0.1 maps to [::1] and 0.0.0.0 maps to [::], so a loopback-only service on v4 stays loopback-only on v6. Services bound through pasta (quadlets without a Network= line) remain v4-only because pasta can't bind v6 ports in the current version. Two pitfalls to be aware of: host firewall rules that only filter IPv4 (iptables without matching ip6tables, or a firewall UI that only surfaces v4) leave v6 ports reachable even when the equivalent v4 rule blocks them; and lerd lan:expose on will answer AAAA with the host's primary global-unicast v6, which on a SLAAC-addressed LAN can be reachable beyond the v4 NAT boundary. Both are covered in more detail under Security caveats.
Podman Quadlets: containers are defined as systemd unit files (.container files) managed by the Quadlet generator. This means systemctl --user start lerd-nginx works like any other systemd service, and containers restart on failure and at login.
Shared nginx: a single nginx container serves all sites via virtual hosts. nginx uses a Podman-network-aware resolver to route fastcgi_pass to the correct PHP-FPM container by hostname.
Shared hosts files: containers resolve .test domains through two generated files that lerd bind-mounts as /etc/hosts. ~/.local/share/lerd/hosts goes into every PHP-FPM container so server-side HTTP from PHP reaches local sites, and it also carries the host.containers.internal entry that Xdebug and host-database overrides rely on. ~/.local/share/lerd/browser-hosts goes into services declaring share_hosts: true, today Selenium, so Dusk and Pest Browser can reach a site. Every other service quadlet mounts the first file too, for a second reason: without an explicit mount Podman derives the container's /etc/hosts from base_hosts_file, which defaults to the host's own, so anything you have added there for your own use leaks in and can shadow a container name that Podman's DNS would otherwise resolve. Mounting a lerd-managed file cuts that inheritance. Both files pin lerd-nginx's address on the Podman bridge, and Podman assigns a fresh one every time the container is recreated, so a reboot that brings the quadlet units up without lerd start would otherwise leave every container resolving .test to a dead address. A background watcher inside lerd-ui inspects the nginx container once per 30-second tick and repoints both files when the address has moved. The same loop keeps host.containers.internal on a routable address across network changes, reprobing only when the host's primary LAN IP changes, since that probe is a container exec and costs far more than the inspect.
Per-version PHP-FPM: each PHP version gets its own container built from a local Containerfile. The image includes all extensions needed for Laravel out of the box: pdo_mysql, pdo_pgsql, bcmath, mbstring, xml, zip, gd, intl, opcache, pcntl, exif, sockets, redis, imagick.
Automatic volume mounts: the PHP-FPM and nginx containers bind-mount $HOME by default. When a project lives outside the home directory (e.g. /var/www, /opt/projects), lerd automatically adds the extra volume mount to both containers and restarts them. This happens transparently during lerd link, lerd park, or the first lerd php / composer / laravel new invocation from the outside path.
Ephemeral system trees (/tmp, /var/tmp, /run, /proc, /sys, /dev) are deliberately excluded from auto-mounting: they vanish on reboot and would leave containers with dead mounts, and IDEs dropping randomly named temp files there would otherwise cascade container restarts. Running lerd php from such a path is refused with a clear message rather than an opaque runtime error. To opt a specific scratch root in anyway (common for AI coding agents whose session files live under /tmp), list it under mounts: in ~/.config/lerd/config.yaml:
mounts:
- /tmp/claudeEach entry is bind-mounted at the same location in the PHP-FPM and nginx containers, on the next lerd start or on the first lerd php invocation from a path it covers. Paths are mounted verbatim (host path equals container path), so a script's working directory and file arguments resolve unchanged.