Migrate to CelerSearch
Moving from Meilisearch Cloud or a self-hosted Meilisearch means copying your indexes over the API: create each index, apply its settings, copy its documents, then point your app at the new endpoint. The script on this page does all of it.
Before you start
- Count your documents. The script prints the total before it copies anything. Pick a plan whose document limit covers it. The limit applies across all indexes.
- Create your instance at app.celersearch.com and choose the same or a newer Meilisearch version than your source. See Regions & Provisioning.
- Get both sets of credentials:
- Source: the URL and a key that can read indexes, settings, documents and stats. That's the master key on a self-hosted instance, or the Default Admin API Key on Meilisearch Cloud.
- Target: the endpoint URL and API key from your instance's Connection details. See Connect Any App.
- Pause writes to the source while the script runs, or plan to re-run it at the end. Re-running is safe because documents are replaced by primary key.
The migration script
It needs Node.js 18 or newer and has no dependencies. Your source must run Meilisearch 1.2 or newer.
// Copies every index (settings and documents) from one Meilisearch to another.
// SOURCE_URL=… SOURCE_KEY=… TARGET_URL=… TARGET_KEY=… node migrate.mjs
const { SOURCE_URL, SOURCE_KEY, TARGET_URL, TARGET_KEY, EMBEDDER_API_KEY } = process.env
const BATCH_SIZE = Number(process.env.BATCH_SIZE || 1000)
if (!SOURCE_URL || !SOURCE_KEY || !TARGET_URL || !TARGET_KEY) {
console.error('Set SOURCE_URL, SOURCE_KEY, TARGET_URL and TARGET_KEY.')
process.exit(1)
}
async function api(base, key, method, path, body) {
const res = await fetch(base.replace(/\/+$/, '') + path, {
method,
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await res.text()
let data = null
try { data = text ? JSON.parse(text) : null } catch { data = null }
if (!res.ok) {
const error = new Error(`${method} ${path} failed (${res.status}): ${data?.message ?? text}`)
error.status = res.status
error.code = data?.code ?? data?.error
throw error
}
return data
}
const source = (method, path, body) => api(SOURCE_URL, SOURCE_KEY, method, path, body)
const target = (method, path, body) => api(TARGET_URL, TARGET_KEY, method, path, body)
async function waitForTask({ taskUid }) {
for (;;) {
const task = await target('GET', `/tasks/${taskUid}`)
if (task.status === 'succeeded') return
if (task.status === 'failed' || task.status === 'canceled') {
throw new Error(`Task ${taskUid} ${task.status}: ${task.error?.message ?? ''}`)
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
async function listIndexes() {
const indexes = []
for (let offset = 0; ; offset += 100) {
const page = await source('GET', `/indexes?offset=${offset}&limit=100`)
indexes.push(...page.results)
if (page.results.length === 0 || indexes.length >= page.total) return indexes
}
}
// Copies settings and returns the embedders whose vectors live in the documents.
async function copySettings(uid) {
const settings = await source('GET', `/indexes/${uid}/settings`)
const userProvided = []
for (const [name, embedder] of Object.entries(settings.embedders ?? {})) {
if (embedder.source === 'userProvided') userProvided.push(name)
// API keys come back partially hidden, so they can't be copied as-is.
if (embedder.apiKey == null && embedder.source !== 'openAi') continue
if (EMBEDDER_API_KEY) {
embedder.apiKey = EMBEDDER_API_KEY
} else {
delete settings.embedders[name]
console.warn(` ! Skipped embedder "${name}": set EMBEDDER_API_KEY and re-run, or add it later.`)
}
}
await waitForTask(await target('PATCH', `/indexes/${uid}/settings`, settings))
return userProvided
}
async function copyDocuments(uid, userProvided) {
for (let offset = 0; ; offset += BATCH_SIZE) {
const page = await source('POST', `/indexes/${uid}/documents/fetch`, {
offset,
limit: BATCH_SIZE,
...(userProvided.length > 0 ? { retrieveVectors: true } : {}),
})
if (page.results.length === 0) return
// Keep only user-provided vectors; other embedders re-embed on the target.
const documents = page.results.map(({ _vectors, ...doc }) => {
const vectors = Object.fromEntries(
Object.entries(_vectors ?? {}).filter(([name]) => userProvided.includes(name)),
)
return Object.keys(vectors).length > 0 ? { ...doc, _vectors: vectors } : doc
})
await waitForTask(await target('POST', `/indexes/${uid}/documents`, documents))
const copied = offset + page.results.length
console.log(` ${copied} / ${page.total} documents`)
if (copied >= page.total) return
}
}
async function main() {
const stats = await source('GET', '/stats')
const indexes = await listIndexes()
const total = indexes.reduce((sum, { uid }) => sum + (stats.indexes[uid]?.numberOfDocuments ?? 0), 0)
console.log(`${indexes.length} indexes, ${total} documents to copy`)
for (const { uid, primaryKey } of indexes) {
console.log(`→ ${uid}`)
try {
await target('GET', `/indexes/${uid}`)
} catch (error) {
if (error.status !== 404) throw error
await waitForTask(await target('POST', '/indexes', { uid, ...(primaryKey ? { primaryKey } : {}) }))
}
const userProvided = await copySettings(uid)
await copyDocuments(uid, userProvided)
}
console.log('Done.')
}
main().catch((error) => {
console.error(error.message)
if (error.code === 'document_quota_exceeded') {
console.error('Your plan\'s document limit is reached. Move to a larger plan and re-run.')
}
process.exit(1)
})
Run it with both sets of credentials:
SOURCE_URL=https://your-old-meilisearch \
SOURCE_KEY=your-source-key \
TARGET_URL=https://your-celersearch-endpoint \
TARGET_KEY=your-celersearch-api-key \
node migrate.mjs
Each index is created with its primary key, its settings are applied, and its
documents are copied in batches of 1,000 (set BATCH_SIZE to change that). The
script waits for every task, so when it prints Done. your data is
searchable.
If the target rejects a setting, for example one that depends on an experimental feature you enabled on the source, the script stops and prints Meilisearch's error message. Contact us and we'll help you sort it out before you re-run.
AI indexes
- Embedder API keys are hidden. Meilisearch only returns them partially, so
the script can't copy them. Set
EMBEDDER_API_KEYto your embedding provider's key (for example your OpenAI key) and the script uses it for every embedder that needs one. Without it, those embedders are skipped with a warning, and you can add them to the index settings later. - Documents are re-embedded. Generated vectors aren't copied. The target embeds each document as it arrives, which takes longer than a keyword index and is billed by your embedding provider.
- User-provided vectors are copied. If an embedder uses
userProvided, its vectors travel with the documents.
What isn't copied
- API keys: use the instance API key; see Connect Any App
- Task history and chat workspace settings
- Snapshots and dumps: your new instance is covered by encrypted backups
Switch your app over
- Run the script and check document counts and a few searches on the new instance.
- If your source kept receiving writes, re-run the script to catch up.
- Replace the URL and key in your app's configuration with the CelerSearch Endpoint URL and API key, then deploy.
- Keep the old instance until you're confident, then shut it down.
Large migrations
Moving millions of documents, or AI indexes that take a long time to embed? Contact us before you start and we'll help you plan it.