Skip to content

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

ComponentTechnology
CLIGo + Cobra, single static binary
Web serverPodman Quadlet (nginx:alpine)
PHP-FPMPodman Quadlet per version (locally built image with all Laravel extensions)
PHP CLIphp binary inside the FPM container (podman exec)
Composercomposer.phar via bundled PHP CLI
Nodefnm binary, version per project
ServicesPodman Quadlet containers
DNSdnsmasq container + NetworkManager or systemd-resolved integration
TLSmkcert, locally trusted CA
Debug bridgeAlways-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.
NotificationsSingle 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:

yaml
mounts:
  - /tmp/claude

Each 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.

Released under the MIT License.