Contentful

Contentful Migration Scripts: Automating Content Changes

A comprehensive guide to Contentful migration scripts covering content type changes, entry transformations, environment workflows, and CI/CD automation with Node.js.

Contentful Migration Scripts: Automating Content Changes

Overview

Contentful migration scripts are the version control system for your content model. Without them, content type changes live as manual clicks in the Contentful web app -- unreviewable, unrepeatable, and impossible to coordinate across environments. Migration scripts turn every schema change into a reviewable, testable, deployable artifact that flows through your CI/CD pipeline the same way application code does.

I have managed Contentful content models across four production projects, and the ones that used migration scripts from day one never had a "who changed the Blog Post content type and broke staging?" incident. The ones that relied on manual changes in the web app always did. This article covers the full lifecycle of Contentful migrations: writing them, testing them, chaining them, transforming data, and automating them in CI/CD.

Prerequisites

  • Node.js 18+ installed
  • A Contentful space with at least one environment
  • Contentful CLI installed (npm install -g contentful-cli)
  • A Contentful Management API token (not the Content Delivery token)
  • Basic familiarity with Contentful content types and fields
  • A package.json project to hold migration dependencies

Setting Up the Contentful CLI

The Contentful CLI is the entry point for running migrations. Install it globally and authenticate:

npm install -g contentful-cli
contentful login

The login command opens a browser window and stores your management token locally. For CI/CD environments where browser login is not possible, export the token directly:

export CONTENTFUL_MANAGEMENT_TOKEN=CFPAT-xxxxxxxxxxxxxxxxxx

Verify your setup by listing spaces:

contentful space list

Next, install the migration package in your project:

npm install contentful-migration --save-dev

Your project structure should look like this:

project/
  migrations/
    001-create-author.js
    002-add-seo-fields-to-blog-post.js
    003-transform-markdown-to-richtext.js
    004-link-authors-to-posts.js
  scripts/
    run-migrations.js
  package.json

Writing Migration Scripts

Every migration script exports a single function that receives a migration object. This object provides methods for creating, editing, and deleting content types, fields, and entries.

Creating a Content Type

// migrations/001-create-author.js
module.exports = function (migration) {
  var author = migration.createContentType("author", {
    name: "Author",
    displayField: "name",
    description: "Content authors and contributors"
  });

  author.createField("name", {
    name: "Full Name",
    type: "Symbol",
    required: true,
    validations: [
      { size: { min: 2, max: 100 } }
    ]
  });

  author.createField("slug", {
    name: "Slug",
    type: "Symbol",
    required: true,
    validations: [
      { unique: true },
      { regexp: { pattern: "^[a-z0-9-]+$", flags: null } }
    ]
  });

  author.createField("bio", {
    name: "Biography",
    type: "Text",
    required: false,
    validations: [
      { size: { min: 0, max: 2000 } }
    ]
  });

  author.createField("avatar", {
    name: "Avatar",
    type: "Link",
    linkType: "Asset",
    required: false
  });

  author.createField("twitter", {
    name: "Twitter Handle",
    type: "Symbol",
    required: false,
    validations: [
      { regexp: { pattern: "^@[a-zA-Z0-9_]+$", flags: null } }
    ]
  });

  // Control the editor appearance
  author.changeFieldControl("bio", "builtin", "markdown", {
    helpText: "Supports markdown formatting"
  });

  author.changeFieldControl("slug", "builtin", "slugEditor", {
    trackingFieldId: "name"
  });
};

Run this migration against a specific environment:

contentful space migration --space-id YOUR_SPACE_ID \
  --environment-id dev \
  migrations/001-create-author.js

Editing an Existing Content Type

This is where migrations earn their keep. Adding SEO fields to an existing Blog Post content type:

// migrations/002-add-seo-fields-to-blog-post.js
module.exports = function (migration) {
  var blogPost = migration.editContentType("blogPost");

  blogPost.createField("metaTitle", {
    name: "Meta Title",
    type: "Symbol",
    required: false,
    validations: [
      { size: { min: 10, max: 70 } }
    ]
  });

  blogPost.createField("metaDescription", {
    name: "Meta Description",
    type: "Symbol",
    required: false,
    validations: [
      { size: { min: 50, max: 160 } }
    ]
  });

  blogPost.createField("canonicalUrl", {
    name: "Canonical URL",
    type: "Symbol",
    required: false,
    validations: [
      { regexp: { pattern: "^https?://", flags: null } }
    ]
  });

  blogPost.createField("noIndex", {
    name: "No Index",
    type: "Boolean",
    required: false
  });

  // Move SEO fields into a group by repositioning them
  blogPost.moveField("metaTitle").afterField("articleContent");
  blogPost.moveField("metaDescription").afterField("metaTitle");
  blogPost.moveField("canonicalUrl").afterField("metaDescription");
  blogPost.moveField("noIndex").afterField("canonicalUrl");

  // Set editor controls
  blogPost.changeFieldControl("metaTitle", "builtin", "singleLine", {
    helpText: "50-60 characters recommended for search results"
  });

  blogPost.changeFieldControl("metaDescription", "builtin", "singleLine", {
    helpText: "120-155 characters. This appears in Google search results."
  });

  blogPost.changeFieldControl("noIndex", "builtin", "boolean", {
    helpText: "Set to true to exclude this page from search engine indexing",
    trueLabel: "Excluded from indexing",
    falseLabel: "Indexed normally"
  });
};

Deleting Content Types and Fields

Deleting requires care. You cannot delete a content type that still has published entries, and you cannot delete a field that is the display field.

// migrations/005-remove-legacy-fields.js
module.exports = function (migration) {
  var blogPost = migration.editContentType("blogPost");

  // Fields must be disabled before deletion
  blogPost.editField("legacyCategory", {
    omitted: true
  });

  blogPost.deleteField("legacyCategory");

  // To delete an entire content type, unpublish and delete all entries first
  // migration.deleteContentType("legacyPost");
};

The omitted: true step is important. Contentful requires you to first omit a field (which hides it from the API response) before deleting it. In practice, I run these as two separate migrations with a deployment gap between them, so consuming applications have time to stop reading the field.

Transforming Existing Entries

The real power of migration scripts is data transformation. The transformEntries method lets you read existing field values and write new ones across every entry in a content type.

Populating SEO Fields from Existing Data

// migrations/006-populate-seo-fields.js
module.exports = function (migration) {
  migration.transformEntries({
    contentType: "blogPost",
    from: ["title", "synopsis"],
    to: ["metaTitle", "metaDescription"],
    transformEntryForLocale: function (fromFields, currentLocale) {
      var title = fromFields.title ? fromFields.title[currentLocale] : "";
      var synopsis = fromFields.synopsis ? fromFields.synopsis[currentLocale] : "";

      if (!title) {
        return;
      }

      // Truncate title to 60 chars for meta title
      var metaTitle = title.length > 60
        ? title.substring(0, 57) + "..."
        : title;

      // Truncate synopsis to 155 chars for meta description
      var metaDescription = synopsis
        ? (synopsis.length > 155 ? synopsis.substring(0, 152) + "..." : synopsis)
        : metaTitle;

      return {
        metaTitle: metaTitle,
        metaDescription: metaDescription
      };
    }
  });
};

Deriving Linked Entries

The deriveLinkedEntries method creates new entries from existing data and links them automatically. This is how you extract inline data into a separate content type:

// migrations/007-derive-authors-from-posts.js
module.exports = function (migration) {
  migration.deriveLinkedEntries({
    contentType: "blogPost",
    derivedContentType: "author",
    from: ["authorName"],
    toReferenceField: "authorRef",
    derivedFields: ["name", "slug"],
    identityKey: function (fromFields) {
      // Use the author name as the identity key to deduplicate
      var name = fromFields.authorName ? fromFields.authorName["en-US"] : "";
      return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
    },
    shouldPublish: true,
    deriveEntryForLocale: function (inputFields, currentLocale) {
      var name = inputFields.authorName ? inputFields.authorName[currentLocale] : "";
      if (!name) {
        return;
      }

      var slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");

      return {
        name: name,
        slug: slug
      };
    }
  });
};

This single migration does three things: creates Author entries from the authorName strings on blog posts, deduplicates them using the identityKey, and creates a link field (authorRef) on blogPost pointing to the derived Author entries. That is an enormous amount of manual work automated into a single script.

Changing Field Types with Data Migration

Changing a field type requires a multi-step process because Contentful does not allow in-place type changes. You must create a new field, copy data, remove the old field, and rename:

// migrations/008-convert-category-to-array.js
module.exports = function (migration) {
  var blogPost = migration.editContentType("blogPost");

  // Step 1: Create new array field
  blogPost.createField("categories", {
    name: "Categories",
    type: "Array",
    items: {
      type: "Symbol",
      validations: [
        { in: ["Engineering", "DevOps", "Architecture", "AI", "Database", "Cloud"] }
      ]
    },
    required: false
  });

  // Step 2: Copy data from old single-value field to new array field
  migration.transformEntries({
    contentType: "blogPost",
    from: ["category"],
    to: ["categories"],
    transformEntryForLocale: function (fromFields, currentLocale) {
      var category = fromFields.category ? fromFields.category[currentLocale] : null;
      if (!category) {
        return;
      }
      return {
        categories: [category]
      };
    }
  });

  // Step 3: Omit the old field (delete in the next migration)
  blogPost.editField("category", {
    omitted: true
  });
};

Then in a subsequent migration, after application code has been updated:

// migrations/009-cleanup-old-category-field.js
module.exports = function (migration) {
  var blogPost = migration.editContentType("blogPost");
  blogPost.deleteField("category");
};

Changing Field Validations

Updating validations on existing fields is straightforward but frequently needed as content requirements evolve:

// migrations/010-update-validations.js
module.exports = function (migration) {
  var blogPost = migration.editContentType("blogPost");

  // Expand the title character limit
  blogPost.editField("title", {
    validations: [
      { size: { min: 5, max: 200 } }
    ]
  });

  // Add an image dimension validation
  blogPost.editField("blogImage", {
    validations: [
      {
        assetImageDimensions: {
          width: { min: 800, max: 4000 },
          height: { min: 400, max: 3000 }
        }
      },
      {
        assetFileSize: { min: 0, max: 5242880 } // 5MB
      }
    ]
  });
};

Migration File Naming and Ordering

Migrations execute in the order you specify them. I use a numeric prefix convention:

migrations/
  001-create-author.js
  002-add-seo-fields-to-blog-post.js
  003-transform-markdown-to-richtext.js
  004-link-authors-to-posts.js
  005-remove-legacy-fields.js
  006-populate-seo-fields.js

Three-digit padding matters. If you use single digits, 10-something.js sorts before 2-something.js alphabetically. Pad to three digits and you have room for 999 migrations before needing a new convention.

Include a timestamp in the filename if you have multiple developers writing migrations concurrently:

migrations/
  20260115-001-create-author.js
  20260116-002-add-seo-fields.js

Environment-Based Workflow

Contentful environments are the key to safe migrations. Never run untested migrations against your master environment. The workflow:

  1. Create a sandbox environment cloned from master
  2. Run migrations against the sandbox
  3. Test your application against the sandbox
  4. Promote by running the same migrations against master
# Create a sandbox from master
contentful space environment create \
  --space-id YOUR_SPACE_ID \
  --name "migration-test-$(date +%Y%m%d)" \
  --source master

# Run migrations against the sandbox
contentful space migration --space-id YOUR_SPACE_ID \
  --environment-id migration-test-20260213 \
  migrations/008-convert-category-to-array.js

# After testing, run against master
contentful space migration --space-id YOUR_SPACE_ID \
  --environment-id master \
  migrations/008-convert-category-to-array.js

# Clean up sandbox
contentful space environment delete \
  --space-id YOUR_SPACE_ID \
  --environment-id migration-test-20260213

Dry Runs

Always do a dry run first. The --dry-run flag shows what changes would be made without actually applying them:

contentful space migration --space-id YOUR_SPACE_ID \
  --environment-id dev \
  --dry-run \
  migrations/008-convert-category-to-array.js

This outputs a plan showing every content type change, field addition, and entry transformation. Review it before running for real.

Migration Runner Script

For running multiple migrations in sequence with tracking, build a runner:

// scripts/run-migrations.js
var execSync = require("child_process").execSync;
var fs = require("fs");
var path = require("path");

var SPACE_ID = process.env.CONTENTFUL_SPACE_ID;
var ENVIRONMENT = process.env.CONTENTFUL_ENVIRONMENT || "dev";
var MANAGEMENT_TOKEN = process.env.CONTENTFUL_MANAGEMENT_TOKEN;
var MIGRATIONS_DIR = path.join(__dirname, "..", "migrations");
var STATE_FILE = path.join(__dirname, "..", ".migration-state.json");

function loadState() {
  if (fs.existsSync(STATE_FILE)) {
    var raw = fs.readFileSync(STATE_FILE, "utf-8");
    return JSON.parse(raw);
  }
  return { applied: {} };
}

function saveState(state) {
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}

function getMigrationFiles() {
  var files = fs.readdirSync(MIGRATIONS_DIR);
  return files
    .filter(function (f) { return f.endsWith(".js"); })
    .sort();
}

function runMigration(file, dryRun) {
  var filePath = path.join(MIGRATIONS_DIR, file);
  var cmd = [
    "contentful", "space", "migration",
    "--space-id", SPACE_ID,
    "--environment-id", ENVIRONMENT,
    "--management-token", MANAGEMENT_TOKEN,
    "--yes"
  ];

  if (dryRun) {
    cmd.push("--dry-run");
  }

  cmd.push(filePath);

  console.log("[migrate] Running: " + file + (dryRun ? " (dry run)" : ""));

  try {
    var output = execSync(cmd.join(" "), {
      encoding: "utf-8",
      stdio: "pipe"
    });
    console.log(output);
    return { success: true, output: output };
  } catch (err) {
    console.error("[migrate] FAILED: " + file);
    console.error(err.stderr || err.message);
    return { success: false, error: err.message };
  }
}

function main() {
  if (!SPACE_ID || !MANAGEMENT_TOKEN) {
    console.error("Set CONTENTFUL_SPACE_ID and CONTENTFUL_MANAGEMENT_TOKEN");
    process.exit(1);
  }

  var dryRun = process.argv.indexOf("--dry-run") !== -1;
  var state = loadState();
  var files = getMigrationFiles();
  var envKey = ENVIRONMENT;
  var applied = state.applied[envKey] || [];
  var pending = files.filter(function (f) {
    return applied.indexOf(f) === -1;
  });

  if (pending.length === 0) {
    console.log("[migrate] No pending migrations for environment: " + ENVIRONMENT);
    return;
  }

  console.log("[migrate] " + pending.length + " pending migration(s) for: " + ENVIRONMENT);
  console.log("[migrate] Pending: " + pending.join(", "));

  var failed = false;

  for (var i = 0; i < pending.length; i++) {
    var result = runMigration(pending[i], dryRun);

    if (!result.success) {
      console.error("[migrate] Stopping due to failure at: " + pending[i]);
      failed = true;
      break;
    }

    if (!dryRun) {
      if (!state.applied[envKey]) {
        state.applied[envKey] = [];
      }
      state.applied[envKey].push(pending[i]);
      saveState(state);
      console.log("[migrate] Recorded: " + pending[i]);
    }
  }

  if (failed) {
    process.exit(1);
  }

  console.log("[migrate] Complete. " + (dryRun ? "(dry run - no changes applied)" : ""));
}

main();

Usage:

# Dry run all pending migrations
CONTENTFUL_SPACE_ID=xxx CONTENTFUL_MANAGEMENT_TOKEN=yyy \
  CONTENTFUL_ENVIRONMENT=dev \
  node scripts/run-migrations.js --dry-run

# Apply all pending migrations
CONTENTFUL_SPACE_ID=xxx CONTENTFUL_MANAGEMENT_TOKEN=yyy \
  CONTENTFUL_ENVIRONMENT=dev \
  node scripts/run-migrations.js

The runner tracks which migrations have been applied per environment in .migration-state.json. This file should be gitignored since each environment has its own state.

CI/CD Pipeline Integration

Automate migrations as part of your deployment pipeline. Here is a GitHub Actions workflow:

# .github/workflows/contentful-migrate.yml
name: Contentful Migrations

on:
  push:
    branches: [master]
    paths:
      - 'migrations/**'
  pull_request:
    branches: [master]
    paths:
      - 'migrations/**'

jobs:
  migrate-staging:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Install Contentful CLI
        run: npm install -g contentful-cli

      - name: Create sandbox environment
        run: |
          contentful space environment create \
            --space-id ${{ secrets.CONTENTFUL_SPACE_ID }} \
            --name "pr-${{ github.event.pull_request.number }}" \
            --source master \
            --management-token ${{ secrets.CONTENTFUL_MANAGEMENT_TOKEN }}

      - name: Run migrations (sandbox)
        env:
          CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
          CONTENTFUL_MANAGEMENT_TOKEN: ${{ secrets.CONTENTFUL_MANAGEMENT_TOKEN }}
          CONTENTFUL_ENVIRONMENT: "pr-${{ github.event.pull_request.number }}"
        run: node scripts/run-migrations.js

      - name: Cleanup sandbox on failure
        if: failure()
        run: |
          contentful space environment delete \
            --space-id ${{ secrets.CONTENTFUL_SPACE_ID }} \
            --environment-id "pr-${{ github.event.pull_request.number }}" \
            --management-token ${{ secrets.CONTENTFUL_MANAGEMENT_TOKEN }} \
            --yes

  migrate-production:
    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Install Contentful CLI
        run: npm install -g contentful-cli

      - name: Dry run against master
        env:
          CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
          CONTENTFUL_MANAGEMENT_TOKEN: ${{ secrets.CONTENTFUL_MANAGEMENT_TOKEN }}
          CONTENTFUL_ENVIRONMENT: master
        run: node scripts/run-migrations.js --dry-run

      - name: Apply migrations to master
        env:
          CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
          CONTENTFUL_MANAGEMENT_TOKEN: ${{ secrets.CONTENTFUL_MANAGEMENT_TOKEN }}
          CONTENTFUL_ENVIRONMENT: master
        run: node scripts/run-migrations.js

This setup runs migrations against a throwaway sandbox environment for every PR (catching errors before merge) and applies them to master on merge.

Rollback Strategies

Contentful migrations do not have a built-in rollback mechanism. You need to plan for this yourself.

Strategy 1: Inverse migration files. For every migration that adds a field, write a corresponding rollback that removes it. Keep rollback scripts in a rollbacks/ directory:

// rollbacks/002-rollback-seo-fields.js
module.exports = function (migration) {
  var blogPost = migration.editContentType("blogPost");

  blogPost.editField("metaTitle", { omitted: true });
  blogPost.editField("metaDescription", { omitted: true });
  blogPost.editField("canonicalUrl", { omitted: true });
  blogPost.editField("noIndex", { omitted: true });

  blogPost.deleteField("metaTitle");
  blogPost.deleteField("metaDescription");
  blogPost.deleteField("canonicalUrl");
  blogPost.deleteField("noIndex");
};

Strategy 2: Environment snapshots. Before running migrations on master, clone it to a backup environment. If the migration fails or causes issues, point your API keys at the backup:

# Before migration
contentful space environment create \
  --space-id YOUR_SPACE_ID \
  --name "master-backup-20260213" \
  --source master

# Run migration on master
contentful space migration --space-id YOUR_SPACE_ID \
  --environment-id master \
  migrations/008-convert-category-to-array.js

# If something goes wrong, alias the backup as master
# (requires Contentful API, not CLI)

Strategy 3: Feature flags. For destructive changes, use your application's feature flags to control whether new fields are read. Roll out the migration, then flip the flag. If issues surface, flip the flag back without touching Contentful.

I strongly prefer Strategy 2 for production. Environment clones are cheap and give you a complete snapshot to fall back to.

Handling Migration Errors

Contentful migrations are not transactional. If a migration fails partway through, some changes will have been applied. This is the most dangerous aspect of the system.

Mitigate this by keeping migrations small. A migration that creates a content type, adds 15 fields, transforms 2000 entries, and deletes three fields is asking for trouble. Split it into focused steps:

011-create-content-type.js
012-add-fields.js
013-transform-entries.js
014-cleanup-old-fields.js

If a migration does fail partway, you need to manually inspect the environment state, determine which operations succeeded, and either write a fixup migration or fix the state in the web app before retrying.

Complete Working Example

Here is a full series of migrations that evolve a blog content model from a simple flat structure to a properly linked, SEO-optimized model. The sequence:

  1. 001-create-author.js -- Creates the Author content type (shown above)
  2. 002-add-seo-fields-to-blog-post.js -- Adds meta title, meta description, canonical URL, noIndex (shown above)
  3. 006-populate-seo-fields.js -- Transforms existing entries to populate SEO fields from title/synopsis (shown above)
  4. 007-derive-authors-from-posts.js -- Extracts author names into Author entries and links them (shown above)
  5. 008-convert-category-to-array.js -- Converts single category to categories array (shown above)
  6. 010-update-validations.js -- Tightens image validations (shown above)

Run them all with the migration runner:

export CONTENTFUL_SPACE_ID=your_space_id
export CONTENTFUL_MANAGEMENT_TOKEN=CFPAT-your_token
export CONTENTFUL_ENVIRONMENT=dev

# Dry run first
node scripts/run-migrations.js --dry-run

# Apply
node scripts/run-migrations.js

Expected output:

[migrate] 6 pending migration(s) for: dev
[migrate] Pending: 001-create-author.js, 002-add-seo-fields-to-blog-post.js, ...
[migrate] Running: 001-create-author.js
  Content type "author" - created
  Field "name" - created
  Field "slug" - created
  Field "bio" - created
  Field "avatar" - created
  Field "twitter" - created
  Migration successful
[migrate] Recorded: 001-create-author.js
[migrate] Running: 002-add-seo-fields-to-blog-post.js
  Content type "blogPost" - edited
  Field "metaTitle" - created
  Field "metaDescription" - created
  Field "canonicalUrl" - created
  Field "noIndex" - created
  Migration successful
[migrate] Recorded: 002-add-seo-fields-to-blog-post.js
...
[migrate] Complete.

Add a package.json script for convenience:

{
  "scripts": {
    "migrate": "node scripts/run-migrations.js",
    "migrate:dry": "node scripts/run-migrations.js --dry-run",
    "migrate:prod": "CONTENTFUL_ENVIRONMENT=master node scripts/run-migrations.js"
  }
}

Common Issues and Troubleshooting

1. "Cannot delete field used as display field"

Error: Cannot delete field "title" because it is used as the display field.

You must change the display field to another field before deleting the current one:

module.exports = function (migration) {
  var post = migration.editContentType("blogPost");
  post.displayField("headline"); // Change display field first
  post.editField("title", { omitted: true });
  post.deleteField("title");
};

2. "Rate limit exceeded" during entry transformations

Error: 429 Too Many Requests - Rate limit exceeded

Contentful's Management API has rate limits (7-10 requests/second on free plans). For large transformations, the migration tool handles retries internally, but extremely large datasets can still fail. Reduce batch size by splitting transformations across multiple migrations targeting subsets of entries, or upgrade your Contentful plan for higher rate limits.

3. "Environment not found" in CI/CD

Error: The environment "staging" does not exist in space "xyz"

Environment creation is asynchronous. After creating an environment, it takes time to become ready. Add a wait step in your pipeline:

# Wait for environment to be ready
sleep 30
contentful space environment list --space-id $SPACE_ID | grep "pr-$PR_NUMBER"

Or poll programmatically:

var contentful = require("contentful-management");

function waitForEnvironment(client, spaceId, envId, maxWait) {
  var start = Date.now();
  maxWait = maxWait || 60000;

  return new Promise(function (resolve, reject) {
    function check() {
      client.getSpace(spaceId)
        .then(function (space) { return space.getEnvironment(envId); })
        .then(function (env) {
          if (env.sys.status.sys.id === "ready") {
            resolve(env);
          } else if (Date.now() - start > maxWait) {
            reject(new Error("Environment not ready after " + maxWait + "ms"));
          } else {
            setTimeout(check, 3000);
          }
        })
        .catch(function (err) {
          if (Date.now() - start > maxWait) {
            reject(err);
          } else {
            setTimeout(check, 3000);
          }
        });
    }
    check();
  });
}

4. "Migration already applied" but environment is out of sync

If your .migration-state.json says a migration was applied but the environment was recreated or rolled back, the state file is stale. Delete the state file or remove the specific environment key from it:

# Reset state for a specific environment
node -e "
var fs = require('fs');
var state = JSON.parse(fs.readFileSync('.migration-state.json', 'utf-8'));
delete state.applied['dev'];
fs.writeFileSync('.migration-state.json', JSON.stringify(state, null, 2));
console.log('Reset migration state for dev');
"

5. Partial migration failure leaves inconsistent state

A migration that creates three fields but fails on the second leaves one field created and two missing. The migration cannot be re-run because the first field already exists. Fix by writing a cleanup migration that uses try/catch patterns:

// migrations/fix-partial-002.js
module.exports = function (migration) {
  var blogPost = migration.editContentType("blogPost");

  // Only create fields that are missing -- editField on existing, createField on new
  // The migration tool will skip createField if it already exists when using --yes
  blogPost.createField("metaDescription", {
    name: "Meta Description",
    type: "Symbol",
    required: false
  });

  blogPost.createField("canonicalUrl", {
    name: "Canonical URL",
    type: "Symbol",
    required: false
  });
};

Best Practices

  • One concern per migration. Do not mix content type creation with entry transformation in the same file. If the transformation fails, you cannot re-run the creation. Keep them separate.

  • Always dry-run first. Run every migration with --dry-run before applying. Read the output line by line. I have caught field name typos, wrong validation values, and missing required flags in dry runs that would have been painful to fix after applying.

  • Use environment branching. Never run untested migrations against master. Create a sandbox environment, run the migration, point your local dev server at the sandbox, verify, then apply to master. Environments are free.

  • Track applied migrations per environment. The runner script above does this with a JSON state file. Without tracking, you will accidentally re-run migrations or skip new ones when switching between environments.

  • Split destructive changes across two deployments. When removing a field: first deploy code that stops reading the field, then run the migration that deletes it. When adding a required field: first run the migration with required: false, deploy code that writes to the field, backfill existing entries, then update the field to required: true in a follow-up migration.

  • Version your migration state in Contentful itself. Instead of a local JSON file, store the last-applied migration number as an entry in Contentful. This way the state lives with the environment and survives environment cloning:

// Store migration state as a Contentful entry
module.exports = function (migration) {
  var migrationState = migration.createContentType("migrationState", {
    name: "Migration State",
    displayField: "lastApplied",
    description: "Tracks applied migrations (do not edit manually)"
  });

  migrationState.createField("lastApplied", {
    name: "Last Applied Migration",
    type: "Symbol",
    required: true
  });

  migrationState.createField("appliedAt", {
    name: "Applied At",
    type: "Date",
    required: true
  });
};
  • Keep migrations idempotent when possible. Design transformations so that running them twice produces the same result. If a transform sets metaTitle from title, check whether metaTitle is already populated before overwriting.

  • Commit migrations alongside the application code that uses them. The PR that adds a metaTitle field to the template should include the migration that creates the metaTitle field in Contentful. Reviewers can see the full picture.

References

Powered by Contentful