Skip to content
Cardinal
cardinal.wiki
GitLab
Migrate

Move in from Wiki.js 2.5. Leave whenever you like.

Pick where your 2.5 pages live, fill in the connection, and copy the exact command. The import only reads the source, dry-runs first, and verifies before you cut over.

Step 1 · Where are your pages now?
Step 2 · Connection

This builder never asks for your database host, user or password. Copy the command, then fill in the <placeholders> in your own terminal. A SELECT-only role is all it needs.

Step 3 · Options
Phases (all four unless you narrow)
Your command
cd backend
npm run migrate -- \
  --site-id <destination-site-id> \
  --source-host <2.5.x-db-host> \
  --source-port 5432 \
  --source-database <2.5.x-db-name> \
  --source-user <readonly-role> \
  --source-password <password> \
  --dry-run \
  --report-file /tmp/migration-dry-run.json
What you'll see · dry-run reportsample numbers, yours will differ
phasefoundwouldCreateskipconflictsunmappablestatus
settings171201ok
users184181003ok
content2,3182,318000ok
assets906906000ok
unmappable  settings · unsupported-storage-module  gdrive
            users · unsupported-auth-provider  azure ×3
provider-fallback accounts: 41  →  GET /_api/users/fallback-accounts
dry run · nothing written
Then verify · counts and content hashes against the same source
npm run verify-migration -- \
  --site-id <destination-site-id> \
  --source-host <2.5.x-db-host> \
  --source-port 5432 \
  --source-database <2.5.x-db-name> \
  --source-user <readonly-role> \
  --source-password <password> \
  --against-report /tmp/migration-dry-run.json \
  --sample-size 20
The cutover, start to finish

Six steps. The dry run and the verify pass are what make the last one boring.

  1. 01

    Freeze writes on 2.5

    The import reads a point-in-time snapshot. Block everything but GET and HEAD at the proxy so the old wiki stays readable while you work, or stop its process.

    location / {
      limit_except GET HEAD { deny all; }
      proxy_pass http://wiki25-backend;
    }
  2. 02

    Point at the source

    Postgres: a SELECT-only role is all the connector needs. Anything else: run Export to Disk in the 2.5 admin and hand the bundle over.

  3. 03

    Dry run

    Same command, with --dry-run. Read every conflicts and unmappable entry. Decide what happens to users on providers Cardinal.js has no module for.

  4. 04

    Run it for real

    Drop --dry-run, keep --report-file. If anything errors, truncate the destination and start again; there is no patching forward. A run that leaves any phase unwritten exits non-zero.

  5. 05

    Verify

    Counts per entity, then a random sample of pages hash-compared against the source. Then a human logs in and reads a few pages, because a hash cannot tell you a page reads right.

  6. 06

    Cut over, keep the exit

    Flip DNS or the proxy. Leave 2.5 frozen a while; since nothing ever wrote to it, rolling back is flipping the switch back.

The honest list

What doesn't come across from 2.5 yet.

The report names every one of these before you write anything, so none of it is a surprise on cutover day.

SSO users
Anyone on Google, GitHub, OIDC, LDAP, SAML and friends is imported as a local account with an unusable password and a forced reset. Where the login method is set to trust email, they re-link on first SSO login instead. The list is at /_api/users/fallback-accounts.
auth providers
azure, dropbox and firebase have no Cardinal.js module. Users on them are listed as unmappable and get no account; recreate them by hand if you need them. Local, Google, GitHub and OIDC settings carry across; LDAP, SAML and the rest are reported, not copied: set them up again in Admin.
storage modules
box, digitalocean, dropbox, gdrive, onedrive and s3generic are not carried. The site keeps its default storage targets.
2FA
TOTP secrets carry over, so authenticator apps keep working. 2.5 had no recovery codes; users generate them after their first sign-in.
API tokens
2.5 API tokens are not migrated. Issue new keys under Admin › API Access, or personal access tokens, and move integrations from GraphQL to the REST API.
Coming from somewhere else?

Markdown, Word, HTML, MediaWiki markup, reStructuredText and other formats import today through the page import dialog, and a whole folder through the disk storage target. Native importers for Confluence, Notion and the rest get built in the order people ask for them; an issue with a sample export moves one up.

Ask for an importer
Leaving

Your pages are yours.

Pages are stored as source text, Markdown unless someone chose the AsciiDoc or HTML editor, and no step of leaving needs our permission.

Any page, one request

The stored source as Markdown or the rendered body as HTML, with a personal access token: read:pages for HTML, plus read:source for Markdown. A PDF of the live page view is one click on the page.

GET /_api/sites/<siteId>/pages/<pageId>/export
    ?format=markdown
Authorization: Bearer <token>

The whole site, as files

The git and disk storage targets keep every page mirrored as files, and Admin › Utilities exports a whole site as an archive you can import elsewhere.

The database itself

Underneath all of it is one PostgreSQL database with a documented schema and a backup guide. pg_dump is always an export.

The whole site, through MCP

The same MCP server your agents use is a complete read path: list_navigation walks the tree, get_page with includeSource returns source and metadata, list_assets finds the files. A narrowed token limits what a script can see, exactly like a person.

# pseudo-walk, any MCP client
def walk(path):
    nav = list_navigation(siteId, path=path)
    for item in nav.items:
        if item.isPage:
            page = get_page(siteId, path=item.path, includeSource=true)
            write(f"out/{item.path}.md", page.content)
        if item.isFolder:
            walk(item.path)
    for asset in list_assets(siteId, path=path):
        download(asset)

walk("/")