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.
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.
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
| phase | found | wouldCreate | skip | conflicts | unmappable | status |
|---|---|---|---|---|---|---|
| settings | 17 | 1 | 2 | 0 | 1 | ok |
| users | 184 | 181 | 0 | 0 | 3 | ok |
| content | 2,318 | 2,318 | 0 | 0 | 0 | ok |
| assets | 906 | 906 | 0 | 0 | 0 | ok |
unmappable settings · unsupported-storage-module gdrive
users · unsupported-auth-provider azure ×3
provider-fallback accounts: 41 → GET /_api/users/fallback-accounts
dry run · nothing writtennpm 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
Six steps. The dry run and the verify pass are what make the last one boring.
- 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; }
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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 importerYour 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("/")