Configuration As Code

What It Is

Configuration as code keeps a tenant's configuration as a folder of JSON files in a repository. The folder holds types and their properties, roles and their grants, queries, views, modules, canvases, automations, email templates, translations and languages.

The jetstack CLI moves configuration between the folder and a tenant:

  • jetstack tenant pull writes the configuration into the folder.
  • You change the files and review the change like any code change.
  • jetstack tenant plan shows what applying the files would change.
  • jetstack tenant apply makes the tenant match them.

Why It Matters

  • Review. Configuration changes go through pull requests, with a readable diff and a history of who changed what and why.
  • One source for every environment. The same tree is applied to development, staging and production, and the plan says exactly what each one gets.
  • Builders keep working. Changes made in the builders are not lost: jetstack tenant status shows them, and jetstack tenant pull brings them into the repository next to your own edits.

Use configuration as code when a tenant's configuration is changed by a team, promoted between environments, or needs review before it reaches production. To take development's configuration and apps to production in one step, see promotion; for copying part of a configuration between unrelated tenants, configuration packages. For checkpoints within one tenant, see Version Management.

The Tree

jetstack tenant pull writes the configuration to the repository: to tenant/ in a workspace, to jetstack/ in a one-app project. The examples below use tenant/:

PathHolds
tenant.jsonThe tree's format and the tenant's languages
types/<type>.jsonA type, its properties ($properties), its form layout ($meta) and, for enumerations and checklists, their items in order ($items)
roles/<role>.jsonA role and its system, type and property grants ($grants). A grant limited to some records names its permission query: "edit": { "state": 1, "query": "query:cases_i_own" }
translations/<locale>.jsonThe texts of one language, by the object and field they translate
queries/, views/, modules/, canvases/, automations/, email-templates/, groups/, roots/, folders/, filter-lists/, legal-grounds/, purposes/, purpose-groups/, connectors/, connections/, mcp-servers/, schedules/, permalinks/, routes/One entity per file
migrations/<id>.jsonA data migration (its mode and description), with its code in <id>.php beside it
other/<kind>.jsonWhat the forms above cannot carry exactly, kept verbatim
renames.jsonRenames to make instead of removing and creating (you write this file)

A type file reads like the type's form:

{
  "$selector": "type:case",
  "type_name": "Case",
  "type_name_plural": "Cases",
  "role": "entity",
  "$properties": {
    "title": { "property_name": "Title", "property_type": "pText", "object_order": 1 },
    "status": { "property_name": "Status", "property_type": "pSelect", "select_options": { "open": "Open", "closed": "Closed" } }
  }
}

An enumeration's or checklist's $items lists each item with its title, its code and the values of its other fields — a colour, a flag, the roles or views it names by selector, the item of another list it points to as enumeration_item:<list>#<code>:

"$items": [
  { "title": "Open", "code": "open", "values": { "color": "#3366ff", "is_final": 0, "allowed_roles": ["role:support"], "default_priority": "enumeration_item:priority#low" } },
  { "title": "Done", "code": "done", "values": { "color": "#22aa44", "is_final": 1, "allowed_roles": [], "default_priority": null } }
]

An item is matched by its code (by its title when it has none); applying sets its title and the values listed. Fields holding files, users or records of other types are not part of an item. A list of thousands of items (all municipalities) is read and written in bulk; automations that react to records of a list do not run for the items an apply writes, and a plan names the first item changes of a list and counts the rest.

What refers to a list item names it the same way: a query's filter ("in": ["enumeration_item:channel#phone"]), a value a view puts into new records, a relation's default. A subquery filter names its query ("in_subquery": "query:all_channels"). Each environment finds its own item and query, whatever their ids.

How the files are read:

  • Identity. Each file names its entity with $selector, such as type:case. The file name is only a readable name, and renaming a file changes nothing.
  • References. Entities refer to each other by selector (query:open_cases, property:case.status), never by the numeric ids of one tenant. The same tree applies to any tenant.
  • Canvases and automations are written as readable JSON. Email template bodies are .html files next to their JSON.
  • Leaving a field out of a file leaves it as it is. A file only needs the fields you care about, and new entities get the platform's defaults for the rest. To clear a field, write it as null or empty.
  • Leaving an entity out of the tree removes it from the tenant when the tree is applied (see Removals).

The Workflow

flowchart LR
    P[pull] --> E[edit & review]
    E --> PL[plan]
    PL --> A[apply]
    B[builder edits in the tenant] --> S[status]
    S --> P

Pull

jetstack tenant pull --env <env> brings the environment's configuration into tenant/ (jetstack/ in a one-app project).

In a git repository, a pull replays the environment's change history: each entry since your last pull becomes a commit on a branch of its own, jetstack/<env>, with the entry's author, time and message. That branch is then merged into yours:

  • Your committed edits stay.
  • What changed in the tenant arrives as commits, one per change, so git log and git blame show who changed what.
  • Where both sides changed the same lines, git reports an ordinary conflict.

The folder must have no uncommitted changes; commit or stash them first. The environment then learns that its entries are in git (see the Reviewed policy).

Without git, with --no-git, or on a first pull the history cannot place, the tenant merges its changes into your files instead, without commits:

  • Your edits stay.
  • What changed in the tenant since your last pull is added.
  • Where both sides changed the same field differently, your version stays and the tenant's is written next to the file.

--force replaces the folder with the tenant's configuration and discards local edits.

.jetstack/sync.json remembers, per environment, the last entry taken and the commit that holds it. Commit it with the tree.

Status

jetstack tenant status --env <env> answers two questions: what changed in the tenant since your last pull, and whether your tree differs from the tenant.

Plan

jetstack tenant plan --env <env> lists every create (+), update (~, with the fields that change), removal (-) and rename (») that applying the tree would make. It also lists blocked entries (see What Apps Manage) and warnings, such as a view that refers to a query the tree does not define.

Apply

jetstack tenant apply --env <env> shows the plan, asks for confirmation, applies the tree in the background and follows it to the end:

  • The apply is bound to the plan you saw. If the tenant changed in the meantime, or the plan would come out differently, the apply is refused and you plan again.
  • One configuration apply runs at a time per tenant. App deploys wait while it runs.
  • When it ends, the CLI says whether the tenant now matches the tree, or lists what still differs. A run with differences left exits with an error, so a CI pipeline notices.
  • The apply is recorded in the change history with the commit it came from, when the folder has no uncommitted changes. The next pull knows that state is already in git.
  • Under the Reviewed policy, an apply to production waits until production's direct changes are pulled or acknowledged.

Removals

An entity the tree no longer defines is removed from the tenant:

  • Configuration without records is removed by the apply: queries, views, modules, canvases, automations, email templates, grants, translations, languages, filter lists and connectors.
  • Configuration that holds records or assignments is removed only with --allow-destructive: types, properties, roles, groups, roots, folders, legal grounds, purposes and purpose groups. Removing a type deletes its records. Without the option these stay in place and the apply reports them as held back.
  • Protected entities (is_protected) are never removed.
  • Enumeration items follow their list: an apply sets titles and order, and removes items whose codes the list lacks only with --allow-destructive.

Renames

Changing an entity's system name in a file and applying would remove the old entity and create a new one, losing its records. To rename instead, add the rename to tenant/renames.json:

{ "type:case": "type:matter", "property:matter.old_title": "property:matter.subject" }

The apply renames first; records stay, and references to the entity follow. A rename that is already done is ignored, so the file can stay in the repository.

Migrations

A change to the data model can leave existing records in the wrong shape. For example, a property is split in two and the old values must be copied over. The tree can carry the data migration that moves them:

jetstack migration new fill-case-numbers --tenant

This writes two files:

  • tenant/migrations/<date>-fill-case-numbers.json, which declares the migration: $selector migration:<id>, mode and description;
  • <id>.php beside it, which is the code. It is PHP like an automation's, with $jetstack and $migration, and runs one batch at a time.

How the apply runs it:

  • Once per environment. Each environment runs a migration once and records it. Promoting the tree to production runs exactly what production has not run yet.
  • When. A blocking migration runs during the apply, after the configuration is written and before anything is removed. A background migration runs afterwards, from the scheduler.
  • As whom. A migration runs as the person who applied the tree, with their permissions. Its writes start no automations unless the file sets "triggers": true. Applied with a tenant token, it runs as the token's principal, so give that principal the record access it needs, or apply as a person.
  • Failure. If a migration fails, so does the apply: nothing is removed, and applying again carries on from the failed batch.
  • Edits after it ran. A migration that already ran is not run again when its file changes; the plan says so.
  • Removed files. Removing the files stops declaring the migration. What it changed stays changed.
  • Restores. A restore never runs migrations.

jetstack tenant plan lists the tree's migrations and what each will do. On production they need explicit consent, as described in Data Migrations On Production:

  • either a confirmation (--allow-migrations in scripts);
  • or another person's approval, in which case the apply waits as a request on production's History screen until someone approves it there or with jetstack approve --tenant.

Conflicts

  • In git, a conflict is a merge conflict in a file of tenant/: resolve it, git add the file and git commit. The next pull continues from there.
  • Without git, when a pull finds a field changed both in the tenant and in your tree, your version stays and the tenant's version is written next to the file as <file>.conflict.json. The pull then exits with an error. Resolve each conflict in the file it names, then delete the .conflict.json file. plan and apply refuse to run while conflict files exist.

What Apps Manage

Entities an app's manifest declares belong to the app and deploy from the app's repository. They are left out of the tree, together with their properties, grants and translations and the app's service principal. See Custom Apps. A tree that defines one of them is refused: the plan lists it as blocked, naming the app.

What The Tree Does Not Hold

  • Records, users and files: configuration only.
  • Secrets. A connection is written with the name of the secret it needs, never its value, and the secret is set in each tenant.
  • Custom apps, which have their own repositories.
  • Tasks scheduled in the Scheduler. A schedule definition (schedules/<automation>.json) is configuration: each environment schedules its automation from the definition's next occurrence.
  • Links made on the Links screen, which have no name. Links with a name (made with the MCP builder) travel by it: each environment claims its own short address, and a view's button opens the link of that name there. Routes travel by their mask.
  • Tags, and the folder and tag state of queries.

Access

  • Pulling, planning and applying need the manageTenantConfig permission (superusers have it).
  • People sign in with jetstack login.
  • Pipelines use a tenant token, created on the History page under Configuration as code. A token may read (pull, status, plan) or read and apply. It belongs to a dedicated principal, not to a person, and is shown once.

Every apply is recorded in the change history and the Event Log.

Example: A Pipeline

- run: php jetstack.phar tenant plan --env prod
  env: { JETSTACK_TOKEN_PROD: '${{ secrets.JETSTACK_TENANT_TOKEN_PROD }}' }
- run: php jetstack.phar tenant apply --env prod --yes
  env: { JETSTACK_TOKEN_PROD: '${{ secrets.JETSTACK_TENANT_TOKEN_PROD }}' }

Run the plan on pull requests and the apply after merging, so that what reaches production is what was reviewed.

Best Practices

  • Pull before you start editing, and pull again before applying if the tenant is also edited in the builders.
  • Read the plan before every production apply. Treat --allow-destructive as a decision to delete records.
  • Use renames.json for renames; never rename by removing and adding.
  • Keep one tree for all environments, and let environment differences live in secrets and settings, not in forks of the tree.

Tenant Settings

This key is not on the Application settings screen; set it in the tenant configuration.

tenantConfigAsCode

What it controls:

  • whether the tenant's configuration can be read and applied as code (tenant.* in the REST API, jetstack tenant)

Default:

  • true

How to configure it:

  • set it to false to refuse every pull, plan and apply, for example on a tenant whose configuration is only ever changed in the builders

What it affects:

  • while it is off, tenant tokens and CLI sign-ins get 403 on tenant.*
  • configuration and data are not touched; turning it on again resumes where the repository left off