Services โ
A service is an opt-in AWS capability an app consumes โ live video, search, transcoding, image analysis. An app declares the ones it uses by bare name in its yolo.yml:
services: [ivs, typesense]An entry is just a name. All shape โ sizing, versions, retention โ is either hardcoded or lives in the environment manifest, never the app manifest, so two apps can never declare competing configuration for shared infrastructure. Unknown names, duplicates, or anything but a flat list hard-fail validation.
The two tiers โ
Every service is one of two kinds, and the kind decides where it's shaped, which sync provisions it, and how far a change reaches:
| Service | Tier | Provisioned by | What the app gets |
|---|---|---|---|
ivs | environment-backed | sync:environment + sync:app | an ivs:* task-role grant; a shared per-environment event-logging pipeline |
typesense | environment-backed | sync:environment + sync:app | a shared search cluster; a per-app scoped key + Scout wiring |
mediaconvert | app-side | sync:app | a per-app MediaConvert role + job IAM |
rekognition | app-side | sync:app | a rekognition:* task-role grant |
App-side services โ
mediaconvert and rekognition are a plain per-app claim. Enabling one adds it to the app's yolo.yml and sync:app grants the IAM the container needs โ nothing is shared, there's no environment-manifest half, and the capability is a pay-per-call AWS API the app calls as itself. When an app stops claiming one, its IAM melts away on the next sync.
Environment-backed services โ
ivs and typesense also have a half in the environment manifest โ a services.{name} entry carrying any shape โ because the underlying infrastructure is shared by every app in the environment. The app's bare claim says I use this; the environment manifest says the environment runs this, shaped like so. That shared half is governed by the service lifecycle.
Managing services โ yolo services โ
yolo services <env> is the front door, and it's self-guiding โ so this is the short version:
yolo services productionIt prints a Service ยท Description ยท Status table (Status = whether this app uses each one) and a picker. Pick a service to:
- Enable / disable it for this app โ a surgical edit to your
yolo.ymlserviceslist, preserving its comments and formatting. For an app-side service that's the whole change. For an env-backed service, enabling also walks you through its environment offer (Typesense's version / nodes / CPU / RAM, pre-filled with defaults) on a local copy of the env manifest. - Apply โ nothing touches AWS until you say so. An app-side change offers to run
sync:app; an env-backed change spells out (and offers to run)environment:manifest:pushthensync. Both default to not applying, because provisioning is real, billed infrastructure.
For agents and CI there's a non-interactive surface โ --json to read the gate as data, and --add / --set / --remove to drive the environment offer directly:
yolo services production --json
yolo services production --add=typesense --set version=30.2 --set nodes=3
yolo services production --remove=typesenseSee the services command reference for every flag.
The service lifecycle โ
An environment-backed service (IVS or Typesense) is governed by one fact: whether the environment manifest declares it โ a services.{name} entry. That declaration is the environment's catalogue (the record of what it runs and how it's shaped) and a deliberate, billed decision. The service stands up on declaration alone, independent of whether any app currently consumes it, and is torn down only when you remove the entry.
sync:environment reconciles every env-backed service against the manifest, every sync:
| Declared | What sync does |
|---|---|
| yes | Provisions it and keeps it reconciled |
| no | Tears down whatever still exists (a WOULD DELETE behind the confirm gate); otherwise skips |
Provisioning deliberately does not depend on a consuming app. An earlier design gated it on "a running app uses it" โ but that means a consumer being down at sync time (mid-deploy, scaled to zero, an incident) would tear the shared cluster, and its index, out from under everything. Declaration drives provisioning instead; the consumption signal informs a warning, not a teardown.
An idle service is a warning, not a teardown. When sync:environment provisions a declared service that no running app uses, it surfaces a plan warning โ you're paying for an idle cluster โ naming the fix (remove it from the env manifest). It's a nudge, never a gate. (Suppressed while a running app hasn't published its services since this YOLO release, since that app might be a consumer YOLO can't yet see.)
Two hard errors guard the edges:
- An app that uses a service the environment doesn't declare fails
build,deployandsync:appwith the fix spelled out (declare it via the manifest pull/push flow, or take it out ofyolo.yml). On a greenfield environment whose manifest hasn't been seeded yet, the check defers to the first sync instead of bricking it. environment:manifest:pushrefuses to remove a service while running apps still use it โ naming them โ and likewise while any running app hasn't published what it uses yet. (Hand-editing the bucket manifest to drop a service apps still use is caught at sync as a hard error too, rather than silently tearing it out from under them.)
Retiring a service is therefore self-enforcing, with hard edges the whole way: remove it from each app's yolo.yml โ deploy/sync:app (the app's per-service IAM melts away in the same pass) โ remove the env-manifest entry and push (accepted once nothing is using it) โ sync:environment plans the teardown for you to confirm.
IVS โ live video โ
Amazon IVS for live, low-latency video streaming. Environment-backed, but the shared half is deliberately thin: the app drives IVS itself at runtime (it creates channels and stream keys on demand), so there's no stable resource to provision per app and nothing to scope the grant to.
- App grant.
services: [ivs]grants the app's ECS task roleivs:*(on*โ channels and stream keys don't exist until the app makes them). - The shared event pipeline.
sync:environmentprovisions one event-logging pipeline per environment: a CloudWatch log group (/aws/ivs/yolo-{env}, 14-day retention) and an EventBridge rule (yolo-{env}-ivs-state-change) matching everyaws.ivsevent in the account, targeting the log group with a resource policy that lets EventBridge write to it. It's one pipeline per environment โ not per app โ because theaws.ivsevent stream is account-wide. Cost is negligible. - Observability. Each consuming app's CloudWatch dashboard gains an IVS logs panel sourced from that log group.
- Manifest shape.
services.ivs: {}โ no offer keys; the pipeline isn't sized.
Typesense โ the environment's search cluster โ
Declaring services.typesense (a pinned version plus optional cpu/memory per-node sizing, tasks.*-style โ see the manifest reference) gives the environment a self-hosted, three-node Typesense cluster, shared by every app with typesense in its services list:
- Durable by replication, not by disk. The three single-task ECS services (AZ-spread, one per public subnet, arm64) form a Raft quorum: writes commit on 2-of-3, and a replaced node catches up from the surviving majority over the network โ the persistence model that works with Fargate's ephemeral storage. Losing one node degrades nothing; losing two degrades to read-only until a node returns. The search index is a rebuildable projection of your database (
scout:import), never a source of truth. - Stable peer addresses come from a private Cloud Map DNS namespace (
{env}.internal): each node ownstypesense-{n}.{env}.internal, re-resolving to its replacement task within seconds. Typesense itself never resolves those names โ a fail-closed entrypoint baked into the image does, rewriting the nodes file (IPs only) exclusively on rounds where every peer resolves. Typesense treats whatever resolution returns as new Raft membership truth, so handing it hostnames means a transient VPC resolver wobble becomes a membership rewrite โ and a fatal, replicatingbraftconfig entry that can destroy the whole cluster. With the wrapper owning resolution, a failed or partial round simply leaves the last-known-good peer list standing, and a replaced node's new IP lands on the next successful round. - The image is the secret boundary. Sync seed-generates an admin API key into the env-shared
.env, then buildstypesense/typesense:{version}plus a config file carrying that key (and CORS, enabled so browsers can query the nodes directly) into an env-scoped ECR repository, content-tagged by version + a fingerprint of the whole baked config โ unchanged inputs never rebuild, and a version bump, key rotation or config change re-tags the image and rolls the nodes one at a time, each waiting for stability before the next (the quorum holds throughout). The task definition carries no secret. - The env services cluster (
yolo-{env}-services) hosts the node tasks, kept apart from the per-app clusters so app liveness derivation never mistakes shared-service tasks for an app โ which is also whyservicesis a reserved app name. - Node-to-node Raft traffic (8107) is locked to the node security group itself; the search API (8108) admits the environment's ALB plus each consuming app's task security group.
Two deliberate traffic paths. Browsers reach the cluster on search.{domain} (the env manifest's domain โ required once the environment runs typesense): the service owns its own public ingress, so it asserts an apex + wildcard cert on the env domain (reusing an app's existing cert when the domain is shared, minting one when it's new) and bootstraps the shared :443 listener from that cert when no app has yet โ it never waits on an app, since a service may run on a domain no app shares. A Name-tagged listener rule then forwards the host to a target group health-checking Typesense's own /health (so a catching-up node drops out of rotation while the quorum serves), and a Route 53 alias points the host at the ALB. Because the node services are load-balanced, this whole ingress chain (target group โ cert/:443 โ rule) is wired before the nodes are created โ ECS rejects a service whose target group isn't yet attached to a load balancer. The nodes answer CORS for any origin so a page on the app's own domain can query search.{domain} directly โ the browser carries a search-only key, so that key's scope and the rate limit are the controls, not an origin allowlist. App/Scout indexing stays private, in-VPC โ the build injects the Cloud Map node addresses, so bulk reimports never meet the ALB, the WAF, or its rate budget.
The WAF knows about search. Keystroke queries behind CGNAT would false-positive the general per-IP rate rule, so while the search host is active, yolo-rate-limit carves it out and a YOLO-owned yolo-search-rate-limit rule (1,000 req/min per IP, host-scoped) takes over.
Keys are scoped per app. The cluster admin key lives alone in the env-shared .env and never leaves the env tier (baked into the env-scoped image; app builds never read it). sync:app mints each consuming app two keys via the admin API, both restricted to its own {prefix}* collections (a leaked key reaches that app's collections only): a server-side key (all actions) the app indexes and queries with from PHP, and a search-only key (documents:search) safe to embed in the page for browser-direct InstantSearch. Both are written into the app's environment-side .env (env/.env.{app} in the env config bucket), beside the env manifest and the per-app claim files. Keeping them there, not in the app's developer .env (a per-app config bucket the admin tier running sync is fenced from), is what lets sync mint them at all โ and it isolates each app's keys: the build merges in only its own env/.env.{app}, never the admin key or a sibling's. Both keys are minted once, together (rotation = delete the lines, re-sync); while the cluster isn't up yet, the mint skips with instructions and lands on the next sync. The stored values are verified against the cluster on every sync โ minted keys are cluster data (raft-replicated, ephemeral disks), so a full node replacement boots a cluster that no longer recognises them while apps keep serving the baked values, every search 401ing behind a green /health. A scoped probe with the stored search key catches exactly that, and sync re-creates the dead pair with the same stored values โ so the keys baked into every existing build work again the moment the sync applies, no rebuild or release needed. (Collections still need a scout:import backfill after a cluster loss; the keys are the part that self-heals.) The build also injects SCOUT_DRIVER=typesense, SCOUT_PREFIX, the private Cloud Map node addresses for server-side indexing (TYPESENSE_HOST/PORT/PROTOCOL + the full TYPESENSE_NODES list), and the public search host for the browser (TYPESENSE_SEARCH_HOST/PORT/PROTOCOL).
Observability rides the consumer's dashboard (search node health with the quorum floor annotated, request count + p99, per-node memory/CPU, rate-limit blocks, a Typesense logs panel), and the env SNS topic gets the quorum alarm pair โ healthy nodes < 3 warns, < 2 means read-only โ plus per-node memory alarms at 80% of the offer.
The index self-heals โ persistence is a recovery loop, not a disk. YOLO's runtime service provider ships two artisan commands into every consuming app and schedules the heal itself โ there is nothing to add to a console kernel. composer update codinglabsau/yolo + a release, and self-healing is on (YOLO_SEARCH_HEAL=false is the opt-out; the app's TYPESENSE_*/Scout config is all the commands need):
scout:healโ runs every five minutes, provider-scheduled. It walks the app's searchable models โ thescout.typesense.model-settingskeys, unioned with a sweep ofapp/for anything using Scout'sSearchable(resolved through wrapper traits too) โ and when a model's collection is missing, or sits empty while the database plainly isn't, it refills it through Scout's own machinery:scout:queue-importfans ID-range jobs across the queue workers and the engine recreates the collection with the declared schema on the first batch (a model with a non-numeric scout key, which queue-import refuses, takes a single queued rebuild through the alias-swap engine instead). A full cluster loss costs one heal interval plus a refill, unattended. Run-once is the command's own property โ it takes a cache lock itself and marks each dispatched refill so later ticks don't double the queue while one is still landing; noonOneServer()/withoutOverlapping()decorations needed (adding them is harmless). It deliberately doesn't chase partial drift (live churn makes count-matching a false-alarm machine), warns rather than refills a searchable model with no declared schema, and reports a cluster that stops honouring the app's key asyolo sync:app's fix, never "healed".scout:reimport {model?*}โscout:import --freshwithout the blackout (the name follows scout-extended's command for the same temporary-index pattern). Run bare in a terminal it offers a picker over the discovered searchable models; non-interactive runs name their models or pass--all, so a fat-fingered scheduler entry can't rebuild the world. Each collection is rebuilt beside the live one and swapped in atomically via a Typesense collection alias: build a timestamped collection from the model's declared schema, import everything into it while the live index keeps serving, flip the alias, delete the old collection, then replay rows that changed during the build window through Scout's normal path (updated_at >=build start โ the database is the source of truth, so catch-up is never guesswork). Exact mirror (orphans die with the old collection), current schema applied, zero search downtime. The first run per model migrates Scout's literal collection to the alias layout (a one-time sub-second gap); a record deleted mid-build lingers until the next reimport โ the temporary-index pattern's standard blind spot. During each swap the old and new collections coexist, so run order is smallest-first (the command does this itself) and rebuilding a large collection on tightly-sized nodes may wantservices.typesense.memorybumped first.
Sizing rule of thumb: Typesense holds the whole index in memory at ~2โ3ร the raw indexed size, so a few hundred MB of records fits comfortably on 1 GB nodes; resizing is an env-manifest edit and a sync. Losing all three nodes at once is genuine DR only for the minutes it takes to notice: the index is a rebuildable projection, and the heal loop rebuilds it from the database unattended.
Consuming it from your app โ
YOLO injects everything the app needs at build time โ but the two traffic paths wire up differently, because only one of them is a Laravel Scout feature.
Server-side (indexing + PHP queries) is stock Scout โ SCOUT_DRIVER, SCOUT_PREFIX, and TYPESENSE_API_KEY (the server-side key) plus TYPESENSE_HOST/PORT/PROTOCOL are exactly the variables Scout's published config/scout.php stub already reads, so this half works with no changes. Wire TYPESENSE_NODES only if you want the client's native multi-node failover:
'typesense' => [
'client-settings' => [ // standard Scout โ server-side
'api_key' => env('TYPESENSE_API_KEY'), // all-actions, scoped to {app}_*
'nodes' => [[
'host' => env('TYPESENSE_HOST'),
'port' => env('TYPESENSE_PORT', '8108'),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
]],
],
// ... your model-settings (collection-schema, search-parameters) ...
],Browser-direct search is not a Scout feature โ Scout assumes every query runs through PHP, so it never reads a public key or talks to the browser. The browser half is the typesense-instantsearch-adapter's job, and the four TYPESENSE_SEARCH_* variables YOLO injects (TYPESENSE_SEARCH_KEY + TYPESENSE_SEARCH_HOST/PORT/PROTOCOL) are your app's own convention, not values Scout knows about. Expose them through a config block of your own and hand them to the adapter:
// config/scout.php โ an app convention, NOT read by Scout
'typesense' => [
// ... client-settings as above ...
'search' => [
'key' => env('TYPESENSE_SEARCH_KEY'), // documents:search only โ safe in-page
'host' => env('TYPESENSE_SEARCH_HOST'), // search.{domain}
'port' => env('TYPESENSE_SEARCH_PORT', '443'),
'protocol' => env('TYPESENSE_SEARCH_PROTOCOL', 'https'),
],
],// frontend โ fed the `search` block (e.g. via an Inertia shared prop)
const adapter = new TypesenseInstantSearchAdapter({
server: {
apiKey: config.key, // search-only, public by design
nodes: [{ host: config.host, port: config.port, protocol: config.protocol }],
},
additionalSearchParameters: { query_by: "name,description" },
});The search-only key is meant to ship in the page: it can only run searches, only over this app's own {prefix}* collections, and the per-IP rate limit on search.{domain} is the abuse control โ so an origin allowlist would guard nothing (which is why the nodes answer CORS for any origin). For per-user or multi-tenant filtering, derive a scoped search key from it at request time โ an HMAC with embedded filters, no admin call โ rather than minting more keys.
MediaConvert โ video transcoding โ
AWS Elemental MediaConvert for file-based video transcoding. App-side only โ jobs run on the account's default on-demand queue, so there is no environment-manifest half.
- App grant.
sync:appprovisions a per-app IAM role for MediaConvert to assume, and grants the task role the job operations (CreateJob,GetJob,ListJobs,DescribeEndpoints) plusiam:PassRolelocked to that one role and to MediaConvert itself. - Build value. The role's ARN is baked into the build as
AWS_MEDIACONVERT_ROLE_ID, so the app passes it when it submits a job. - Lifecycle. The role is torn down on the sync after the app stops claiming the service.
- Observability. The app's CloudWatch dashboard gains a MediaConvert jobs panel (completed + errored on the account's default queue).
Rekognition โ image & video analysis โ
Amazon Rekognition for image and video analysis. App-side only and the lightest service there is โ a pure pay-per-call API, so nothing is provisioned at all.
- App grant.
sync:appgrants the app's ECS task rolerekognition:*(on*). The detection APIs are resource-less โ they operate on request payloads or on S3 objects read with the caller's own credentials, so reads of the app'sbucketride its existing grant. - Observability. The app's CloudWatch dashboard gains a Rekognition requests panel (by operation: successful, throttled, user and server errors).
