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.
RUN apk add --no-cache git supervisor supercronic nodejs \
&& 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
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.
What YOLO generates ​
During yolo build, YOLO writes two files into the build context that your Dockerfile copies in:
| File | Purpose |
|---|---|
.yolo-entrypoint.sh | The container entrypoint. Runs your deploy-all hooks (e.g. php artisan optimize) on startup, 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. |
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.*. - 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 three 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 runsfrankenphp php-serverand 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.
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 (frankenphp php-server) 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. - 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.
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. 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.
