Hi folks
– we wanted to share this working spec with you seeing as it’s one of the most requested features ever. Hopefully we can sharpen it together before our team starts developing this. So please post your questions and your feedback here on the thread.
Thanks for being part of the community and without further ado…
Directus Environment Sync RFC: Promoting Schema and Configuration Between Instances
Promote Directus configuration between instances with git as the audit trail. Model in the Studio, pull changes to files, review the diff in a PR, then push to the next environment.
This is a proposal. Nothing here is built yet. We are sharing the design early so the Directus community can tell us where it is unclear, unsafe, awkward, or missing something important.
Prior art and community tools
The Directus community has already built meaningful tooling in this space. directus-sync is the most established example - it provides a solid foundation for syncing Directus configuration between environments, and much of the design thinking in this proposal builds on patterns it has established. We want to acknowledge that work explicitly.
What this proposal adds is a first-party solution maintained by the Directus core team, with long-term support guarantees, and critically, improvements at the API level that make these capabilities more native to the platform. Rather than working around the existing API, we want to extend it so that the sync primitives are reliable foundations for any tooling in the ecosystem, including community projects.
How this relates to our roadmap
This proposal is our response to the heavily upvoted roadmap item “Env Sync | Config-as-Code”. We want to be upfront: this is not a fully code-first workflow in the way that other tools may implement, where schema and configuration are defined entirely in code and the database is derived from it.
That distinction is intentional. Our research, including community discussions, customer conversations, and discovery calls, surfaces a wide range of expectations for what “config-as-code” means in practice. But one finding is consistent across almost all of them: the core need is for improved, reliable tooling for auditable resource promotion, version control, peer review, and repeatability across schema, system resources, and item data. That is what this proposal solves.
A true code-first authoring workflow remains on our radar. We haven’t ruled it out. But the evidence points clearly to the above as the right priority to address now, and there is meaningful overlap: working with configuration files makes it easier to define resource shapes at the code level and apply them across environments, which is a step in that direction.
What we are proposing
Environment Sync has two pieces:
- A server API for comparing and applying schema, plus importing rows.
- A sync CLI tool that orchestrates those API calls and stores working state in
.directus/sync/.
The team spec defines the API contract and CLI flow. This community draft also proposes a git-reviewable file workflow, production gates, and command names. Those product-layer details are open for feedback, not final API requirements.
The CLI is the main user experience:
directus sync pull --from staging # instance -> json files
directus sync push --to prod # json files -> instance
The intended workflow:
- Change your Directus project in the Studio.
- Run
directus sync pull --from localor-from staging. - Commit the generated
directus/files. - Review the diff in a pull request.
- Run
directus sync push --to stagingor-to prod. - Review the plan, confirm, and apply.
The committed files are meant to be readable enough that a one-field Studio change creates a small, reviewable git diff.
What syncs
This proposal separates four kinds of things:
| Kind | Examples | Proposed default |
|---|---|---|
| Schema | collections, fields, relations | synced |
| Config resources | flows, operations, roles, policies, permissions, dashboards, panels, settings, translations | synced |
| Content | your item data | not synced |
| Users | directus_users and user-specific records |
not synced |
Config resources are stored as rows in Directus system collections, so the API treats them as data. The CLI still presents them as project configuration.
The team spec says data export happens when data is flagged. This draft proposes syncing config resources by default, while still allowing teams to include or exclude specific resource types. Dependency closure is default-on, but should be explicitly overridable.
This draft also proposes keeping content and users off by default because they are usually environment-specific and harder to match safely across instances.
Safe defaults
The bare command should be hard to misuse:
directus sync push --to prod
Proposed default behavior:
- syncs schema and config resources
- respects explicit include/exclude scope
- leaves content alone
- leaves user accounts alone
- uses
mergemode for imports, so it does not delete rows - uses the ID map to remap records already synced before
- shows a plan and asks before writing
Important limitation: safe by default does not mean no-risk. merge can still overwrite in-scope target configuration. For example, if someone manually widened a field in production, the next sync can change it back to match the source. That is usually what you want when source-controlled config is authoritative, but it must be clear in the plan.
Modes
Mode controls how the target is treated. The team spec defines add and merge for import. The schema API also supports mirror
| Mode | Source record conflicts with target | Already mapped / exists on both | On target, absent from source |
|---|---|---|---|
add data only |
created with remapped ID | left unchanged | left unchanged |
merge default |
updates mapped target record, or creates with remapped ID if unmapped ID conflicts | overwritten to match source | left unchanged |
mirror schema only |
same as merge for data |
overwritten to match source | schema deleted; data left unchanged |
mirror needs special care:
- It can drop collections and fields.
- Dropping a field drops the data in that column.
- In v1, it does not delete config resources or content. A flow removed from the source survives on the target.
- A full unscoped
mirroris the only mode/scope combination that can drop whole collections.
If exposed in the CLI, power users could split schema and data modes:
directus sync push --to prod --schema-mode mirror --data-mode merge
First sync matching
The hardest part is the first sync into an environment that already has similar records.
Example: local and production both have an “Editor” role, but the records have different IDs. If the CLI only used source IDs, it could create a duplicate “Editor” role in production.
The team spec requires an ID map and notes that first sync duplicates may need CLI-level conflict resolution. One possible mitigation is reconciliation before import:
| Resource type | Matching strategy |
|---|---|
| System resources | natural keys, such as role name, policy name, flow name, policy + collection + action for permissions |
| Junction rows | remapped foreign-key pair |
| Content | declared match key per collection, or create-as-new with a warning |
Ambiguous matches are not guessed. If two target roles are both named “Editor”, the CLI should stop and ask.
Content has no universal natural key. Teams may need declared match keys or may need to accept that records can be created as new.
JSON files on disk
Product-layer proposal: pull writes a deterministic tree under directus/, and push reads it back. The team spec only requires working state under .directus/sync/.
directus/
├── schema/
│ └── collections/
│ └── articles/
│ ├── collection.json
│ ├── fields/
│ │ ├── title.json
│ │ └── body.json
│ └── relations/
│ └── author.json
├── flows/
│ └── publish-webhook.json
├── dashboards/
│ └── content-overview.json
├── policies/
│ └── editor.json
├── roles/
│ └── editor.json
├── translations.json
├── settings.json
└── content/
└── posts.json
Rules:
- JSON, stable key order, 2-space indent
- volatile fields stripped
- one file per selectable resource
- dependent children inline where that is easier to review, such as operations inside a flow
- filenames are human-readable slugs, not identity
Identity comes from the CLI’s working state and reconciliation, not from filenames. Renaming a file should not create a new target record if the resource is already mapped.
The CLI also keeps gitignored working state in .directus/sync/ for ID maps, cached snapshots, reconciliation results, and undo history.
Selection
You select whole resources, not their internal parts:
--collections articles,authors
--exclude-collections drafts
--roles editor
--exclude-flows nightly-cleanup
--content
--no-content
There is no --fields, --operations, or --panels flag. Those only make sense inside their parent resource. If you select a flow, you get its operations. If you select a dashboard, you get its panels.
The dependency graph does not mean every related system collection is always pulled. It describes what the CLI should include by default when selected records reference other records. For example, if you sync users, the CLI should pull their referenced roles, policies, and access rows by default so user records do not dangle. But that dependency pull should be opt-out: teams can choose to sync users without also syncing roles, policies, and access.
The plan still writes only what changed.
Production and CI
The API does not know what production is. The CLI owns that safety gate.
A profile can be marked as production:
{
"profiles": {
"prod": {
"url": "<https://prod.example.com>",
"production": true
}
}
}
For production targets:
- interactive
pushalways shows a plan and requires confirmation push --yesis refused unless it uses a previously reviewed plan- there is no
-force -yesnever opts into deletion; deletion still requires-mode mirror
Possible CI flow:
# PR: fail if prod would drift
- run: directus sync plan --to prod --json
env:
DIRECTUS_PROD_URL: ${{ secrets.PROD_URL }}
DIRECTUS_PROD_TOKEN: ${{ secrets.PROD_TOKEN }}
# merge to main: apply reviewed plan
- run: directus sync plan --to prod --out plan.json
- run: directus sync apply --to prod --plan plan.json --yes
Undo and recovery
Product-layer proposal: before applying, the CLI snapshots affected resources into .directus/sync/<project>/history/. The team spec requires .directus/sync/<project>/id_map.json, schema cache, and data cache, but does not specify undo history.
directus sync undo --to prod
Undo is partial:
- It can revert schema from the pre-apply snapshot.
- It cannot un-overwrite existing rows changed by
merge. - It cannot resurrect data dropped by a destructive schema change.
You still want and need database backups before any major changes (especially production).
Replacing directus-template-cli
The existing template CLI is additive seeding. Environment Sync keeps that workflow:
directus sync push --to <instance> --from ./template --mode add
directus-template-cli |
Sync CLI proposal |
|---|---|
apply |
directus sync push --to <instance> --from ./template --mode add --content |
extract / generate-template |
directus sync pull --from <instance> |
| additive-only loader | add mode |
| no diff or dry-run | directus sync plan |
Existing templates remain usable as a source tree.
Known limitations
These are proposed v1 boundaries. Tell us which ones are dealbreakers.
- First sync can create duplicates. The team spec calls this out directly. CLI-level conflict resolution may mitigate it, but it is not specified yet.
- Content matching has no universal answer. User collections need declared match keys or create-as-new behavior.
- Manually managed primary keys require merge. The primary key must be explicitly defined because the system cannot infer it.
- No deletion of config resources or content.
/importnever deletes rows.mirrordeletes schema only. - Schema and data are not one transaction. If schema apply succeeds and data import fails, the target may be partially migrated.
- Data plans are advisory. Schema apply is hash-gated by the server. Data import has no equivalent server-side precondition in v1.
- Undo is partial. Backups are still required for production safety.
- No background imports. Large content syncs run synchronously in v1. Websockets and progress reporting are future improvements.
Questions for feedback
- Defaults: Should config sync by default while content and users stay off?
- Deletion: Is schema-only deletion enough for v1, or do removed flows/policies/content need deletion support immediately?
- Identity: Would you rather depend on client-side ID maps and natural-key reconciliation, server-stored
sync_id, or UUID primary keys? - Content matching: How would you expect Directus to identify the same content record across instances?
- Safety gates: Should production protections live in the CLI, the server, or both?
- Apply boundaries: Is separate schema/data apply acceptable for your deployment process?
- Commands: Are
pull,push,plan, andapplythe right surface? - File layout: Would this tree produce useful PR diffs for your team?
Technical Appendix
The CLI is the recommended interface, but the design relies on a small set of API operations.
API pipeline
| # | Endpoint | Side | Effect |
|---|---|---|---|
| 1 | GET /schema/snapshot |
source | reads collections, fields, relations |
| 2 | POST /schema/diff |
target | computes schema changes and returns a target hash |
| 3 | POST /schema/apply |
target | applies schema changes if the hash still matches |
| 4 | GET /items/* |
source | reads config resources and optional content |
| 5 | POST /import |
target | creates or updates rows, resolves dependencies, remaps IDs |
Schema and data are separate writes. There is no single transaction across both.
Only schema can delete in v1. /import never deletes rows.
CLI flow from the team spec
The sync CLI keeps working state in .directus/sync/.
- Profiles live in
.directus/sync/profilesand represent connections. - Projects live in
.directus/sync/projectsand scope incremental sync tasks. - ID maps live at
.directus/sync/<project>/id_map.json. - ID mappings are scoped by source, target, and collection so repeated syncs update the same target records.
- Schema snapshots are cached in
.directus/sync/<project>/schema. - Data exports are cached in
.directus/sync/<project>/data.
The flow:
- Select or create a profile.
- Select or create a project.
- Retrieve the source schema snapshot.
- Store schema files and metadata.
- Generate a schema diff against the target.
- Apply schema changes.
- Export data if data was flagged.
- Create or reconcile the ID map.
- Import data with
/import. - Update the ID map from the import response.
Schema API
GET /schema/snapshot
GET /schema/snapshot
GET /schema/snapshot?includeCollections=articles
GET /schema/snapshot?excludeCollections=drafts,internal
Full snapshots are version: 1. Partial snapshots are version: 2.
Passing both includeCollections and excludeCollections is an error until glob support exists.
{
"version": 2,
"directus": "12.0.2",
"vendor": "sqlite",
"collections": [{ "collection": "articles", "meta": {}, "schema": {} }],
"fields": [{ "collection": "articles", "field": "id", "type": "integer", "meta": {}, "schema": {} }],
"systemFields": [],
"relations": []
}
POST /schema/diff
Body is a snapshot. The response includes a hash that pins the target state.
{
"hash": "4e4b34928b41ffc7f69456ff1f8005b0d97fdaa0",
"diff": {
"collections": [{ "collection": "articles", "diff": [{ "kind": "N", "rhs": {} }] }],
"fields": [{ "collection": "articles", "field": "id", "diff": [{ "kind": "N", "rhs": {} }] }],
"systemFields": [],
"relations": []
}
}
merge strips delete operations. mirror keeps them.
Scope controls how far deletes can reach:
| Scope | merge |
mirror |
|---|---|---|
| partial | create/update only | can drop fields and relations inside selected collections |
| full | create/update only | can drop fields, relations, and whole collections |
POST /schema/apply
Applies the diff transactionally and refuses if the hash no longer matches the target.
The schema API supports merge and mirror. CLI-level add is implemented by filtering the plan before apply.
Import API
POST /import
POST /import/:collection
Parameters:
dry-runmode=add|merge
Responsibilities:
- create or update rows
- never delete rows
- resolve dependency order
- defer nullable cyclical relations to a second pass
- deconflict IDs
- return the ID map for created or remapped records
Example body:
[
{ "collection": "authors", "items": [{ "id": "123", "name": "Lorem Ipsum" }] },
{ "collection": "articles", "items": [{ "id": "123", "title": "Hello World" }] }
]
Example response:
{
"applied": true,
"collections": {
"articles": {
"existing": ["5", "6", "7"],
"new": ["101", "102"],
"mapped": { "4": "101", "8": "102" }
}
}
}
POST /import/:collection returns 204.
Expected error states:
- non-nullable circular dependency
- missing foreign-key target
Dependency graph
The dependency graph describes default dependency closure for selected resources. It does not mean every related resource is always included. If selected records reference another resource, the CLI should include that dependency by default to avoid dangling references, but users can explicitly opt out of pulling those dependencies.
Open CLI proposal: dependency closure should be default-on and opt-out. If users are selected, referenced roles, policies, and access should come along by default, but teams should be able to disable that behavior when they intentionally want users only.
