Multi-Tenancy β
Everything multi-tenant lives in one multitenancy block. One container image and one ECS service serve every tenant. What differs is how a request identifies its tenant β and that decides what YOLO provisions and what onboarding a tenant costs.
| In the route | In the subdomain | Its own domain | |
|---|---|---|---|
| Tenant is reached at | app.example.com/acme | acme.app.example.com | acme.com.au |
Needs wildcard-subdomains | no | yes | no |
| Declared in the manifest | not at all | not at all | that tenant's domain |
| Per-tenant AWS resources | none | none | hosted zone, certificate, SNI attachment, listener rules, DNS records |
| Onboarding a tenant | a row in your database | a row in your database | a manifest edit and a yolo sync |
The first two cost YOLO nothing per tenant β the app resolves the tenant from the request itself, so tenants come and go as database rows with no infrastructure run. Only a custom domain needs YOLO to know a tenant exists.
The third composes with either of the first two, which is how a tenant migrates onto its own domain: it is a per-tenant choice, not an app-wide mode.
How a landlord and a tenant are declared β
The landlord and each tenant are declared the same way, so one rule covers both:
domain: β¦ # the host it is served on
wildcard-subdomains: true # β¦and every subdomain of it, one label deepapex is never declared β YOLO derives it from domain by walking the domain's labels against the hosted zones in the account, so a certificate lands on the right zone with nothing to configure.
Tenant in the route β
The app serves one host and reads the tenant off the URL β app.example.com/acme, or a header, or the session. Nothing about that is visible to AWS, so the manifest is a landlord and nothing else:
environments:
production:
account-id: '123456789012'
region: ap-southeast-2
tasks:
web:
autoscaling: true
multitenancy:
landlord:
domain: app.example.comNo wildcard-subdomains, no tenants. YOLO provisions exactly what a single-tenant app gets β one hosted zone, one certificate, one forward rule, one queue set β because there is nothing to fan out over. Tenants are database rows; onboarding one touches neither the manifest nor AWS.
This is the shape most tenant_id-column apps want, and the cheapest place to start.
Tenant in the subdomain β
Wildcard the landlord and every tenant is reached beneath it:
multitenancy:
landlord:
domain: app.example.com
wildcard-subdomains: trueapp.example.com serves the landlord; acme.app.example.com and every other subdomain reach the same service, which resolves the tenant from the request host. YOLO provisions one certificate covering app.example.com + *.app.example.com, one wildcard listener rule, and one *.app.example.com alias record β so, as with the route shape, bringing a tenant live needs no infrastructure run and no manifest edit.
The wildcard is scoped to the landlord's own domain, never the apex: several apps commonly share one zone (app.example.com, admin.example.com), and a wildcard at the apex would have one app swallow the others' traffic. It is one label deep, so acme.app.example.com is served and a.b.app.example.com is not.
Tenant on its own domain β
A tenant reached at a domain of its own is the one case YOLO has to know about, because only then does it have resources to provision. Declare the tenant and give it a domain:
multitenancy:
landlord:
domain: app.example.com
wildcard-subdomains: true
tenants:
acme:
domain: acme.com.au # its own zone, certificate and rules
globex:
domain: globex.io
wildcard-subdomains: true # β¦and *.globex.io as wellEach such tenant gets its own hosted zone, its own DNS-validated certificate, an SNI attachment onto the shared :443 listener, a forward rule routing its host to the app's target group, and β when its domain is one half of the apex/www pair β a redirect rule 301ing the sibling. globex additionally serves *.globex.io, its certificate moving off the apex onto globex.io so the wildcard reaches a level deeper.
Rule identity is the rule's Name tag, keyed by tenant id, so changing one tenant's domain rewrites that tenant's rule in place and never touches a sibling's.
A tenant declared without a domain is served the way the landlord is (under its wildcard, or off the route) and gets no DNS or TLS resources β declaring it anyway buys it queues of its own and nothing else.
Absorbing a domain that already exists β
A tenant domain usually pre-dates YOLO β the zone, and often a certificate, are already live. Sync adopts rather than recreates:
- A hosted zone found for the tenant's apex is tag-stamped, not created. It is never deleted, and
destroy:appwithdraws only the records YOLO itself wrote. - A certificate found for the tenant's certificate domain is reused. YOLO never requests a duplicate and never deletes one β teardown detaches it from the listener and leaves it standing.
Because every step diffs before it writes, a sync over already-correct infrastructure reports Already in sync instead of proposing work.
Graduating a tenant onto its own domain β
A custom domain is a per-tenant choice, so one app runs it alongside either of the other two shapes β which is how a tenant migrates:
multitenancy:
landlord:
domain: app.example.com
wildcard-subdomains: true
tenants:
acme:
domain: acme.com.au # graduated: zone, certificate, rules of its own
globex: # still globex.app.example.com, under the wildcardEach per-tenant DNS/TLS step asks one question β does the app's own certificate already cover this host? β and skips itself when the answer is yes. Nothing else in the plan changes.
Migrations β
Answer "yes" to the multi-tenant prompt in yolo init and it scaffolds the deploy hooks to match your migration layout, since what a tenanted app has to migrate depends on where its tenants live.
A single database scoping rows by a tenant_id column has one flat database/migrations and one call, exactly like a solo app:
deploy:
- php artisan migrate --forceA database per tenant splits its migrations, and both sets have to run β the second over every tenant connection. init scaffolds this form when it finds database/migrations/landlord and database/migrations/tenant:
deploy:
- php artisan migrate --path=database/migrations/landlord --force
- php artisan tenants:artisan "migrate --path=database/migrations/tenant --database=tenant --force"init says which it assumed. Neither is enforced afterwards β deploy is yours to edit, and an app that adopts a split layout later just changes the hooks.
What gets provisioned β
yolo sync (or sync:app) fans the per-tenant steps out across every tenant:
- Queues, when
queue-isolationisdedicatedβ a landlord SQS queue and depth alarm for shared/central work, plus a per-tenant queue and alarm for each tenant. On the defaultsharedstrategy one queue set serves every tenant instead, with the tenant carried in the job payload. A standalone, autoscaling queue tier scales on the backlog across whichever set it drains β see Scaling β multi-tenant queues. - Hosted zone, certificate, SNI attachment and listener rules, for each tenant on a domain the landlord's certificate doesn't already cover.
- DNS records for every tenant domain, pointed at the shared load balancer, UPSERTed during
yolo deploy.
Declaring tenants without a domain β
multitenancy.tenants is optional, and the route and subdomain shapes leave it out entirely β YOLO has nothing to provision per tenant, so a list it can only fall out of step with buys nothing.
The one reason to declare a tenant anyway is queue-isolation: on dedicated, each declared tenant gets its own SQS queue, depth alarm and worker program. Everything else about it stays exactly as it was.
Tearing down β
destroy:app reverses all of it. Each tenant's listener rules, SNI attachment, queues and DNS records are removed alongside the app's own, each step self-gating exactly as its sync counterpart did β so a tenant under the landlord's wildcard, which never had resources of its own, reports nothing.
Two things deliberately survive, both per tenant: the hosted zone and the ACM certificate. They are the tenant's domain-level infrastructure, not YOLO's β teardown withdraws only the records YOLO wrote and detaches the certificate from the listener. That is the same asymmetry as absorbing a domain, read backwards: YOLO adopts a domain without taking it over, and releases it without taking it down.
Single-tenant operations β
Use --tenant=<id> to narrow the per-tenant steps to one tenant β useful when onboarding a new tenant or running a single-tenant cutover without touching the rest:
yolo sync:app production --tenant=acmeThere is no sync:tenant or deploy:tenant verb β tenancy is a step-level concern, controlled by the --tenant flag on the normal commands.
Domains β
See Domains βΊ Multi-tenant domains for the full domain rules.
