copy
Copy stories, assets and a whole space schema between Storyblok spaces, with a durable ledger, a PLAN gate and honest failure reports.
copy
sb-mig copy <subcommand> --from <sourceSpaceId> --to <targetSpaceId> [options]copy moves things from one Storyblok space into another and remembers what it
did. It has five subcommands; this page is their reference as of
sb-mig@6.5.0-beta.17. Run sb-mig copy --help for the same facts in the
terminal.
| Subcommand | What it does | Writes Storyblok |
|---|---|---|
copy space | Copy a space's schema — languages, space settings, component groups, components, presets, datasources and entries — into another existing space. No stories, no assets. | Yes unless --dry-run |
copy stories | Copy one story, a folder subtree, a folder's children or a folder shell, with every reference rewritten to the target. | Yes unless --dry-run |
copy assets | Copy assets and asset folders, all or by selection. | Yes unless --dry-run |
copy relink | Repair references in stories that were already copied, without copying content again. | Yes unless --dry-run |
copy manifests | Read the ledger back: list, inspect one space pair, or prune it. | Never |
Every subcommand that writes prints a PLAN block first and asks before the
first write. Pass --yes to skip the question; without a terminal and without
--yes the command refuses to write. --dry-run prints the plan and stops.
The recommended order for a whole space is copy space → copy stories --with-assets → copy relink → copy manifests. The
Copy a space guide walks through it.
The ledger
Every copy stories and copy assets apply writes a ledger: JSONL files
that map each source id to the id it became in the target.
<manifestRoot>/copy/<sourceSpaceId>/<targetSpaceId>/
manifest.jsonl # the combined ledger — the only file a copy run reads
stories.manifest.jsonl # per-resource views of the same lines
assets.manifest.jsonl
asset-folders.manifest.jsonl--manifestRoot defaults to .sb-mig in the current directory. One line per
mapping:
{"type":"story","source_space_id":"12345","target_space_id":"67890","source_id":611092713,"target_id":220423440598428,"source_uuid":"…","target_uuid":"…","source_full_slug":"blog/post-1","target_full_slug":"blog/post-1","action":"created","created_at":"2026-09-15T19:30:21.552Z"}type is story, asset or asset_folder. action is created when the
run made the target item, or matched_by_target_key when it adopted one that
already sat at that path (or, for assets, had that file name).
The ledger is what makes the family safe to rerun:
- A second
copy storiesrun over the same selection resumes: mapped stories are reused and updated in place, nothing is created twice. - References to stories copied in an earlier run — even a run with a different selection — resolve through the ledger, so the site can be copied folder by folder.
copy relinkandcopy manifestsread it;copy relinkalso adds to it.
Keep the ledger directory for as long as the two spaces exist. --fresh
(copy stories only) moves every ledger file of the pair aside with a
timestamp suffix (manifest.jsonl.2026-09-15T19-30-20-327Z.bak) and starts
empty; nothing is deleted. A later --with-assets run then starts without the
archived asset mappings too. copy manifests --prune is the only command that
deletes a ledger.
A run that stops halfway (a closed terminal, a restart, a lost connection) is resumed by running the same command again: the ledger holds what was already copied, so nothing is created or uploaded twice.
Progress and --verbose
A long copy reports where it is with one progress line per phase, the same for
copy stories, copy relink and copy assets:
assets 412/1322 (31%) · 4m07 elapsed · ~9m02 left · ok 410 · failed 2 · last: hero-banner.pngThe line holds the phase label, done/total, the percentage, the elapsed
time, an estimate of the time left (~… left, once enough items are done),
the counters (ok, and metadata failed, skipped and failed when they are
not zero) and last: with the name of the last item. The phases are
listing stories and reading stories (the source pages, then each story's
full content), matching target, then asset folders, assets, shells and
content for a copy, or relinking for copy relink. With --source / the
two reading phases run once per root, each with that root's own total.
--progress picks how the line is written:
| Value | Output |
|---|---|
auto (default) | line when the output is a terminal and CI is not set; plain otherwise. |
line | One line redrawn in place, cut to the terminal's width. |
plain | Whole heartbeat lines — every 50 items or every 30 seconds — and never a carriage return. |
off | No progress line. |
A run piped into a file or a pager — 2>&1 | tee run.log — is not writing to a
terminal, so auto writes plain lines by itself and the log holds whole lines
only. Setting CI does the same in a terminal. A value other than the four is
rejected before anything is read: --progress must be one of: auto, line, plain, off.
--verbose prints the per-item detail again — downloads, uploads, per-asset
and per-story lines, the periodic heartbeats — instead of the progress line.
Warnings, failures and the PLAN block are printed either way.
copy stories
sb-mig copy stories --from 12345 --to 67890 --source blog/post-1 --destination imported
sb-mig copy stories --from 12345 --to 67890 --source blog --destination imported
sb-mig copy stories --from 12345 --to 67890 --source blog/* --destination imported
sb-mig copy stories --from 12345 --to 67890 --source blog --mode self --destination /
sb-mig copy stories --from 12345 --to 67890 --source blog --source docs --destination /
sb-mig copy stories --from 12345 --to 67890 --source blog/*,docs --destination /
sb-mig copy stories --from 12345 --to 67890 --source / --destination / --with-assets --dry-run --outputPath sbmig/copy-plans/site.json
sb-mig copy stories --from 12345 --to 67890 --source / --exclude test-pages,drafts --destination / --with-assets --yes
sb-mig copy stories --from 12345 --to 67890 --source blog --destination / --with-assets --dry-run --outputPath sbmig/copy-plans/blog.json
sb-mig copy stories --from 12345 --to 67890 --source blog --destination / --with-assets --yesWhat a run does
- Selects the source stories: each
--sourceresolves to a story or folder, expanded by its mode. - Plans target paths under
--destination, checks the ledger and the target for existing paths, classifies every reference in the selected content, scans assets when--with-assetsis passed, and runs the pre-flights. It prints the PLAN block and asks. - Creates or adopts shells for every folder and story, top-down. Shells are saved as drafts; each new mapping is appended to the ledger at once.
- Rewrites and fills: every selected story's content is rewritten —
story ids and uuids,
multilinkids andcached_urls, asset ids and file names, including inside__i18n__language fields and richtext marks — through the ledger, then saved into its shell. - Applies the publication mode to stories. Folders are never published.
A story whose shell could not be created takes its descendants with it (they have no parent to be created under). Every other failure is recorded and the run carries on; see Failures and outcomes.
Selection
--source value | Mode | Result |
|---|---|---|
blog/post-1 | subtree (default) | One story, attached under --destination. |
blog | subtree (default) | The folder and all its descendants under --destination. |
blog/* | children (default for folder/*) | The folder's descendants under --destination, without the folder root. |
blog --mode self | self | Only the story or the empty folder shell. |
blog,docs/guide or --source blog --source docs/guide | per value | Several values planned as one run. A story inside a selected folder is planned once, under the folder. |
/ (or /*) | subtree per root | The whole space: every root story and folder of the source, expanded to one selection per root in full_slug order. --exclude a,b drops roots. --mode children is refused with /. |
--source must resolve to an existing source story or folder, or be /.
Folder startpages are part of their folder's subtree.
/ reads the source's roots on every run, so a story or folder added to the
root later is included next time without editing the command. The run says
what it meant — Sources: / -> 23 roots of space 12345, mode 'subtree', destination '/'. — and the PLAN lists the roots and the excluded ones. The
report keeps normalized.source: "/" next to normalized.roots and
normalized.excluded, and its reproduce-commands carry --exclude. Excluding
every root, naming a root both in --source and --exclude, or excluding a
name that is not a root are errors before anything is read; a root the run
cannot read stops the run instead of being skipped silently.
--destination must be an existing target folder full_slug. Omit it, or pass
/ or root, to copy into the target's root. The copied tree keeps its own
shape below that point: --source blog --destination imported yields
imported/blog/....
Options
| Flag | Type | Effect |
|---|---|---|
--from | string | Source space ID. Falls back to the configured spaceId. |
--to | string | Target space ID. Falls back to the configured spaceId. |
--source | string, repeatable or comma-separated | Source story or folder full_slug; folder/* for the children only; / for every root of the space. |
--exclude | string, repeatable or comma-separated | Root full_slugs to drop from --source /, with or without a trailing slash. Only valid together with /. |
--destination | string | Existing target folder full_slug. Omit, / or root for the target root. |
--mode | subtree, children, self | How a --source expands. Default subtree; folder/* defaults to children. |
--with-assets | boolean | Copy the assets the selected stories reference first (folders included), then point the copied content at the target asset ids and file names. A reference is an asset field or an asset URL written into any string — see Asset references. |
--publicationMode | preserve-layers, collapse-draft, save-only | What to do with publish state after content is saved. Default preserve-layers. |
--publicationLanguages | default, all, or language codes | Which languages to publish when the mode publishes. Default all. Not allowed with save-only. |
--dry-run | boolean | Print the PLAN block, check target path conflicts, write the report if --outputPath is passed, and write nothing to Storyblok. |
--yes | boolean | Skip the confirmation after the PLAN block. Required in non-interactive runs. |
--fresh | boolean | Ignore the ledger: move every ledger file of the pair aside with a timestamp suffix and start empty. |
--manifestRoot | string | Directory holding the ledger. Default .sb-mig. |
--outputPath | string | Write the run's JSON report: the plan on --dry-run; on apply an outcome for every item plus every failed write. Nothing is written locally without it. |
--progress | auto, line, plain, off | How the run reports where it is. Default auto. See Progress and --verbose. |
--verbose | boolean | Print the per-item detail instead of the progress line. |
Legacy aliases still work: --sourceSpace (--from), --targetSpace
(--to), --what (--source), --where (--destination).
The PLAN block
From a real whole-site run (space ids replaced):
PLAN
815 items (82 folders) -> space 67890 (815 create, 0 adopt existing, 0 resume from ledger)
folders: 82 (never published)
ledger: 599 entries loaded from .sb-mig/copy/12345/67890/manifest.jsonl (resuming; use --fresh to ignore)
references: 9679 will relink, 377 leave your selection and WILL BREAK
WILL BREAK, by story:
shared/how-to-apply
content.body[2].link.id -> b92c6a92-ec55-4adb-baee-8ac6b586bd4e
...
assets: 1322 will copy, 0 already mapped
schema drift: 200 occurrences in 70 stories
sb-blockquote.citation: expected richtext, got string (100)
sb-blockquote.content: expected richtext, got string (99)
sb-text.content: expected richtext, got string (1)
will fail: 70 stories (schema drift)| Line | Meaning |
|---|---|
N items (F folders) -> space T (a create, b adopt existing, c resume from ledger) | What each planned item will do. Adopt means a story already sits at the planned target path and is not in the ledger: it is adopted (matched_by_target_key) and updated in place. Resume means the ledger maps it already. |
... ledger mappings no longer resolve ... and will be discarded | A mapped target story was trashed; it is created again. A mapped story that moved keeps its mapping. |
folders: F (never published) | Folders are created as shells only. Publishing a folder in Storyblok cascades to every story inside it, so each story's own state is reproduced instead. |
ledger: ... | loaded from <path> (resuming; use --fresh to ignore), IGNORED (--fresh; starting empty) or none at <path> (starting empty). |
references: a will relink, b leave your selection and WILL BREAK | Every story reference in the selected content. Will relink: the target is in the selection, in the ledger, or already at its target path. Will break: it points at a story that is neither, so the copied content will hold a source uuid that means nothing in the target. The first stories are listed with the exact content path; the --outputPath report holds the full list. |
assets: a will copy, b already mapped | With --with-assets. Without it: assets: not copied (pass --with-assets). |
translated slugs: ... | Printed only when the selection has translated slugs; says how many are carried and which languages the target lacks. |
schema drift: N occurrences in M stories | Content whose shape does not match its field's type in the target schema (a string in a richtext field, a string in a bloks field...). Storyblok rejects those writes. One line per component.field group. |
will fail: N stories (schema drift) | The stories the target will reject. They are still attempted, so the report names them. |
Answer y (or pass --yes) and the writes begin.
Asset references
An asset is referenced in two shapes, and both count:
| Shape | Where | Example |
|---|---|---|
| object | asset / multiasset fields | { "id": 217443523626671, "filename": "https://a.storyblok.com/f/12345/1080x695/6f6a1b47af/hero.png" } |
| string | any text, link, richtext or plugin field, at any depth, in any language copy | an asset link (linktype: "asset" with href/url/cached_url), the SEO plugin's og_image, <img src="…"> inside HTML, a richtext image node's src |
The identity of an asset inside a string is its path, /f/<space>/<size>/<hash>/<name>
— never the whole URL. The short form without a dimensions segment,
/f/<space>/<hash>/<name> (migrated PDFs, some images), is an asset key too,
read and rewritten the same way. The asset library reports most files as
https://s3.amazonaws.com/a.storyblok.com/f/… while content writes
https://a.storyblok.com/f/…; comparing URLs as text would match nothing.
When a string reference is rewritten only that path changes: the host form as
written, a /m/800x0 resize suffix, a ?utm… query and every character
around the URL stay byte-identical. Only URLs of the source space whose
file the ledger proves was copied are rewritten; a URL of another space, or of
a file that was never copied, is left exactly as it is — nothing is ever built
by swapping a space id. An asset object whose stored id no longer matches the
library's id for the same file (a replaced upload) is found by its path and
takes both the target's id and file name.
The dry-run says how the references are held:
[dry-run] Asset refs: 3522 mapped, 54 planned, 97 unresolved.
[dry-run] Asset refs by shape: 3586 in asset fields, 87 as URLs in text (4 unique asset(s)).and the report's assetReferenceSummary.byShape carries the same counts. On a
real site roughly 7% of asset references were strings — PDF links, share
images, inline images — and before 6.5.0-beta.9 none of them was copied or
rewritten. The short /f/<space>/<hash>/<name> form is read since
6.5.0-beta.10.
Pre-flights and what they do not catch
The plan predicts what the target will reject before the first write:
- Schema drift (above): a value whose shape does not match the target
field type —
richtext,bloks,multilink,asset,multiasset. An empty string in arichtextfield is drift too; Storyblok rejects it. - Breaking references (above).
- Component compatibility: components missing in the target and components
in fields whose whitelist does not allow them are reported as warnings in the
JSON report (
componentCompatibility,summary.componentIssues). They are not counted aswill fail: Storyblok saves an unknown component and a whitelist violation alike and the editor shows "out of schema".
Known gaps, found on real spaces and tracked in the project tracker:
- A story whose root component is not a content type (
is_root: falsein the target) fails to create with422 please select a content type component as your root component. Storyblok checks this on create only, so a source space can hold such stories. Fix: set the component's block type to Content type or Universal in the target (aftercopy space, which copies the source flag) or in the source. - An unknown component inside a field with a whitelist is rejected, unlike an unknown component in an unrestricted field.
- A required field that is empty in the source is rejected on save.
Publication modes
Shells are always created as save-only drafts. After the full rewritten content
is saved, --publicationMode decides what is published:
| Mode | Behavior |
|---|---|
preserve-layers (default) | A clean published source story is published. A dirty published source story (published with unpublished changes) has its published version copied and published in the target, then its draft restored as save-only. Draft-only stories stay drafts. |
collapse-draft | Published source stories are published from their current draft. |
save-only | Nothing is published. |
--publicationLanguages scopes the publish call: all (default), default
(the default language only), or a comma-separated list of language codes.
Per-language publish state
Within that scope, each language is published as it was — when the target
space publishes translations individually (the space setting
use_translated_stories, Enable Individual Translation Publishing):
- A translation that is live in the source story is published.
- A translation that was only saved, or was unpublished again, stays unpublished.
- A translation with unpublished changes is published only from its published
version (the
preserve-layerspath for a dirty story), never from its draft; anywhere else it is left as it is.
The state is read from the source story, so the source must publish translations individually too; a story from a space that publishes all languages together is live in every language when it is published. When the source has the setting on and the target off, the PLAN warns:
languages: space 12345 publishes translations individually and the target does not, so each translation's publish state cannot be reproduced; copy the setting first with copy space --only settingsWhen the target publishes all languages together, a publish takes every
language at once and the PLAN says so (with save-only there is no
languages: line). The two forms of the line:
languages: 310 stories publish in all their live languages; 12 stories keep 19 unpublished translation(s) unpublished; 4 translation(s) with unpublished changes left as they are
languages: this space publishes all languages together (use_translated_stories off) — a published story goes live in every languageEach report item carries publishedLanguages and leftLanguages.
Failures and outcomes
copy stories never stops at a failed write. Each failure is printed with the
Storyblok status and message, the offending component, field and content path
when the message names them, the source and target ids, and a note on what was
skipped because of it. At the end:
✘ 83 target story/stories could not be created and 0 item(s) under them were skipped. The run carries on with everything else and exits 1.
✘ 100 story/story shell update(s) failed; the rest of the copy still completed. Failed stories:
✘ - _site-configuration/forms/pre-application (source id 567297527, target id 220423770834369) [update]
✘ 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.The command exits 1 when any write failed. Each item in the --outputPath
report carries an outcome:
| Outcome | The target holds |
|---|---|
updated, published, publish_skipped | The story's full content. publish_skipped means the mode chose not to publish it. |
created, matched | Only the shell was reached; the content write did not happen (the run was interrupted, or the update failed and is listed separately). |
update_failed | A shell with no content: Storyblok rejected the write. |
create_failed | Nothing; the story does not exist in the target. |
skipped_parent_failed | Nothing; its parent could not be created. |
publish_failed | copy relink only: the repair is saved, the publish was rejected. |
changed_since_read | copy relink only: someone changed the story after the run read it, so nothing was written; a rerun picks it up. |
A rerun of the same command without --fresh resumes from the ledger:
existing shells are reused, every story's content is written again, so fixing
the cause and rerunning is the repair path. Failed creates are attempted again
too.
Reports
--outputPath writes one JSON document per run (schemaVersion: 1,
command: "copy stories", dryRun, generatedAt, input, normalized,
summary, translatedSlugs, items, graph, assetCopy, manifestPaths,
warnings, errors). summary counts planned folders and stories, created and
matched stories, asset folders and assets, warnings and errors. graph is the
reference scan: every story and asset reference with its classification, plus
opaqueFields — fields the scanner could not read safely (a warning
unsupported_field per path). On apply, items[].outcome is the table above
and errors lists every failed write with its message.
Translated slugs
Storyblok reads translated slugs as translated_slugs and writes them as
translated_slugs_attributes; a plain copy of the story JSON loses them
silently. copy stories carries them for every language the target space has
and reports the ones it cannot carry (translatedSlugs.unsupported,
unsupportedLangs in the report). When the target languages cannot be read,
everything is carried and the API has the last word.
copy relink
sb-mig copy relink --from 12345 --to 67890 --source blog --destination imported --dry-run
sb-mig copy relink --from 12345 --to 67890 --source blog --destination imported --yes
sb-mig copy relink --from 12345 --to 67890 --source / --destination / --dry-runA story copied before the stories it references keeps source ids and uuids in
its target content forever; copying the referenced stories later does not touch
content that was already written. copy relink repairs that: it takes the same
--source, --destination and --mode as the copy stories run it repairs,
so the planned target paths line up, builds the mapping from the ledger plus the
target's own paths, and rewrites each target story's content in place.
PLAN
130 planned items (12 folders) in space 67890 (118 mapped by ledger, 0 adopted by target path, 12 missing from target)
12 planned stories are not in the target and cannot be relinked; copy them first.
rewrite: 41 references in 9 stories; 760 already correct and left untouched(Illustrative numbers; the line shapes are the command's own.)
| Line | Meaning |
|---|---|
mapped by ledger / adopted by target path / missing from target | How each planned story was found. Adopted stories are added to the ledger as matched_by_target_key. Missing stories were never copied: nothing to repair there — copy them first, then relink again. |
rewrite: N references in M stories; K already correct and left untouched | The exact number of reference values about to change. A story whose references already resolve is not written. |
copy relink never creates stories and never copies content from the source;
only reference values change. It has no --fresh: the ledger is its input.
Options: --from, --to, --source (including /), --exclude,
--destination, --mode, --publicationMode, --publicationLanguages,
--dry-run, --yes, --manifestRoot, --outputPath, --progress,
--verbose.
Publication state
copy relink keeps each story's publication state, read from the target
story it repairs:
| Target story | What the repair does |
|---|---|
| Published, no unpublished changes | Saved and republished with the repair. |
| Draft, or unpublished | Saved, not published. |
| Published with unpublished changes, and its history holds a published version | The published version from the story's own history is repaired and republished, then the draft is put back (repaired too), so the unfinished draft never goes live. |
| Published with unpublished changes and no published version in its history, or a story that does not say its state | The repair is saved as a draft only, and the story is listed for a person to review and publish. |
The PLAN counts the four:
publication: 212 published — republished with the repair; 40 drafts — saved only; 6 with unpublished changes — live version repaired from its history; 1 with unpublished changes and no published version — draft only, listed
listed: about/team (dirty_without_published_layer)The reason is dirty_without_published_layer or published_state_unknown.
The first 20 listed stories are named in the PLAN, the rest in the report.
When the target publishes translations individually, the reason reads
dirty_without_published_layer: dirty (a translation or the default language has unpublished changes), because the story-level flag also turns on for a
translation's edit.
--publicationMode save-only saves every repair as a draft and publishes
nothing. copy relink refuses --publicationMode collapse-draft.
--publicationLanguages scopes the publishes as in copy stories, and each
language is published as it was, read from the target story — see
Per-language publish state.
Before it publishes, copy relink reads the story again. A story someone
changed after the run read it is not written (outcome changed_since_read); a
rerun picks it up. A publish that Storyblok rejects is publish_failed; for a
story with unpublished changes the draft is put back even then.
copy relink repairs asset references in both shapes (see Asset
references): a target story that still holds a
source-space asset URL inside a link, an SEO field or HTML is re-pointed once
the ledger holds that file. That makes it the second half of a native
Storyblok "duplicate space", which copies stories but not assets: run
copy assets --all, then copy relink --source / --destination / — see
After a Storyblok duplicate.
copy assets
sb-mig copy assets --from 12345 --to 67890 --all
sb-mig copy assets --from 12345 --to 67890 --all --dry-run --outputPath sbmig/copy-plans/assets.json
sb-mig copy assets --from 12345 --to 67890 --asset hero.jpg
sb-mig copy assets --from 12345 --to 67890 --asset 217443523626671
sb-mig copy assets --from 12345 --to 67890 --assetFolder Marketing/Heroes
sb-mig copy assets --from 12345 --to 67890 --referenced-by-stories --source blog| Selector | Copies |
|---|---|
--all | Every asset and asset folder in the source (paginated; large spaces are not cut at the first API page). |
--asset <id, URL or unique file name> (repeatable) | The asset and its folder ancestors, so its folder mapping is preserved. |
--assetFolder <id or path> (repeatable) | The folder, its ancestors, its descendants and every asset inside that subtree. |
--referenced-by-stories --source <story or folder> | Only the source-space assets the selected stories reference — as asset objects or as URLs inside strings (see Asset references). --mode applies as in copy stories. |
Use one selector family per run. Apply uploads each file, finalizes the upload, writes the asset's metadata, and appends the asset and folder mappings to the ledger.
What the plan decides
The dry-run and the apply decide with the same code, before anything is created:
- A folder is reused from the ledger, then found by its path in the target, and otherwise created.
- An asset is reused from the ledger, then found in the target by a unique file name, and otherwise uploaded. A file-name match never claims a target file that another source asset's copy already owns, and a file of the same name under a different dimensions segment (a different size) is a different file.
- A ledger entry whose target file was deleted from the library, or is owned by another source file, is stale: the asset is uploaded again.
copy assets --dry-run reads the ledger and the target library and prints
the result, so a rerun shows what is left to do:
assets: 1204 already copied (ledger), 3 found in target, 115 will upload, 0 stale in ledger (will upload again); folders: 44 already copied, 0 found in target, 2 will createWith a ledger present it also prints the ledger: line (loaded from … (resuming)), and each asset in the dry-run listing says (already copied),
(found in target) or (stale in ledger: …; will upload again). The one
limit of a dry-run left in the report is manifests_not_written_in_dry_run.
Metadata
Every copied asset gets one metadata write carrying the source's alt,
title, copyright and source, each exactly as the source holds it: an
empty value stays empty and a null stays null, so a value cleared in the
source is cleared in the target too. A rerun writes the metadata of assets
that were already copied again.
Internal tags
Internal tags are matched by name (trimmed). copy assets never creates
or removes a tag: the Management API refuses tag writes to a personal access
token. A tag the target already has is attached to the copied asset; a tag it
lacks is named in the PLAN:
internal tags: 5 matched, 2 missing in space 67890 — create them in Storyblok (Assets → Tags) and rerun: campaign-2025, legalThe asset's alt, title and copyright are written either way. Create the named tags in the target under Assets → Tags and run the same command again: nothing is uploaded twice, and the metadata of the assets already copied is written again, now with the tags.
Retries
Each step of an asset copy — the download from the source, the upload to
storage, the finalize call — is tried up to three times, after 1 second and
then 3 seconds, when the error is transient: a dropped or refused connection
(ECONNRESET, ETIMEDOUT, ECONNABORTED, EPIPE, EAI_AGAIN,
ENOTFOUND, ECONNREFUSED, socket hang up) or HTTP 429, 500, 502,
503, 504. The request for a signed upload URL is retried on 429 and
503 only. Each retry is printed:
retrying download for 'hero.png' (1/2) after read ECONNRESETA step that still fails is recorded with (after 3 attempts) at the end of its
message, and the run carries on. A download that answers with an HTTP error
status fails instead of uploading the error page.
Assets that live in another Storyblok space (a URL with a different space id)
are reported and left as they are; they are never fetched from that space.
copy stories --with-assets runs this selection for you, scoped to the
stories it copies. Without --with-assets, run copy assets first when the
copied stories should point at target-space assets.
Options: --from, --to, --all, --asset, --assetFolder,
--referenced-by-stories, --source, --mode, --dry-run, --manifestRoot,
--outputPath, --progress, --verbose.
copy manifests
sb-mig copy manifests
sb-mig copy manifests --pair 12345:67890
sb-mig copy manifests --pair 12345:67890 --type story --slug blog
sb-mig copy manifests --pair 12345:67890 --outputPath sbmig/copy-plans/ledger.json
sb-mig copy manifests --prune 12345:67890 --dry-run
sb-mig copy manifests --prune 12345:67890 --yescopy manifests reads the ledger and makes no Storyblok request in any mode.
| Form | Behavior |
|---|---|
no --pair | Lists every ledger under --manifestRoot with its absolute path and the ledger file's real modification time. Says nothing about whether any of them is healthy. |
--pair <source>:<target> | Prints the mappings a copy run would use — superseded and unusable lines collapsed away — then the health of the ledger behind them. --type story|asset|asset_folder (repeatable) and --slug <text> narrow the view. |
--prune <source>:<target> | Deletes that pair's whole ledger directory and everything in it, after listing every entry it would delete and asking. --dry-run prints the list and stops; --yes skips the question. Not archived: a copy run that would have resumed from it starts over. |
Health: the command exits 1 on an error — a source key mapped to two
different targets, a line it cannot read, a line belonging to another space
pair, an unreadable file, or a mapping recorded only in a per-resource file
(the combined manifest.jsonl is the authority, because that is the only file
a copy run reads). It warns about duplicate lines and about story mappings with
no target path, which is why stored link paths such as cached_url stay stale
until copy relink fills them in.
Safety rules: both space ids must be plain numbers (a space id becomes a
directory name), naming half a pair is an error, and the command never falls
back to the configured spaceId. --prune names its own pair, so it cannot be
combined with --pair, --from, --to, --type or --slug; it refuses when
any part of the path is a symbolic link, when --outputPath points inside the
directory being deleted, or when --outputPath is a symbolic link. A ledger
that is clean here can still be stale against the space: copy manifests
cannot tell whether the target still holds the stories the mappings name.
copy space
sb-mig copy space --from 12345 --to 67890 --dry-run
sb-mig copy space --from 12345 --to 67890 --yes
sb-mig copy space --from 12345 --to 67890 --only groups,components --outputPath sbmig/copy-plans/space.json
sb-mig copy space --from 12345 --to 67890 --only settings --dry-run
sb-mig copy space --from 12345 --to 67890 --only components --allow-missing-plugins --yescopy space copies a space's schema into another, already existing space:
languages, space settings (internationalization switches and Visual Editor
preview URLs), component groups (nesting included), components, presets, and
datasources with their entries and dimension values. It copies no stories and no
assets; run copy stories and copy assets into the prepared space afterwards.
Create the target as a blank space in Storyblok first; --from and --to must
be two different spaces.
Matching and rewriting
Resources are matched by name and a match is overwritten with the source version; anything unmatched is created; anything that exists only in the target is left as it is. A rerun therefore updates instead of duplicating.
| Resource | Matched by | Rewritten on the way |
|---|---|---|
| languages | code | The target's language list gains the source languages; target-only languages stay. |
| settings | field | Seven fields of the space, see Space settings. Only the fields that differ are written. |
| component groups | full path | Parent uuids become the target's. |
| components | name | component_group_uuid and every component_group_whitelist entry become the target's groups. A whitelisted group with no counterpart in the target is dropped and listed. Generated schema.<field>.id values are dropped. Internal tags are not copied. |
| presets | component name + preset name | component_id becomes the target component's id. image and icon URLs still point at the source space; reported, not rewritten. A component's default preset (preset_id) is restored to the copied preset when both travel in the run. |
| datasources | name | Dimension values are copied. |
| datasource entries | name within the datasource | Values and dimension values. |
Because a match is overwritten with the source version, a change made only
in the target (a component switched to Universal, a widened whitelist) does
not survive the next copy space run over that resource. Make such changes in
the source, or repeat them after each schema copy.
Options
| Flag | Type | Effect |
|---|---|---|
--from | string | Source space ID. |
--to | string | Target space ID. Must differ from --from. |
--only | languages, settings, groups, components, presets, datasources; comma-separated or repeatable | Restrict the run to some resources. The write order stays languages, settings, groups, components, presets, datasources. |
--allow-missing-plugins | boolean | Write even when the target lacks field-type plugins the source components use. Those components are then rejected by Storyblok and reported. |
--dry-run | boolean | Print the PLAN block and write nothing. |
--yes | boolean | Skip the confirmation after the PLAN block. |
--outputPath | string | Write the plan (and on apply the outcome) as JSON. |
The PLAN block
From a real dry-run into a blank space (space ids replaced):
PLAN
schema of space 12345 -> space 67890 (languages, groups, components, presets, datasources)
languages: 23 create, 0 update, 1 skip
settings: 4 change, 3 same, 0 kept
use_translated_stories: false -> true
show_stories_alternative_versions: false -> true
domain: none -> https://preview.example.com/…
environments: 0 -> 3 (added: LOCALHOST, PROD EDITOR, dev-preview)
groups: 18 create, 0 update, 0 skip
components: 332 create, 0 update, 0 skip
presets: 262 create, 0 update, 4 skip
datasources: 12 create, 0 update, 0 skip
entries: 645 create, 0 update, 0 skip
never deletes: anything that exists only in space 67890 is left as it is.
dropped whitelist groups: none
entries Storyblok will reject: colors-theme 25 of 25
field-type plugins missing in target: seo-metatags (3 components), backpack-breakpoints-v2 (125 components)
the run refuses to write until space 67890 has them assigned; pass --allow-missing-plugins to write anyway (those components will be rejected).
preset images: 90 presets keep image or icon URLs that point at space 12345; they are reported, not rewritten.
component images: 114 keep URLs that point at space 12345
default presets: 118 restored, 0 not restorable
not copied: stories, assets, workflow stages, roles, webhooks, collaborators, internal tags.| Line | Meaning |
|---|---|
settings: a change, b same, c kept | Per setting, then one line per change and per kept value. See Space settings. |
<resource>: a create, b update, c skip | Per resource. A skip names its reason (a preset whose component is not in the source, a default preset that cannot be restored...). |
dropped whitelist groups | Whitelist entries pointing at a group the target will not have, listed as component.field: group path (uuid). |
entries Storyblok will reject: <datasource> n of m | Datasource entries whose name starts with -, = or @. Storyblok refuses to create such names (The following characters are not allowed at the beginning of name); existing ones in the source are grandfathered. They are planned as skipped with the reason instead of failing. |
field-type plugins ... | See below. |
preset images / component images | URLs left pointing at the source space. |
default presets: a restored, b not restorable | Component preset_id values re-pointed at the copied presets. |
not copied: ... | Always printed: stories, assets, workflow stages, roles, webhooks, collaborators, internal tags — plus environments when settings is not in scope. |
Space settings
The settings resource copies seven fields of the space object, written with
one PUT right after the languages and only when something differs:
| Setting in Storyblok | Field | How it is copied |
|---|---|---|
| Internationalization → Enable Individual Translation Publishing | use_translated_stories | Turned on when the source has it. Never turned off in the target. |
| Internationalization → Enable Alternative Versions | show_stories_alternative_versions | Turned on when the source has it. Never turned off in the target. |
| Internationalization → Hide the flags | hide_flag_icons | Mirrors the source. |
| Internationalization → Flags represent language / country | flag_icons_display_mode | Mirrors the source. |
| Visual Editor → Location (default environment) | domain | Overwritten when the source has one. |
| Visual Editor → Preview URLs | environments ({ name, location }) | Merged by name, the way languages are: a source entry replaces the target's first entry of the same name, new names are appended, preview URLs that exist only in the target are kept. |
| Visual Editor → Encode URL | encode_preview_urls | Mirrors the source. |
Every field ends as change (written), same, or kept (the target keeps
what it has: a capability flag that is on, a domain the source does not have).
A value Storyblok returns as null counts as not set and is never written.
Nothing else on the space object — tokens, webhooks, plan, owner — is read into
a write.
Preview URLs usually carry the frontend's preview secret in their query
string. They are written exactly as they are and never printed: the
PLAN, the console and the --outputPath report show the domain by its origin
only (https://preview.example.com/…), preview URLs by name and count, and a
rejected settings write by its HTTP status and field names
(settings write rejected: 422 (domain)), never the response text.
Individual translation publishing matters beyond the editor: publishing a
single language through the Management API (publish?lang=…) only works when
it is enabled in the space.
The copied preview URLs point at the source's frontend, which reads the source space. Change the target's default location afterwards when the copy should preview its own content.
Field-type plugins
Storyblok validates every custom field of a component against the plugins
assigned to the target space; a component using an unassigned plugin is
rejected with The following field-type plugin(s) are not available in this space: .... A Storyblok "duplicate space" inherits the assignments; a blank
space only has the organization-wide ones. Before writing, copy space reads
the plugins the source components use and compares them with the target:
| Plan line | Meaning |
|---|---|
field-type plugins: all N the source uses are assigned to space T | Nothing to do. |
field-type plugins missing in target: a (n components), b (m components) + the run refuses to write until ... | Assign them in Storyblok (plugin → space), then rerun. --allow-missing-plugins writes anyway; the rejected components are counted per plugin at the end: components not written: 3 — missing plugins: seo-metatags (3). |
field-type plugins the source uses: ... + space T's plugins could not be read with this token | The token cannot list the target's plugins. The plan names every plugin the target must have assigned and the run goes on. |
Failures
A failed write is reported and the run carries on with the next resource, then
exits 1. Everything written before the failure stays written, and a rerun
updates it in place.
Storyblok rules the family runs into
These are platform behaviors, not sb-mig behaviors. They explain most "it exists in the source, why does the target refuse it" cases:
- Storyblok validates content on save. Content written before a field's type changed keeps its old shape until something saves it — a copy is that save.
- A story can only be created with a root component whose block type is
Content type or Universal (
is_root: true). Existing stories with a nestable root keep working. - A whitelist violation or an unknown component in an unrestricted field is saved and flagged "out of schema" in the editor. An unknown component in a field with a whitelist is rejected.
""or any string in arichtextfield is rejected:must be a prosemirror document.- A datasource entry name starting with
-,=or@cannot be created. - Field-type plugins are assigned per space; a blank space has only the organization-wide ones.
- Publishing a folder publishes every story inside it.
copy storiestherefore never publishes folders. - A deleted story stays readable by id with
deleted_atset (soft delete); the ledger treats such a mapping as gone and creates the story again.
sb-mig