Scaling ​
By default an app runs as one Fargate task doing everything — Octane plus the bundled queue worker and scheduler. That's the cheap floor and fine at low scale. The three workloads have different scaling shapes, though, so each can be extracted into its own ECS service that scales independently:
| Service | How it scales | Opt in with |
|---|---|---|
| web | target tracking (request concurrency + CPU), min→max | tasks.web.autoscaling |
| queue | backlog-per-task, scales to zero | top-level tasks.queue |
| scheduler | never — pinned singleton (exactly one task) | top-level tasks.scheduler |
Extraction is opt-in by presence — there are no tasks.web.queue / tasks.web.scheduler flags. Add a top-level tasks.queue block to peel the worker tier (the queue worker and the scheduler) out of web, leaving web as pure Octane; add tasks.scheduler as well to give cron its own pinned-singleton task (see the scheduler).
You can scale the web (and queue) service two ways:
- Autoscaling — let AWS adjust the task count automatically from live metrics.
yolo scale— set the capacity yourself, out of band, without a deploy.
Autoscaling bounds (min/max) live in the manifest and are reconciled by sync, so they're declarative and never drift — with a guard so a stale manifest can't scale production down unattended (see Reducing capacity is guarded). A fixed service's desired count is create-only — set once, then owned by yolo scale, never reset by a routine sync or deploy.
Autoscaling ​
autoscaling is required on web and queue — there's no implicit default, and neither accepts the bare tasks.web: true / tasks.queue: true shorthand (only the scheduler does). yolo init scaffolds new apps with tasks.web.autoscaling: true (bounds 1–5), so a fresh app scales out of the box. To set your own bounds, expand the shorthand into a block:
tasks:
web:
autoscaling:
min: 1
max: 6
cpu-utilization: 65 # optional — the safety-net policy's targetThe scaffolded shorthand takes the defaults (min: 1, max: 5):
tasks:
web:
autoscaling: true # shorthand for the defaults; `false` = a fixed single taskAn enabled web tier must declare autoscaling — omitting it (or using the bare tasks.web: true shorthand) hard-fails the manifest check, so a tier's scaling posture is always a deliberate decision rather than an inherited default. With true (or a block), the next yolo sync / yolo sync:app registers an Application Auto Scaling scalable target on the ECS service (bounded by min/max) and attaches target-tracking policies to it. Set autoscaling: false to keep the service a fixed single task instead.
Two metrics, composed ​
YOLO runs two target-tracking policies at once. Application Auto Scaling takes the maximum desired count any policy asks for, so they compose rather than fight — scale-out always wins.
| Policy | Metric | Role |
|---|---|---|
| Request concurrency | in-flight requests per task (derived) | The default, leading signal — concurrency climbs the instant traffic does, ahead of CPU. Scales the web tier under normal HTTP load. No tuning: its target comes from the task's pinned worker pool (sized from vCPU). |
| CPU | ECSServiceAverageCPUUtilization | The safety net. Catches load that pegs the CPU without raising request concurrency — a few heavy, low-rate requests. Target defaults to 65% — see what that assumes. |
Both are on the moment the web tier declares autoscaling: true (or a block) — there's nothing to seed from a load test first. Scaling on the requests a task is actively serving rather than trailing CPU means faster responses need fewer tasks for the same traffic, and a spike is caught as it arrives.
Scale-in belongs to the CPU policy ​
The concurrency policy is scale-out only (its target-tracking configuration sets DisableScaleIn). Its signal includes latency, and latency dips the moment a freshly-added task starts serving — so under a load ramp the concurrency scale-in alarm reads "over-provisioned" during the exact window the tier is recovering from a burst, and would remove the task burst just added while the burst alarm is still in ALARM. Burst then re-adds it, and the tier thrashes instead of climbing.
Application Auto Scaling's own scale-in alarms aren't configurable (it manages them, and a composite alarm can't drive a scaling action), so the fix is ownership rather than tuning: only the CPU policy scales in. Its scale-in alarm needs 15 consecutive minutes under target, which can't complete inside a burst, and scale-in-cooldown applies to it alone. The concurrency and burst policies can only ever add capacity.
What the CPU target assumes ​
The default target of 65% assumes replacement capacity arrives quickly. Target tracking adds a task once average CPU crosses the target, and the tier runs at or above it for the whole scale-out lead — the alarm's evaluation window plus the new task's cold start — so the higher the target, the closer to saturation the tier sits while it waits. Observed in practice: at 65 a target-tracking policy will scale in while the tier is still under load, because the average dips under target as soon as the added tasks start serving. A tier with a slow cold start (a large image, a warm-up-heavy boot) or a low tolerance for saturation wants enough headroom under the target to cover that lead. How much is application-specific — measure it under load rather than guessing — and tasks.web.autoscaling.cpu-utilization sets it.
How the concurrency target is derived ​
The ALB doesn't publish in-flight concurrency, so YOLO derives it with CloudWatch metric math from two metrics it does — request rate and response time (Little's Law, concurrency = rate × latency):
concurrency_per_task = (RequestCountPerTarget / 60) × TargetResponseTimeand target-tracks it against the task's pinned concurrency ceiling held at 70% utilisation. A 1 vCPU Octane task → 8 workers → a target of 5 concurrent requests, leaving headroom for the within-minute peak and the next task's cold start. Resize the task (tasks.web.cpu) and both the ceiling and the target follow; set tasks.web.concurrency and they follow that instead — there's no separate target knob.
What the ceiling is, and why YOLO pins it ​
Whichever mode the tier runs, one PHP worker or thread serves one request at a time and blocks for that request's whole lifetime — including a downstream wait or an SSR render it can't yield during. So the size of that pool is the per-task concurrency ceiling, and YOLO pins it from the task's real vCPU allocation rather than letting FrankenPHP auto-detect it. Auto-detection reads the CPUs visible to the process, which on Fargate is the microVM's fixed ~2 vCPUs — so it would pin ~4 on every task whatever its size, and resizing the task wouldn't move it.
- Octane (worker mode, the default) — a resident pool of
8 × vCPUworkers, capped by memory, pinned withoctane:start --workers. Fixed at boot: the pool is the ceiling. - Classic mode (
tasks.web.octane: false) — threads spawned on demand between a floor of8 × vCPUand a ceiling of16 × vCPU, both capped by memory. The ceiling is what the target tracks; the floor is just where the pool idles. The extra headroom absorbs a within-minute arrival spike while ECS brings another task up.
Both formulas assume a request that spends part of its life parked on a downstream rather than burning the core, which is why the pool is sized above the vCPU count — but at half of what a purely I/O-bound pool would justify, and that is deliberate. Load testing a CPU-bound app measured throughput flat across pool sizes; what differed was how the two mistakes fail. Too few workers fails visibly: requests queue at the load balancer, where the saturation metric and the autoscaler both see them and respond. Too many fails invisibly: the excess queues inside the task, each waiting request holding a database connection, the load balancer sees healthy latency because the task is still accepting, and the saturation metric under-reads. The default errs toward the visible failure. An app that knows better in either direction — a CPU-bound one that wants fewer, or a genuinely I/O-bound one that wants the larger pool — sets tasks.web.concurrency, which takes the count directly — absolute, not per vCPU: the Octane pool becomes exactly that, and classic mode pins both thread bounds to it, giving up the derived 2× headroom. That headroom is only capacity when a thread is parked; for a CPU-bound app every thread past the value is contention, slowing everything in flight while holding a connection, so an influx is better queued at the task where queue_depth reads it honestly and the in-flight work finishes at full speed. The autoscaling target and the burst denominator follow whichever value is in force, so scaling reads the same number the runtime was started with.
The task's CPU size matters as much as the pool's shape, and the two compound. The default web task is 512 CPU units — half a vCPU — which is a reasonable place for a light app to sit, and YOLO leaves it there. But a fractional vCPU can't absorb a CPU-bound request without queuing, and the pool formula then places several workers on it, so that's the shape where oversubscription bites hardest. If your requests burn CPU rather than waiting on a downstream, treat a whole vCPU (tasks.web.cpu: '1024') as the sensible floor before tuning anything else — a bigger pool on half a core buys nothing that the load balancer can see.
Classic mode needs both bounds written explicitly, which is why YOLO generates a docker/Caddyfile and runs frankenphp run --config against it rather than the simpler frankenphp php-server: that command exposes no thread flag and reads no Caddyfile, so its pool can't be moved off the microVM-derived default. max_threads auto isn't an option either — it sizes off host memory without consulting the container's limit, returning the same ceiling whether the task is capped at 512 MB or 4 GB, so a small task would grow a pool it can't hold.
Because the signal includes latency, a slow downstream dependency (a struggling database) raises concurrency and scales the web tier out even when more tasks won't help — the max bound is the backstop there, since CPU stays low when the stall is downstream.
Latency can also drift inside a long-lived worker, and the concurrency signal reads that the same way — by adding tasks that don't address it. Laravel's published config/octane.php ships CollectGarbage::class commented out under OperationTerminated, so reference cycles are freed only when PHP's automatic collector fires. Those cycles aren't a bug in your app: ordinary Laravel object graphs point back at themselves, and refcounting alone can never free them. The automatic collector does collect them, but its frequency is driven by allocation churn while its cost scales with the live object graph a resident worker has accumulated — so on a heavy, high-throughput app the two compound and each pass gets slower. Worth knowing that the same config file ships 'garbage' => 50 uncommented, with a paragraph describing the collection it configures, so the file reads as though this is already handled when the listener that acts on it is off.
Whether it's worth acting on is a function of heap size and request rate rather than anything wrong with the application, and octane:start recycles every worker after 500 requests, which bounds the drift into a sawtooth rather than letting it run away. So YOLO doesn't assert it. If you see warm-request latency climbing between recycles, uncommenting CollectGarbage::class is the one-line change to try first.
Faster scale-out: burst ​
The two policies above scale on ALB metrics, which are 1-minute resolution — a good signal, but ~1 min behind a sudden spike. So once you're autoscaling, YOLO also runs a burst path. There's no knob for it: it's near-free and fails safe, and no app wants slower scaling, so — like the concurrency and CPU policies — it's just part of how web autoscaling works, provisioned wherever the scalable target is, in either serving mode. (The signal is FrankenPHP's pool gauges, which Octane and classic mode each expose. Nothing to switch on or off.)
Burst adds a step-scaling policy driven by a high-resolution alarm (10s) on a signal the container reports about itself: each web task publishes its saturation — FrankenPHP's busy workers over the worker-pool size, both read from its metrics endpoint — an earlier indicator than the ALB, since concurrency fills the pool before latency even climbs. busy_workers counts every request dispatched to the worker script, including those still waiting for a worker, so under overload it carries the queue in front of the pool and reads past 100 — the deeper the overshoot, the bigger the step. The task also brackets every request it handles and keeps the window's peak in-flight count as a floor under that reading: a scrape samples one instant, and one that lands on a momentary low can't under-read a window the app itself saw busier. The in-app count is only the floor, not the signal — it enters once a worker has picked the request up and Laravel is handling it, so under a sustained pin it peaks at a fraction of the pool while the queue in front of the workers, and the time a busy worker spends outside the framework's handle span, go uncounted.
In classic mode the denominator is instead the thread ceiling YOLO pinned (max_threads, injected on the task definition as YOLO_BURST_THREADS): FrankenPHP's total_threads gauge reports the floor the pool boots with, not what its thread autoscaler has grown to, so busy_threads can legitimately exceed it. The numerator is busy_threads + queue_depth: the thread gauge is sampled while the sampling request's own thread is still busy, so it doesn't self-undercount the way the worker gauge does, and a request waiting for a thread is load the ceiling hasn't absorbed. Queueing pushes the reading past 100 naturally — one queued request on a 16-thread task is ~6 points — rather than tripping the alarm outright, since one momentarily-queued request must not buy a task that scale-in then holds for its 15-minute window.
Burst reacts to thread saturation, not CPU. A CPU-pinned task with threads to spare reads low here and waits on the CPU target-tracking policy — that's the CPU policy's job, and the two signals are deliberately separate: burst catches the arrival spike that fills the pool, CPU target-tracking catches the sustained compute climb.
Detection drops from ~60s to ~20–25s: the alarm needs two consecutive 10s datapoints over its line (plus the reporter's ~5s debounce), so a single window in which a few requests happen to coincide can't buy a task, while a real spike — the gauge climbing from tens to hundreds inside one window — clears it on the first two readings. The line itself is per serving mode. In Octane it's 100%: busy_workers counts every dispatched request, load-balancer health checks included, and on a small pool two or three probes landing in one window read 6-of-8 busy on an idle task — a fraction-of-pool line mistakes that for a burst and the CPU policy scales it straight back in, every few minutes. Probes can never queue a pool, so the honest cut is dispatched exceeds the pool: strictly over 100% means requests are waiting for a worker. In classic mode the line stays at 70%: its numerator already carries the queue and its thread ceiling is larger, so a fractional reading below a full pin is real load, and a 70% line trips a step below the pin (e.g. 6-of-8 busy on a small ceiling) where a tighter one would need a sustained 100% pin that rarely holds. Past the line, up to 10 points over adds a task and beyond that adds two — so in classic mode a pinned task gets two, and in Octane one queued request on a pool of ten or fewer (9-of-8 is 112.5%) already goes straight to two: accepted, since a real queue is real demand. Scale-in stays with the CPU target-tracking policy, so burst can only ever scale out faster, never fight it.
How it works, and what it costs:
- YOLO's service provider publishes the saturation directly via
PutMetricDatafrom an after-response hook on the autoscaling web tier — so the work rides a request that already holds a CPU slice rather than a separate loop competing for one on a pinned box. It publishes only while hot (≥50%, under both lines, so the alarm is fed a not-breaching reading as load ramps) and debounces to at most one read + put per ~5s per task (Redis), so CloudWatch is touched only during a spike. It keeps publishing through a breach rather than pausing: the alarm needs the second consecutive datapoint, and the step policy's cooldown is what stops a standing ALARM from adding a task per window. If the pool-size scrape fails under load, it corroborates with a cheap local cgroup CPU read and breaches when CPU is high — a read the worker can always do, independent of the (possibly starved) endpoint (taken as a percentage of the task's allocated vCPU, which YOLO injects on the task-def, since the Fargate microVM reports more vCPUs than a fractional task is throttled to — so a percent-of-visible-cores reading would never trip). Going direct lands the datapoint synchronously; an EMF log line instead rides the logs pipeline — and the ECSawslogsdriver exposes no flush-interval knob (AWS recommends ≤5s for high-res EMF alarms) while extraction is async, so it surfaces on a cadence you don't control. The cost is one namespace-scopedcloudwatch:PutMetricDatagrant on the task role plus theaws/aws-sdk-phpSDK — which ships in the image transitively via YOLO itself, a production dependency of every deployed app (a build preflight hard-fails if YOLO is only a dev dependency). - To turn that endpoint on, YOLO runs Octane against a Caddyfile it generates — your installed Octane stub with the top-level
metricsglobal option added, passed via--caddyfile. It's your stub untouched bar that one line (so Octane still fills its own placeholders). An env var won't do here:octane:startrebuildsCADDY_GLOBAL_OPTIONSitself, discarding any value set on the task. In classic mode the same option goes into the Caddyfile YOLO already generates for the thread bounds. Either way themetricsoption is added only for an autoscaling web tier. The endpoint binds container-loopback (localhost:2019) only — never the load-balanced port — so it adds no external surface. - Cost is the one high-resolution alarm ($0.30/app/month) plus the custom metric (another ~$0.30, but only in months the service actually bursts — a metric is billed only when it receives data). The puts themselves are effectively free: inside CloudWatch's 1M-request/month API free tier, and $0.01 per million beyond it.
Burst is not a substitute for warm capacity
Even instant detection still waits ~55s for the new task to boot and pass ALB health. So reactive scaling — burst included — bottoms out at ~1 min to relief; below that you need a task that's already running (min ≥ N). Burst makes the spike that exceeds your warm headroom land faster; it doesn't remove the need for the headroom.
The in-request publish is also best-effort: on a single hard-pinned task (min 1 on a small box at ~99% CPU) where no request even completes, nothing inside the container escapes and burst can go dark. The CPU fallback covers the busy-but-serving case, but the CPU/concurrency target-tracking policies are the guaranteed backstop and min ≥ 2 or a larger task is the lever — burst sharpens the light-pin and multi-task case, it isn't a substitute for either.
Each window's sample is also written to the app log as yolo-burst: window sample — the in-flight peak, the raw counter, the ceiling in force, and every FrankenPHP gauge the scrape carries (total_workers, ready_workers, busy_workers, worker_queue_depth, worker_crashes, worker_restarts, total_threads, busy_threads, queue_depth). The published metric is one ratio; when it disagrees with what the load balancer sees, this line is what shows which term is wrong — a counter that under-reports, a pool with fewer ready workers than it was sized for, or a queue the formula doesn't count. Hot windows (at or above the 50% emit floor) log at info; colder ones at debug, so dropping LOG_LEVEL to debug for a load test shows the ramp into a pin, not just the plateau. A primed scrape failure logs at warning with the CPU reading and whether it breached.
The burst signal is graphed on the app's CloudWatch dashboard: the Worker saturation panel charts the busiest task's saturation with the tier's Burst trip threshold (100% in Octane, 70% in classic mode) drawn as a reference line, so you can see how close the tier runs to a burst and when one fired. The panel appears only on an autoscaling web tier — the only place the metric exists.
The burst alarm and step policy aren't taggable, so (like the target-tracking policies) they don't appear in yolo audit; setting autoscaling: false deletes both on the next sync.
Shedding SSR under load ​
On an Inertia SSR app, the same worker-saturation reading drives a second, instant lever. Scale-out still bottoms out at ~1 min to relief (a new task has to boot), so to cover that window YOLO routes SSR through a saturation-aware gateway: while a task is flagged hot (the burst trip), it skips the Node render and serves CSR instead. Server-side rendering is the most expensive per-request CPU on the box, so shedding it the instant saturation trips frees the worker immediately and keeps the task responsive while the new capacity lands — one signal, a slow lever (add a task) and an instant local one (stop rendering). The flag is per task and self-expires on the burst cooldown, so SSR resumes automatically once the task stops tripping. The same gateway also bounds each render with a timeout so a single slow render can't pin a worker (a worker stuck on a synchronous CPU-bound render is how one hot task spirals into a health-check death-loop). It needs nothing in the manifest — it talks the stable Inertia SSR config/protocol so it's version-agnostic across Inertia v2/v3, is active wherever burst runs and Inertia SSR is enabled, a no-op on a non-Inertia app, and degrades to CSR on any failure, never an error.
YOLO owns the Inertia Gateway binding
On the autoscaling web tier YOLO binds Inertia\Ssr\Gateway to its saturation-aware gateway during its own service-provider boot. Container bindings are last-writer-wins, so an app that rebinds Inertia\Ssr\Gateway in its own provider silently drops the load-shedding — the saturation bypass and the render timeout both vanish, with no error, re-opening the death-loop the gateway exists to close. If you need custom SSR behaviour, extend Codinglabs\Yolo\Runtime\Ssr\SaturationAwareSsrGateway and call parent::dispatch() rather than binding the interface fresh.
Turning autoscaling off ​
Autoscaling is declarative — sync reconciles live state down to what the manifest asks for. Since the key is required you turn it off by setting autoscaling: false (not by removing it); that deregisters the scalable target on the next yolo sync, which cascades the delete to every policy and alarm on it.
Deregistering doesn't drop tasks — the service reverts to a fixed task count frozen at its current live count. Bring it down with yolo scale if you no longer need the extra capacity.
What isn't tagged ​
Application Auto Scaling targets and policies can't carry tags, so they don't show up in yolo audit — they're reconciled by config (above) rather than by the tag-driven audit.
Manual scaling ​
yolo scale changes capacity without a build or deploy. Like env:push, it shows a current → new comparison and asks before applying.
yolo scale production --web --min=3 --max=10 # web autoscaled: set the bounds
yolo scale production --web 3 # web fixed: set the desired count
yolo scale production --queue --min=0 --max=20 # queue bounds (min 0 = scale to zero)Under autoscaling you set the bounds (--min/--max), never a desired count — the policies own desired count and would override it. Crucially, scale writes the bounds back to the manifest (surgically — your comments and formatting survive), so the manifest stays the single source of truth and the next yolo sync reconciles to the same values rather than clobbering your change.
For a fixed service (autoscaling: false — web or queue) a positional count sets the ECS desired count directly. An autoscaling service (autoscaling: true or a block) only takes --min/--max. The scheduler is a singleton and can't be scaled (--scheduler errors out).
Reducing capacity is guarded ​
Because the manifest is authoritative, a yolo sync run with a stale manifest could otherwise scale production down — exactly the wrong thing during an incident. So lowering a live bound is gated:
yolo scaledown → an explicit confirm that defaults to no.yolo sync(interactive) → the reduction shows in the plan and the normal confirm gate guards it; abort and nothing changes.yolo sync --force/ non-interactive → the reduction is refused (skipped + warned). Lowering capacity must be deliberate and attended — an interactive sync oryolo scale. Raises always apply.
So an emergency yolo scale production --web --min=10 is durable: it's written to the manifest and live, and no unattended sync can quietly walk it back.
The queue (scale to zero) ​
Add a top-level tasks.queue block to give the queue worker its own ECS service, separate from web:
tasks:
web:
autoscaling: true
queue:
autoscaling:
min: 0 # scale to zero when idle (opt-in; the default floor is 1)
max: 20
backlog-per-task: 100
spot: true # optional: ~70% cheaper interruptible capacityLike web, the queue must declare autoscaling — true takes the defaults (min: 1, max: 5), false pins a fixed single task. It scales on backlog per task — ApproximateNumberOfMessagesVisible / RunningTaskCount, computed with CloudWatch metric math (no Lambda) and held at backlog-per-task messages per running task. As the backlog grows it scales out toward max; as it drains it scales back in toward min.
With autoscaling.min: 0 the queue scales to zero: no tasks and no compute cost when idle. Target tracking can't lift it off zero (dividing by zero running tasks is undefined), so YOLO also attaches a step-scaling alarm that sets the service to exactly one task the instant a message becomes visible; target tracking owns it from one upward. The cost is a ~30–60s cold start (image pull + boot) on the first message after idle.
That makes the choice of where the queue lives a latency decision:
| Topology | Idle cost | Pickup latency | Use for |
|---|---|---|---|
Bundled (no tasks.queue block) | included in web | instant (worker always warm) | light, latency-sensitive jobs |
Standalone, min: 0 | ~$0 | ~30–60s cold start from idle | bursty, latency-tolerant async |
Standalone, min: 1+ | one always-on task | instant, then autoscales | high-volume, always-busy |
Multi-tenant queues ​
On a multi-tenant app the backlog signal follows queue-isolation. Under the default shared strategy there is one default queue at the app name, so the policy tracks it exactly as a solo app does. Under dedicated no queue exists at the app name — the tier drains a landlord queue plus one per declared tenant — so the backlog is the metric-math SUM of ApproximateNumberOfMessagesVisible across every one of those queues, divided by running tasks as before. The scale-to-zero alarm watches the same summed term, so a message on any tenant's queue lifts the tier off zero. Both follow the manifest: declaring a new tenant re-puts the policy and the alarm on the next sync with the new queue in the set, and the following sync plans clean.
Only the default tier of each queue set counts toward the backlog; a high tier (queues:) is meant to stay near-empty, so the base backlog is the throughput signal.
The scheduler ​
The scheduler (supercronic firing schedule:run every minute) must run as a singleton — if it runs on N tasks, every scheduled job fires N times (N× emails, N× billing, N× reports). The queue is safe to multiply (SQS hands each message to one worker); the scheduler is not. There's no stable per-task identity on Fargate to elect one from, so pick one of two strategies.
1. ->onOneServer() ​
Keep the scheduler bundled in its default container (the web container, or the standalone queue if you've extracted one) and add Laravel's onOneServer() to every scheduled task. It takes an atomic lock in the shared cache so only one replica runs each task per minute:
$schedule->command('reports:send')->daily()->onOneServer();This requires a shared lock store (the Valkey/Redis cache YOLO provisions, or a database cache) — which production apps run anyway. It keeps the simple single-service topology and lets the bundled task scale freely.
The catch: it's per-task. A scheduled task registered by a package (Telescope pruning, backups, etc.) that you can't annotate will still multi-fire — which is your signal to reach for strategy 2.
2. Extract the scheduler (recommended once web scales) ​
Give the scheduler its own service with a top-level tasks.scheduler block:
tasks:
web:
autoscaling: { min: 1, max: 6 }
scheduler: {} # its own pinned-singleton serviceYOLO pins it at exactly one task (never a scalable target) and deploys it stop-then-start (minimumHealthyPercent: 0 / maximumPercent: 100) so a rollout stops the old cron before starting the new one — a deploy never briefly runs two schedulers (a missed cron minute is harmless; a double-run isn't). This removes the onOneServer() requirement entirely — it's genuinely a singleton now — though leaving onOneServer() on is harmless. The web tier then scales without any scheduler concern.
TIP
When the scheduler is bundled into a host that runs more than one task — an autoscaling web task, or a standalone queue (both must declare autoscaling) — yolo sync lists an advisory under the plan's Warnings section pointing at these two strategies. It's a nudge, not a gate — YOLO can't see inside your kernel to know whether you've used onOneServer().
