How It Works

The mental model behind Environment Sync, including sync files as the source of truth, record identity across instances, how a push applies, and the safety rules every command follows.

Environment Sync never moves anything directly between two instances. The sync files sit in the middle, and two rules govern everything the commands do:

  1. A pull writes sync files only for what it fetched. Sync files for anything it did not fetch stay unchanged.
  2. 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 records, 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.

Each directory also holds a metadata.json that lists the sync files the CLI wrote. The CLI treats that list as ownership: it removes a stale file it wrote on an earlier pull, and it never deletes a file it did not write. 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 --collections or --exclude-collections, or skip it with --no-schema.
  • Configuration resources: records of eleven directus_* resource types (flows, roles, settings, translations, and the rest), plus opt-in users. Scope it with resource flags like --flows or --no-flows.

Scoping narrows what a pull fetches. The CLI writes the current source state to the sync files for that scope and does not write any other sync files. The pull scope matrix lists which sync files each flag combination writes, 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 the field that names it: name for most resources, email for a user, key for an operation, and language plus key for a translation. Panels have no such field, 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 accepts an 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 looks like this, with the differences between candidates spelled out per option:

Resolve identity 1 of 1: directus_roles source "Editor" — sr1 matches multiple target records
  Use "Editor" — t1           (Same synced values as source; only the ID differs)
  Use "Editor" — t2           (icon: source "edit", target "star")
  Create a separate record    (Adds one record; leaves every existing match unchanged)
  Abort the push              (Applies no remote changes)

Your answer lands in the ID map, so each question is asked exactly once. That's why the map belongs in git: commit it whenever a push changes it, and teammates and CI inherit every decision 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 is safe to the extent that matching starts over: named records re-match by their identifying fields, and records without one can duplicate on the next push. The same applies to repointing a profile at a new domain: the mappings are keyed by URL, so the old URL's decisions no longer apply and matching starts fresh for the new one.

How a push applies

A push applies in two phases, Schema first, and begins by showing you the full plan and asking (unless you pass --yes).

  1. 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.
  2. 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.
  • add only creates records; it never touches an existing one. Its Schema phase behaves exactly like merge: the mode only changes what happens to configuration records.
  • mirror makes 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 supposedly additive 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 version gate

Schema changes require the version recorded in the sync files and the target's version to match exactly, patch release included, because the server refuses cross-version schema diffs; historically, some patches change the schema format.


✖ Version mismatch: the snapshot was pulled from Directus 11.2.0, but the target runs 11.2.5.
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-version-drift to proceed anyway.

--allow-version-drift asks the server to proceed anyway and warns you loudly; the CLI never translates schema between versions. 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:

  • pull is 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.)
  • diff applies nothing. 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.
  • Deletions are gated. Only mirror deletes, always behind its own consent, and --yes never covers it.
  • Identity is never guessed. An ambiguous record match prompts in a terminal and refuses in CI.
  • Stored secrets never enter your repository. Built-in secret columns and fields marked concealed, hashed, or encrypted are stripped during pull, with one warned exception.
  • Failures are loud. Hand-edited sync files, incomplete pulls, version 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. 🐰