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.

Available since 2.2

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 / optionDescription
[config]Path to the config file. Defaults to tenant.config.ts.
--dsnProject DSN. Falls back to the CONTEMBER_* environment variables.
--dry-runPrint the actions that would be performed without executing them.

How each section is applied

FieldMutation(s)Semantics
configconfigureAlways sent. All fields are optional and merged — unset fields preserve the current value.
identityProvidersaddIDP / updateIDP, then enableIDP / disableIDPThe 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.
mailTemplatesaddMailTemplateA server-side upsert keyed by type + variant (+ projectSlug).
authPoliciescreateAuthPolicy / updateAuthPolicyauth_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.
Nothing is removed

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.

Editing roles creates a second policy

Because 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.

This is an entry gate, not authority

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.

A deploy-only token is not enough

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 }}