sb-mig
Guides

Copy a Space

Copy a whole Storyblok space — schema, then stories with their assets — into a blank space, with every reference resolved and a ledger to resume from.

Copy a space

This is the workflow for cloning a production space into a new one with sb-mig: the schema first, then the content, with every story and asset reference pointing at the copy instead of the original. It reads the source only; every write goes to the target. The command reference is copy.

Storyblok's own "duplicate space" does the same in one click when it works; this path is for when you need to see the plan first, copy in stages, rerun after fixing something, or prove afterwards what was copied.

0. Prepare

  • Create a blank target space in Storyblok. copy space needs an existing space and refuses when --from and --to are the same.
  • Use a management token that can read the source and write the target. Put it in .env as STORYBLOK_OAUTH_TOKEN; never in the command line.
  • Assign the field-type plugins the source components use to the target space (plugin → space assignment in Storyblok). copy space --dry-run lists them for you, so run step 1 first if you do not know the list.
  • Pick a directory to run from and keep it: the ledger lives under .sb-mig/copy/<source>/<target>/ there (or under --manifestRoot), and every later run of this pair must see it. sb-mig runs inside a project, so the directory holds storyblok.config.js (or .mjs), .env and a package.json — a minimal { "name": "space-copy", "private": true } is enough; without one the command stops with Cannot find module '…/package.json'.

1. Schema: copy space

sb-mig copy space --from 12345 --to 67890 --dry-run --outputPath sbmig/copy-plans/space-dryrun.json

Read the PLAN block. Two lines decide whether you can go on:

  • field-type plugins missing in target: ... — assign them and dry-run again. The run refuses to write until they are assigned (or you pass --allow-missing-plugins and accept that those components are rejected).
  • entries Storyblok will reject: ... — datasource entries whose names start with -, = or @. They cannot be created anywhere; decide what the consumer of those entries should read instead.

Then apply:

sb-mig copy space --from 12345 --to 67890 --yes --outputPath sbmig/copy-plans/space.json

Languages, space settings (individual translation publishing, alternative versions, the Visual Editor location and preview URLs), component groups, components, presets and datasources now exist in the target with the source's names and flags. Preview URLs are copied as they are and never printed; they still point at the source's frontend, so change the target's default location when the copy should preview its own content. Anything you change in the target afterwards (a block type, a whitelist) is overwritten by the next copy space over that resource — make such changes in the source when you can.

2. Content: copy stories --with-assets, dry-run

Copy the whole space in one run — --source / is every root story and folder of the source, read fresh on every run:

sb-mig copy stories --from 12345 --to 67890 \
  --source / \
  --destination / \
  --with-assets \
  --publicationMode preserve-layers \
  --dry-run \
  --outputPath sbmig/copy-plans/site-dryrun.json

Leave roots out with --exclude test-pages,drafts. One run over every root is better than one run per folder: every reference between folders is then inside the selection and will relink. If you must copy in stages, the ledger carries the earlier stages, and copy relink fixes the references written before their targets existed.

What to read in the PLAN block:

LineWhat you want to see
selections: / -> 23 roots (…) and the root namesWhat "everything" meant. A root you did not expect is a root somebody added to the source; a missing one is a reading error, and the run stops on it rather than skipping it.
N items (F folders) -> space 67890 (N create, 0 adopt existing, 0 resume from ledger)On a first run into a blank space: everything creates. adopt existing means a story already sits at that path in the target and will be updated in place.
references: a will relink, b leave your selection and WILL BREAKWILL BREAK should be references to stories that are dead in the source already (the list shows the uuids). Anything else means a root is missing from --source.
assets: a will copy, b already mapped and Asset refs by shape: … in asset fields, … as URLs in textEvery asset the selected stories reference — in asset fields and as URLs inside links, SEO fields and HTML. Assets in other Storyblok spaces are reported and left as URLs.
schema drift: ... / will fail: N stories (schema drift)Content Storyblok will reject as-is. Fix it in the source (a migrate content script), or accept those stories as missing for now; they are listed by slug in the report.

The dry-run writes nothing to Storyblok and the report is the full plan.

3. Apply

sb-mig copy stories --from 12345 --to 67890 \
  --source / \
  --destination / \
  --with-assets \
  --publicationMode preserve-layers \
  --yes \
  --outputPath sbmig/copy-plans/site.json 2>&1 | tee sbmig/copy-plans/site.log

Order of events: asset folders and assets, then story and folder shells (top-down, saved as drafts, each mapping appended to the ledger immediately), then every story's content rewritten through the ledger and saved, then the publication mode. Folders are never published. A whole site of 800 stories and 1,300 assets in 24 languages takes about two hours at Storyblok's rate limit.

The run never stops at a failed write. At the end it prints every failure with its cause and an outcome count:

✘ copy stories finished with 185 failed write(s); every other item went through. Outcomes: updated 104, published 448, publish_skipped 80, update_failed 100, create_failed 83.

update_failed is a shell without content; create_failed is a story that does not exist in the target (its children were skipped). The reasons are almost always properties of the source that Storyblok tolerates for existing content but refuses for new writes — see Storyblok rules the family runs into. Fix the cause, then:

4. Rerun without --fresh

sb-mig copy stories --from 12345 --to 67890 \
  --source / \
  --destination / --with-assets --publicationMode preserve-layers --yes \
  --outputPath sbmig/copy-plans/site-rerun.json

The same command resumes from the ledger: mapped stories are reused, every story's content is written again, failed creates are attempted again. Nothing is duplicated. Never pass --fresh here — it moves the ledger aside and the next run would create a second copy of everything next to the first.

References that were written while their target did not exist yet (a story that failed to create in step 3 and succeeded in step 4, or a folder copied in an earlier run) still hold source uuids. One pass over the same roots repairs them:

sb-mig copy relink --from 12345 --to 67890 \
  --source / \
  --destination / --dry-run

The PLAN says exactly how many reference values change in how many stories, and how many are already correct, and how each story's publication state is kept (the publication: line). Apply with --yes. Published stories are republished with the repair, drafts stay drafts, and a story with unpublished changes has its live version repaired from its history and its draft put back. Only the stories the PLAN lists — unpublished changes and no published version — are saved as drafts and need a person to publish them. See copy relink → Publication state.

6. Prove it

sb-mig copy manifests --pair 12345:67890
sb-mig copy manifests --pair 12345:67890 --type story --slug blog

This reads the ledger back — every source → target mapping a run would use and the ledger's health — without touching Storyblok. For a stronger proof, compare the two spaces with your own read-only script: for every reference in the source, the target should hold the target uuid of the same story (OK), the source uuid only when that story is not copied yet (pending), and nothing else.

After a Storyblok duplicate

Storyblok's own "duplicate space" copies stories, schema and settings, but not the asset library: every story of the duplicate keeps the source's asset ids and URLs. sb-mig finishes the job without copying any story.

Run it from a directory prepared as in Prepare: storyblok.config.js (or .mjs), .env with the token, and a package.json ({ "name": "space-copy", "private": true } is enough). The asset copy of a large library takes a while; write every run to a log with 2>&1 | tee, which also switches the progress line to whole lines (see Progress and --verbose). A run that stops — a closed terminal, a restart — is resumed by running the same command again: the ledger holds what was copied, and nothing is uploaded twice.

  1. Assets. Copy the whole library into the duplicate; this writes the asset ledger:

    sb-mig copy assets --from 12345 --to 67890 --all --manifestRoot ./manifests \
      --outputPath reports/assets.json 2>&1 | tee reports/assets.log

    --dry-run first shows what will upload, and on a rerun what is already copied.

  2. Tags. Read the internal tags: line of the PLAN. Storyblok does not let a personal access token create tags, so the tags the duplicate lacks are named there:

    internal tags: 5 matched, 2 missing in space 67890 — create them in Storyblok (Assets → Tags) and rerun: campaign-2025, legal

    Create the named tags in the duplicate under Assets → Tags, then run the same copy assets --all command again. Nothing is uploaded twice; the metadata of every copied asset is written again, now with its tags. Skip this step when the line says 0 missing.

  3. Relink, dry-run. Re-point every story of the duplicate at the copied files and read the plan:

    sb-mig copy relink --from 12345 --to 67890 --source / --destination / --manifestRoot ./manifests \
      --dry-run --outputPath reports/relink-dryrun.json

    The PLAN shows every story as adopted by target path — after a duplicate all uuids and ids are new, so the path is the only match — and rewrite: N references in M stories: the asset objects and the asset URLs inside links, SEO fields and HTML. Story references are already correct because Storyblok remapped them. The publication: line says how each story's publish state is kept.

  4. Relink, apply.

    sb-mig copy relink --from 12345 --to 67890 --source / --destination / --manifestRoot ./manifests \
      --yes --outputPath reports/relink.json 2>&1 | tee reports/relink.log

    Run the dry-run again afterwards: 0 references.

  5. Publish what is listed. Publishing is part of step 4: published stories are republished with the repair, drafts stay drafts, and a story with unpublished changes has its live version repaired from its history and its draft put back. The stories the PLAN and the report list (unpublished changes and no published version) hold the repair in their draft only; review and publish those by hand. A Storyblok duplicate carries no version history, so right after a duplicate every story with unpublished changes is in that list. Stories with the outcome changed_since_read were edited during the run; run step 4 again for them.

A story Storyblok rejects on save (schema drift, a blank required field) keeps its old URLs and is listed in the report; assets in other spaces or already deleted in the source stay as they are.

What is not reproduced yet

  • Internal tags the target lacks. They are named in the copy assets PLAN and must be created in Storyblok by hand; a rerun then attaches them.
  • Per-language publish state into a space that publishes all languages together. Each translation is published as it was only when the target has Enable Individual Translation Publishing on; copy it with copy space --only settings first.
  • Assets that live in other Storyblok spaces stay as URLs into those spaces.
  • Workflow stages, roles, webhooks, collaborators, internal tags are not part of copy space.

On this page