Repair the Confluence Cloud pages your migration quietly broke, one space at a time
Mihai Perdum
Author
14 min readSeptember 8, 2026
Key takeaways
END STATE: one space repaired, with a reviewed JSON plan, a backup of every original page, and the fix confirmed in a browser rather than in a 200 response.
Site admin is NOT space admin. Grant yourself space admin first or the run collects 403s for an hour.
A storage-format rewrite that is syntactically valid and semantically wrong writes successfully and reports success. The API cannot tell you it is wrong; only the rendered page can.
Discovery is CQL, so it is only as good as the index. Dump the raw storage XML of a few matches on YOUR tenant before a full run — storage format varies by app version.
Restricted pages block app-data migrations. Back the restrictions up, strip them for the run, restore them on Cloud with usernames resolved to accountIds.
The migration report said the pages moved. The pages did move. Then someone opened a runbook that had a formatted table in it and found a grey box reading Unknown macro, and the question arrived: what else looks like that, and how would we know?
A Confluence Data Center to Cloud migration moves page content. It does not guarantee that what is inside the page still works, and the things that break do not appear in the migration report because from the migration's point of view nothing failed. The bytes arrived.
This walks through repairing them on one space, with the two habits that make it safe: a plan you read before anything is written, and a check that happens in a browser rather than in an HTTP status code.
Note
Prerequisites
Node 18 or later. Verified on v24.15.0.
A Confluence Cloud API token from id.atlassian.com → Security → API tokens, used as Basic auth with your account email.
Space admin on every space you intend to edit. Site admin is not enough — this is the step everyone skips, and step 1 exists entirely to fix it.
Read access to the source Data Center instance for the tools that compare against it.
The Confluence Migration Toolkit, Apache-2.0 and free. Seven tools, no dependencies beyond dotenv and a parser where XHTML genuinely has to be parsed.
About an hour for one space.
1
Grant yourself space admin, because site admin does not include it.
2
Dump the raw storage XML of a few matches before trusting any discovery query.
3
Build a plan and read it
every tool writes one before it writes anything else.
4
Repair the broken HTML macros, choosing the replacement mode deliberately.
5
Flatten the nested macros that Cloud's editor refuses to open.
6
Re-resolve the identity parameters that still hold DC usernames.
7
Back up, strip and restore page restrictions around an app migration.
8
Verify on the rendered page, which is the only check that can fail honestly.
Step 1 — Grant yourself space admin
Confluence Cloud scopes administration per space, and a site admin is not automatically a space admin. A site admin can see a space without being able to edit every page in it, which means a script authenticating as you will authenticate perfectly and then be refused on the write.
How you know it worked: the help text should print the flags below, and the dry run should list the spaces it would touch without granting anything. Confirm --dry-run is present before you run anything without it.
text
1Grant Space Admin — Confluence Cloud
23Adds the current API-token user as an administrator on every Confluence Cloud
4space (or a subset via --space). Additive only: existing permissions are
5preserved. Idempotent.
67Usage:
8 node main/grant_space_admin.js [options]
910Options:
11 --dry-run Preview without granting
12 --space <KEY> Restrict to specific space(s), repeatable or comma-sep
13 --skip-personal Exclude personal spaces (keys starting with ~)
14 --full-grant Grant full admin-equivalent permission set
15 (otherwise just administer:space + read:space)
16 --concurrency <N> Parallel workers (default: 3)
17 --help Show this help
Two words in that output matter more than the rest. Additive means existing permissions are preserved rather than replaced, so running it does not quietly become a permissions rewrite. Idempotent means a second run is not a second grant. Both are properties you should confirm in any tool you point at a permission scheme, because the failure mode of getting either wrong is one you discover from a colleague rather than from a log.
Start with --space on a single key. There is no reason for your first run to be site-wide.
Step 2 — Dump the raw storage XML before you trust discovery
Every one of these tools finds its work with CQL, which means discovery is exactly as good as the search index and as your assumption about what the macro looks like in storage format. That assumption is the part that breaks, because storage format varies by app version. The macro you are searching for may be named something slightly different on your tenant than on the one a tool was written against.
So before a full run, print the raw storage XML of a handful of matches and read it.
bash
1node main/sync_html_macros.js --space ENG --limit5 --plan-only
How you know it worked: you should see a plan file written under logs/ and a match count that is plausible for the space. If it reports zero, do not conclude the space is clean — conclude the query did not match, and go and look at a page you know is broken to see what its storage format actually contains. A count of zero and a genuinely clean space are indistinguishable from the outside, and only one of them is good news.
Step 3 — Build a plan and read it
Every tool here runs in two phases, and the split is the safety property, not a convenience. A read-only plan phase discovers affected pages and writes a reviewable JSON file. An execute phase acts on that file. --dry-run runs the execute phase with the PUT suppressed.
bash
1node main/sync_html_macros.js --space ENG --plan-only
2# read the plan3node main/sync_html_macros.js --space ENG --execute-only --dry-run
The plan is a file you can open, grep and diff. That matters because the failure this catches is not a crash — it is a plan that is internally consistent and wrong, which executes beautifully and produces the wrong pages. You cannot catch that by watching a progress bar; you catch it by reading twenty lines of JSON and noticing that a page you know well is about to be rewritten in a way you did not expect.
How you know it worked: the dry run should report the same page count as the plan and write nothing. If the counts differ, the plan is stale — rebuild it rather than reasoning about the gap.
Step 4 — Repair the broken HTML macros
This is the common case. An HTML or CSS macro whose Cloud build differs, or does not exist at all, leaves either an Unknown macro placeholder or the original XML sitting in page storage, unrendered and unreachable.
The repair extracts the raw content from the DC page's storage format and replaces the broken block on Cloud. There are three modes and choosing the wrong one is the main way this goes wrong:
text
1 "raw" - Replace the entire macro block with inline content (default).
2 Use when the macro app is NOT installed in Cloud.
3 "macro" - Preserve the ac:structured-macro wrapper but fix the CDATA content.
4 Use when the macro app IS installed in Cloud but content is wrong.
5 "code" - Wrap DC content in a Code macro block (preserves HTML/CSS via CDATA).
6 Use when raw HTML gets stripped by Cloud's storage sanitizer.
That third mode is worth understanding before you need it. Cloud runs a storage sanitiser on write, and raw HTML pushed into a page can be stripped by it — which is precisely why the code mode exists, wrapping the content in a Code macro whose CDATA survives. The consequence to plan around is that the request succeeding tells you the write was accepted, not that the content is still there.
bash
1node main/sync_html_macros.js --space ENG --replacement-mode raw --limit1
How you know it worked: run it against exactly one page, then open that page in a browser. Not the API response — the page. A rewrite that is syntactically valid and semantically wrong writes successfully and reports success.
Step 5 — Flatten the nested macros
Nested bodied macros are legal on Data Center and unsupported by Cloud's Fabric editor. The symptom is distinctive: the page renders wrong and the editor refuses to open it, so the obvious repair — fix it by hand — is unavailable exactly where you need it.
The fix rewrites the structure so the previously-nested macro becomes a sibling instead of a child, using a split-around-child strategy that handles arbitrary nesting depth.
How you know it worked: open the page in the editor, not just the viewer. The viewer may render a page the editor still refuses. Editing is the capability you lost, so editing is the capability to test.
Step 6 — Re-resolve the identity parameters
Anything that stored a DC username or group name stops resolving on Cloud, because Cloud is keyed on accountId and groupId. Show If and Hide If visibility macros are the ones that bite. The documented behaviour is that they stop showing protected content — the parameters still name DC usernames and group names, the Cloud build of the app expects ids, the condition no longer matches anyone, and content that was meant to be visible to a group becomes visible to nobody.
Note which direction that is, because it is the recoverable one. Content that has gone missing gets reported by the person who needed it. I have not established what happens in the opposite direction and I am not going to guess at it here — but the reason to test both is that a positive-only test cannot tell the two apart.
The repair resolves the stored users and user-groups parameters against Cloud and rewrites them to ids.
How you know it worked: pick a page whose visibility macro names a group you belong to, and confirm the protected content is visible to you again. Then check a page restricted to a group you are not in and confirm it stays hidden. Testing only the first case cannot distinguish a repaired macro from one that has stopped filtering.
Step 7 — Back up, strip and restore page restrictions
App-data migrations fail on restricted content, and the error is specific enough to search for:
text
1Some data are still not migrated. If these space(s) contain restricted pages,
2please run the migration script provided.
The cause is that the migration runs as an app user which page restrictions exclude. The restrictions are the blocker, and the only reliable fix is to remove them for the duration and put them back afterwards.
Three scripts, in order, and the middle one is the one to be careful with:
bash
1node check_pages.js # DC, read-only: what restrictions exist2node remove_restrictions_dc.js # DC: full backup JSON, THEN remove3node restore_restrictions_cloud.js # Cloud: re-apply, resolving usernames to accountIds
How you know it worked:check_pages.js is read-only and exists to be run first — it reports whether each content id exists and what restrictions it carries. Run it before and after the whole cycle and compare. The backup JSON written by the remove step is your undo, and it is the only one; if that file is lost between stripping and restoring, the restrictions are gone and nobody will notice until the wrong person opens the wrong page.
Do not run the remove step on a Friday.
Step 8 — Verify on the rendered page
Every check in this article has pushed toward the same place, so it is worth saying plainly.
A storage-format rewrite goes through Confluence's API. The API validates that your XHTML is well-formed. It does not, and cannot, validate that the page still means what it meant — that the table still has its columns, that the macro still has its body, that the content a reader needs is still on the page rather than stripped by a sanitiser. Syntactically valid and semantically wrong writes successfully and reports success.
So the verification is not a status code and not a count. Run every tool against one space, then one page, and open the result in a browser before you let it near the rest of the site. That is slower for the first page and very much faster than discovering three weeks later that four hundred pages have an empty box where a runbook used to be.
Grant space admin before anything else. Site admin does not include it, and without it the tools authenticate correctly and then collect 403s on every write.
Confirm the storage format on YOUR tenant. Discovery is CQL and storage format varies by app version, so a zero-match result means "the query did not match", not "the space is clean".
Read the plan. The dangerous failure is not a crash — it is a plan that is internally consistent and wrong, which executes perfectly and produces the wrong pages.
Choose the replacement mode on purpose. Cloud sanitises storage on write, so raw HTML can be accepted, stripped and stored as an empty page with a 200 response. The code mode exists for exactly that.
Verify in a browser, and test both directions. A positive-only check cannot tell a repaired visibility macro from one that has stopped filtering. The API cannot tell you a page is semantically wrong; only the page can.