Instead of calling the configure, addIDP, and addMailTemplate mutations by hand, you can describe the whole tenant setup in a typed tenant.config.ts file and apply it with a single command:
contember tenant:apply
This keeps tenant configuration in version control next to the rest of your project and makes it reproducible across environments. Applying is idempotent — running it repeatedly converges to the same state.
The config file
Author the file with the typed defineTenantConfig helper from @contember/cli for full editor autocompletion:
import { defineTenantConfig } from '@contember/cli'
export default defineTenantConfig({
// → configure mutation (partial merge)
config: {
password: {
minLength: 12,
checkBlacklist: true,
checkHibp: true,
},
login: {
revealUserExists: false,
defaultTokenExpiration: 'PT30M',
},
passwordless: {
enabled: 'optIn',
expiration: 'PT5M',
},
captcha: {
provider: 'turnstile',
secret: process.env.TURNSTILE_SECRET,
threshold: 0.5,
},
rateLimits: {
loginPerIp: { limit: 20, window: 'PT1H' },
},
panel: {
globalRoles: ['super_admin', 'project_admin'],
projectRoles: ['admin'],
},
},
// → addIDP / updateIDP, keyed by provider slug
identityProviders: {
google: {
type: 'oidc',
configuration: {
url: 'https://accounts.google.com/.well-known/openid-configuration',
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
options: { autoSignUp: true, exclusive: false },
disabled: false,
},
},
// → addMailTemplate (server-side upsert)
mailTemplates: [
{
type: 'RESET_PASSWORD_REQUEST',
variant: 'en',
subject: 'Reset your password',
content: 'Click the link to reset your password: {{link}}',
},
],
// → createAuthPolicy / updateAuthPolicy, matched by scope + project + roles
authPolicies: [
{
scope: 'global',
roles: ['super_admin'],
mfaRequired: true,
tokenExpiration: 'PT8H',
},
{
scope: 'project',
project: 'blog',
roles: ['editor'],
idleTimeout: 'PT30M',
rememberMeAllowed: false,
},
],
})
The file is plain TypeScript executed by the CLI, so secrets come straight from process.env — never hardcode credentials.
Running it
# Connect via DSN
contember tenant:apply --dsn "contember://my-app:TOKEN@api.example.com"
# Or rely on CONTEMBER_* environment variables
contember tenant:apply
# Use a non-default config path
contember tenant:apply config/tenant.ts
# Preview the actions without making any changes
contember tenant:apply --dry-run
| Argument / option | Description |
|---|---|
[config] | Path to the config file. Defaults to tenant.config.ts. |
--dsn | Project DSN. Falls back to the CONTEMBER_* environment variables. |
--dry-run | Print the actions that would be performed without executing them. |
How each section is applied
| Field | Mutation(s) | Semantics |
|---|---|---|
config | configure | Always sent. All fields are optional and merged — unset fields preserve the current value. |
identityProviders | addIDP / updateIDP, then enableIDP / disableIDP | The CLI reads the current providers and chooses add vs. update per slug. disabled: true disables the provider; omitting it (or false) keeps/makes it enabled. |
mailTemplates | addMailTemplate | A server-side upsert keyed by type + variant (+ projectSlug). |
authPolicies | createAuthPolicy / updateAuthPolicy | auth_policy rows have no slug, so an entry is matched to an existing policy by what it targets: scope + project + the roles set (order is irrelevant). No match means a new policy. Two entries with the same target are rejected before anything is written — they would create two rows the next apply could no longer tell apart. |
tenant:apply only adds and updates. Identity providers, mail templates or auth policies that exist on the tenant but are not present in the config are left untouched. To remove one, do it explicitly via the corresponding mutation.
roles creates a second policyBecause the role set is part of a policy's identity, changing roles: ['editor'] to roles: ['editor', 'author'] does not edit that policy — it creates a new one and leaves the old one in force. Auth policies aggregate strictest-wins, so the stale row keeps applying. tenant:apply prints a warning for every policy on the tenant that the config does not cover; delete the leftover with deleteAuthPolicy.
The warning is why authPolicies: [] differs from omitting the key: an empty list still means "I manage policies here", so leftovers are reported. Omitting authPolicies skips the section entirely and says nothing.
Panel access
config.panel says who may enter the management panel served by the engine:
config: {
panel: {
globalRoles: ['super_admin', 'project_admin'],
projectRoles: ['admin'],
},
}
An identity may enter when one of its global roles (identity.roles) is listed in globalRoles, or when it holds a membership with one of projectRoles in at least one project. The values above are the defaults. Each list is replaced as a whole; omitting one leaves it unchanged, and an empty list closes that dimension. With both empty, nobody can enter.
The panel must also be enabled on the deployment (CONTEMBER_PANEL_ENABLED) — this setting only decides who gets in once it is served.
Being allowed into the panel grants no rights. Everything done inside it goes through the tenant API and the same ACL as any other client, so a user still sees and changes only what their roles and memberships already permit.
Permissions
The underlying mutations require the CONFIGURE, IDP_*, MAIL_TEMPLATE_*, and (for auth policies) system:configure tenant permissions, held by SUPER_ADMIN and PROJECT_ADMIN.
An ENTRYPOINT_DEPLOYER (deploy) token can run deploy but cannot configure the tenant. Use a token with PROJECT_ADMIN or SUPER_ADMIN privileges for tenant:apply — for example a dedicated CI API key.
In CI
Run it after deploying migrations, with an admin-scoped token:
- run: npm run contember deploy
env:
CONTEMBER_DSN: ${{ secrets.CONTEMBER_DEPLOY_DSN }}
- run: npm run contember tenant:apply
env:
CONTEMBER_DSN: ${{ secrets.CONTEMBER_ADMIN_DSN }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
TURNSTILE_SECRET: ${{ secrets.TURNSTILE_SECRET }}