How It Works
Environment Sync never moves anything directly between two instances. The sync files sit in the middle, and two rules govern everything the commands do:
- A pull overwrites the sync files in its requested scope with the source's current state. Files outside that scope are left unchanged.
- A push only applies what is in the sync files. A push reads the files on disk, not the source instance. Work that never entered the sync files cannot ship.
Everything else on this page is a consequence of those two rules.
The CLI reads and writes the sync directory on disk. It never checks your git state, so a push applies the sync files as they exist, including uncommitted edits. This also allows the d6s sync wizard to pull and push in one pass. Git provides review and history around the sync files. Keep the tree clean when you push so the files on disk match the reviewed commit.
The sync files
A pull writes into a directory you commit (named directus by default, one subdirectory per project):
directus/default/
schema/ # Schema, one JSON file per collection
data/ # Configuration, one JSON file per resource
id_map.json # which target record corresponds to each source record
The sync files are written deterministically: pulling twice with no instance changes produces byte-identical files and a clean working tree. git diff after a pull shows what changed on the instance and nothing else, so a schema change reads like any other code change in review.
Both schema/ and data/ also contain a metadata.json file listing the files the CLI owns. A later pull can remove an owned file that became stale, but it never deletes a file outside that list. Hand-edited or corrupt sync files stop the command with a named error instead of applying invalid state.
Profiles and per-project settings live in directus.config.json at the repository root. It contains URLs and scoping options, never credentials, so it is safe to commit. The reference shows the full file.
Two independent axes
A pull covers two independent things, and you can scope each without affecting the other:
- Schema: collections, fields, relations. Scope it with
--collectionsor--exclude-collections, or skip it with--no-schema. - Configuration resources: records from supported
directus_*collections, including flows, roles, settings, and translations. Users are opt-in. Scope Configuration with resource flags like--flowsor--no-flows.
Scoping narrows what a pull overwrites from the source. Sync files outside the scope are left unchanged. The pull scope matrix lists the effect of each flag combination, and Common Workflows shows how to ship finished work while half-finished work stays out of the repository.
Record identity
The same role or flow carries a different primary key on every instance, so a push has to decide which target record corresponds to each source record before it can update rather than duplicate. Two mechanisms decide, in order:
- The ID map. Each push records its decisions in
id_map.json: this source record corresponds to that target record. Later pushes look there first. - Identifying fields. A record not yet in the map is matched by stable fields:
namefor most named resources,emailfor a user,flowpluskeyfor an operation, andlanguagepluskeyfor a translation. Access rules and permissions use their relationship fields. Panels have no such fields, which is why a first push into a look-alike target can duplicate them once.
For an existing translation, the CLI replaces the source ID with the matching target ID and sends the complete record. Directus 12.2.0 and later accept an import update that repeats that record's current language and key, so both merge and mirror can update translation strings. A pair already owned by another translation still fails as a duplicate.
When two target records could both be the match, the CLI asks you to choose in a terminal and refuses in CI. Ambiguity is never resolved by guessing. The question names both sides, links to the records when the Data Studio has a stable route for them, and explains what every choice will do:
directus_roles — 1 of 1
./directus/default contains 1 role named "Editor".
production — https://cms.example.com contains 2 matching roles.
Role: "Editor" — sr1
Source UI: https://source.example.com/admin/settings/roles/sr1
Target UI 1: https://cms.example.com/admin/settings/roles/t1
Target UI 2: https://cms.example.com/admin/settings/roles/t2
Which target role does this represent?
Existing target role "Editor" — t1 (Same synced values; only the ID differs)
Existing target role "Editor" — t2 (Merge updates the target; icon: local "edit", target "star")
No existing role — create a new one on the target (Creates another "Editor" role)
Abort push (Applies no remote changes)
Your answer lands in the ID map and is reused for later pushes between the same source and target URLs. That's why the map belongs in git: commit it whenever a push changes it, and teammates and CI inherit the decisions already made.
One ID map serves any number of instances. Internally it is keyed by source and target URL, so pushing the same sync files to staging and production writes two independent sets of mappings; neither overwrites the other. Deleting the ID map does not change either instance, but matching starts over on the next push. Named records can usually match again by their identifying fields; records without them can duplicate. Repointing a profile at a new URL also starts a new set of mappings, because decisions stored for the old URL do not apply to the new one.
How a push applies
After record identity is settled, an interactive push dry-runs Configuration, shows the full plan, and asks before applying unless you pass --yes. It then applies in two phases, Schema first:
- Schema. Before applying, the push re-checks that the target's schema still matches what the plan was computed against. If someone changed the target between preview and apply, the push stops rather than apply a stale plan.
- Configuration. Configuration records import in a single server-side transaction once the schema is in place.
The two phases are not one transaction. If the Configuration push fails after Schema applied, the two phases can be out of sync, and the CLI says so plainly:
▲ Schema was applied, but the configuration push did not complete.
✖ Could not reach https://cms.example.com.
Schema is already applied — re-run d6s sync push to retry the configuration push against an empty schema diff.
The guidance is the same in every partial-failure case: run d6s sync diff to see where the target actually stands, then push again. A re-run applies only what is still missing, and a completed push re-run reports "nothing to push" after verifying that against the target, not assuming it.
Push modes and deletions
A push mode answers one question: what happens to things that exist on the target but not in your sync files?
merge(the default) creates and updates, and never deletes. Not in the Schema phase, not in the Configuration phase.addonly creates records; it never touches an existing one. Its Schema phase behaves exactly likemerge: the mode only changes what happens to configuration records.mirrormakes the target match the sync files exactly, which means deleting what the sync files no longer contain, within whatever scope they cover.
Deleting always requires its own explicit consent, separate from confirming the push. Interactively, a mirror push lists what would be lost and asks you to type the profile name. Non-interactively it requires the --dangerously-allow-delete flag; --yes never authorizes a deletion. The gate is a backstop as well as a policy: even if a non-deleting push somehow carried a deletion, it would still be refused without consent.
Because a mirror push makes the target match the sync files exactly, run it against a freshly pulled state. A mirror from stale sync files applies the stale state, including deleting things that only look obsolete because the files are old.
The compatibility gate
Schema comparison requires two things to match: the exact Directus version recorded in the sync files, patch release included, and the target's database vendor. The server refuses the comparison when either differs. Historically, some Directus patches change the schema format, while database vendors describe column types differently.
✖ Version mismatch: the snapshot was pulled from Directus 12.1.1, but the target runs 12.2.0.
The server requires an exact version match for schema diffs — historically some patches are breaking. Align both instances (re-pull if the source was upgraded), or pass --allow-drift to proceed anyway.
The CLI can name a known version mismatch before sending the snapshot. It cannot pre-check the target's database vendor, so a vendor mismatch comes back as an incompatible-snapshot refusal with the server's reason attached.
--allow-drift sends the server's force bypass for either mismatch and prints a ▲ Compatibility check bypassed warning. It does not translate schema across Directus versions or database vendors, so read the plan closely. Projects configured with "schema": false skip the Schema phase and this gate entirely.
The safety model
The rules above add up to a small set of promises, each of which you can watch hold in the Quickstart:
pullis read-only on the source. Every request it makes is a read; it changes nothing on the instance it snapshots. (The one exception: a profile that authenticates with a saved login session refreshes that session when it is close to expiring.)diffchanges no Schema or Configuration records. The Schema comparison is a preview, and the Configuration plan comes from the target server dry-running the import inside a transaction and rolling it back, so the plan is the server's own answer, not a client-side guess. On PostgreSQL, that dry run can advance an integer primary-key sequence and leave harmless gaps.- Deletions are gated. Only
mirrordeletes, always behind its own consent, and--yesnever covers it. - Identity is never guessed. An ambiguous record match prompts in a terminal and refuses in CI.
- Recognized secret fields are stripped. Built-in secret columns and fields marked concealed, hashed, or encrypted are removed during pull. Free-form flow configuration has one warned blind spot.
- Failures are loud. Hand-edited sync files, incomplete pulls, compatibility mismatches, and unreachable instances stop the command with a named error instead of degrading silently. A pull the source itself left incomplete is marked as such and refused at mirror push.
Get once-a-month release notes & real‑world code tips...no fluff. 🐰
Quickstart
Run the full pull, diff, push loop against two throwaway Directus instances, and see what the CLI does at every step.
Common Workflows
End-to-end walkthroughs of the most common Environment Sync workflows, from promoting changes to production to adopting sync on an existing project, rolling back, and recovering a drifted environment.