Skip to content

Manifest Reference ​

yolo.yml is the single source of truth for your application's infrastructure. Both yolo sync (infrastructure) and yolo deploy (code) read from it. This page documents every key.

A minimal manifest ​

The smallest useful web app β€” one container running all three roles (web, queue worker, scheduler), which is what yolo init scaffolds:

yaml
name: my-app

environments:
  production:
    account-id: '123456789012'
    region: ap-southeast-2
    domain: example.com

    tasks:
      web:
        autoscaling: true

    build:
      - composer install --no-cache --no-interaction --optimize-autoloader --no-progress --classmap-authoritative --no-dev
      - npm ci
      - npm run build

    deploy:
      - php artisan migrate --force

The full shape ​

Every key YOLO understands, each value showing its default where one exists. This is a reference skeleton, not a valid manifest β€” some keys are mutually exclusive (a root domain is refused alongside multitenancy; branch and tag are alternatives) β€” so jump to a key's section below for its semantics before copying it.

The manifest is validated against this exact shape: every key, at exactly this nesting, and nothing else. A misspelt or misplaced key β€” autoscaling.mim, a health-check threshold with the wrong name, a tenant apex β€” fails every command up front rather than being silently ignored while the default stays in force.

yaml
name: my-app
timezone: UTC

environments:
  production:
    account-id: '123456789012'
    region: ap-southeast-2

    domain: example.com
    wildcard-subdomains: false

    multitenancy:
      landlord:
        domain: app.example.com
        wildcard-subdomains: true
      queue-isolation: shared
      tenants:
        acme:
        globex: { domain: globex.io }

    branch: main
    tag: 'v*'
    repository: org/repo

    bucket: true
    services:
      - ivs
      - mediaconvert
      - rekognition
    task-role-policies:
      - arn:aws:iam::123456789012:policy/my-app-extra-access

    queues:
      - high
      - default
    queue-visibility-timeout: 90

    database: my-database
    backups: false
    cache:
      store: redis
    session:
      driver: redis

    budget:
      amount: 100
      strategy: balanced

    tasks:
      web:
        autoscaling:
          min: 1
          max: 5
          cpu-utilization: 65
          scale-out-cooldown: 60
          scale-in-cooldown: 300
        octane: true
        cpu: '512'
        memory: '1024'
        concurrency: 8   # derived from cpu/memory when omitted
        shutdown-grace-period: 15
        enable-execute-command: true
        ssr: false
        health-check:
          path: /up
          interval: 10
          timeout: 5
          healthy-threshold: 2
          unhealthy-threshold: 5
          grace-period: 60
      queue:
        autoscaling:
          min: 1
          max: 5
          backlog-per-task: 100
        cpu: '256'
        memory: '512'
        spot: false
        shutdown-grace-period: 60
        enable-execute-command: true
      scheduler:
        cpu: '256'
        memory: '512'
        shutdown-grace-period: 115
        enable-execute-command: true

    build:
      - composer install --no-cache --no-interaction --optimize-autoloader --no-progress --classmap-authoritative --no-dev
      - npm ci
      - npm run build

    deploy:
      - php artisan migrate --force

    start:
      - php artisan optimize
AreaKeys
Appname, timezone, environments
Routingdomain, wildcard-subdomains, multitenancy
CI trustbranch / tag / repository
Infrastructureaccount-id, region, bucket, services, task-role-policies, queues, queue-visibility-timeout, database, backups, cache.store, session.driver, budget
Taskstasks.web, tasks.web.autoscaling, tasks.web.health-check, tasks.queue, tasks.scheduler
Hooksbuild, deploy, start

Required keys

Every command except init fails fast unless these three are present:

  • name (top level)
  • region (per environment)
  • account-id (per environment)

Top-level keys ​

name ​

Required. The application name. Used as the prefix for app-scoped AWS resource names (yolo-{env}-{name}-…) and the deployer role.

timezone ​

The app's timezone. Defaults to UTC. It computes the year.week prefix for build versions β€” set it to your team's timezone so a release cut near a week boundary doesn't trip app-version validation β€” and pins the generated crontab's CRON_TZ, so a backups.schedule fires at the declared local hour.

environments ​

Required. A map of environment name β†’ environment config. The key (production, staging, …) is the <environment> argument you pass to commands.


Routing keys ​

These live directly under an environment and determine how the app is reached. See Domains.

domain ​

The canonical public domain the app is served on (e.g. app.example.com). Under multitenancy it moves to multitenancy.landlord.domain and is refused at the root: there it would be ambiguous, meaning both "where the landlord is served" and "what subdomain tenants hang off", readings that separate the moment one tenant takes a domain of its own. When it's one half of the apex/www pair (the apex itself, or www.{apex}), YOLO serves it and 301-redirects the other half to it. Required for a web app β€” a tasks.web block with no domain (or, multi-tenant, no tenant domains) is refused, since no listener rule would ever route to it. Omit it for a worker app; declaring one there is allowed (the zone + certificate stay provisioned, unattached).

The apex (registrable root, naming the Route 53 hosted zone) is derived automatically β€” there is no apex key. YOLO walks the domain's label-suffixes longest-first and uses the longest one that already has a hosted zone in the account (so app.example.com resolves to the example.com zone). When no ancestor zone exists yet, the domain itself is the apex (sync then creates the zone), with any leading www. stripped. See Domains.

wildcard-subdomains ​

true to serve every subdomain of domain from the same service β€” one wildcard listener rule and one *.{domain} alias record instead of a resource per subdomain. A multi-tenant app that gives each tenant a subdomain then brings a tenant live on a database insert, with no infrastructure run. Defaults to false.

yaml
domain: app.example.com
wildcard-subdomains: true   # tenant-a.app.example.com, tenant-b.app.example.com, …

Requires domain. Under multitenancy it moves inside the block, onto the landlord or tenant whose domain it wildcards (multitenancy.landlord.wildcard-subdomains or per tenant) β€” declared at the root alongside a multitenancy block it is refused. A www-canonical domain is also refused: the wildcard would land at *.www.{apex} and the certificate would stop covering the apex the redirect fires from.

It also moves where the certificate is issued: normally YOLO requests one for the apex (covering {apex} + *.{apex}), but wildcards match a single label, so *.example.com would not cover tenant.app.example.com. With wildcard-subdomains the certificate is issued for domain instead (app.example.com + *.app.example.com). Its DNS validation record and the wildcard alias record are both written into the existing apex zone, so no extra hosted zone or NS delegation is needed.

Wildcards are one label deep on both sides β€” tenant.app.example.com is served, a.b.app.example.com is not.

The wildcard is purpose-agnostic: any extra host the app answers on (api.{domain}, a marketing subdomain) rides it with no manifest declaration β€” traffic reaches the same service and the app routes by Host. An exact host claimed by anything else on the shared listener (an environment service's search.{domain}, a sibling app) always outranks the wildcard β€” see priority banding.

multitenancy ​

Everything multi-tenant, in one block. Its presence is what puts the app in multi-tenant mode.

yaml
multitenancy:
  landlord:
    domain: app.example.com
    wildcard-subdomains: true
  queue-isolation: dedicated
  tenants:
    acme:                      # served at acme.app.example.com
    globex:
      domain: globex.io        # served on its own domain
      wildcard-subdomains: true

Every key is validated explicitly β€” there is no free-form subtree, so a misremembered or hand-written key (apex, say, which is always derived) fails the manifest check rather than being silently accepted and ignored. A root domain or wildcard-subdomains alongside the block is refused with a message naming where it belongs (multitenancy.landlord.domain; the flag onto the landlord or the tenant whose domain it wildcards).

multitenancy.landlord ​

The landlord's own hosting, using the same shape a tenant does: a domain, optionally wildcard-subdomains. Wildcarding the landlord is what serves tenants beneath it, so a tenant needs no domain of its own. It also serves any extra landlord host (api.{domain}, www.{domain}) with no further declaration β€” the app routes by Host β€” which is why domain takes a single host, not a list.

Optional β€” omit it for a multi-tenant app with no landlord host, where every tenant brings its own domain. The :443 listener then takes its default certificate from the first tenant by sorted id.

multitenancy.tenants ​

A map of tenant id β†’ that tenant's config. The id identifies that tenant's resources throughout YOLO.

Also optional. A block with a landlord and no tenants is the shape of an app that resolves its tenants entirely from its own database β€” YOLO serves the landlord (and its wildcard, if declared) and provisions exactly what the solo shape does, because there is nothing to fan out over. Declaring tenants is what buys them AWS resources of their own; a tenant served under the landlord's wildcard still gets none unless queue-isolation is dedicated.

yaml
tenants:
  acme:                       # bare: served under the landlord's wildcard
  globex:
    domain: globex.io         # its own zone, certificate, SNI attachment and rules
    wildcard-subdomains: true # …and *.globex.io

A tenant's apex is derived from its domain exactly as the app's is β€” never declared. A tenant whose domain the landlord's certificate already covers provisions no DNS/TLS resources of its own; see Graduating a tenant onto its own domain.

multitenancy.queue-isolation ​

How tenants map onto SQS queues and worker programs β€” shared (default) or dedicated. Only valid alongside multitenancy.tenants; with a single scope (a solo app, or a landlord-only block) there is nothing to isolate, so the key is refused rather than silently ignored.

ValueQueuesWorkersTrade
shared (default)one queue set at the app name, the same shape a solo app has; the tenant rides the job payloadone worker per tier drains every tenantScales to any tenant count. A whale tenant's backlog delays the others.
dedicatedone queue set per tenant (…-{tenant}[-tier]), plus a landlord setsupervisord runs one queue:work per tenantFair β€” no tenant can starve another. N tenants means N queues and N worker programs per tier, so it scales to dozens, not hundreds.

A shared app pins SQS_QUEUE at build; a dedicated one resolves the per-tenant queue at runtime, so nothing is pinned.

branch / tag / repository ​

Control the CI deployer role's OIDC trust β€” see CI/CD.

  • branch β€” the branch this environment deploys from (default main).
  • tag β€” a tag pattern (e.g. 'v*', or true for any tag) instead of a branch.
  • repository β€” org/repo, inferred from your git origin if omitted; set only to override (monorepo / fork).

Infrastructure keys ​

These live directly under an environment and provision or configure the app's AWS resources. (There is no aws. namespace β€” YOLO is AWS-only, so every key sits at the top of the environment block.)

account-id ​

Required. The AWS account ID to deploy into. Verified against your resolved profile via STS before any change is made.

region ​

Required. The AWS region (e.g. ap-southeast-2).

bucket ​

An S3 bucket for application storage, injected into the container as AWS_BUCKET. This app's ECS task role is automatically granted read+write on it (object get/put/delete + ACL get/set + multipart, plus bucket listing), scoped to this one bucket β€” so the container reaches its bucket through the role, with no credentials to manage.

The value says who owns the bucket, and that is the whole difference:

ValueBehaviour
trueYOLO-provisioned. The bucket is named yolo-{account-id}-{environment}-{app}-data, so it is globally unique by construction and two environments can never end up sharing one. sync:app creates it, with Block Public Access on and a permissive CORS ruleset (origins *, methods GET/PUT/HEAD) that lets the browser PUT directly to it via a presigned URL β€” the signed URL, not CORS, is the access gate.
a bucket nameBring your own. The bucket must already exist on this account; YOLO adopts it and never creates one. The name is published in the app's claim by sync:app, and the next sync:environment widens the env-wide data-read / data-write policies to it β€” so a new bring-your-own bucket takes sync:app then sync <env> before env observers and developers can reach it, and a deploy in between refuses on that pending drift. destroy:app unpublishes the claim; the bucket leaves the env policies on the following env sync. A name YOLO didn't choose sits outside the yolo-* namespace the admin tier may write, so creating or configuring it isn't something YOLO can do β€” see the failure modes below.
omittedNo app data bucket, and no AWS_BUCKET. (false is refused rather than read as this β€” omitting the key already says it.)

Create-only in both modes. Whatever YOLO sets, it sets once at create and then never touches the bucket again: no CORS reconcile, no Block Public Access reconcile, no tags. It holds user data, an app may legitimately serve public objects or own its own CORS rules, and a bucket handed over at birth isn't one YOLO should keep claiming β€” so it also carries no yolo:* tags and never appears in yolo audit.

Never deleted. destroy:app tears down everything else and leaves this bucket standing, in both modes. Three independent things guarantee that: it isn't a deletable resource, S3::deleteBucket refuses it by name, and the admin tier's destructive S3 grants are scoped to the regeneratable bucket suffixes (-config, -assets, -logs) rather than all of yolo-* β€” so a YOLO-provisioned data bucket sits inside the create grant and outside the delete one.

A bring-your-own bucket is verified before the plan runs. Two cases fail the sync up front, with bucket: true offered as the fix:

  • It doesn't exist. YOLO won't create it, so the sync would otherwise fail mid-apply on AccessDenied.
  • It exists in another AWS account. S3's bucket namespace is global, so a name someone else has taken answers a probe with a 403 that is indistinguishable from "yours, but this tier may not read it". Adopting one of those would sync perfectly cleanly, grant the task role an ARN in a foreign account, and then fail every runtime write β€” so ownership is resolved from this account's own bucket listing, and a name that isn't in it is refused by name.

A bucket name S3 would reject (wrong case, too short, doubled dots, IP-shaped) also fails validation here rather than surfacing as an InvalidBucketName part-way through an apply.

services ​

The YOLO-provisioned services this app consumes β€” a list of bare capability names:

yaml
services:
  - ivs

See the Services guide for the full model and the need-to-know for each one; this is the manifest-key summary.

ServiceWhat consuming it gives this app
ivsThe app's ECS task role is granted IVS access (ivs:* β€” channels and stream keys are created by the app at runtime, so there's nothing stable to scope to), and the app's CloudWatch dashboard gains the IVS logs panel
typesenseThe app uses the environment's shared Typesense search cluster β€” the cluster is provisioned by the environment while its env-manifest entry stands, independent of whether any app currently consumes it. No runtime IAM: the app talks to the cluster over HTTP. sync:app opens the app's private path (its task SG onto the search API port) and mints the app two keys scoped to its own {prefix}* collections β€” a server-side key (all actions) and a browser search-only key (documents:search) β€” written into the app's environment-side .env (env/.env.{app} in the env config bucket β€” a YOLO-owned per-app secret channel kept out of the app's developer .env, which the admin tier running sync is fenced from). The build merges that file in and injects SCOUT_DRIVER=typesense, SCOUT_PREFIX, the private Cloud Map node addresses for indexing (TYPESENSE_HOST/PORT/PROTOCOL + the full TYPESENSE_NODES list β€” server-side indexing rides the VPC, never the ALB/WAF), and the public search host for browser-direct search (TYPESENSE_SEARCH_HOST/PORT/PROTOCOL, on search.{domain}). The app's CloudWatch dashboard gains the search panels
mediaconvertA per-app IAM role for AWS Elemental MediaConvert to assume is provisioned, its computed ARN is baked into the build as AWS_MEDIACONVERT_ROLE_ID, the task role is granted the job operations plus iam:PassRole locked to that one role and to MediaConvert itself, and the app's CloudWatch dashboard gains a MediaConvert jobs panel. App-side only β€” jobs run on the account's default on-demand queue, so there is no environment-manifest half
rekognitionThe app's ECS task role is granted Rekognition access (rekognition:* β€” the detection APIs are resource-less, operating on request payloads or S3 objects read with the caller's own credentials, so reads of the app bucket ride its existing grant), and the app's CloudWatch dashboard gains a Rekognition requests panel. App-side only β€” a pure pay-per-call API, nothing is provisioned, so there is no environment-manifest half

An entry is deliberately just a name β€” this app uses ivs: all service shape (sizing, versions, retention) is either hardcoded or belongs to the environment manifest, never the app manifest β€” so two apps can never declare competing configuration for shared infrastructure. Unknown names, duplicate entries, or anything other than a flat list hard-fail validation. The list is also published: every deploy and sync:app writes it (apps/{app}.yml β€” name + services) into the env config bucket, so the environment always knows which apps are using which shared services.

The corresponding env-shared infrastructure is the environment manifest's side of the contract β€” e.g. the IVS event-logging pipeline (one /aws/ivs/yolo-{env} log group + EventBridge rule per environment, because the aws.ivs event stream is account-wide) is provisioned by sync:environment while yolo-environment-{environment}.yml declares services.ivs β€” the service lifecycle. Using an env-backed service the environment doesn't declare is a hard error at build, deploy and sync:app (declare it, or take it out of yolo.yml); when an app stops using a service, its app-side resources (e.g. the MediaConvert role) melt away on the next sync. Defaulted framework backends (cache, session) deliberately stay separate keys β€” services is for opt-in capabilities only.

No waf key

The web application firewall is a compulsory environment resource β€” every environment with a load balancer gets one automatically, so there's nothing to configure here. Day-to-day tuning happens in its allow/block IP sets, not the manifest. Blocked and counted requests are logged, rule-attributed, to the aws-waf-logs-yolo-{env} CloudWatch log group (WAFv2 mandates the prefix), retained 30 days and readable by every app's task role; allowed traffic is already covered by the ALB access logs.

task-role-policies ​

Extra IAM policy ARNs to attach to this app's ECS task role β€” the runtime identity its containers (web, queue and scheduler) assume. YOLO gives every app its own task role, so these grants reach only this app and never another. This is how you let your container call an AWS service YOLO doesn't wire for you (an extra S3 bucket, DynamoDB, Bedrock, …): the role carries the access, so the app authenticates as itself with no credentials to manage.

yaml
task-role-policies:
  - arn:aws:iam::123456789012:policy/my-app-extra-access   # customer-managed
  - arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess          # AWS-managed

The list is reconciled on every yolo sync: an ARN you add gets attached, and one you remove gets detached β€” the role's attachment set is YOLO's to own, so there's no left-behind grant. Each entry must be a customer- or AWS-managed IAM policy ARN; a malformed value fails the sync plan rather than silently dropping the grant. The YOLO baseline policy (ECS Exec channels, this app's SQS queues, SES send, and read+write on the bucket when declared) is always attached and isn't listed here.

queues ​

A list of queue tier names in strict-priority order β€” the worker drains the first tier to empty before glancing at the next (the comma-list semantics of Laravel's --queue). Declaring tiers provisions one SQS queue per tier per scope: high becomes yolo-{env}-{app}-high while the default tier keeps the naked scope name (it's Laravel's default queue, so un-routed jobs keep landing on it and adding tiers never renames it). On a multi-tenant app with queue-isolation: dedicated, every tenant (and the landlord) gets the full tier set.

yaml
queues:
  - high
  - default

Tiers are names only β€” the map form (queues: {high: ...}) is rejected. Omit the block entirely for a single queue at the app's name.

queue-visibility-timeout ​

How long SQS hides a delivered message before re-delivering it, in seconds β€” the visibility timeout on every queue the app provisions (all tiers, every tenant scope). Default 90; SQS caps it at 43200 (12 hours).

Visibility must outlast your longest job: a message re-delivered while its job is still running executes the job twice, so keep this above the worker's job timeout (60s unless a job declares its own $timeout). Raise it in step with long-running jobs β€” and consider tasks.queue.shutdown-grace-period alongside it, so an in-flight long job also survives a deploy's drain window. The value is reconciled on every yolo sync, so a change reaches already-provisioned queues.


database ​

Declares the RDS instance or Aurora cluster the app connects to, so YOLO can chart it β€” the Database section of the app's CloudWatch dashboard and the Database tab of yolo status (CPU, connections, freeable memory, read/write latency) β€” and health-check it: yolo audit reads the same identifier to verify deletion protection is on (an error if it isn't), classify its network posture (managed / external / exposed β€” warnings, never sync drift), and report the instance/cluster basics. It's also what yolo db:tunnel forwards to. Entirely optional: omit it and the database panels, the audit probes and the tunnel are simply dropped.

YOLO doesn't manage your database, so it can't discover the identifier on its own. It's declared in the manifest β€” rather than read from DB_HOST in the app's .env β€” because the dashboard is written by yolo sync under the admin tier, which is deliberately barred from reading app secrets; a manifest value is read identically by every tier, so the dashboard never drifts between who writes it and who checks it.

A single flat value: the database's name (its DBInstanceIdentifier or DBClusterIdentifier), never an endpoint hostname.

yaml
database: my-app-db

YOLO looks the name up and detects which kind it is. An Aurora cluster is charted through its cluster roles β€” the writer series follows failovers, and readers chart as an aggregate alongside (with a replica-lag panel) β€” while anything else is charted as a plain instance. Endpoints are resolved live wherever one is needed: yolo db:tunnel describes the name and forwards to a cluster's writer endpoint or the instance endpoint.

A name that matches no RDS cluster or instance in the account/region fails the sync: a declared database that doesn't exist is a manifest typo to surface loudly, not an empty dashboard panel to puzzle over.

Where the database should live, the managed/external/exposed postures, and how to reach a private one are covered in the Databases guide.


backups ​

Opt in to scheduled logical database backups. On each run the scheduler's host container dumps every database (mysqldump | zstd, tenant databases included on a multi-tenant app), verifies the archive at the producer (integrity via zstd -t, completeness via the dump's own Dump completed trailer β€” a bad dump fails the run rather than shipping), and uploads it to the env backups bucket on a timestamped key under this app's prefix ({app}/{database}/{YYYY-MM-DD-HHMM}.sql.zst) β€” every run keeps its own object whatever the schedule cadence. Retention is plain lifecycle: dumps expire after 35 days. The bucket stays versioned purely as tamper protection β€” the producer's write-only grant cannot destroy an existing object. The app's task role can write only its own prefix and read none β€” restores are an operator action, not an app capability.

yaml
backups: true          # daily at 05:00 (manifest timezone)

backups:
  schedule: "0 */4 * * *"   # or any standard 5-field cron expression

The schedule is a generated crontab entry, not anything the app registers: yolo build writes the cron line β€” backups.schedule, default daily at 05:00, pinned to the manifest timezone via CRON_TZ β€” into the crontab the scheduler host's supercronic runs, with every argument β€” destination, region, tenant list β€” baked in from the manifest, so Laravel's own scheduler is never involved. Backups therefore ride the scheduler host (where each role runs), and tasks.scheduler: false also turns them off. yolo backup:database <env> runs the identical invocation on demand as a one-off task with streamed output. yolo build also probes the built image for the mysqldump and zstd binaries when backups are on and refuses to ship without them (see Runtime checks) β€” the scaffolded Dockerfile installs both. More in the Databases guide.


cache.* ​

Declares the app's cache store. Every app that runs tasks defaults to redis β€” web or web-less. The per-task filesystem is broken across multiple Fargate tasks, and worker apps lean on a shared cache just as hard (atomic locks, rate limiters, onOneServer), so a working shared cache is the right default for anything that runs. redis provisions a shared ElastiCache for Valkey cache for the environment (one cluster shared by every app, isolated by a per-app key prefix). Set cache.store to file, database, or array to opt out (app-managed, nothing provisioned). Build-only apps (no tasks) run no containers, so they get no default.

yaml
cache:
  store: redis   # the default for any app with tasks; set file/database/array to opt out

redis provisions, with hardcoded sensible defaults (no tuning knobs until a real need lands):

  • a single-node replication group on cache.t4g.micro (auto-failover / Multi-AZ off β€” a standard single instance, ~A$11/mo), at-rest encryption on;
  • maxmemory-policy=allkeys-lru (writes never fail under memory pressure);
  • a security group allowing ingress on 6379 only from the Fargate task security group (the cache has no public endpoint);
  • a cache subnet group across the VPC subnets.

With the redis store, the container env gets CACHE_STORE=redis, REDIS_HOST (the cluster's primary endpoint) and REDIS_PORT=6379 β€” each only if your .env doesn't already set it β€” plus a per-app REDIS_PREFIX, which is enforced: it's the only separation between apps sharing the environment's cache node, so a conflicting value in your .env fails the build. Scaling is a manual vertical resize; there's no autoscaling. For availability, see the Laravel failover cache store rather than adding a replica. For a backend YOLO doesn't model, set CACHE_STORE in your .env and cache.store: file.


session.* ​

Declares the app's session backend. Web apps (tasks.web) default to redis β€” sessions land on the shared Valkey cluster, which gives strong read-after-write consistency (~1Β ms), so a session is readable the instant after it's written (no stale-read flicker right after login). YOLO injects SESSION_DRIVER (only if your .env doesn't already set it) and provisions infrastructure only for the driver that needs it. Non-web apps have no sessions, so no default.

yaml
session:
  driver: redis   # the web-app default; redis | database | cookie | file
session.driverYOLO provisionsAlso injectsNotes
redis (default)Nothing new (reuses the Valkey cache)SESSION_DRIVER onlyRequires cache.store: redis (the default) β€” there's no redis store without it, and YOLO hard-fails if you opt the cache out without re-pinning the session driver. Sessions sit on Laravel's stock default connection (DB 0), the cache on the cache connection (DB 1) β€” same Valkey instance, separate keyspace, so a cache:clear never touches sessions. YOLO injects SESSION_DRIVER=redis only and leaves SESSION_CONNECTION unset; the split is inherited from your stock config/database.php, not enforced by YOLO, and relies on cluster-mode-disabled Valkey. A single node has no session HA β€” a node loss logs users out. See Sessions and cache share the node, not the keyspace for the mechanism and caveats.
database / cookie / fileNothingSESSION_DRIVER onlyApp-managed (pin-only). cookie is capped at ~4Β KB per browser cookie β€” risky once flashed validation errors are stored.

On a web app, omitting session gives you the redis default; set a driver to override it. On a non-web app, SESSION_DRIVER is left to your .env.


budget ​

An advisory monthly spend target for the app. YOLO never enforces it β€” it never acts on your account on its own. The budget is read by yolo status:budget (spend vs cap) and by the /yolo skill, which weights its recommendations by the strategy.

yaml
budget:
  amount: 100          # USD per month (advisory cap)
  strategy: balanced   # lean | balanced | conservative (default: balanced)
KeyDefaultDescription
budget.amountβ€”The monthly spend target in USD. Optional; omit it and status:budget reports spend with "no budget set".
budget.strategybalancedHow aggressively the /yolo skill should trade cost against headroom β€” lean (cost-first), balanced, or conservative (headroom-first).

Spend is read from AWS Cost Explorer via the app's yolo:app tag; it shows once that tag is activated as a cost-allocation tag in Billing.

The budget block is two-tier: the same budget shape can also be declared in the environment manifest, where it caps the whole environment (every app + shared infra, attributed via the yolo:environment tag) and is reported by status:environment. App-tier budget lives in yolo.yml; env-tier budget in yolo-environment-<env>.yml.


tasks.web.* ​

Declaring tasks.web as a config object makes the app a Fargate web service; it must declare autoscaling β€” there's no implicit default, and the bare tasks.web: true shorthand isn't accepted (a scalar tier has nowhere to state its scaling behaviour). tasks.web: false (or just omitting web) drops the web tier entirely β€” a web-less worker app that runs only a standalone tasks.queue and/or tasks.scheduler. A tasks block that yields no service at all (no web, and neither role extracted β€” the bundled queue/scheduler would have no container to ride) is rejected up front rather than silently provisioning nothing. Omitting tasks entirely is a build-only app with no containers.

Where each role runs ​

Every app runs three roles β€” web, the queue worker, and the scheduler (cron + schedule:run). The web tier runs Octane (FrankenPHP worker mode) by default; set tasks.web.octane: false to run FrankenPHP in classic mode instead (per-request boot, no resident app). By default the queue worker and scheduler both share the one web container β€” the cheap single-task floor. Each can be extracted into its own service, or switched off entirely.

To run web in isolation, extract the worker tier: add a top-level tasks.queue block and the queue worker and the scheduler move out to their own service, leaving the web container running just the web server. Add tasks.scheduler as well to give cron its own pinned-singleton task. Placement is derived from which blocks are present β€” there are no tasks.web.queue / tasks.web.scheduler flags.

Each block is true | false | {config} (the same boolean-or-object form as tasks.web.ssr): true extracts the role with default sizing, a config object extracts it with overrides, and false switches it off so the role runs nowhere β€” neither bundled nor extracted. An empty block (queue:) or empty object ({}) is rejected β€” state the intent explicitly. The queue block is the one exception to bare true: like web, a standalone queue must be a config object that declares autoscaling (web and queue both need a definitive scaling decision). Only scheduler β€” a pinned singleton that never scales β€” keeps the bare true shorthand.

Placement is reconciled, not just applied: if an app was running an extracted queue or scheduler service and you later bundle the role back in (remove the block) or switch it off (false), the next sync tears the now-orphaned ECS service down β€” and for the queue, its scalable target, scaling policies and scale-to-zero alarm with it β€” so a dropped block never strands a live service. Switching the queue off (false) goes further: because jobs then run inline (QUEUE_CONNECTION=sync) and nothing is ever enqueued, sync also tears down the app's SQS queue and its depth alarm, and the CloudWatch dashboard drops its queue panel (single-tenant apps; a multi-tenant app's per-tenant queues are torn down by destroy:app instead).

In the placement table below, queue: true is shorthand for "queue extracted" β€” the real manifest writes queue: { autoscaling: … }; only scheduler: true is literal.

Manifestweb containerworker containerscheduler container
web onlyweb + queue + schedulerβ€”β€”
web + queue: truewebqueue + schedulerβ€”
web + queue: true + scheduler: truewebqueuescheduler
web + queue: falseweb (no worker)β€”β€”
web + scheduler: falseweb + queue (no cron)β€”β€”
queue: true (no web)β€”queue + schedulerβ€”
queue: true + scheduler: true (no web)β€”queuescheduler
scheduler: true (no web)β€”β€”scheduler

The scheduler rides the worker container (the web + queue row) rather than getting its own task β€” there's no point paying for a separate one-task service for cron when the queue is already a managed tier. Because cron then runs on the autoscaling queue, guard scheduled tasks with ->onOneServer(), or add tasks.scheduler for a true singleton. A queue that hosts the scheduler can't scale to zero β€” cron would stop when it idled β€” so its floor stays at 1 (an explicit tasks.queue.autoscaling.min: 0 there is rejected).

The last three rows are web-less worker apps: a pure queue consumer, a scheduled-job runner, or both. They get the same shared plumbing a web app does (ECR, cluster, task role, security groups, database access, log group) with no ALB attachment, target group, CDN, or web autoscaling. A scheduler-only app runs no worker anywhere, so its queue behaves exactly like queue: false β€” jobs run inline (QUEUE_CONNECTION=sync, enforced at build) and no SQS queue is provisioned. Worker apps are the headless shape β€” they need no domain (declaring one anyway is fine; it's metadata) β€” while a web task always requires one. Acceptance-test a worker's first deploy by watching the service actually consume its queue or fire its schedule β€” there's no health-checked URL to probe.

Disabling the queue (queue: false) means no worker runs anywhere, so jobs can't be processed off-request: YOLO bakes QUEUE_CONNECTION=sync (jobs run inline at dispatch) and fails the build if your .env pins it to anything else, rather than ship an app that black-holes queued work. Disabling the scheduler (scheduler: false) stops schedule:run running anywhere β€” framework and package maintenance that rides cron (model pruning, auth:clear-resets, Telescope/Pulse pruning, …) silently stops, so sync surfaces a warning. Reach for these only when the app genuinely has no background work.

KeyDefaultDescription
tasks.web.autoscaling(required)true for scaling on defaults, false for a fixed single task, or an object to tune β€” see tasks.web.autoscaling.*.
tasks.web.octanetrueRun the web tier on Octane (FrankenPHP worker mode) via octane:start. Set false to run FrankenPHP in classic mode β€” per-request boot, no resident app β€” for an app that isn't Octane-safe yet. Same image and port either way; only the launch command differs, and the build's Octane preflight is skipped (classic mode needs no laravel/octane). YOLO sizes the classic thread pool from this task's cpu/memory β€” see what the ceiling is.
tasks.web.cpu'512'Fargate CPU units. The default is half a vCPU β€” fine for a light app, but a fractional vCPU can't absorb a CPU-bound request without queuing inside the task, so a CPU-bound app wants a whole vCPU or more.
tasks.web.memory'1024'Fargate memory (MB).
tasks.web.concurrency(derived)How many requests one web task serves at once β€” an absolute count, not per vCPU. Sets the Octane worker pool (octane:start --workers) or, in classic mode, both thread bounds (num_threads and max_threads) to exactly that, so the pool never grows past it. Omit it and YOLO derives the pool from cpu/memory β€” see what the ceiling is. Set it for an app the default misjudges in either direction β€” a genuinely I/O-bound one wanting a larger pool, or a CPU-bound one wanting fewer: the derived 8 Γ— vCPU assumes a request spends part of its life parked on a downstream, and a CPU-bound app oversubscribes the task at that size β€” requests queue inside the task, each holding a database connection, where the load balancer can't see the saturation. Under an influx the excess queues at the task rather than spawning threads the cores can't clear, so the in-flight requests finish at full speed and the queue depth trips burst scale-out honestly. Positive integer; the autoscaling concurrency target and the burst denominator follow it. Applies with autoscaling off too β€” it describes one task.
tasks.web.shutdown-grace-period15Seconds the web process gets on SIGTERM before SIGKILL. It's also the ALB drain window and the container stopTimeout. See graceful shutdown.
tasks.web.enable-execute-commandtrueEnable ECS Exec so yolo run can attach. Access is gated by MFA on the admin IAM tier; set false to disable it for this group.
tasks.web.ssrfalseRun Inertia's SSR renderer (inertia:start-ssr, a Node process on 127.0.0.1:13714) bundled in the web container, so PHP server-renders your Vue pages. true, or an object to override its shutdown-grace-period. SSR is always bundled β€” never its own service. Needs a Node runtime in your Dockerfile and an SSR bundle from npm run build; YOLO injects INERTIA_SSR_ENABLED=true unless your .env sets it. See Inertia SSR.
tasks.web.health-check(defaults)ALB health-check tuning β€” see tasks.web.health-check.*.

YOLO manages the ECS task and execution roles for you β€” the task role is per-app (extend it with task-role-policies); the execution role is shared per environment.

tasks.web.autoscaling.* ​

Application Auto Scaling is required for the web service β€” autoscaling is true | false | {config} and the key can't be omitted (nor can web use the bare tasks.web: true shorthand). autoscaling: true takes the defaults (min: 1, max: 5); autoscaling: false pins a fixed single task (no scalable target); a {min, max, …} object sets bespoke bounds. An empty object ({}) is rejected β€” write true or false. Web min must be β‰₯ 1 (it serves traffic and can't idle to zero). With autoscaling on, YOLO scales on request concurrency β€” the default, leading signal, with its target derived from the task's pinned worker pool (sized from vCPU) so there's nothing to tune β€” and composes a CPU policy alongside as a safety net. Only the CPU policy scales in; concurrency and burst are scale-out only. The only knobs are the bounds and cooldowns.

KeyDefaultDescription
autoscaling(required)true for scaling on defaults, false for a fixed single task, or an object to tune. No implicit default β€” must be declared.
autoscaling.min1Minimum number of tasks (must be β‰₯ 1).
autoscaling.max5Maximum number of tasks.
autoscaling.cpu-utilization65Target average CPU % β€” the safety-net policy composed alongside concurrency. The default assumes replacement capacity arrives quickly; a tier with a slow cold start or a low tolerance for saturation wants more headroom under it β€” see what the CPU target assumes.
autoscaling.scale-out-cooldown60Seconds between scale-out steps (both policies).
autoscaling.scale-in-cooldown300Seconds between scale-in steps on the CPU policy β€” the only one that scales in (kept conservative).

There's no burst knob: real-time burst scale-out (a high-res worker-saturation alarm + step policy, ~20s spike detection) is just part of how web autoscaling works β€” provisioned with the scalable target, like the concurrency and CPU policies, in either serving mode.

The request-concurrency policy itself has no manifest knob: its target is the task's pinned worker pool (8 Γ— vCPU, capped by memory) at 70% utilisation (see Scaling).

yaml
tasks:
  web:
    autoscaling:
      min: 1
      max: 6
      cpu-utilization: 65

Bundled scheduler

A plain web app bundles the scheduler in the web container, so scaling to N tasks runs cron N times β€” every scheduled task would fire on each replica. Every scheduled task must use Laravel's ->onOneServer(), or extract the scheduler into its own service (tasks.scheduler). The sync plan lists an advisory under its Warnings section whenever the scheduler is bundled into an autoscaling host (the web task, or a standalone queue β€” both must declare autoscaling). See Scaling β†’ the scheduler.

tasks.web.health-check.* ​

ALB target-group health check. The path defaults to Laravel's built-in /up health route, which returns 200 only once the framework boots without exceptions (and 500 otherwise) β€” so a broken boot fails the check. Requests to it also dispatch Laravel's Illuminate\Foundation\Events\DiagnosingHealth event, so you can add a listener that checks your database or cache and throws to mark the app unhealthy.

The other defaults are tuned to avoid false-positive failures on a Laravel/Octane app under load: when the FrankenPHP worker pool is saturated the /up probe answers slowly (4–5s) rather than failing, so the timeout sits at 5s β€” a slow-but-alive task stays in service β€” with a roomier 5-failure unhealthy threshold for cushion. A genuine deadlock (no response / 30s+) still trips within ~a minute. Capacity is autoscaling's job, not the health check's. (An app on classic mode β€” tasks.web.octane: false β€” boots per request rather than saturating a worker pool, so its latency shape differs, but the same generous defaults apply.) Override any field per app if you need to:

KeyDefaultDescription
health-check.path/upPath the ALB requests β€” defaults to Laravel's built-in /up health route. Keep it on a route that exercises PHP so a broken boot still fails the check.
health-check.interval10Seconds between checks.
health-check.timeout5Seconds before a check times out. Must stay below the interval.
health-check.healthy-threshold2Consecutive successes to mark healthy.
health-check.unhealthy-threshold5Consecutive failures to mark unhealthy.
health-check.grace-period60Seconds after task start before health checks count (the ECS health-check grace period).

tasks.queue.* ​

tasks.queue is a config object that extracts the queue worker into its own ECS service (so it scales independently of web), or false to switch the worker off entirely (it runs nowhere, and YOLO enforces QUEUE_CONNECTION=sync β€” see Where each role runs); omitting the block leaves the worker bundled in the web container (on a web-less app there's no web container to bundle into, so an omitted queue is off there too). Like web, a standalone queue must declare autoscaling β€” there's no bare queue: true shorthand, and an empty block (queue:) or empty object ({}) is rejected.

autoscaling is the same true | false | {min, max, backlog-per-task} knob as web: true takes the defaults (min: 1, max: 5), false pins a fixed single task (no scalable target, no backlog policy). Set autoscaling.min: 0 to opt into scale to zero: zero tasks β€” and zero compute cost β€” when the queue is empty, at the cost of a ~30–60s Fargate cold start on the first message after idle (so it suits bursty, latency-tolerant work). The queue min may be 0 (unlike web); but when the queue also hosts the scheduler (a tasks.queue block with no tasks.scheduler) it can't scale to zero β€” cron would stop β€” so an explicit tasks.queue.autoscaling.min: 0 is rejected there.

Scaling is backlog-per-task target tracking (ApproximateNumberOfMessagesVisible / RunningTaskCount, CloudWatch metric math β€” no Lambda). A scale-to-zero queue (autoscaling.min: 0) also gets a step-scaling alarm that lifts it 0β†’1 the instant a message arrives (target tracking can't divide by zero running tasks). On a multi-tenant app with queue-isolation: dedicated the visible-message term is the SUM across the landlord queue and every tenant queue, for the policy and the alarm alike β€” see Scaling β†’ multi-tenant queues.

KeyDefaultDescription
tasks.queue.autoscaling(required)true for scaling on defaults, false for a fixed single task, or an object to tune. No implicit default β€” must be declared.
tasks.queue.autoscaling.min1Minimum tasks. 0 = scale to zero when idle.
tasks.queue.autoscaling.max5Maximum tasks.
tasks.queue.autoscaling.backlog-per-task100Target visible messages per running task β€” the scale-out trigger.
tasks.queue.cpu'256'Fargate CPU units.
tasks.queue.memory'512'Fargate memory (MB).
tasks.queue.spotfalsetrue runs the queue on Fargate Spot (~70% cheaper, interruptible β€” fine for a worker whose jobs retry).
tasks.queue.shutdown-grace-period60Seconds the worker gets on SIGTERM to finish its in-flight job before SIGKILL.
tasks.queue.enable-execute-commandtrueEnable ECS Exec on the queue service.

See Scaling β†’ the queue.


tasks.scheduler.* ​

tasks.scheduler is true | false | {config}. true (or a config object) extracts the scheduler (supercronic firing schedule:run) into its own ECS service, pinned at exactly one task β€” a genuine singleton, so ->onOneServer() is no longer required. It deploys stop-then-start (minimumHealthyPercent: 0 / maximumPercent: 100) so a rollout never briefly runs two crons; a missed cron minute is harmless, a double-run isn't. false switches cron off entirely β€” schedule:run runs nowhere, so framework/package maintenance that rides the scheduler silently stops (sync warns); use it only for an app with no scheduled work. Omitting the block leaves the scheduler riding the standalone queue if there is one, else the web container (see Where each role runs); on a web-less app with no standalone queue there's nowhere to ride, so the block is required. An empty block (scheduler:) or empty object ({}) is rejected β€” write true for default sizing.

The scheduler never scales (a per-minute cron can't tolerate a cold start), so it has no min/max.

KeyDefaultDescription
tasks.scheduler.cpu'256'Fargate CPU units (the scheduler is light β€” the smallest tier is usually plenty).
tasks.scheduler.memory'512'Fargate memory (MB).
tasks.scheduler.shutdown-grace-period115Seconds an in-flight schedule:run gets to finish after SIGTERM β€” supercronic stops launching new runs immediately, and its stop overlaps the other programs', so the default hands the run the whole stop window (Fargate's 120s stopTimeout cap minus buffer). A run cut off at the wire should self-heal on a later tick; routinely long work still belongs on the queue.
tasks.scheduler.enable-execute-commandtrueEnable ECS Exec on the scheduler service.

Hooks ​

Three arrays run shell commands at different points in an image's lifecycle β€” see Building & Deploying.

build ​

Runs at build time on your machine, in the build context. For dependency installation and asset compilation.

deploy ​

Runs once per deploy as a one-off ECS task, before traffic shifts. For migrations.

start ​

Runs on every container start, via the entrypoint, before the container's role process comes up β€” a deploy, a scale-out replica, a replaced task, a one-off yolo run. For cache warming like php artisan optimize; keep it idempotent.


App modes ​

Your manifest implies one of three modes:

ModeConditionBehaviour
Solodomain set at the environment levelOne app, one hosted zone + certificate, served on its domain.
Multi-tenanta multitenancy blockA landlord on its own host plus per-tenant resources: queues, and for a tenant on its own domain a hosted zone, certificate, SNI attachment and listener rules. With no tenants declared it provisions exactly what the solo shape does.
Headlessno domain or tenant domainsA worker app β€” no web task (web requires a domain), no ALB attachment or DNS. Still deploys and processes queued/scheduled work.

The mode is the domain axis β€” whether and how the app is exposed. The tasks block sets the orthogonal topology axis: a web service, a web-less worker app (a standalone queue and/or scheduler with no web container), or a build-only app (no tasks at all).


The environment manifest (yolo-environment-{environment}.yml) ​

yolo.yml declares what one app needs; the environment has a declaration of its own. yolo-environment-{environment}.yml (e.g. yolo-environment-production.yml β€” the environment is in the filename, so a pulled copy can never be pushed at the wrong environment) lives in the env config bucket (yolo-{account-id}-{env}-config), not in any app's repo β€” it's seeded by the environment's first sync and from then on owned by the operator, edited through the environment:manifest:pull / environment:manifest:push commands. Every sync:environment pulls it fresh from S3 and reconciles toward it, from any app repo.

yaml
domain: example.com.au   # the env's canonical domain for shared-service ingress
services: {}             # env-shared services β€” the extension point for what sync:environment provisions
# budget:                # advisory monthly cap for the whole environment (every app + shared infra)
#   amount: 500
#   strategy: balanced
# peering:               # VPC peering to infrastructure outside the YOLO network (e.g. a database mid-migration)
#   - vpc-0abc123
KeyPurpose
domainThe environment's canonical domain for shared-service hostnames (e.g. search.{domain}). Distinct from any app's domain β€” shared services are served on the environment's name, reachable from every app regardless of their own domains. Required once the environment declares a service with a public host (services.typesense).
budgetThe env-tier half of the two-tier budget: the same amount / strategy shape as the app key, capping the whole environment (every app + shared infra, attributed via the yolo:environment tag) and reported by status:environment. Advisory β€” never enforced.
peeringA list of VPC ids this environment peers with β€” the declared bridge to infrastructure outside the YOLO network, typically an externally-hosted database mid-migration. For each entry, sync:environment reconciles the bridge in a strict order: the peering connection created and accepted (same-account); routes both ways β€” the peer's CIDR into every yolo-managed route table (the public and private tiers), the env's CIDR into every peer-VPC route table with at least one subnet association (the peer's main table only as a fallback when nothing in that VPC is associated β€” a route in an unassociated main table steers no subnet); and DNS resolution over the peering last, only once every route exists, so nothing resolves across a bridge that can't route yet. The bridge makes exactly two writes into resources YOLO doesn't own β€” the return routes in the peer's tables, and the database-port ingress rule on an external database's security group β€” and the plan names each and marks it not yolo-managed. Entries must be VPC ids (vpc-…); anything else hard-fails. Removing an entry tears the whole bridge down on the next sync, in reverse: DNS resolution off, the yolo-side routes, the return routes YOLO wrote into the peer's tables (matched strictly by destination and connection β€” nothing else in the foreign tables is ever touched), then the connection. Environment-scoped on purpose: peering is VPC-to-VPC, so it can never live in an app's manifest.
servicesThe env-shared services this environment runs β€” a map of service β‡’ config (services.ivs: {}). The declaration is the whole trigger of the service lifecycle: sync:environment provisions a declared service (independent of any consumer) and plans its teardown once the entry is removed; a declared service no running app uses is flagged as idle (a plan warning), not torn down. environment:manifest:push refuses to remove a service apps still use. Each entry is a map (never a scalar or list); its allowed keys come from the service's definition.
services.ivsThe environment's IVS event-logging pipeline β€” one /aws/ivs/yolo-{env} log group + EventBridge rule per environment, because the aws.ivs event stream is account-wide. Takes no config: services: { ivs: {} } is the complete entry.
services.typesenseThe environment's Typesense search cluster. version (the typesense/typesense image tag) is required β€” an environment never runs an implicit search engine version. nodes, cpu and memory follow the tasks.* conventions: optional, defaulting to 3 nodes at '256'/'1024' each. nodes accepts 3 or 5 β€” five spreads read load wider and survives two losses; an even count pays for an extra node without gaining the ability to lose another one, and a single node would lose its search data whenever the task is replaced, so neither is offered. services: { typesense: { version: "30.2" } } is a complete entry. A version bump or resize is a manifest edit + sync:environment β€” the nodes roll one at a time.

Like yolo.yml, the file is validated against a strict allow-list β€” an unrecognised key hard-fails both environment:manifest:push (before upload) and any sync that reads it. The allow-list is compiled into each release, so adding a new env-manifest key means updating codinglabsau/yolo in the environment's app repos before pushing the key β€” an older binary hard-fails (with an upgrade hint) rather than silently ignoring declarations it doesn't know. See The environment declaration for the model.

Released under the MIT License.