sb-mig
Reference

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.

SubcommandWhat it doesWrites Storyblok
copy spaceCopy 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 storiesCopy 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 assetsCopy assets and asset folders, all or by selection.Yes unless --dry-run
copy relinkRepair references in stories that were already copied, without copying content again.Yes unless --dry-run
copy manifestsRead 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 stories run 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 relink and copy manifests read it; copy relink also 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.png

The 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:

ValueOutput
auto (default)line when the output is a terminal and CI is not set; plain otherwise.
lineOne line redrawn in place, cut to the terminal's width.
plainWhole heartbeat lines — every 50 items or every 30 seconds — and never a carriage return.
offNo 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 --yes

What a run does

  1. Selects the source stories: each --source resolves to a story or folder, expanded by its mode.
  2. 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-assets is passed, and runs the pre-flights. It prints the PLAN block and asks.
  3. 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.
  4. Rewrites and fills: every selected story's content is rewritten — story ids and uuids, multilink ids and cached_urls, asset ids and file names, including inside __i18n__ language fields and richtext marks — through the ledger, then saved into its shell.
  5. 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 valueModeResult
blog/post-1subtree (default)One story, attached under --destination.
blogsubtree (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 selfselfOnly the story or the empty folder shell.
blog,docs/guide or --source blog --source docs/guideper valueSeveral values planned as one run. A story inside a selected folder is planned once, under the folder.
/ (or /*)subtree per rootThe 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

FlagTypeEffect
--fromstringSource space ID. Falls back to the configured spaceId.
--tostringTarget space ID. Falls back to the configured spaceId.
--sourcestring, repeatable or comma-separatedSource story or folder full_slug; folder/* for the children only; / for every root of the space.
--excludestring, repeatable or comma-separatedRoot full_slugs to drop from --source /, with or without a trailing slash. Only valid together with /.
--destinationstringExisting target folder full_slug. Omit, / or root for the target root.
--modesubtree, children, selfHow a --source expands. Default subtree; folder/* defaults to children.
--with-assetsbooleanCopy 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.
--publicationModepreserve-layers, collapse-draft, save-onlyWhat to do with publish state after content is saved. Default preserve-layers.
--publicationLanguagesdefault, all, or language codesWhich languages to publish when the mode publishes. Default all. Not allowed with save-only.
--dry-runbooleanPrint the PLAN block, check target path conflicts, write the report if --outputPath is passed, and write nothing to Storyblok.
--yesbooleanSkip the confirmation after the PLAN block. Required in non-interactive runs.
--freshbooleanIgnore the ledger: move every ledger file of the pair aside with a timestamp suffix and start empty.
--manifestRootstringDirectory holding the ledger. Default .sb-mig.
--outputPathstringWrite 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.
--progressauto, line, plain, offHow the run reports where it is. Default auto. See Progress and --verbose.
--verbosebooleanPrint 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)
LineMeaning
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 discardedA 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 BREAKEvery 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 mappedWith --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 storiesContent 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:

ShapeWhereExample
objectasset / multiasset fields{ "id": 217443523626671, "filename": "https://a.storyblok.com/f/12345/1080x695/6f6a1b47af/hero.png" }
stringany text, link, richtext or plugin field, at any depth, in any language copyan 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 a richtext field 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 as will 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: false in the target) fails to create with 422 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 (after copy 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:

ModeBehavior
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-draftPublished source stories are published from their current draft.
save-onlyNothing 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-layers path 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 settings

When 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 language

Each 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:

OutcomeThe target holds
updated, published, publish_skippedThe story's full content. publish_skipped means the mode chose not to publish it.
created, matchedOnly the shell was reached; the content write did not happen (the run was interrupted, or the update failed and is listed separately).
update_failedA shell with no content: Storyblok rejected the write.
create_failedNothing; the story does not exist in the target.
skipped_parent_failedNothing; its parent could not be created.
publish_failedcopy relink only: the repair is saved, the publish was rejected.
changed_since_readcopy 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.

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-run

A 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.)

LineMeaning
mapped by ledger / adopted by target path / missing from targetHow 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 untouchedThe 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 storyWhat the repair does
Published, no unpublished changesSaved and republished with the repair.
Draft, or unpublishedSaved, not published.
Published with unpublished changes, and its history holds a published versionThe 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 stateThe 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
SelectorCopies
--allEvery 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 create

With 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, legal

The 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 ECONNRESET

A 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 --yes

copy manifests reads the ledger and makes no Storyblok request in any mode.

FormBehavior
no --pairLists 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 --yes

copy 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.

ResourceMatched byRewritten on the way
languagescodeThe target's language list gains the source languages; target-only languages stay.
settingsfieldSeven fields of the space, see Space settings. Only the fields that differ are written.
component groupsfull pathParent uuids become the target's.
componentsnamecomponent_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.
presetscomponent name + preset namecomponent_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.
datasourcesnameDimension values are copied.
datasource entriesname within the datasourceValues 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

FlagTypeEffect
--fromstringSource space ID.
--tostringTarget space ID. Must differ from --from.
--onlylanguages, settings, groups, components, presets, datasources; comma-separated or repeatableRestrict the run to some resources. The write order stays languages, settings, groups, components, presets, datasources.
--allow-missing-pluginsbooleanWrite even when the target lacks field-type plugins the source components use. Those components are then rejected by Storyblok and reported.
--dry-runbooleanPrint the PLAN block and write nothing.
--yesbooleanSkip the confirmation after the PLAN block.
--outputPathstringWrite 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.
LineMeaning
settings: a change, b same, c keptPer setting, then one line per change and per kept value. See Space settings.
<resource>: a create, b update, c skipPer resource. A skip names its reason (a preset whose component is not in the source, a default preset that cannot be restored...).
dropped whitelist groupsWhitelist entries pointing at a group the target will not have, listed as component.field: group path (uuid).
entries Storyblok will reject: <datasource> n of mDatasource 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 imagesURLs left pointing at the source space.
default presets: a restored, b not restorableComponent 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 StoryblokFieldHow it is copied
Internationalization → Enable Individual Translation Publishinguse_translated_storiesTurned on when the source has it. Never turned off in the target.
Internationalization → Enable Alternative Versionsshow_stories_alternative_versionsTurned on when the source has it. Never turned off in the target.
Internationalization → Hide the flagshide_flag_iconsMirrors the source.
Internationalization → Flags represent language / countryflag_icons_display_modeMirrors the source.
Visual Editor → Location (default environment)domainOverwritten when the source has one.
Visual Editor → Preview URLsenvironments ({ 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 URLencode_preview_urlsMirrors 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 lineMeaning
field-type plugins: all N the source uses are assigned to space TNothing 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 tokenThe 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 a richtext field 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 stories therefore never publishes folders.
  • A deleted story stays readable by id with deleted_at set (soft delete); the ledger treats such a mapping as gone and creates the story again.

On this page