Multiple organizations
The model is 1 organization = 1 database = 1 xrmd process. An installation generated by xrmd server init
runs any number of organizations on one SQL Server: each has its own database, its own xrmd-<org> compose service,
host port, administrator, token audience and CLI profile. Several organizations in one process are not supported.
xrmd server init --dir ./xrmd-server --org acme --org zenith # acme on 5001, zenith on 5002
xrmd sdk install --dir ./xrmd-server # once per installation, if init did not install it
xrmd server up --dir ./xrmd-server
export XRMD_DEV_SIGNING_KEY=$(grep '^AUTH_DEV_SIGNING_KEY=' ./xrmd-server/.env | cut -d= -f2)
xrmd auth status --profile acme # WhoAmI on acme
xrmd server org add orbit --dir ./xrmd-server # 5003; starts only xrmd-orbit
xrmd server status --dir ./xrmd-server # one row per organization
xrmd server org remove orbit --dir ./xrmd-server # stops and removes xrmd-orbit; the database is kept
Organization names. A name must match ^[a-z][a-z0-9]{0,31}$ and must not be master, tempdb, model or
msdb; anything else is exit 2 with the rule (upper case is refused, never lower-cased). The name is used verbatim as
the database name, the service name xrmd-<org>, Seed:OrgName and the CLI profile name. Without --org,
server init creates one organization named xrmd.
Ports. --port is the first organization's host port (default 5001); every later organization takes the next
port above the highest one in use. server org add --port <n> chooses it; a port already used by another
organization is exit 2. Ports are bound to 127.0.0.1 unless XRMD_BIND=0.0.0.0 is set in .env. With the
generated proxy (see Host names) the organizations publish no host port unless EXPOSE_PORTS=true;
ORG_<ORG>_PORT stays in .env either way.
.env is the installation. server init, server org add and server org remove write .env first and then
re-render docker-compose.yml from it; server up and server upgrade re-render it too, so a hand edit of .env is
what runs. Never edit docker-compose.yml: it starts with # Generated by xrmd and is overwritten. A compose file
without that header is never overwritten (exit 5; server init --force replaces it). The keys:
.env key |
Meaning |
|---|---|
XRMD_ORGS |
the organizations, comma-separated, in creation order |
ORG_<ORG>_PORT |
host port of the organization (<ORG> is the name in upper case), mapped to container port 5000 |
ORG_<ORG>_ADMIN_OBJECT_ID |
object id (GUID) of the organization's initial System Administrator (Seed:AdminObjectId) |
ORG_<ORG>_AUDIENCE |
optional; empty = http://localhost:<port>/. With AUTH_MODE=Entra, the API's Application ID URI for that organization (Auth:Audience). Must be empty with AUTH_MODE=Development (dev tokens carry the profile URL as audience) |
ORG_<ORG>_TDS_PORT |
optional; the host port of the organization's read-only SQL endpoint (--tds-port), AUTH_MODE=Local only. See SQL endpoint |
SQL_CONNECTION |
empty for the bundled sqlserver service, or an external SQL Server (see below), single-quoted |
SQL_BACKUP_DIRECTORY |
with an external SQL Server: its backup directory, as a path on the SQL Server host (empty = /var/opt/mssql/backup) |
SA_PASSWORD |
the bundled SQL Server's sa password (generated; kept by server init --force; absent with an external SQL Server) |
MSSQL_PID |
the bundled SQL Server's edition or product key (Express for a new installation, --sql-edition; kept by server init --force; absent with an external SQL Server); see SQL Server editions |
ACCEPT_EULA |
Y once you have accepted the bundled SQL Server's license terms (--accept-sql-server-eula or the question of server init/up); kept by server init --force; absent with an external SQL Server; see License terms |
AUTH_MODE, AUTH_TENANT_ID, AUTH_DEV_SIGNING_KEY |
shared by every organization: Development (also when empty, as in the compose file) or Entra; tenant GUID; the dev signing key (generated; kept by server init --force) |
UI_ENTRA_CLIENT_ID, UI_ENTRA_SCOPE, SEED_ADMIN_NAME, FILES_* |
shared by every organization, as in Configuration |
XRMD_IMAGE |
the server image, written by init (pinned to the CLI version, or xrmd:local for a development CLI); upgrade --to <version> moves its tag |
XRMD_BIND |
not written by init; add it to choose the bind address |
XRMD_DOMAIN, ORG_<ORG>_HOST, PROXY_MODE, TLS_MODE, TLS_ACME_EMAIL, TRUSTED_PROXIES, EDGE_SUBNET, EXPOSE_PORTS, PROXY_BIND |
the public host names and the proxy, see Host names and Configuration |
An organization listed in XRMD_ORGS without its keys, keys of an organization that is not listed, a duplicate port,
a non-GUID administrator or an invalid name make every server command that reads the installation exit 5 with a
message naming the key; nothing is rendered.
Adding and removing.
server org add <name> [--admin-object-id <guid>] [--port <n>]adds the organization to.env, re-renders the compose file and runsdocker compose up -d xrmd-<name>: only the new service starts, the others keep running. The database is created and seeded by the new process on its first start.--admin-object-idis required with Entra (dev default00000000-0000-0000-0000-000000000001).server org remove <name>runsdocker compose rm -s -f xrmd-<name>, removes the organization from.envand its CLI profile (a current profile on it moves to the first remaining organization). The database is kept.--drop-databasealso drops it, throughDELETE /admin/orgs/<name>sent with the profile of the first remaining organization, after a confirmation (--yesskips it) asked before anything changes. An organization that is not part of the installation is exit 4; removing the last one is exit 5 (usexrmd server down, with--volumesto delete the data).org create <name>(Admin API) still creates a database, and now says that no process serves it and prints thexrmd server org add <name> --admin-object-id <guid>command that serves it (in JSON, ahintproperty).- Legacy installations. Installations created before multi-organization support (a single
xrmdservice onDatabase=xrmverse, noXRMD_ORGS) are refused byserver org add|removewith exit 5;server up|down|upgrade|logskeep working on them unchanged. To move one to the multi-organization layout, in its directory:xrmd org backup xrmversewhile it runs (note the backup file).xrmd server down --dir <dir>, without--volumes: the SQL Server data and the backups stay.xrmd server init --org setup --force --dir <dir>.--forceover an existing.envkeeps itsSA_PASSWORDandAUTH_DEV_SIGNING_KEY(and says so): SQL Server sets thesapassword only when its data volume is first created, so a new password would lock the services out.xrmd server up --dir <dir>:upandupgraderundocker compose up -d --remove-orphanson a multi-organization installation, so the oldxrmdcontainer is removed instead of serving on.xrmd org restore <name> --file <backup file>(through thesetupprofile, now the current one), with<name>the oldSEED_ORG_NAME(defaultxrmd) so that the seeded organization matches. The restore goes to a database no process serves yet: a restore over a served database is refused (409).xrmd server org add <name> --dir <dir>serves it; thenxrmd server org remove setup --drop-database --dir <dir>.
Profiles and tokens. In dev mode server init and server org add create one CLI profile per organization, named
as the organization (URL http://localhost:<port>/, or https://<host>/ when it has a public host, the organization's administrator as object id, key from
XRMD_DEV_SIGNING_KEY); the first organization of init becomes the current profile. All organizations share one
signing key, but each validates its own audience, so a token minted for one organization is rejected (401) by the
others. With --auth entra no profile is created (the app registration is not known): init and org add print the
xrmd auth login --profile <org> --url http://localhost:<port>/ --mode device-code … command to run, and the token
audience the server expects.
Status, doctor, upgrade. With a multi-organization installation in --dir (default .), server status and
server doctor query every organization through the profile named as it and print one row per organization (with
-o json, an array of {org, url, health|error} or {org, url, checks|error}). Each probe gives up after 15 s; an
organization without a profile, unreachable or timed out is reported in its row without stopping the others.
status exits 1 unless every organization is Healthy, doctor exits 1 if any check is an error or an organization
cannot be checked; stderr names them. An explicit --profile or --url queries that one server only.
server upgrade restarts every organization, so it waits until every organization with a profile is healthy
(5 minutes), whatever --profile says, and exits 1 naming the ones that are not, or those without a profile.
One process per database. At startup, before the migrations, xrmd takes the exclusive session applock
xrmd-instance in its organization database and holds it on a dedicated connection for its whole lifetime. A second
process on the same database (another service, a copied compose file, a process started by hand) fails startup with:
Another xrmd process already serves the database '<db>' (applock 'xrmd-instance' is held). Run one xrmd process per organization database.
The refused process neither migrates nor seeds, and the running one is not disturbed. If the lock connection is lost
(SQL Server restart, failover, killed session), the process notices it within 10 s and takes the lock again, retrying
every Startup:SqlRetryDelaySeconds up to Startup:SqlRetries times; if another process holds it by then, or the retries
run out, it logs a Critical error and stops with exit code 1. It stops within about 10 s once another process holds
the lock, and at most about 70 s (with the defaults) when SQL Server cannot be reached, so a process can serve without
the lock for that long. DELETE /admin/orgs/<db>
(org delete) and a restore over an existing database (org restore --overwrite) answer 409 The database '<db>' is served by another xrmd process; stop it first. while another process serves that database.
Shared volumes. files (file and image columns; blob keys are prefixed by the database name) and backups are
shared by every organization; with the bundled SQL Server so are mssql-data and the .bak files of org backup
(in backups, at /var/opt/mssql/backup in both the sqlserver and the xrmd-<org> containers).
External SQL Server. server init --sql-connection '<connection string>' uses an existing SQL Server instead of the
bundled one: no sqlserver or backups-init service is generated and no SA_PASSWORD. The connection string must
parse, set Server, and must not set Database/Initial Catalog (exit 2): each organization's connection string is
SQL_CONNECTION plus ;Database=<org>. It is stored single-quoted in .env so that $ and # stay literal; it
must not contain a single quote or a line break. The SQL Server edition, and its license, are then your own (see
SQL Server editions).
- Minimum permissions of the login, at a process's startup: connect to
master;CREATE DATABASE(dbcreator, orCREATE ANY DATABASE) when the organization's database does not exist yet;ALTER DATABASE … SET READ_COMMITTED_SNAPSHOT ONwhen RCSI is off (the database owner can;xrmdcreates the database, so it owns it); thendb_ownerin the database (migrations and seed). The Admin API needs more for what it is used for:BACKUP DATABASEfororg backup,CREATE DATABASEandRESTOREfororg createandorg restore,DROP DATABASEfororg delete(andserver org remove --drop-database). - The generated installation uses one login for every organization (there is one
SQL_CONNECTION). One SQL login per organization is possible only by running eachxrmdprocess yourself, outside the generated compose file, with its ownConnectionStrings:Org: if a DBA creates the database beforehand withREAD_COMMITTED_SNAPSHOT ONand makes that login itsdb_owner, the process needs neitherCREATE DATABASEnor any server-level permission, and the Admin API calls above fail with SQL Server's permission error for anything outside that database. - Backups: the
.bakoforg backupis written by SQL Server toAdmin:BackupDirectory, a path on the SQL Server host:SQL_BACKUP_DIRECTORYin.env(rendered asAdmin__BackupDirectory), default/var/opt/mssql/backup(a Linux SQL Server; on Windows set it, e.g.SQL_BACKUP_DIRECTORY=D:\Backups). The.files.tararchive is written by xrmd to thebackupsvolume, mounted at/var/lib/xrmd/backupswithAdmin__FilesBackupDirectorypointing at it.org restoreneeds both: the.bakon the SQL Server host and the archive in that volume (or--no-files).
Known limitations.
- With Entra,
xrmd auth loginasks for the scope<--url>/.default. An organization whoseORG_<ORG>_AUDIENCEis not its URL (an Application ID URI such asapi://…) gets a token for the wrong audience, and the server rejects it. Until the CLI can take the scope separately, the CLI works only with an organization whose App ID URI is its URL. UI_ENTRA_CLIENT_IDandUI_ENTRA_SCOPE(web admin sign-in with Entra) are shared by every organization, while each organization validates its own audience: the web admin signs in to the organization whose API scopeUI_ENTRA_SCOPEnames; for the others the server rejects the token.
Host names
Item 2.19: each organization can have a public host, https://<org>.<domain> on port 443, as in Dataverse.
server init --domain <domain> gives every organization <org>.<domain>; --public-host <org>=<host> gives one
organization another host (ORG_<ORG>_HOST); server org add gives the new organization <name>.<domain>, or
--public-host <host>. A host must be a lower-case DNS name with at least two labels, unique across the organizations
(exit 2 otherwise, nothing written). With a host:
- Proxy. A generated Caddy service,
proxy(caddy:2.11.9), terminates TLS (--tls acme|files|internal) on 443, redirects 80 to 443, and routes each host toxrmd-<org>:5000over the compose networkedge. ItsCaddyfileis rendered from.envlike the compose file, starts with# Generated by xrmd, and is never overwritten once that header is gone (exit 5). With--no-proxy --trusted-proxy <ip|cidr>(PROXY_MODE=external) your own proxy does this instead, and the ports are published onXRMD_BINDfor it. - Forwarded headers. Each organization honors
X-Forwarded-Proto,-Hostand-Foronly from the proxy (its fixed.10address onedge, orTRUSTED_PROXIES) and only for its own host, so the URLs it generates (@odata.context,@odata.nextLink, the SOAP address, the file downloadLocation, the OAuth metadata) usehttps://<host>. A forwarded host of another organization is ignored and logged once. - Audience. The default audience becomes
https://<host>/;ORG_<ORG>_AUDIENCEstill overrides it. - Changing organizations.
server org add|removerewrite theCaddyfilein place (the proxy reads it through a bind mount) and runcaddy reloadin the proxy (docker compose exec -T proxy …): the other organizations keep running. The first host starts the proxy alone (up -d); removing the last host removes it (rm -s -f proxy), but the network<project>_edgestays untildocker network rm <project>_edge. When the proxy is not running (the stack is down) nothing is reloaded: the nextserver upstarts it with the new file. - Status and doctor.
server statusadds apublicUrlcolumn;server doctoradds, per host, adnscheck (the host resolves on the machine that runs the CLI) and acertificatecheck (what<host>:443serves). - Internal CA. With
--tls internal,server upexports Caddy's root to<dir>/certs/caddy-root.crtand records it asCaFilein the dev profiles of the organizations with a host: the CLI trusts it for that host only.
The whole procedure, certificates, DNS and configurations for nginx, Traefik and IIS ARR are in Installing xrmverse.
Giving an existing installation its first host names. server org add --public-host on an installation without
TLS_MODE exits 2 naming the key: the proxy settings come first. Two ways, after xrmd server down --dir <dir>
(without --volumes):
- Edit
.env(keeps every key): setXRMD_DOMAIN=<domain>(orORG_<ORG>_HOSTper organization) andTLS_MODE(withTLS_ACME_EMAILforacme; forfilescopy the pair to<dir>/certs/server.crtandserver.key, mode 600). Runxrmd server up --dir <dir>: it renders the proxy and theCaddyfile. The dev profiles still point athttp://localhost:<port>/, which no longer answers: re-create each withxrmd auth login --profile <org> --mode dev --url https://<host>/ --object-id <ORG_<ORG>_ADMIN_OBJECT_ID>, and with--tls internalrunxrmd server up --dir <dir>once more so that they record the CA file. xrmd server init --forcewith the host options (--domain/--public-host,--tls …). It rewrites.envfrom its options and keeps onlySA_PASSWORD,AUTH_DEV_SIGNING_KEY,MSSQL_PIDandXRMD_IMAGE. Everything else comes from the options again: pass the same--orglist in the same order,--port(later organizations take the next ports, so ports that were not consecutive change), one--admin-object-idper organization (without it every organization gets the dev default00000000-0000-0000-0000-000000000001, which is not the administrator seeded in an existing database),--auth/--tenant-id, and--sql-connectionfor an external SQL Server (without it the installation switches to the bundled one).ORG_<ORG>_AUDIENCE,SQL_BACKUP_DIRECTORY,UI_ENTRA_*,FILES_*,SEED_ADMIN_NAMEandXRMD_BINDgo back to their defaults: set them again. It re-creates the dev profiles with the public URLs.
Then create the DNS records init printed (or one per host), and with Entra change each API's Application ID URI to
https://<host> (or keep ORG_<ORG>_AUDIENCE) and sign in again with --url https://<host>/.