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

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.

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.

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.

Released under the MIT License.