Directus Schema Snapshots and Migrations Across Environments

This guide, part of Directus Data Layer Patterns, treats the Directus data model as code. It shows how to capture the model as a snapshot file, review changes in pull requests, check drift with dry-run applies, promote changes from development to staging to production in CI, and handle destructive changes and data migrations, which snapshots alone do not cover.

Because Directus collections are SQL tables, a model change is a database schema change. Adding a field adds a column, and deleting a field drops the column and its data. Making those changes by hand in each environment’s studio leads to drift, mistakes and lost data. Snapshots make the model a reviewable file and applying it a repeatable step, but they describe only the target shape; moving data between shapes is still the team’s job.

Promoting a model changeA developer changes the model in a local instance and takes a snapshot, which is committed; CI runs a dry-run apply against staging to show the diff, applies it to staging after review, runs the frontend's tests, and then applies the same snapshot to production.Local instancechange modelSnapshotcommittedDry-run diffin CIApply tostaging + testsApply toproductionreviewedtests pass
The same snapshot file moves through every environment; nothing is changed by hand.

The Problem

An agency maintained Directus instances for development, staging and production for a client. Model changes were made in each studio by hand, usually by different people. After a year, the production model had fields staging lacked, staging had a renamed field that production still used under the old name, and a developer testing a cleanup in staging accidentally made the same cleanup in production, dropping a column that held two years of event registration notes. Restoring it took a database backup restore and a lost afternoon of new registrations.

How Snapshot-Based Promotion Works

Snapshot. npx directus schema snapshot ./snapshots/schema.yaml writes the current model: collections, fields, relations and their settings. Commit it with the code that depends on it.

Dry run. npx directus schema apply --dry-run ./snapshots/schema.yaml against another instance prints what would change, without changing anything. Run it in CI for every pull request that touches the snapshot, and post the output for review.

Apply. npx directus schema apply --yes ./snapshots/schema.yaml applies the snapshot. Run it in CI against staging after review, then against production after tests pass.

Lock the studio. Restrict data model changes in staging and production studios to nobody, or to an emergency admin, so the snapshot is the only path.

Data migrations separately. Moving data between fields, backfilling new fields or transforming values needs scripts or SQL migrations, run in the same pipeline, in the right order relative to the snapshot.

Renaming a field without losing dataA field rename done as expand and contract across three deploys: add the new field and deploy a frontend reading both, backfill data with a script, switch the frontend to the new field, then remove the old field in a later snapshot.Snapshot adds new fieldFrontend reads bothBackfill scriptFrontend reads new onlySnapshot removes old field0 days2.5 days5 days7.5 days10 days12.5 dayscontract
The destructive step comes last, after the data has moved and nothing reads the old field.

Implementation

A CI job runs the dry run for pull requests and the apply for merges, with per-environment admin tokens stored in CI secrets.

YAML
# .github/workflows/directus-schema.yml
name: Directus schema
on:
  pull_request:
    paths: ["snapshots/**", "migrations/**"]
  push:
    branches: [main]
    paths: ["snapshots/**", "migrations/**"]

jobs:
  schema:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - name: Dry run against staging
        if: github.event_name == 'pull_request'
        run: npx directus schema apply --dry-run ./snapshots/schema.yaml
        env: { DB_CLIENT: pg, DB_CONNECTION_STRING: "${{ secrets.STAGING_DB_URL }}" }
      - name: Apply to staging, migrate data, test
        if: github.event_name == 'push'
        run: |
          npx directus schema apply --yes ./snapshots/schema.yaml
          node migrations/run.mjs --env staging
          npm run test:integration -- --env staging
        env: { DB_CLIENT: pg, DB_CONNECTION_STRING: "${{ secrets.STAGING_DB_URL }}", DIRECTUS_URL: "${{ secrets.STAGING_URL }}", DIRECTUS_ADMIN_TOKEN: "${{ secrets.STAGING_ADMIN_TOKEN }}" }

A separate, manually approved job applies the same snapshot and data migrations to production, after a database backup. The CLI connects to the database directly, so the job needs network access to the database, typically through a runner inside the same network or a bastion.

Data migrations are small scripts that use the Directus API or SQL, each recorded in a table so it runs once per environment.

JavaScript
// migrations/2026-09-10-backfill-subtitle.mjs
export default async function migrate({ directus }) {
  // Copy the old "tagline" into the new "subtitle" where subtitle is empty.
  const items = await directus.items("events").readByQuery({ fields: ["id", "tagline", "subtitle"], filter: { subtitle: { _null: true } }, limit: -1 });
  for (const item of items.data ?? []) {
    if (item.tagline) await directus.items("events").updateOne(item.id, { subtitle: item.tagline });
  }
}

For large tables, prefer SQL for backfills, which is faster and does not trigger flows for every row; disable or scope revalidation flows during bulk migrations and revalidate once afterwards.

Ordering schema changes and data migrations

The order of steps matters, and it differs by change type. For additive changes, apply the snapshot first, then run data migrations that fill the new fields, then deploy the frontend that uses them. For removals, the order reverses: deploy the frontend that no longer reads the field, run any migration that preserves its data elsewhere, and apply the snapshot that drops it last. For renames and type changes, use the full expand-and-contract sequence across several releases, as shown above. Encode the order in the pipeline rather than in a checklist: the CI job applies the snapshot, then runs pending migrations, and destructive snapshots are only allowed in pull requests that reference a completed contract step. A simple convention works well, marking migration files with the snapshot version they require, so the runner refuses to run a data migration against a schema that is not yet in place and never runs one twice.

Configuration Reference

Step Tool Rule
Capture schema snapshot Commit with dependent code.
Review schema apply --dry-run Posted on every pull request.
Promote schema apply --yes in CI Staging first, production after tests.
Data versioned migration scripts Run once per environment, recorded.
Destructive changes expand and contract Remove fields only after data and code moved.
Studio model changes locked in shared envs Snapshot is the only path.

Gotchas & Edge Cases

  • Version mismatches. Snapshots are tied to the Directus version that produced them. Upgrade all environments together, or apply snapshots only between instances on the same version.
  • Deletions in dry runs. Read dry-run output carefully for deletions; a field missing from the snapshot will be dropped with its data.
  • Permissions and flows. Depending on the version, snapshots may not include permissions, flows or settings. Manage those with setup scripts or the API in the same pipeline.
  • Direct database access. The CLI needs database credentials. Keep them in CI secrets and restrict network access to the runner.

Worked Example

After the lost column, the agency reconciled the three instances into one snapshot, reviewed with the client, locked data model editing in staging and production, and moved all model changes to pull requests with dry-run output. The next field rename went through expand and contract over two weeks, with a backfill script and a frontend that read both fields in between. No model change has since been made by hand in a shared environment, and dry runs have caught two accidental deletions before they reached staging.

Model differences between environmentsThe number of field and relation differences between staging and production before reconciliation and at monthly checks afterwards.Before reconciliation31 differencesMonth 10 differencesMonth 32 differencesMonth 60 differences
With snapshots as the only path, environments stayed identical apart from changes in flight.

Local Development with Snapshots

Snapshots also make local development reproducible. A new developer starts a local Directus with Docker, applies the committed snapshot, runs the data migrations and imports seed content, and has a working instance in minutes with the same model as staging. Changes made locally are captured with a new snapshot and proposed in a pull request, where the dry run shows reviewers exactly what will change in staging. Keep seed content small and synthetic, with a few items per collection including edge cases, so it can be committed and does not contain personal data, as described in onboarding developers.

Rollout Checklist

  • Reconcile environments into one committed snapshot.
  • Post dry-run diffs on every pull request touching the model.
  • Apply snapshots and data migrations in CI, staging before production.
  • Use expand and contract for renames, retypes and removals.
  • Lock data model editing in shared environments.
  • Back up the database before production applies.

Frequently Asked Questions

Can snapshots migrate data?

No. They describe the schema only. Data moves need separate migration scripts or SQL, run in the same pipeline.

Should the snapshot live with the frontend code?

Yes, when one frontend depends on the model; the pull request then shows model and code changes together. When several applications share the model, keep it in its own repository and publish generated types as a package that each application updates deliberately.

How long does applying a snapshot take?

Seconds for most changes. Changes that rewrite large tables, such as altering a column type on millions of rows, can take much longer and lock the table; schedule those for quiet periods or use expand and contract instead.

What about content in the snapshot?

Snapshots contain structure, not content. Use seed scripts for development content and data migration scripts for changes to production content, both committed next to the snapshot.

How do we handle emergency fixes?

Through the same pipeline with an expedited review. A manual change in production must be captured in the snapshot immediately, or the next apply will revert it.