Skip to content

feat(image): multi-stage PHP-FPM build cuts image size by ~60%#364

Merged
geodro merged 4 commits into
mainfrom
slim/php-image-on-358
May 17, 2026
Merged

feat(image): multi-stage PHP-FPM build cuts image size by ~60%#364
geodro merged 4 commits into
mainfrom
slim/php-image-on-358

Conversation

@geodro
Copy link
Copy Markdown
Owner

@geodro geodro commented May 17, 2026

Stacked on top of #358 (now merged into main). Refactors the PHP-FPM Containerfile into a builder + runtime two-stage build so the ~800MB of autoconf/make/g++/-dev headers used to compile extensions doesn't carry into the final image.

localhost/lerd-php85-fpm:local goes from 1.36 GB to 535 MB after #358's base, a 60% reduction. Same set of PHP extensions loads (68 modules), composer/node/npm/ghostscript/imagemagick/ffmpeg/mysql-client/zsh toolchain all intact.

The custom-extension path needed a small fix. apkDepsForExt installs build-time apk packages in the builder stage, but their compiled .so files need those libs at runtime too (e.g. imap needs c-client to dlopen). A new buildCustomExtRuntimeDeps emits a single apk RUN line in the runtime stage that reinstalls the same packages, so user-added extensions keep working after the refactor.

baseContainerfileHash updated to strip the new {{.CustomExtensionsRuntime}} placeholder so pre-built base image cache keys stay stable.

Also includes a fix for #358's legacy-alpine break: PHP 7.4 and 8.0 pin to alpine 3.16 which doesn't ship starship/eza/zoxide, so their image builds failed after #358 merged. The zsh layer now splits the apk add into a must-have core (zsh, fzf, bat) and an optional set behind || true, so legacy tiers get a degraded but working shell instead of failing the build.

@geodro geodro changed the base branch from feat/zsh-container-shell to main May 17, 2026 09:55
geodro added 3 commits May 17, 2026 12:55
The PHP-FPM image was carrying ~800MB of build toolchain (autoconf,
make, g++, linux-headers, and a long list of -dev headers) that pecl
and docker-php-ext-install need at compile time but nothing needs at
runtime. Splitting the Containerfile into a builder stage that compiles
every extension and a runtime stage that only ships musl runtime libs +
the compiled .so files brings localhost/lerd-php85-fpm:local from
1.36 GB to 535 MB on PR #358's base, with no functional regressions.

A new buildCustomExtRuntimeDeps helper emits a single apk RUN line in
the runtime stage that reinstalls the apk packages user-configured
extensions need. Without this, custom extensions like imap (which
needs c-client at runtime, not just at build time) would compile in
the builder fine but fail to dlopen at runtime. The well-known imap
case verified the round-trip works.

baseContainerfileHash now strips the new {{.CustomExtensionsRuntime}}
placeholder so pre-built base image cache keys stay stable.

All other functionality preserved: every PHP extension (68 modules
including imagick, redis, mongodb, igbinary, pcov, xdebug, opcache,
all base extensions), composer, node + npm, ghostscript, imagemagick,
ffmpeg, mysql-client, and the zsh/starship/etc shell environment from
PR #358.
pecl install redis prompts for optional serializer support (igbinary,
lzf, lz4, zstd, msgpack, json) and other pecl modules may also prompt
on certain podman build environments. The custom-extensions helper
already pipes yes '' to feed default answers; doing the same for the
inline pecls (redis, imagick, igbinary, mongodb, pcov, xdebug) makes
lerd php:rebuild work unattended on any host.
Three new Go tests cover the runtime-deps helper: empty input produces
nothing, the runtime block lists every package the builder block
installs (so compiled .so files can dlopen at runtime), and shared
deps across multiple extensions get deduped to a single apk add line.
The MatchesBuilderDeps case explicitly guards against the bug fixed in
this PR where custom extensions like imap compiled fine but failed to
load because their c-client runtime lib never made it past the builder
stage.

The base-images workflow already strips template placeholders to hash
the Containerfile; it now strips {{.CustomExtensionsRuntime}} too so
cache keys stay stable. After each per-arch push, a new smoke step
pulls the just-built amd64 image, counts loaded PHP modules, fails if
the count drops below 50, and verifies a handful of load-bearing
extensions (curl, gd, intl, mbstring, pdo_mysql, redis, xdebug, zip)
are present plus that PDO has its three drivers and composer runs.
Catches silent extension drops at publish time instead of on user
rebuilds 24 hours later.
@geodro geodro force-pushed the slim/php-image-on-358 branch from a957a6c to 431e841 Compare May 17, 2026 09:56
The zsh layer that landed in #358 fails on legacy PHP tiers (7.4 / 8.0)
because they pin to alpine 3.16, which doesn't ship starship, eza, or
zoxide in its repos. Split the apk add into two: the always-available
core (zsh, fzf, bat) and the optional set behind || true. The zshrc
already gates starship init behind command -v so the conditional
wiring was already correct.

PHP 8.1+ on alpine 3.18+ keeps the full shell experience; legacy
tiers fall back to zsh + fzf + bat which is still better than bare sh.
@geodro geodro merged commit 0b16aa5 into main May 17, 2026
3 checks passed
@geodro geodro mentioned this pull request May 18, 2026
geodro added a commit that referenced this pull request May 18, 2026
First beta of the 1.21.0 line. The headline is desktop notifications via Web Push (#353), with a per-category settings page polished alongside a dashboard health row (#354). The PHP-FPM image grows a real shell environment, zsh plus starship plus eza, bat, fzf, zoxide, isolated from the host (#358), then loses around 800 MB of build toolchain in a multi-stage split that drops the image from 1.36 GB to 535 MB without losing any of its 68 PHP modules (#364). A new on-demand commands feature surfaces one-shot framework actions across the dashboard, the lerd run CLI, the command palette, and four new MCP tools, all backed by a generalised Dropdown component that replaces every native select in the UI (#363). The site detail header gets a browser-style address bar with the favicon, TLS lock, LAN-share chip, and worktrees promoted from a dropdown to tabs (#365), an Env tab joins Overview, Tinker, and Dumps to show the project .env verbatim (#366), and the tray menu picks up Dump bridge and Notifications toggles that update live via a new KindDumpsStatus event (#373). Postgres grows 17 and 18 alternates alongside a new MySQL 9.7 LTS line, all gated by a canonical-version pin so flipping the yaml canonical no longer silently major-jumps existing installs (#361). Türkçe joins the dashboard languages (#355), a public_dir override lands in .lerd.yaml for projects with a non-standard document root (#370), every git invocation in the tree now flows through internal/git (#356), and worker-failure pushes are batched so a systemd cascade no longer fires six near-identical notifications back to back (#372). Plus the post-1.20.2 fix queue covers the worktree-manager button rendering on non-git sites (#357), TLS certs not refreshing when a secured site's domain set changed (#367), streamed worktree install and a wave of audit follow-ups (#368), and tinker swallowing bare-expression results when the dump bridge was on (#371).
geodro added a commit that referenced this pull request May 18, 2026
The multi-stage rebuild in #364 cut image size by moving git into the builder stage, where it was only needed for the phpredis/imagick fallback clones. The runtime stage lost it without anyone noticing. Composer falls back to dist for most flows, but VCS-typed repositories declared in composer.json hard-require a git clone, and any plugin that shells out to git for tag detection or repo state breaks the same way. Re-adding it to the runtime apk add brings the previous behaviour back at the cost of a few megabytes, and a smoke test on the embedded Containerfile catches a future drop.
@geodro geodro mentioned this pull request May 19, 2026
geodro added a commit that referenced this pull request May 19, 2026
The 1.21.0 line graduates from beta with eight follow-up commits on top of v1.21.0-beta.1. A LAN-exposure audit closes three dashboard endpoints that were reachable on lan:expose installs (raw .env, push-test, an unauthenticated mailpit webhook) and adds path-traversal validation for the new public_dir override (#382). mysql and mariadb pick up catatonit as PID 1 via a new init flag on the preset schema, so podman stop returns in around a second instead of timing out at 30s and lerd service restart stops wedging at the 30-90s mark (#383, closes #380). Host workers stopped via the UI or lerd worker stop no longer resurrect on the next fsnotify event or launchd heal tick, and the same fix puts lerd's bin directory on PATH for npm-spawned subprocesses so wayfinder and friends can find php (#375, #376, closes #381). The PHP-FPM runtime stage gets git back after the multi-stage split in #364 dropped it (#377), restoring VCS-typed composer repositories. Notification clicks land on the right tab now: worker_failed deep-links via the site's primary domain and dump arrivals jump straight to the Dumps sub-tab (#384). The .lerd.yaml container block accepts a target field for multi-stage Containerfiles, with the cache key mixing target in so flipping stages on an unchanged file actually rebuilds (#385, addresses #379), and the MCP service_add tool picks up the matching init argument so agent-driven flows reach feature parity with the YAML path (#386). And a security pass bumps jwt-go to 5.2.2, svelte to 5.55.8, and kysely to 0.28.17 closing one high-severity JWT header-parsing flaw, three medium svelte XSS paths, and one high kysely JSON-path traversal injection (#387).
geodro added a commit that referenced this pull request May 19, 2026
The v1.21.0 release tag tripped the Docs workflow because the #364 changelog entry mentions `{{.CustomExtensionsRuntime}}` verbatim, and Vue's compiler parses interpolations even inside backtick code spans. The leading dot fails JS expression parsing and the build halts with `Unexpected token (1:1)`. Same shape as the previous Resources.Memory case we fixed by wrapping the token in an explicit <code v-pre> element so Vue skips it.

Docs CI is gated to non-prerelease tags so the issue never showed up on v1.21.0-beta.1, only surfacing now that stable shipped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant