The Container Image ​
YOLO deploys your app as a single Docker image to Fargate. That one image runs everything — the web server and, optionally, queue workers and the scheduler — supervised by supervisord.
You own a small Dockerfile; YOLO generates the moving parts (entrypoint, process config) into the build context at build time. This page is the contract between the two.
What yolo init scaffolds ​
yolo init writes a Dockerfile and .dockerignore to your project root. The default Dockerfile is built on FrankenPHP and looks like this:
FROM dunglas/frankenphp:1-php8.4-alpine
# supercronic runs the scheduler's cron as a non-root user (busybox crond can't);
# nodejs is the runtime for Inertia SSR (tasks.web.ssr) — drop it if you don't use SSR;
# mariadb-client + zstd drive the scheduled database dumps (opt-in via `backups:
# true` in yolo.yml) — drop them if this app runs no database backups.
RUN apk add --no-cache git supervisor supercronic nodejs mariadb-client zstd \
&& install-php-extensions intl pcntl bcmath redis pdo_mysql opcache excimer
WORKDIR /app
COPY --chown=www-data:www-data . /app
# Place the generated supervisor config at a default search path so both
# `supervisord` and an interactive `supervisorctl` find it without -c.
COPY docker/supervisord.conf /etc/supervisord.conf
# PHP configuration — YOLO's baseline, generated into the build context unless the
# app publishes its own docker/php.ini. conf.d loads alphabetically, so it sorts
# after the extension fragments and its values win.
COPY docker/php.ini $PHP_INI_DIR/conf.d/yolo.ini
RUN chmod +x /app/.yolo-entrypoint.sh
USER www-data
ENV SERVER_NAME=:8000
EXPOSE 8000
# The entrypoint dispatches on the role argument (default web → supervisord).
# Each ECS task definition passes its own role; a one-off command (e.g. a
# deploy migration) is exec'd directly.
ENTRYPOINT ["/app/.yolo-entrypoint.sh"]
CMD ["web"]Customise it freely — add PHP extensions, system packages, a different base image. Just keep the contract below intact.
The base image is re-resolved every build ​
The scaffolded FROM is a floating tag, so the image it names moves as upstream publishes. yolo build therefore passes --pull to docker build: every build re-resolves the tag rather than reusing whatever the builder happened to cache. A fresh CI runner does that anyway; a machine that has built before would not, and would quietly keep shipping a base runtime that may be months old.
That matters because a base runtime older than the app expects fails at container start, not at build. A mismatch between the image's web-server binary and the version Laravel Octane accepts, for example, makes Octane try to replace the binary in place — which an unprivileged container can't do — so the web program goes FATAL, health checks fail, and the deploy times out while ECS cycles tasks. Pinning the FROM tag to an exact version is the other way to get a predictable base; --pull makes the floating form behave predictably too.
--pull re-resolves FROM only. The layer cache above it is untouched, and still seeds from the last pushed image via --cache-from.
PHP configuration ​
The base image activates no php.ini, so without one PHP runs its compile defaults — 2M uploads, 8M POST bodies, opcache stat'ing an immutable filesystem on every request. Nothing else in the stack imposes a request-body limit, so those two ini values are the app's upload ceiling.
Every build therefore ships YOLO's baseline — a file that sets only deliberate departures from PHP's defaults (request-body limits, immutable-image opcache settings, session upload progress switched off). yolo build bakes it into the build context as docker/php.ini, and the Dockerfile copies it to $PHP_INI_DIR/conf.d/yolo.ini — so every app behaves the same with nothing to publish, and baseline improvements arrive with each YOLO upgrade. An app that needs its own values publishes docker/php.ini (start by copying vendor/codinglabsau/yolo/stubs/php.ini.stub): a published copy is the app's to own — the build ships it untouched and YOLO never rewrites it, so from then on the baseline's movements are yours to adopt by hand.
What YOLO generates ​
During yolo build, YOLO writes three files into the build context that your Dockerfile copies in:
| File | Purpose |
|---|---|
.yolo-entrypoint.sh | The container entrypoint. Runs your start hooks (e.g. php artisan optimize), then dispatches on the container command: a role (web / queue / scheduler) is supervised and traps SIGTERM so the web tier keeps serving across the ALB drain window before forwarding the stop; any other command — a one-off task such as a deploy migration — is exec'd directly (no supervise, no drain). ECS can override the command, not the entrypoint, which is why the dispatch lives here. |
docker/supervisord.conf | The web container's supervisord program tree — FrankenPHP/Octane, plus the queue:work worker and the scheduler unless you've extracted them into their own services (or switched them off with tasks.queue / tasks.scheduler: false). A crontab is generated wherever cron runs (skipped when the scheduler is disabled). A standalone queue that also hosts the scheduler gets a second docker/supervisord.queue.conf. |
docker/php.ini | YOLO's baseline PHP configuration — skipped when the app has published its own copy at that path, which then ships untouched. |
Because these are generated, your Dockerfile doesn't need to know how to run Octane, the queue, or the scheduler — it just copies the config and runs the entrypoint.
The contract ​
For your image to work with YOLO, the Dockerfile must:
- Copy the application into
/app:dockerfileWORKDIR /app COPY --chown=www-data:www-data . /app - Copy the generated supervisord config to a default search path:dockerfile
COPY docker/supervisord.conf /etc/supervisord.conf - Make the generated entrypoint executable and use it:dockerfile
RUN chmod +x /app/.yolo-entrypoint.sh ENTRYPOINT ["/app/.yolo-entrypoint.sh"] CMD ["web"] - Expose port
8000— the web port is hardcoded to8000(no manifest key). The ALB health-checks this port at/up(Laravel's built-in health route) — override the path or timing viatasks.web.health-check.*. - Copy the PHP configuration (YOLO's generated baseline, or the app's published copy) into the runtime's scan directory (conf.d loads alphabetically, so
yolo.inisorts after the extension fragments and its values win):dockerfileCOPY docker/php.ini $PHP_INI_DIR/conf.d/yolo.ini - Have
supervisorandsupercronicinstalled (the default Dockerfile installs both viaapk add). supervisord runs the container's process tree; supercronic drives the scheduler's cron — the container runs aswww-data, and busyboxcrondsilently loads zero jobs for a non-root user, so it cannot stand in.
Runtime checks ​
yolo build runs six preflights so a deploy can't ship an image that won't run:
- Octane — before the build, it reads
composer.lockand fails iflaravel/octaneisn't in the production requirements, since the web role runsoctane:start. Skipped whentasks.web.octane: false: classic mode runs thefrankenphpbinary directly and needs no octane package. - Scheduler (supercronic) — it runs the freshly-built image and fails if
supercronicisn't on thePATH. The scheduler runs in almost every app (the check is skipped only when cron is switched off withtasks.scheduler: false), and the failure this prevents is silent: busyboxcrond— the obvious fallback already in the base image — ignores crontabs not owned by root without logging a word, so an image without supercronic deploys green, stays healthy, and simply never fires a scheduled job. - SSR (Node) — when
tasks.web.ssris on, it runs the freshly-built image and fails ifnodeisn't on thePATH. Like the scheduler check, this matters because a missing SSR runtime is otherwise silent — Inertia falls back to client-side rendering and the web tier stays healthy on/up, so the deploy goes green with SSR quietly off. - PHP configuration — it runs the freshly-built image and fails if PHP loaded no app ini fragment (
yolo.ini— see PHP configuration). The build always suppliesdocker/php.ini, so a failure here means the Dockerfile lost itsCOPYline. An image with no ini runs PHP's compile defaults, and the failure that causes is silent: 2M uploads deploy green and only surface when a user's upload dies. - FrankenPHP metrics — when the web tier autoscales, it runs the freshly-built image and fails if
/app/docker/Caddyfilehas no top-levelmetricsoption. YOLO generates that Caddyfile into the build context in both serving modes (the Octane stub plus the option, or the classic-mode file that also pins the thread bounds), so a failure here means the Dockerfile doesn't copy the build context (COPY . /app). The failure this prevents is silent: with no gauges the burst reporter publishes nothing, the burst alarm never gets a datapoint, and the deploy goes green on the target-tracking policies alone. - Database backups (mysqldump + zstd) — when the app opts into database backups, it runs the freshly-built image and fails if either binary is missing. The failure this prevents is silent in the worst way: the deploy goes green, the app serves, and the daily backup errors unnoticed in the scheduler's logs until the day a restore is needed.
The image probes run the image (rather than grepping the Dockerfile), so they see the resolved base image and multi-stage COPY --from layers too — no false negatives.
YOLO doesn't assert the image's base runtime (e.g. that PHP is present) — Docker makes that the app's to swap, and a genuinely missing PHP runtime already fails loudly when octane:start crash-loops and the deployment rolls back.
Processes in the container ​
Every app runs three roles — web, the queue worker, and the scheduler — and by default they all share the one web container (each can be extracted into its own service, or switched off with tasks.queue / tasks.scheduler: false):
tasks:
web:
autoscaling: true- Web always runs the web server on port
8000. By default that'sphp artisan octane:startserving Laravel Octane on FrankenPHP: the image is FrankenPHP and YOLO enforcesOCTANE_SERVER=frankenphpat build (a conflicting value in your.envhard-fails the build), so there's nothing to seed or set. Settasks.web.octane: falseto run FrankenPHP in classic mode instead — per-request boot, no resident app — for an app that isn't Octane-safe yet; thefrankenphpbinary ships in the base image independent oflaravel/octane, so it serves even with no octane package. Classic mode runsfrankenphp runagainst a Caddyfile YOLO generates intodocker/Caddyfile, because that file is the only place FrankenPHP's thread pool can be configured — see what the ceiling is. - The queue worker runs
queue:work, bundled in the web container until you extract it. - The scheduler runs supercronic, firing
php artisan schedule:runevery minute, bundled until you extract it. (YOLO uses cron, notschedule:work, so the scheduler survivesSIGTERMcleanly — supercronic stops scheduling on stop and waits out the in-flight run.) ssr: trueadds Inertia's SSR renderer — see Inertia SSR below.
CPU priority within the container ​
Where these programs share one container they share one Fargate CPU quota, so YOLO orders them by nice priority to keep the kernel scheduler arbitrating contention in the app's favour:
web ≈ ssr (default) > scheduler (nice 10) > queue (nice 19)- Web is the top priority. Burst detection now rides the web request itself — YOLO's service provider publishes worker saturation from an after-response hook (see burst step-scaling), not a separate process — so the web tier protects both serving and the scaling signal, and there's nothing extra to arbitrate against it.
- The scheduler outranks the queue. When bundled with the web server, the scheduler launches under
nice -n 10and the queue worker undernice -n 19: a brief, time-sensitive cron tick should win over a heavy, backlog-tolerant queue batch (which has its own queue-depth scaling), and neither can starve Octane. (None of the nicing needsCAP_SYS_NICEor a task-definitionulimit— the background tier is only ever niced down.)
nice only biases the scheduler when CPU is saturated, so under the normal case every program still runs at full speed; it reallocates CPU rather than capping it, so a burst still shows on the CloudWatch CPU metric, it just no longer hits web latency. A web-only, queue-only, or scheduler-only service (the split-service topology below) keeps the same ordering for whatever it co-locates — and a single-role container with nothing to arbitrate runs at normal priority.
Independent task groups
Run web in isolation by extracting the worker tier: add a top-level tasks.queue block and the queue worker and scheduler move to their own service, leaving the web container running just the web server. Add tasks.scheduler too for a dedicated singleton cron. Where each role runs is derived from which blocks you've added; see Where each role runs and Scaling.
Inertia SSR ​
Set tasks.web.ssr: true to server-render your Inertia + Vue pages (better SEO, faster first paint). YOLO adds an ssr program to supervisord that runs php artisan inertia:start-ssr — a Node process listening on 127.0.0.1:13714. PHP calls it on localhost for each render, so SSR is always bundled in the web container — never its own service. YOLO injects INERTIA_SSR_ENABLED=true (unless your .env already sets it); the render URL comes from Inertia's default config/inertia.php.
Two things are on you:
- A Node runtime in your image. The scaffolded Dockerfile already installs
nodejs, so SSR works out of the box. If you've slimmed it out (or moved to a base image without Node), add it back — after building the imageyolo buildruns it and checksnodeis on thePATHwhenssris on, and hard-fails the build if it's missing (see Runtime checks). - An SSR bundle from your build. Your
npm run buildmust emit the SSR bundle (bootstrap/ssr/) — that's standard Inertia SSR setup in yourvite.config.js. The bundle is copied into the image automatically (it isn't excluded by.dockerignoreor the build'snode_modulescleanup).
If the SSR process is down, Inertia falls back to client-side rendering, so the app keeps serving — the ALB health check stays on PHP's /up and isn't coupled to SSR. supervisord restarts a crashed renderer automatically.
The .dockerignore ​
The scaffolded .dockerignore trims the build context but deliberately keeps a few things the image depends on:
.env— the environment's file, baked in at build timevendor— installed by yourbuildhook, not the Dockerfilepublic/build— compiled Vite assetsdocker/— the generated supervisord config(s) and the scheduler's crontab.yolo-entrypoint.sh— the generated entrypoint
Don't add those to .dockerignore or the build will produce a broken image.
yolo build stages the app into .yolo/build/ with the same exclusions before Docker ever sees it, so the .dockerignore mainly protects a direct docker build .. Both drop what must never ship: VCS and CI metadata, node_modules, tests, editor cruft, and the agent worktree dirs that nest inside the app (.claude/worktrees, .cursor/worktrees) — each worktree is a whole second checkout of the app, vendor included, and would otherwise be baked into the image layer and pulled on every scale-out.
Graceful shutdown ​
When ECS replaces a task it sends SIGTERM. The entrypoint traps it and holds the web tier open for the shutdown grace period so the ALB can drain in-flight requests before the container exits — that's what gives you deploys with no 502s. Tune it per process:
tasks:
web:
shutdown-grace-period: 30 # seconds; bump for long uploads/exports/SSEThe same value sets the container's stopTimeout and the ALB deregistration delay, keeping all three in lock-step. See tasks.web.shutdown-grace-period.
The scheduler gets special treatment: supercronic stops launching new schedule:run ticks the instant SIGTERM lands, and the in-flight run gets the rest of the stop window — by default everything Fargate allows, since its stop overlaps the other programs' rather than delaying them. Every program in a container sits in one supervisord group, so SIGTERM reaches them all at once and each grace overlaps the others rather than queueing behind them. All of a container's graces share Fargate's 120s stopTimeout ceiling; a combination that overcommits it fails the deploy with an error instead of being silently cut short at the wire.
