Skip to content

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:

dockerfile
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.

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). 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:

FilePurpose
.yolo-entrypoint.shThe 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.confThe 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.iniYOLO'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:

  1. Copy the application into /app:
    dockerfile
    WORKDIR /app
    COPY --chown=www-data:www-data . /app
  2. Copy the generated supervisord config to a default search path:
    dockerfile
    COPY docker/supervisord.conf /etc/supervisord.conf
  3. Make the generated entrypoint executable and use it:
    dockerfile
    RUN chmod +x /app/.yolo-entrypoint.sh
    ENTRYPOINT ["/app/.yolo-entrypoint.sh"]
    CMD ["web"]
  4. Expose port 8000 — the web port is hardcoded to 8000 (no manifest key). The ALB health-checks this port at /up (Laravel's built-in health route) — override the path or timing via tasks.web.health-check.*.
  5. 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.ini sorts after the extension fragments and its values win):
    dockerfile
    COPY docker/php.ini $PHP_INI_DIR/conf.d/yolo.ini
  6. Have supervisor and supercronic installed (the default Dockerfile installs both via apk add). supervisord runs the container's process tree; supercronic drives the scheduler's cron — the container runs as www-data, and busybox crond silently 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.lock and fails if laravel/octane isn't in the production requirements, since the web role runs octane:start. Skipped when tasks.web.octane: false: classic mode runs the frankenphp binary directly and needs no octane package.
  • Scheduler (supercronic) — it runs the freshly-built image and fails if supercronic isn't on the PATH. The scheduler runs in almost every app (the check is skipped only when cron is switched off with tasks.scheduler: false), and the failure this prevents is silent: busybox crond — 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.ssr is on, it runs the freshly-built image and fails if node isn't on the PATH. 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 supplies docker/php.ini, so a failure here means the Dockerfile lost its COPY line. 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/Caddyfile has no top-level metrics option. 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):

yaml
tasks:
  web:
    autoscaling: true
  • Web always runs the web server on port 8000. By default that's php artisan octane:start serving Laravel Octane on FrankenPHP: the image is FrankenPHP and YOLO enforces OCTANE_SERVER=frankenphp at build (a conflicting value in your .env hard-fails the build), so there's nothing to seed or set. Set tasks.web.octane: false to run FrankenPHP in classic mode instead — per-request boot, no resident app — for an app that isn't Octane-safe yet; the frankenphp binary ships in the base image independent of laravel/octane, so it serves even with no octane package. Classic mode runs frankenphp run against a Caddyfile YOLO generates into docker/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:run every minute, bundled until you extract it. (YOLO uses cron, not schedule:work, so the scheduler survives SIGTERM cleanly — supercronic stops scheduling on stop and waits out the in-flight run.)
  • ssr: true adds 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 10 and the queue worker under nice -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 needs CAP_SYS_NICE or a task-definition ulimit — 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:

  1. 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 image yolo build runs it and checks node is on the PATH when ssr is on, and hard-fails the build if it's missing (see Runtime checks).
  2. An SSR bundle from your build. Your npm run build must emit the SSR bundle (bootstrap/ssr/) — that's standard Inertia SSR setup in your vite.config.js. The bundle is copied into the image automatically (it isn't excluded by .dockerignore or the build's node_modules cleanup).

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 time
  • vendor — installed by your build hook, not the Dockerfile
  • public/build — compiled Vite assets
  • docker/ — 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:

yaml
tasks:
  web:
    shutdown-grace-period: 30   # seconds; bump for long uploads/exports/SSE

The 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.

Released under the MIT License.