TIHIDI: Syncing Content Between SitecoreAI Environments (...and local) with PowerShell and the CLI
SitecoreAI moves authoring to the cloud, and authoring there means each environment - dev, test, uat, prod - has its own content. Sooner or later you need to copy that content between them. "Copy down the latest news articles from prod to UAT for QA." "Pull today's prod content into my local environment so I can debug a layout against real data." "Hot-fix a single page on UAT to match what's on prod, right now."
Out of the box, dotnet sitecore ser pull / push does the heavy lifting. What it doesn't do is wrap that with the boring-but-important details: auth, scoping, retry-on-524, and the safety rails that keep you from doing something you really, really didn't mean to do.
This is a TIHIDI post - This Is How I Do It. A 400-line PowerShell script called migrate-content.ps1 that I run often. It's not the only way. It's just what I use.
Why a script and not just the CLI?
Three reasons.
- Auth is annoying. Every environment needs
dotnet sitecore loginwith client credentials, and switching environments means re-logging-in. A script reads creds once from.env, logs into source for the pull, then re-logs into target for the push, all without you typing anything twice. - Scoping needs its own
sitecore.json. The serialization module config you ship with the project is platform-shaped - templates, renderings, structural anchors. The content sync needs different scoping: content and media paths only, no platform items. That deserves its own SCS module in its own folder with its ownsitecore.json. The script runs from inside that folder so the CLI scopes correctly. - You will, at some point, try to push to prod. Not on purpose. By autocomplete, by muscle memory, by reading "test" as "prod" at 11:47 PM. The script's job is to refuse on your behalf.
The environment table
The script ships with a small lookup table. Names map to CM URLs, plus an AllowWrite flag:
$Environments = @{
local = @{ CmUrl = "https://local.cm.example"; AllowWrite = $true }
dev = @{ CmUrl = "https://xmc-...sitecorecloud.io"; AllowWrite = $true }
test = @{ CmUrl = "https://xmc-...sitecorecloud.io"; AllowWrite = $true }
uat = @{ CmUrl = "https://xmc-...sitecorecloud.io"; AllowWrite = $true }
prod = @{ CmUrl = "https://xmc-...sitecorecloud.io"; AllowWrite = $false }
}
Note prod has AllowWrite = $false. Every push goes through a check that asks "is the target write-allowed?" and throws if not. Pulls from prod are fine - the flag only gates writing. This single line of config has saved me more than once.
The migration module config
The script runs from inside a migration/ directory at the repo root. That folder has its own sitecore.json referencing two scoped SCS modules - one for content, one for media. Here's the shape (sanitized; substitute your own <tenant> / <site> names):
migration/sitecore.json:
{
"$schema": "../.sitecore/schemas/RootConfigurationFile.schema.json",
"modules": [
"migration-media.module.json",
"migration-content.module.json"
],
"plugins": [
"Sitecore.DevEx.Extensibility.Serialization@6.0.23",
"Sitecore.DevEx.Extensibility.Publishing@6.0.23",
"Sitecore.DevEx.Extensibility.Indexing@6.0.23",
"Sitecore.DevEx.Extensibility.ResourcePackage@6.0.23",
"Sitecore.DevEx.Extensibility.XMCloud@1.1.99"
],
"serialization": {
"defaultMaxRelativeItemPathLength": 100,
"defaultModuleRelativeSerializationPath": ".",
"removeOrphansForRoles": false,
"removeOrphansForUsers": false,
"continueOnItemFailure": false,
"progressiveMetadataPull": false
},
"settings": {
"telemetryEnabled": false,
"cacheAuthenticationToken": true,
"versionComparisonEnabled": true,
"apiClientTimeoutInMinutes": 10
}
}
migration/migration-content.module.json:
{
"namespace": "Project.Migration.Content",
"items": {
"path": "items",
"includes": [
{
"name": "site-home",
"path": "/sitecore/content/<tenant>/<site>/Home",
"database": "master",
"scope": "ItemAndDescendants"
},
{
"name": "site-data",
"path": "/sitecore/content/<tenant>/<site>/Data",
"database": "master",
"scope": "DescendantsOnly"
},
{
"name": "site-redirects",
"path": "/sitecore/content/<tenant>/<site>/Settings/Redirects",
"database": "master",
"scope": "DescendantsOnly"
}
]
}
}
migration/migration-media.module.json:
{
"namespace": "Project.Migration.Media",
"items": {
"path": "items",
"includes": [
{
"name": "media-site",
"path": "/sitecore/media library/Project/<tenant>/<site>",
"database": "master",
"scope": "DescendantsOnly"
},
{
"name": "media-shared",
"path": "/sitecore/media library/Project/<tenant>/shared",
"database": "master",
"scope": "DescendantsOnly"
}
]
}
}
A few details worth pointing out:
DescendantsOnlyvs.ItemAndDescendants.HomeisItemAndDescendantsso the Home item itself rides along on a pull. The other content paths (Data,Settings/Redirects, the media folders) useDescendantsOnly, because the container items themselves are platform-owned and shouldn't be overwritten by content snapshots.- One namespace per module.
Project.Migration.ContentandProject.Migration.Mediaare referenced by name from the script's push step (dotnet sitecore ser push --include "Project.Migration.Content"). That's how the script enforces media-first / content-second ordering and dodges Cloudflare 524s on big pushes. - No platform modules here. The whole point of the
migration/folder is that it scopes to a different set of paths than the platform serialization at the repo root. Templates, renderings, layouts, tenant settings - those live in the rootsitecore.json's modules. This file pretends they don't exist.
The basic invocation
End-to-end environment-to-environment sync:
.\migrate-content.ps1 -Source prod -Target uat
In order:
- Restore the Sitecore CLI from the repo (
dotnet tool restore). - Log into source (
prod). - Pull all configured content + media items into
migration/items/(which is gitignored). - Log into target (
uat). - Push the items back up, in two batches: media first, then content.
The pull side is full-tree replacement: the script cleans migration/items/ first so back-to-back pulls don't leave hybrid state from the previous source.
-SkipPush: pull only
Another example I use often:
.\migrate-content.ps1 -Source prod -SkipPush
Pulls prod content into migration/items/ and stops. Nothing gets pushed anywhere. The items land in a gitignored directory, and...well, more on the 'why would you do that' later...
Why split this off as its own mode? Because the script also has a -PushOnly partner. You can -Source prod -SkipPush in the morning, review what was pulled, hand-edit migration/items/ if you really want to (almost never, but the option exists), and -Target uat -PushOnly later when satisfied. I can't say that I've actually used -PushOnly yet, but if you have one, why not have the other? ¯\_(ツ)_/¯
-PushOnly: push previously-pulled items
.\migrate-content.ps1 -Target uat -PushOnly
Pushes whatever is sitting in migration/items/ already. Useful when you pulled with -SkipPush earlier and deferred the push. Also useful when a push 524s halfway and you want to retry without re-pulling the source. The pull side can take minutes against prod's content tree; re-running it just because the push timed out is silly.
Ad-hoc scoped sync
-ItemPath for surgical syncs without serializing the whole content module:
.\migrate-content.ps1 -Source prod -Target local `
-ItemPath "/sitecore/content/<tenant>/<site>/Home/news/2026/05/some-article"
Default scope is item-and-descendants. Two modifiers:
-ItemOnly- just that one item, no children-Children- item + its direct children, no grandchildren
The script generates a tiny ad-hoc SCS module on the fly (with the right scope), backs up migration/sitecore.json, swaps in the ad-hoc module, runs the pull/push, and restores the original sitecore.json in a finally block. The original module config stays untouched even if the pull bombs mid-way.
This is the mode for "Steve published a hot fix, I need that one page locally to repro a bug" workflows. Quick, scoped, doesn't disturb the rest of the snapshot.
The safety rails
Beyond AllowWrite = $false on prod:
- No same-environment syncs.
-Sourceequal to-Targetthrows. - Push retries on Cloudflare 524. Pushes through XM Cloud go through Cloudflare, and Cloudflare gets impatient with large item sets. The script splits the push into a
Mediabatch and aContentbatch, runs each up to 3 times, and only counts a524/timeout response in the error body as retry-worthy (any other error fails fast). On a clean run you don't notice the retries; on a slow day they're what keeps the script from making you do the push by hand. -PushOnlyand-SkipPushare mutually exclusive. Pass both and the script refuses.-ItemOnlyand-Childrenare also mutually exclusive. Both require-ItemPath.- Module config is restored in
finally. Even on a thrown exception mid-push, the script puts yourmigration/sitecore.jsonback to what it was. The temporary ad-hoc module file is deleted. The next invocation finds clean state.
None of these are clever. But useful to me.
What's NOT in the script
A few things I deliberately didn't build:
- A confirmation prompt before push. I trust the
AllowWriteflag more than I trust myself to read prompts at 11:47 PM. The flag is durable. The prompt is just one more thing to dismiss. - A "diff before push" mode. SCS's pull output is already a git-style diff against
migration/items/. If I want to inspect, Igit statusandgit diffthe tree after a pull, before a push. Reinventing that in the script would just be wrappinggitbadly. - A "promote everything except X" exclusion list. If you find yourself wanting that, your content scoping is probably wrong - move X to a separate module, scope this one tighter.
Get the script
The full migrate-content.ps1 is on a public gist. Grab it, swap in your own environment table, drop the matching migration/sitecore.json and two .module.json files alongside, and you're rolling.
Drop your Sitecore Cloud client credentials in a .env file next to the script:
SITECORE_CLI_CLIENTID=your-client-id
SITECORE_CLI_SECRET=your-client-secret
These are the same client credentials dotnet sitecore login --client-id ... --client-secret ... takes. The script reads them once and reuses them across the pull-then-push sequence. Don't commit the .env.
Closing
That's the whole tour. A few hundred lines of PowerShell, roughly half of which is validation and error handling, and the rest is six different invocations of dotnet sitecore ser pull and push glued together with environment lookups.
If you build something similar for your team, the only piece I'd call non-negotiable is the AllowWrite flag on prod. Everything else is taste. That one is brakes.
Happy Sitecore trails, my friend!