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 spaceneeds an existing space and refuses when--fromand--toare the same. - Use a management token that can read the source and write the target. Put
it in
.envasSTORYBLOK_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-runlists 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 holdsstoryblok.config.js(or.mjs),.envand apackage.json— a minimal{ "name": "space-copy", "private": true }is enough; without one the command stops withCannot find module '…/package.json'.
1. Schema: copy space
sb-mig copy space --from 12345 --to 67890 --dry-run --outputPath sbmig/copy-plans/space-dryrun.jsonRead 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-pluginsand 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.jsonLanguages, 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.jsonLeave 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:
| Line | What you want to see |
|---|---|
selections: / -> 23 roots (…) and the root names | What "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 BREAK | WILL 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 text | Every 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.logOrder 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.jsonThe 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.
5. Relink
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-runThe 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 blogThis 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.
-
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-runfirst shows what will upload, and on a rerun what is already copied. -
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, legalCreate the named tags in the duplicate under Assets → Tags, then run the same
copy assets --allcommand again. Nothing is uploaded twice; the metadata of every copied asset is written again, now with its tags. Skip this step when the line says0 missing. -
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.jsonThe 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 — andrewrite: N references in M stories: the asset objects and the asset URLs inside links, SEO fields and HTML. Story references arealready correctbecause Storyblok remapped them. Thepublication:line says how each story's publish state is kept. -
Relink, apply.
sb-mig copy relink --from 12345 --to 67890 --source / --destination / --manifestRoot ./manifests \ --yes --outputPath reports/relink.json 2>&1 | tee reports/relink.logRun the dry-run again afterwards:
0 references. -
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_readwere 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 assetsPLAN 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 settingsfirst. - 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.
sb-mig