Skip to content
← All posts

PHP Base Images v1

The images size their own FPM pool from measured memory and expose the queue-depth signals a scale-out decision needs. What they didn't have was version numbers. v1 adds releases, a channel tag that gets weekly security patches without crossing a tooling major, and tags that only move after the image passes.

· 10 min read · Sylvester Damgaard
PHP Base Images v1

I've been building PHP base images since 2022. They started under a different org, phpeek, and stayed private for the first three years: an internal thing our own deploys pulled from and nobody else saw. They moved to cboxdk and went public about a year ago, and until last week they still had no version numbers. You pulled 8.4-bookworm and got whatever main built last. No release notes, no way to say "we're on this one", no way to go back.

That's fine while the only person pulling the image is the person building it. Cbox PHP Base Images is now at v1: real releases, tags that only move once the image has been verified, and a channel tag you can pin in production without watching it rot.

Rolling tags make you pick

A PHP image is mostly operating system. Debian packages, nginx, OpenSSL, curl, the runtime itself. All of that gets CVEs, forever. So you want two things from a tag, and rolling only gives you one.

You want security patches without having to ask, because last month's image has this month's known holes in it. You also want the behavior not to shift under you, because a tag that quietly picked up a new nginx major is a tag that broke your Tuesday. Rolling gives you the first and refuses the second. Digest pins do the reverse.

Most teams I've watched go the digest route, because a broken deploy is loud and an unpatched CVE is quiet. Then they forget, and nine months later they're running an image with a long list of fixable highs in it. That's the failure mode, and it isn't carelessness. Both of the options on offer were wrong, just in different directions.

Channel tags

v1 adds a third kind of tag. A channel tag gets rebuilt weekly with OS security patches and never crosses a tooling major:

bash
ghcr.io/cboxdk/php-baseimages/php-fpm-nginx:8.5-bookworm-v1

The -v1 is the channel. Inside it, packages move forward every week and nothing that could change how your container behaves moves at all. When there's a v2, the v1 channel keeps getting rebuilds for six months, built from its own release branch, so you get a window to move instead of a deprecation notice and a shrug.

Three tag kinds now, and they mean different things on purpose:

  • 8.5-bookworm follows the latest release. Good for development, wrong for production.

  • 8.5-bookworm-v1 is the channel. Pin this one.

  • A digest is immutable, for when you need byte-for-byte reproducibility and want to own the update cadence yourself.

What's in the images

Before the release model, the thing being released. Official PHP images give you PHP. These give you a container that knows something about how it's being run, and most of that comes from Cbox Init as PID 1. It supervises PHP-FPM, nginx, queue workers, Horizon, Reverb and schedulers as a dependency graph, with health-check-driven restarts, /readyz and /livez owned by the supervisor rather than the app, and warmup hooks that hold traffic until the app is ready.

Then there's what happens to capacity.

Capacity that gets measured

pm.max_children is the number everyone guesses. Here it's seeded at boot from the container's cgroup limits instead of from something someone typed into a config in 2019. Set CBOX_FPM_TUNE=true and fpm-tune keeps re-sizing it at runtime from live per-worker memory: atomic drop-in file, graceful reload, no dropped connections. I wrote about why that number is a guess in pm.max_children Is a Guess.

1.3.0 fixed the thing that made me uneasy about recommending it. The tuner sized the pool from memory alone, so a pool that was queueing because the host's CPU was saturated got handed more workers, with no core free to run them. Cost: 16% of CPU-bound throughput. Turning the tuner on could make a CPU-bound app slower than leaving it off.

fpm-tune 1.1.0 holds the pool at its current size once CPU headroom is gone. Over 90 seconds of saturated load the pool stays put and throughput matches the untuned default, 363 requests per second against 360. Growing into a full CPU was worse than doing nothing, and the fix is mostly the tuner learning to do nothing.

Vertical and horizontal are different questions

fpm-tune answers "how many workers should this container run". It doesn't answer "should there be another container". Those need different signals, which is why the images ship both.

The signals CPU and memory don't give you

A PHP-FPM pool that's out of workers looks idle from outside. CPU is low, memory is flat, and the requests pile up in a queue nothing outside the pool can see. Your autoscaler sits still while the app times out.

fpm-exporter ships in every image, disabled by default, on CBOX_FPM_EXPORTER=true. It discovers the FPM pools over FastCGI itself and exposes phpfpm_* on port 9114: listen queue depth, worker saturation, and how often the pool hit max children. Those are the scale-out signals.

Running a tuner and an exporter in one container only helps if you don't have to scrape three endpoints to assemble one story. Since 1.2.0 the main endpoint on port 9090 carries the whole container's telemetry. fpm_tune_* rides on it natively while the tuner runs, and with the exporter enabled phpfpm_* and laravel_* get federated onto the same scrape. A source that's down degrades to up 0 instead of failing the scrape, and an exporter that was never enabled emits nothing rather than a permanent zero.

The weekly rebuild wasn't rebuilding

"Rebuilt weekly with security patches" was on the box for a while before it was true. Two bugs, both caught by the same gate.

The layer cache replayed itself. CI had no cache busting on the root stages, so a scheduled rebuild reused cached apt layers whenever nothing else had invalidated them, and the weekly promise only held on weeks when upstream PHP happened to push a new digest. Root stages now take an ISO-week CACHE_REFRESH build-arg: scheduled rebuilds bust the cache once a week, ordinary pushes during the week keep full cache speed.

The second one is that apt-get install doesn't upgrade what you didn't name. The parent image ships packages our Dockerfiles never mention, and those weren't being patched at all. The case that exposed it was libssh2, pulled in via curl, with a fix already sitting in bookworm-security. The pre-promotion CVE gate refused to promote three cascades in a row before I worked out why. The weekly refresh layer now runs a real apt-get upgrade against bookworm-security, so the promise covers every package in the image and not just the ones on our install list.

Both bugs were invisible from outside

The images built, the tags moved, the badge was green. Without a gate that checks the result against published security data, I'd still be shipping stale layers and calling them weekly.

Tags move only after the image passes

The old flow built an image and tagged it in the same step. If the smoke test failed afterwards, the tag already pointed at the bad image and someone had to notice.

v1 splits it. The build pushes by digest. The Trivy regression gate and the per-image smoke tests then run against that pushed image rather than a local build artifact, and only once both pass do the production tags move. A failure leaves every tag pointing at the previous good image, so a bad build is a non-event instead of an incident.

Promotion is a manifest operation, so provenance and SBOM attestations carry across unchanged. That took some verifying. Attestations have a habit of not surviving multi-arch manifest merges.

About that CVE baseline

Builds fail on any new fixable CRITICAL or HIGH that isn't in a triaged baseline. The baseline holds 84 entries, each annotated with its fix path.

Eighty-four isn't zero and I'm not going to pretend it is. Most of them are packages where Debian hasn't shipped the fix yet, which isn't something a base image can solve on its own. What the gate guarantees is direction: nothing new gets in, and every entry has a note saying what it's waiting on. Full unfiltered scans still go to the GitHub Security tab, so you get the whole picture rather than the filtered one.

Provenance and SBOMs you can find

Every image carries SLSA provenance and an SPDX SBOM, built with BuildKit's mode=max. They were on the images for months before 1.2.1 and nobody could find them, which makes them decoration rather than supply chain.

SECURITY.md now shows the one-liner, and GitHub releases carry the default-PHP images' SBOMs as downloadable assets:

bash
docker buildx imagetools inspect \
  ghcr.io/cboxdk/php-baseimages/php-fpm-nginx:8.5-bookworm-v1 \
  --format '{{ json .Provenance }}'

What 1.0.0 removed

Cutting a 1.0 was also the moment to stop maintaining things nobody was running. Alpine, Debian 13 and the Ubuntu-based FrankenPHP and Swoole variants are gone. Everything is Debian 12 with glibc.

Every OS variant multiplies the build matrix and the test surface, and the musl ones generated a steady trickle of "works on Debian, not on Alpine" issues in exchange for an image size win that mattered less every year. Four tiers cover the ground instead: slim, standard, chromium, dev. Default PHP is 8.5, with 8.2 through 8.5 supported.

The lifecycle banners were lying

One more fix, because it was actively misleading people. versions.json carried EOL dates from before PHP's 2022 policy change, so images for 8.2 and 8.3 printed deprecation banners for versions that are still under security support until the end of 2026 and 2027.

Lifecycle is now two dates, active_support_until and security_support_until, both checked against php.net. There's a new security-only state that prints nothing at all, because security-only is a normal supported phase and not a reason to shout at someone's build log.

Shipping to myself, or shipping to you

Nobody picks a base image because it has digest promotion. People pick one for what it does at runtime, and the tuner and the metrics are why you'd pick this one. The release engineering is what makes it reasonable to ask someone else to run it.

Rolling tags were honest while I was the only one pulling them. They stop being honest the moment someone else has to answer "what changed since Friday" and the only true answer is "whatever landed on main".

The full changelog has every release since 1.0.0, including the 1.3.0 cold start work that took container start to first HTTP 200 from 6.15s down to 1.26s. That one's its own post.

Sylvester Damgaard
Sylvester Damgaard

The person behind Cbox. Has been writing code and running servers since 2000. Built the CMS and infrastructure behind TV2's regional news sites, co-founded a drone inspection startup, and makes open source packages for PHP teams.