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.
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.
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.
# .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.
// 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.
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.