Tenants
One ulams API deployment serves the platform and any number of tenants. Each tenant has its own hosts, database, storage bucket, encryption and token keys, Redis prefix and theme. Tenants are managed from the command line on the platform, or over HTTP with the platform tenant API (off by default); there is no admin screen for them.
This page is for operators who create and maintain tenants. For how tenant resolution works inside the code, see Tenancy and ADR 0007.
What a tenant gets
Section titled “What a tenant gets”For a tenant with the slug coffee, with the default naming:
| Resource | Value |
|---|---|
| API | http://coffee.localhost |
| Learner front | http://coffee.app.localhost |
| Admin panel | http://coffee.admin.localhost |
| Content origin | http://coffee.content.localhost (written as CONTENT_ORIGIN) |
| PostgreSQL role and database | ulams_coffee |
| MinIO/S3 bucket | ulams-coffee, public read |
| Env file | .env.coffee.localhost on top of the platform .env |
| Storage directory | storage/coffee_localhost/ |
| Passport keys | storage/coffee_localhost/oauth-*.key |
| Redis prefix | ulams_coffee_ (cache ulams_coffee_cache, Horizon ulams_coffee_horizon:) |
| E-mail domain | coffee.ulams.app (sender no-reply@coffee.ulams.app) |
Every tenant has its own APP_KEY and Passport key pair, so a token issued by one tenant is
rejected by every other. The tenant also gets its own LTI signing keys and its own
H5P_INTERNAL_TOKEN for calls between the API and the H5P service.
The content origin is a separate host that serves only SCORM, cmi5 and similar packages and their players: no cookies and no API. Uploaded package code therefore never runs on the API or front origin.
The registry is the tenants table in the platform database. Secrets in it (database
password, APP_KEY, Passport keys) are encrypted with the platform APP_KEY. The env files,
domain registrations and key files are derived from that table and can be rebuilt at any
time (see sync-env).
Commands
Section titled “Commands”All ulams:tenant:* commands run on the platform, without --domain. They refuse to run
inside a tenant. In the Docker setup prefix them with docker compose exec api.
Create or update a tenant: ulams:tenant:create
Section titled “Create or update a tenant: ulams:tenant:create”php artisan ulams:tenant:create coffee \ --name="The Coffee Atlas" --theme=coffee --accent="#C2552D" --users=5| Argument / option | Meaning |
|---|---|
slug |
Lowercase letters and digits. Used for the hosts, database and bucket names. |
--name= |
Display name: APP_NAME, the mail sender name and the global.companyName setting. |
--theme= |
Front theme preset key, stored as the theme.theme setting. The reference front knows coffee, oncall, nightsky, gravity, poland and ulam. Letters, digits, - and _, up to 40 characters. |
--accent= |
Accent colour as #RRGGBB, stored as the theme.accent setting. |
--users=5 |
Number of demo students to create (default 5). |
--demo=on / --demo=off |
Turns demo mode on or off (DEMO_MODE in the env file). |
--redo=<step> |
Runs a step again even if it is recorded as done. Repeatable. |
The command is resumable. Each finished step is recorded on the tenant row
(tenants.steps). If a step fails, the tenant is marked failed, the error is printed, and
running the same command again continues from the first unfinished step.
Running it for an existing tenant updates it. Changing --name, --theme or --accent
rewrites the env file and re-runs the demo step (which stores the settings). Changing only
--demo rewrites only the env file.
The steps, in order:
| Step | What it does |
|---|---|
database |
Creates the PostgreSQL role and database through the pgsql_admin connection (needs CREATEROLE/CREATEDB). |
bucket |
Creates the bucket and a public read policy. |
env |
Registers the host and writes .env.<host>. |
migrate |
migrate --force in the tenant database. |
passport_keys |
Generates the Passport key pair; an encrypted copy is stored on the tenant row. |
passport_client |
Creates the personal access client. |
permissions |
Seeds roles and permissions and creates the admin user. |
lti_keys |
Creates the tenant’s LTI signing keys (ulams:lti:rotate-keys --init, see LTI). |
demo |
Creates the tutor and the students and stores the name, front URL, theme and accent settings. |
Every step from migrate on runs as a child process php artisan … --domain=<host>, so it
uses the tenant configuration and not the platform one. Child processes time out after
TENANCY_PROCESS_TIMEOUT seconds (default 900).
When it finishes, the command prints the tenant URLs and the seeded accounts:
admin@<slug>.ulams.app(admin)tutor@<slug>.ulams.app(tutor)student1@<slug>.ulams.app…studentN@<slug>.ulams.app
All of them get the password from TENANT_DEMO_PASSWORD (default secret). This is meant
for development; change the passwords of any tenant that is not a throwaway demo.
List tenants: ulams:tenant:list
Section titled “List tenants: ulams:tenant:list”php artisan ulams:tenant:listphp artisan ulams:tenant:list --hostsThe table shows slug, name, theme, demo mode, status (provisioning, active, failed),
the API, front and admin hosts, and the finished steps (for example 9/9). With --hosts
it prints only the API hosts of active tenants, one per line; the worker scripts use this.
A tenant created before a step was added (for example lti_keys) shows fewer finished
steps, such as 8/9. Run ulams:tenant:create <slug> again to run the missing step.
Rebuild env files: ulams:tenant:sync-env
Section titled “Rebuild env files: ulams:tenant:sync-env”php artisan ulams:tenant:sync-envphp artisan ulams:tenant:sync-env --migrateRebuilds the .env.<host> files, domain registrations and Passport key files of every
tenant from the tenants table. --migrate also runs pending migrations in every tenant
database. The container start script (api/init.sh) runs it with --migrate after the
platform migrations, so a fresh container or pod gets all tenants back. It runs without
--migrate when DISABLE_DB_MIGRATE=true, and not at all when DISABLE_TENANT_SYNC=true.
Because the env files are generated, edit tenant values through ulams:tenant:create, not
by hand: hand edits are overwritten on the next sync.
AI settings per tenant: ulams:tenant:set-env
Section titled “AI settings per tenant: ulams:tenant:set-env”Every tenant inherits the platform AI settings (ANTHROPIC_API_KEY, AI_DRIVER, AI_MODEL_*,
ANTHROPIC_BASE_URL) when create or sync-env writes its env file, so one platform key serves all
tenants and a changed key reaches them on the next sync-env (which also runs on container start).
A tenant can override them:
php artisan ulams:tenant:set-env coffee --set=ANTHROPIC_API_KEY=sk-... --set=AI_MODEL_DEFAULT=...php artisan ulams:tenant:set-env coffee --set=AI_DRIVER=disabledphp artisan ulams:tenant:set-env coffee --unset=ANTHROPIC_API_KEY # inherit the platform value againOverrides are stored encrypted in the tenants table and only the listed keys are accepted. The
command prints key names, never values; the key is never logged. See ADR 0063.
Delete a tenant: ulams:tenant:delete
Section titled “Delete a tenant: ulams:tenant:delete”php artisan ulams:tenant:delete coffee --forceWithout --force the command refuses. With it, it drops the database and role, the bucket
contents, the env file, the storage directory and the tenant’s Redis keys. This cannot be
undone.
Typical workflow
Section titled “Typical workflow”- Create the tenant on the platform:
docker compose exec api php artisan ulams:tenant:create acme --name="Acme Academy" --theme=coffee --accent="#1F6FEB" - Make the hosts resolve. Locally,
*.localhostresolves to127.0.0.1on most systems; otherwise addacme.localhost,acme.app.localhost,acme.admin.localhostandacme.content.localhostto/etc/hosts. - Open
http://acme.admin.localhostand log in asadmin@acme.ulams.app. - Queue workers and the scheduler pick the new tenant up on their next pass; no restart is needed.
Configuration
Section titled “Configuration”Platform environment variables of the tenancy package (defaults in
api/packages/tenancy/src/config.php):
| Variable | Default | Purpose |
|---|---|---|
TENANCY_PLATFORM_HOSTS |
api.localhost,localhost,127.0.0.1,caddy,api |
Hosts served with the platform .env. |
TENANCY_ENFORCE_HOSTS |
true |
Answer 404 to unknown hosts. |
TENANCY_SCHEME |
http |
Scheme of the generated URLs. |
TENANCY_API_HOST, TENANCY_FRONT_HOST, TENANCY_ADMIN_HOST, TENANCY_CONTENT_HOST |
{slug}.localhost, {slug}.app.localhost, {slug}.admin.localhost, {slug}.content.localhost |
Host patterns. |
TENANCY_EMAIL_DOMAIN |
{slug}.ulams.app |
Domain of the seeded accounts and the mail sender. |
TENANCY_DATABASE, TENANCY_BUCKET, TENANCY_REDIS_PREFIX |
ulams_{slug}, ulams-{slug}, ulams_{slug}_ |
Resource names. |
TENANCY_STORAGE_PUBLIC_URL |
http://storage.localhost |
Public base URL of the object store; the bucket name is appended. |
TENANCY_S3_ENDPOINT, TENANCY_S3_REGION, TENANCY_S3_KEY, TENANCY_S3_SECRET |
the AWS_* values |
Object store used to create buckets. |
TENANCY_ADMIN_CONNECTION |
pgsql_admin |
Database connection with rights to create roles and databases. |
TENANCY_DATABASE_PROVISIONER |
admin |
manual: you create each tenant database (shared hosting); the database step only checks the login. Pass its password to ulams:tenant:create --db-password= (16+ characters). See Install on MyDevil. |
TENANCY_S3_PUBLIC_READ_POLICY |
true |
false for stores without bucket policies (Cloudflare R2): no policy is attached; make the buckets public yourself. |
TENANT_DEMO_PASSWORD |
secret |
Password of the seeded accounts. Development only. |
TENANCY_PROCESS_TIMEOUT |
900 |
Timeout of each child artisan process, in seconds. |
TENANCY_PLATFORM_API |
false |
Turn on the platform tenant API (/api/platform/*, ulams tenants). Platform hosts only. |
TENANCY_NEW_SITES |
false |
Lets an author with the platform_admin permission create a new site for a course from the Course Builder. |
TENANCY_PLATFORM_HOST |
api.localhost |
The host platform commands run under when a tenant’s queue worker has to create a tenant. |
See also Environment variables and Artisan commands.
Known limitations
Section titled “Known limitations”- All tenants use the same object store credentials; isolation is per bucket, not per key.
- There is no admin UI for tenants. They are managed with the artisan commands above or, when
TENANCY_PLATFORM_API=true, over HTTP and withulams tenants(Platform tenant API). - Theme presets are defined in the frontends. The reference front (
front/web) uses thetheme.themesetting if it is a known preset, else the slug if that names a preset, elsecoffee. - The older setup that declares domains through
MULTI_DOMAINSenvironment variables still works; see api/docs/multidomain.md and the tenancy package README.