How It Works

The mental model behind Environment Sync, including 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 files in your repository sit in the middle, and two rules govern everything the commands do:

  1. A pull only rewrites what it fetched. Everything it did not fetch keeps its committed state, untouched.
  2. A push only applies what is in the files. A push reads your repository, not the source instance. Work that never entered the repository cannot ship.

Everything else on this page is a consequence of those two rules.

One honest note about "the files": the CLI reads and writes the directory on disk, and it never checks your git state. Committing is the workflow that makes the files trustworthy, not something the CLI enforces; a push applies the directory as it stands, uncommitted edits included, which is also how the d6s sync wizard can pull and push in one pass. Git is how you control and review what enters the files. Keep the tree clean around pushes and the two views never differ.

The files

A pull writes into a directory you commit (named directus by default, one subdirectory per project):

directus/default/
  schema/            # the schema, one JSON file per collection
  data/              # configuration records, one JSON file per resource
  id_map.json        # which target record each committed record became

The 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 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 files stop the command with a named error rather than syncing something the instance never said.

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 ten directus_* resource types (flows, roles, settings, and the rest), plus opt-in users and translations. Scope it with resource flags like --flows or --no-flows.

Scoping narrows what a pull fetches; combined with rule 1, that means a scoped pull refreshes its slice of the files and preserves every other file exactly as committed. The pull scope matrix in the reference lists what each flag combination refreshes and preserves, and Common Workflows shows why you'd want that: shipping 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 a committed record is before it can update rather than duplicate. Two mechanisms decide, in order:

  • The identity map. Each push records its decisions in id_map.json: this committed record became 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. Panels have no such field, which is why a first push into a look-alike target can duplicate them once.

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 identity 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 map file serves any number of instances. Internally it is keyed by source and target URL, so pushing the same files to staging and to production writes two independent sets of mappings; neither overwrites the other. Deleting the file 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. **Data.** Configuration records import in a single server-side transaction once the schema is in place.

The two phases are not one transaction. If the data import fails after the schema applied, the target holds the new schema and the old configuration, and the CLI says so plainly:

▲ Schema was applied, but the data import did not complete. ✖ Could not reach https://cms.example.com. The import may still have been applied on the server. Run d6s sync diff before retrying — a blind retry can duplicate records.


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 files?

- **`merge`** (the default) creates and updates, and never deletes. Not in the schema phase, not in the data 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 data.
- **`mirror`** makes the target match the files exactly, which means deleting what the files no longer contain, within whatever scope the files 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 files _exactly_, run it against a freshly pulled state. A mirror from stale files applies the stale state, including deleting things that only look obsolete because the files are old.

## The version gate

Schema changes require the files' Directus version 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](/guides/environment-sync/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 data 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 at export, with [one warned exception](/guides/environment-sync/secrets-and-limitations#the-one-blind-spot).
- **Failures are loud.** Hand-edited files, cut-short exports, version mismatches, and unreachable instances stop the command with a named error instead of degrading silently. An export 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. 🐰