Runtime
#LaraOCI
Production-ready OCI images for Laravel applications: secure defaults, a single
shared runtime layer, and a maintained security posture. You bring vendor/ and
your code; LaraOCI brings a correct PHP runtime, signal handling, logging, and
weekly rebuilds.
Status: pre-release. M0 (foundations), M1 (the shared
runtimelayer), M2 (cli,fpm,builder) and M3 (queue,scheduler, opcache preload) are complete; publishing, signing and scanning arrive in M4. Nothing is published yet - the image references below are what M4 will push; until then images are built locally withbin/build-chain.sh. Seedocs/laraoci-Spec.md.
#Image catalog
| Image | Parent | Purpose |
|---|---|---|
runtime |
php:X.Y-fpm-trixie |
Shared layer: extensions, user, ini, entrypoint, imagick policy |
cli |
runtime |
Artisan commands, migrations, one-off tasks |
fpm |
runtime |
PHP-FPM behind Nginx/Caddy/Traefik |
builder |
runtime |
Composer + Node build toolchain (not for production runtime) |
queue |
cli |
Laravel queue worker - drains on SIGTERM, raise your grace period |
scheduler |
cli |
Laravel scheduler - run exactly one replica |
#Configuration
Every image is configured through the environment; the entrypoint renders it
into /usr/local/etc/php/conf.d/zz-laraoci.ini at start, so any lexically later
mount still wins. The full table is §6.4 of the spec - what follows is what you
are most likely to need.
#LARAOCI_DEFAULT_BINARY - what a dash-first argument means
| Image | Value |
|---|---|
fpm |
php-fpm |
cli, builder, queue, scheduler |
php |
A signal, not a knob. Docker's official PHP images prepend php-fpm to any
command whose first argument starts with a dash, which is right for fpm and
wrong for everything else - docker run cli -r 'echo 1;' would start an FPM
master. This variable is how each image says which binary it means, and it is
read once by the LaraOCI entrypoint. Overriding it changes what a dash-invoked
command starts; it is documented so docker inspect answers the question
without anyone reading a Dockerfile.
#LARAOCI_ALLOW_UNWRITABLE_CONFIG — for a read-only root filesystem
The entrypoint renders ${PHP_INI_DIR}/conf.d/zz-laraoci.ini at start, and that
file is how every PHP_* variable reaches PHP. It is pre-created owned by the
image's own uid, so a container run as a different user — docker run --user 5000, or a securityContext.runAsUser that does not match — cannot write it
and will refuse to start, rather than serving traffic on the build-time
defaults while ignoring everything you set.
If the build-time configuration is what you want — a read-only root filesystem,
say — set LARAOCI_ALLOW_UNWRITABLE_CONFIG=1. The container then starts and
logs which file it could not write. Your PHP_* overrides will not apply.
#Preload - opt-in opcache preloading
Every image ships a preload script at /usr/local/share/laraoci/preload.php. It
is inert until you name it, and naming it is necessary but not sufficient:
PHP_OPCACHE_PRELOAD=/usr/local/share/laraoci/preload.php PHP_OPCACHE_MEMORY_CONSUMPTION=320
Preload is FPM-only by default. The entrypoint writes the opcache.preload
directive only when the process it is about to start is php-fpm. opcache in a
CLI process is per-process - the shared segment is created at startup and
destroyed at exit - so on cli every php artisan call would compile the whole
framework and throw the result away.
Long-lived CLI workers opt in with LARAOCI_PRELOAD_FORCE=1. queue bakes
that flag, because a queue worker compiles once and then serves thousands of jobs
from the warm segment. It still does nothing until you name the script:
services: queue: image: ghcr.io/laraoci/queue:8.4-trixie environment: PHP_OPCACHE_PRELOAD: /usr/local/share/laraoci/preload.php PHP_OPCACHE_MEMORY_CONSUMPTION: 320
A wrapper command disables it. The gate reads the first argument - argv[0]
- so anything that is not the PHP binary itself reads as "not an FPM process":
# preload ON command: ["php-fpm"] # preload OFF - argv[0] is `sh`, and the reason is logged at start: # laraoci: PHP_OPCACHE_PRELOAD is set but 'sh' is not an FPM process; ignoring command: ["sh", "-c", "php-fpm"]
command: ["sh","-c",…] is the usual shape in a Kubernetes manifest, and an init
shim or a wait-for-it-style wrapper has the same effect. This is deliberate -
the gate fails closed rather than enabling a preload the process would throw away
- so if you need a wrapper, invoke the binary directly where you can, or set
LARAOCI_PRELOAD_FORCE=1to state that this process is long-lived.
| Variable | Default |
|---|---|
PHP_OPCACHE_PRELOAD |
(unset) |
LARAOCI_PRELOAD_FORCE |
(unset; 1 on queue) |
LARAOCI_PRELOAD_ROOT |
/var/www/html |
LARAOCI_PRELOAD_PATHS |
vendor/laravel/framework/src/Illuminate,vendor/composer |
LARAOCI_PRELOAD_IGNORE |
/tests/,/Tests/,/stubs/,/Stubs/,/Testing/,/migrations/,/resources/,/Console/ |
Both list variables replace the list; neither appends. To preload your own classes as well, restate the defaults:
LARAOCI_PRELOAD_PATHS=vendor/laravel/framework/src/Illuminate,vendor/composer,app
vendor/symfony is deliberately absent from the default. Illuminate already
pulls in the Symfony components it uses; compiling the whole tree adds Mailer and
HttpKernel subtrees most applications never touch, for no hit-rate gain.
/Console/ is ignored by default, and that follows from the line above.
Without the Symfony tree, Illuminate\Console\Command cannot resolve its parent
class, so it and the ~78 artisan commands extending it cannot be preloaded - they
only produce warnings. Console code is on no hot path either: fpm never touches
it while serving a request, and queue loads it once at worker start. Measured
on a Laravel 13 tree, ignoring it removes 131 of 176 startup warnings and 2.4 MB
of preload memory, and costs 80 preloaded classes that were doing nothing.
If your workload really is artisan-heavy and you want them back, restate the list without that last entry:
LARAOCI_PRELOAD_IGNORE=/tests/,/Tests/,/stubs/,/Stubs/,/Testing/,/migrations/,/resources/
You will still see about 45 Can't preload unlinked class warnings at start
on a typical Laravel application, naming parents in Symfony\…, Psr\…,
Monolog\… and GuzzleHttp\…. That is expected output, not a fault: those
packages are outside the preload path set by design, so classes extending them
are compiled but not preloaded. Silencing them entirely would mean preloading
those trees too, at a memory cost the default deliberately declines.
Four things to know before you turn it on:
-
It needs
vendor/at container start. With an empty root the script no-ops, logs zero and the container starts normally - silent, not fatal, by design. If you bind-mount your application, preload compiles whatever was there when the container started. -
It costs opcache memory. Preloaded entries count against
opcache.memory_consumption, which is why the pairing above raises it to 320 MB.opcache.max_accelerated_files(20000 by default) must exceed the preloaded file count, or compilation stops part way and the startup line reports a number lower than reality. -
Changing anything preloaded requires a container restart. Consistent with
opcache.validate_timestamps = 0. -
Measure, do not guess. The startup line tells you it ran:
laraoci: preload enabled: /usr/local/share/laraoci/preload.php [31-Jul-2026 10:52:49 UTC] laraoci: preloaded 1325 files, skipped 362
Both lines go to stderr. The first is the entrypoint's; the second is the script's, carrying the timestamp
error_log = /proc/self/fd/2adds. If you setlog_errors = Off, the second line disappears - the preload still runs.and
opcache_get_status()tells you what it cost:docker compose exec queue php -r \ 'print_r(opcache_get_status()["preload_statistics"]);'
That reports the memory consumed and the class/function/script counts. Tune against those numbers rather than against a target someone else measured.
You do not need opcache.preload_user. It is only required when the FPM master
runs as root, and LaraOCI runs as laravel throughout.
#fpm - pool sizing and shutdown
| Variable | Default | Notes |
|---|---|---|
PHP_FPM_PM |
dynamic |
|
PHP_FPM_MAX_CHILDREN |
20 |
|
PHP_FPM_START_SERVERS |
4 |
|
PHP_FPM_MIN_SPARE_SERVERS |
2 |
|
PHP_FPM_MAX_SPARE_SERVERS |
6 |
|
PHP_FPM_MAX_REQUESTS |
500 |
|
PHP_FPM_PROCESS_CONTROL_TIMEOUT |
10s |
the graceful-drain ceiling - see below |
The entrypoint refuses to start if any of these is unset or empty, so a typo is a loud failure rather than a blank directive php-fpm silently accepts.
fpm ships STOPSIGNAL SIGQUIT, which php-fpm reads as "finish in-flight
requests, then exit". That signal alone is not enough: with FPM's default
process_control_timeout = 0 the master takes the graceful code path and then
exits without waiting for its busy children, truncating the response anyway.
PHP_FPM_PROCESS_CONTROL_TIMEOUT is the ceiling that makes it real - it is not a
delay, so an idle container still stops in ~0.2s.
If your requests can run longer than 10s, raise this and your orchestrator's
grace period together (docker stop -t, or terminationGracePeriodSeconds).
Raising only one silently keeps the shorter of the two.
You will also see these two lines at every start. They are expected output,
not a misconfiguration - the master runs as laravel, and a non-root master
cannot setuid. The directives are kept because they are the only thing
standing between docker run --user 0 and workers running as www-data:
NOTICE: [pool www] 'user' directive is ignored when FPM is not running as root NOTICE: [pool www] 'group' directive is ignored when FPM is not running as root
#fpm - healthcheck and endpoints
The image ships its own HEALTHCHECK; you do not need to write one.
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3
It speaks FastCGI straight to the pool with cgi-fcgi, so it exercises the same
socket your web server uses rather than a side channel. Two pool-internal
endpoints back it:
| Endpoint | Purpose |
|---|---|
/fpm-ping |
returns pong - what the healthcheck asks for |
/fpm-status |
FPM's own status page, for operators |
Both are answered by FPM itself and never reach PHP, so an application route
cannot shadow them, and they keep working when your application does not. See
Security notes before routing to /fpm-status.
In Compose, condition: service_healthy is what turns this into a real gate:
services: fpm: image: ghcr.io/laraoci/fpm:8.4-trixie nginx: depends_on: fpm: condition: service_healthy
#builder - mountable cache paths
| Variable | Default |
|---|---|
COMPOSER_HOME |
/home/laravel/.composer |
NPM_CONFIG_CACHE |
/home/laravel/.npm |
Both are part of the contract, so you can mount BuildKit caches at them. Both are
owned 1000:1000, and the image runs as laravel, so pass the ids - a cache
mount that only root can write is a silent no-op:
FROM ghcr.io/laraoci/builder:8.4-trixie AS build COPY composer.json composer.lock ./ RUN --mount=type=cache,target=/home/laravel/.composer,uid=1000,gid=1000 \ composer install --no-dev --no-scripts --prefer-dist --no-interaction COPY . . RUN --mount=type=cache,target=/home/laravel/.npm,uid=1000,gid=1000 \ composer dump-autoload --optimize --classmap-authoritative \ && npm ci && npm run build FROM ghcr.io/laraoci/fpm:8.4-trixie COPY --from=build --chown=laravel:laravel /var/www/html /var/www/html
builder is not a production runtime. It carries a package manager, a VCS
client and a JavaScript runtime; use it in a build stage and copy the result
into fpm, cli, queue or scheduler.
Limitation - no native-module toolchain. npm packages that compile through
node-gyp will fail: python3 is not installed. The C/C++ toolchain inherited
from the upstream PHP image (make, gcc, g++) is present but is not
sufficient on its own. Packages shipping prebuilt binaries are unaffected, which
covers the ordinary Laravel front-end stack. If you need node-gyp, add
apt-get install -y python3 to your own build stage.
#queue - graceful shutdown, and the one setting you must change
The image ships:
CMD ["php","artisan","queue:work","--no-interaction","--tries=3","--max-time=3600","--rest=0.1"]
| Flag | Why |
|---|---|
--tries=3 |
A poison job reaches failed_jobs instead of looping forever |
--max-time=3600 |
Bounds worker lifetime so leaks and stale state never accumulate |
--rest=0.1 |
Yields the CPU for 100 ms between jobs |
--no-interaction |
A prompt in a container is a hung worker |
queue:work traps SIGTERM through pcntl and finishes the job in hand before
exiting. The image carries STOPSIGNAL SIGTERM, pcntl/posix are compiled in,
and tini is PID 1 forwarding the signal - so docker stop drains rather
than truncates.
⚠️ Your orchestrator's grace period must exceed your job timeout. Laravel's default job timeout is 60 s. Docker's default stop grace is 10 s. Against those defaults every rolling deploy kills in-flight jobs after ten seconds, no matter how correct this image is. The image cannot fix this - it does not know how long your jobs run.
docker stop --timeout 120 my-worker
# compose services: queue: image: ghcr.io/laraoci/queue:8.4-trixie stop_grace_period: 120s
# kubernetes spec: template: spec: terminationGracePeriodSeconds: 120
Pick a number above your longest job's timeout, and raise both together if you
change either. --timeout is deliberately not set on the shipped CMD, so
Laravel's 60 s default applies until you set one.
What getting it wrong looks like. A 20 s job against a 2 s grace, which is the same shape as a 60 s job against Docker's 10 s default:
$ docker stop --timeout 2 worker # returns after 2.4s
$ docker inspect worker --format '{{.State.ExitCode}}'
137 # 128+9 = SIGKILL
The job wrote its start marker and never its end. Exit 137 on a worker is the
signature of this specific mistake - the grace period expiring on a container that
was still working. A drained worker exits 0, and docker stop returns in the
job's remaining time rather than at the ceiling.
No HEALTHCHECK, deliberately. A queue worker has no meaningful synchronous
health signal, and a naive check is worse than none - it reports healthy while the
queue backs up behind a wedged worker. Monitor the queue instead:
php artisan queue:monitor default:100 # on a schedule
or run Horizon, which is built for it.
#scheduler - run exactly one replica
The image ships CMD ["php","artisan","schedule:work","--no-interaction"]: one
foreground process that stays alive, dispatches your due tasks on each minute
boundary, and logs to stdout like every other container. No cron daemon, no log
files, no process tree to reap.
⚠️ Exactly one replica. Two scheduler containers fire every task twice. There is no image-level guard against this and there cannot be - an image cannot know how many copies of itself are running.
# compose services: scheduler: image: ghcr.io/laraoci/scheduler:8.4-trixie deploy: replicas: 1
# kubernetes spec: replicas: 1 strategy: type: Recreate # never two schedulers overlapping during a rollout
Recreate matters as much as replicas: 1: the default RollingUpdate starts
the new pod before terminating the old one, so a rollout briefly runs two
schedulers - and a task due in that window fires twice.
If you need the scheduler to survive a node failure, that is application
configuration, not image configuration. Give the tasks withoutOverlapping() and
point the cache at a shared store (Redis, database) so the lock is visible to
every replica:
Schedule::command('reports:build')->hourly()->withoutOverlapping();
With the default per-container cache store each scheduler takes its own local lock and the guard does nothing.
#Repository layout
config/images.yml- the single source of truth; CI derives the build matrix from it.bin/-matrix.sh,affected.sh,build-chain.sh,size-check.sh,structure-test.sh,fetch-tools.sh, and the container entrypoint.images/- per-image Dockerfiles.tests/- bats unit tests, structure tests, the smoke harness, fixtures..github/workflows/-lint,build(reusable),pr,smoke.
#Local development
The Makefile is convenience only - every recipe is a single delegation to a
script under bin/ or to an already-pinned tool, and CI calls those scripts
directly rather than going through make. It saves typing; it is never the
source of truth.
make # list every target
make tools # fetch the pinned tools (see tools.env for the current set)
make test # unit suite, under the pinned bats
make lint # shellcheck every tracked shell script
make fmt # shell formatting check (make fmt-fix applies it)
make dockerfiles # hadolint every Dockerfile
make actions # actionlint the workflows
make matrix # the full CI build matrix as JSON
make sizes # image sizes against their budgets (advisory)
make structure IMAGE=runtime # add PHP=8.5 for a non-default version
make structure tests the image tag bin/build-chain.sh produces, so build it
first; without PHP= it uses whichever version carries default: true in
config/images.yml.
Run make tools first: the linting targets call the cached binaries under
.cache/tools/bin, so local runs use the same pinned versions as CI. Everything
you should have green before pushing:
make hooks # prints the sequence, so you can paste it
Images below runtime begin FROM ghcr.io/laraoci/runtime:…, a reference that
exists in no registry until M4 publishes one. Build an image together with every
ancestor it stands on:
bin/build-chain.sh --image fpm --php 8.4
The end-to-end suite builds cli, fpm and builder, seeds a Laravel fixture
into a named volume, brings up fpm behind nginx, and tears the whole stack down
unconditionally - including on failure and on Ctrl-C:
tests/smoke/run.sh --php 8.4 # add --keep to inspect the stack
#Supply chain
Every published image carries an SPDX SBOM, SLSA provenance (mode=max), a
Cosign keyless signature, and the full OCI label set. All four are verifiable
from a terminal with nothing installed but cosign, docker and jq.
Every command below was executed against a real image this pipeline published before it was written here - on a staging namespace, by the same workflows that publish production. A verify command that has never been run is worse than none: it teaches you a check that always fails, and you stop running checks. Substitute the tag you are actually pulling.
#Verify the signature
cosign verify \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-identity-regexp '^https://github\.com/laraoci/runtime/\.github/workflows/merge\.yml@refs/tags/v' \ ghcr.io/laraoci/runtime:8.4-trixie-20260811 | jq .
The identity names merge.yml, not release.yml, and that is correct rather
than a typo: Fulcio takes the certificate subject from the OIDC
job_workflow_ref claim, which is the reusable workflow that ran cosign sign. Verified both ways - the merge.yml form succeeds and the release.yml
form exits 1.
The regexp is anchored on refs/tags/v so only tag-built releases verify. An
image built by a manual workflow_dispatch carries a branch ref instead and will
not satisfy this command, deliberately: production images come from tags.
That anchor is the one element here not yet exercised end to end. The signature,
the issuer and the merge.yml subject were all verified against a real signature
on a workflow_dispatch run, whose subject ends @refs/heads/main; the
refs/tags/v form first runs for real on the first tagged release. merge.yml
verifies its own signature in-job on every release, so a wrong subject fails in
CI rather than in your terminal.
Signing is recursive, so a per-platform digest is signed too - the same command
against runtime@sha256:<arch-specific digest> verifies.
#Inspect the SBOM
docker buildx imagetools inspect ghcr.io/laraoci/runtime:8.4-trixie-20260811 \ --format '{{ json (index .SBOM "linux/arm64").SPDX }}' | jq '.packages | length'
Drop the index … wrapper for a single-platform reference. The predicate is
https://spdx.dev/Document. Both architectures carry their own SBOM.
#Inspect the build provenance
docker buildx imagetools inspect ghcr.io/laraoci/runtime:8.4-trixie-20260811 \ --format '{{ json (index .Provenance "linux/amd64").SLSA }}' \ | jq '.buildDefinition.buildType, .buildDefinition.externalParameters.configSource, .runDetails.builder.id, (.buildDefinition.resolvedDependencies | length)'
These are SLSA v1 paths. The older v0.2 shape (.buildType,
.invocation.configSource) does not error against what BuildKit attaches - it
prints null and reads as success, which is the worst possible outcome for a
verification command.
runDetails.builder.id is the workflow run that produced the image. A non-empty
resolvedDependencies is the mode=max marker: the attestation carries the
Dockerfile, the build arguments and the layer source maps, not just a summary.
#Confirm both architectures are present
docker buildx imagetools inspect ghcr.io/laraoci/runtime:8.4-trixie-20260811 \ --format '{{ json .Manifest }}' \ | jq -r '.manifests[].platform | "\(.os)/\(.architecture)"'
unknown/unknown entries in that output are the SBOM and provenance manifests,
not a broken image. Expect one per real platform.
#Choosing a tag
| Tag form | Example | Mutability | Use |
|---|---|---|---|
| PHP minor | :8.4 |
Rolling | Dev, non-critical |
| PHP + Debian | :8.4-trixie |
Rolling | Explicit OS pin |
| Dated | :8.4-trixie-20260811 |
Immutable | Production |
| Patch + dated | :8.4.10-trixie-20260811 |
Immutable | Strict pinning |
| Digest | @sha256:… |
Immutable | Supply-chain-strict |
:latest |
Rolling | Default PHP version only |
Use a dated tag or a digest in production. Rolling tags are repointed by every release and by the weekly rebuild; that is what makes them useful for development and unsuitable for a deployment you want to be able to reproduce.
A dated tag is never overwritten. A same-day rebuild publishes a counter instead:
:8.4-trixie-20260811-2, then -3. So a dated tag you have pinned cannot change
underneath you, and a rebuild is always visible as a new reference.
Keep them current automatically:
// renovate.json { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended"], "packageRules": [ { "matchDatasources": ["docker"], "matchPackageNames": ["ghcr.io/laraoci/**"], "versioning": "regex:^(?<major>\\d+)\\.(?<minor>\\d+)-(?<compatibility>\\w+)-(?<patch>\\d+)(?:-(?<build>\\d+))?$", "pinDigests": true } ] }
The trailing (?:-(?<build>\d+))? is load-bearing: without it the pattern misses
every same-day rebuild, and -20260811-2 is a reference this project really does
publish. compatibility holds the Debian suite, so Renovate will not offer you a
bookworm image as an upgrade from a trixie one. Rolling tags and the
patch+dated form deliberately do not match - the first should not be bumped by a
bot, and the second changes on PHP patch releases as well as dates, which is a
different scheme.
# .github/dependabot.yml version: 2 updates: - package-ecosystem: docker directory: / schedule: interval: weekly
Dependabot updates the tag in your Dockerfile or compose file; Renovate can
additionally pin the digest beside it, which is the strongest form and the one
the signature and SBOM commands above are keyed to.
#Security notes
#/fpm-status is enabled, and your web server must not route to it
fpm ships pm.status_path = /fpm-status. FPM answers it itself, matching
on SCRIPT_NAME before the request reaches PHP — so it is unaffected by your
application's routes, and your application cannot shadow, protect or disable it.
It reports pool internals: process manager, active and idle children, listen
queue depth, slow-request count. With ?full it adds the URI, script and
runtime of every request in flight.
The standard Laravel nginx configuration never exposes it: every dynamic request
arrives as SCRIPT_NAME=/index.php, so FPM never sees /fpm-status. You expose
it by forwarding a raw path — a location ~ \.php$ block combined with a
try_files that passes the original URI, or a broadened Caddy php_fastcgi
matcher. Check your web server config; the failure is silent and on your
side.
/fpm-ping is different and safe to leave reachable: it returns the fixed
string pong and discloses nothing. The image's own HEALTHCHECK depends on it.
#The images run as uid 1000 and expect to write their own config
See LARAOCI_ALLOW_UNWRITABLE_CONFIG.
A container run as another uid refuses to start rather than silently ignoring
your PHP_* settings.
#builder is not a production runtime
It carries a package manager, a VCS client and a JavaScript runtime. Use it in a
build stage and copy the result into fpm, cli, queue or scheduler.
#License
MIT.