Meilisearch is easy to start and easy to misconfigure in ways that only show up later: a master key in browser JavaScript, filterable attributes decided after half a million documents are loaded, or no snapshots until the day you need one.
This is the setup that avoids those, on a single Docker host.
Requirements and sizing
- 2 GB RAM handles roughly 100,000 modest documents comfortably. Indexing is the memory-hungry phase, not searching.
- Free disk of about three times your expected index size. Meilisearch needs working space during indexing, and running out mid-task fails the task.
- A reverse proxy already terminating TLS. Meilisearch serves plain HTTP and should not face the internet directly.
The Compose file
services:
meilisearch:
image: getmeili/meilisearch:v1.13
restart: unless-stopped
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_DB_PATH: /meili_data/data.ms
MEILI_DUMP_DIR: /meili_data/dumps
MEILI_SNAPSHOT_DIR: /meili_data/snapshots
MEILI_SCHEDULE_SNAPSHOT: "86400"
MEILI_LOG_LEVEL: WARN
MEILI_MAX_INDEXING_MEMORY: 2Gb
volumes:
- meili_data:/meili_data
ports:
# Loopback only. The reverse proxy reaches it; nothing else does.
- "127.0.0.1:7700:7700"
volumes:
meili_data:MEILI_MASTER_KEY=paste-a-32-byte-random-string-hereopenssl rand -base64 32
chmod 600 /opt/search/.env
docker compose up -dMEILI_ENV=production disables the bundled search preview UI and makes the
master key mandatory. MEILI_MAX_INDEXING_MEMORY caps what indexing may use —
without it, a large import can push a small host into swap and stall every
search on the box.
The master key must be at least 16 bytes. A shorter one and Meilisearch refuses to start in production mode.
docker run --rm --entrypoint sh getmeili/meilisearch:v1.13 \
-c 'command -v curl wget || echo none'Create scoped API keys
The master key is not for applications. It exists to manage keys.
List the keys Meilisearch derived from it on first start:
export MASTER=$(grep MEILI_MASTER_KEY /opt/search/.env | cut -d= -f2)
curl -sS 'http://127.0.0.1:7700/keys' \
-H "Authorization: Bearer $MASTER" | jq '.results[] | {name, uid, actions, indexes}'{
"name": "Default Search API Key",
"uid": "b1c2d3e4-...",
"actions": ["search"],
"indexes": ["*"]
}
{
"name": "Default Admin API Key",
"uid": "f5a6b7c8-...",
"actions": ["*"],
"indexes": ["*"]
}The default search key works on every index. For anything public, make a narrower one:
curl -sS -X POST 'http://127.0.0.1:7700/keys' \
-H "Authorization: Bearer $MASTER" \
-H 'Content-Type: application/json' \
--data '{
"description": "Public site search",
"actions": ["search"],
"indexes": ["articles"],
"expiresAt": "2027-07-15T00:00:00Z"
}'That key is safe to ship to a browser. It can search one index and do nothing else, and it stops working on a date you chose.
Keep the admin key on the server, used only by your indexing job.
Set index settings before loading documents
Meilisearch infers a schema from your documents, but the four settings that control behaviour are yours to set — and changing them later reindexes everything.
export ADMIN=your-admin-key
curl -sS -X PATCH 'http://127.0.0.1:7700/indexes/articles/settings' \
-H "Authorization: Bearer $ADMIN" \
-H 'Content-Type: application/json' \
--data '{
"searchableAttributes": ["title", "description", "body"],
"filterableAttributes": ["category", "topics", "difficulty"],
"sortableAttributes": ["publishedAt"],
"displayedAttributes": ["title", "description", "url", "publishedAt"],
"pagination": { "maxTotalHits": 200 }
}'{"taskUid":12,"indexUid":"articles","status":"enqueued","type":"settingsUpdate"}The order of searchableAttributes is meaningful — a match in title ranks
above a match in body. Listing every field is the common mistake; it dilutes
relevance and grows the index for no benefit.
Only fields in filterableAttributes can appear in a filter expression. A
filter on anything else returns an error rather than an empty result, which is
at least easy to spot.
displayedAttributes controls what comes back in a hit, not what is searched.
Trimming it is the cheapest response-size win available — if your documents
carry a full article body that the UI never renders, leaving it out of the
displayed list removes it from every response.
Relevance is decided by ranking rules, which have a sensible default order: words, typo, proximity, attribute, sortable and exactness. Leave them alone until you have a concrete complaint about result order, then change one rule and measure. Reordering them speculatively is how search gets worse in ways nobody can attribute.
Everything is a task
Writes are asynchronous. A 202 response means Meilisearch accepted the request, not that it worked.
curl -sS "http://127.0.0.1:7700/tasks/12" -H "Authorization: Bearer $ADMIN" | jq{
"uid": 12,
"indexUid": "articles",
"status": "succeeded",
"type": "settingsUpdate",
"duration": "PT0.412S"
}Any indexing script must poll until status is succeeded or failed. A
deploy pipeline that fires document updates and exits reports success while the
index quietly rejects every one of them.
Check for failures on a schedule:
curl -sS 'http://127.0.0.1:7700/tasks?statuses=failed&limit=5' \
-H "Authorization: Bearer $ADMIN" | jq '.results[] | {uid, type, error}'Snapshots, dumps and upgrades
MEILI_SCHEDULE_SNAPSHOT: "86400" writes a daily snapshot into
/meili_data/snapshots. Snapshots restore fast but only into the same
Meilisearch version.
Dumps are version-independent and are what you use to upgrade:
curl -sS -X POST 'http://127.0.0.1:7700/dumps' -H "Authorization: Bearer $MASTER"
docker compose exec meilisearch ls -lh /meili_data/dumpsAn upgrade, in order:
- Create a dump and copy it off the host.
docker compose down.- Change the image tag in
compose.yaml. - Start with
MEILI_IMPORT_DUMP=/meili_data/dumps/<file>.dumpset. - Confirm document counts against
/statsbefore removing the old volume.
Import time scales with document count and is far slower than indexing from your own source of truth. If you can rebuild the index from a database in ten minutes, that is often the better upgrade path — and it exercises the indexing job, which is worth knowing works.
Verify and monitor
curl -sS 'http://127.0.0.1:7700/health'{"status":"available"}curl -sS 'http://127.0.0.1:7700/stats' -H "Authorization: Bearer $ADMIN" | jq{
"databaseSize": 1348239360,
"lastUpdate": "2026-07-15T08:41:22.117Z",
"indexes": {
"articles": { "numberOfDocuments": 118432, "isIndexing": false }
}
}Three things worth alerting on: /health not returning available,
numberOfDocuments dropping unexpectedly, and any task in failed status.
databaseSize against free disk is a useful fourth.
Common errors
Meilisearch is running in production mode but no master key was found —
MEILI_ENV=production with an unset or too-short MEILI_MASTER_KEY. It must be
at least 16 bytes.
The provided API key is invalid (HTTP 403) — usually the key was
regenerated. Changing the master key invalidates the derived default keys,
because they are computed from it.
Index articles not found — Meilisearch creates indexes on first document
insert, not on a settings call. Add a document first, or create the index
explicitly with POST /indexes.
Attribute category is not filterable — it is missing from
filterableAttributes. Add it, wait for the task, retry.
A task fails with no space left on device. Indexing needs working space
beyond the final index size. Free disk, then re-enqueue — a failed task is not
retried automatically.
Searches slow down sharply after a large import. The index is still being
built. Check isIndexing in /stats; search remains available during
indexing but competes for the same disk.
Security notes
Bind to 127.0.0.1 and let the reverse proxy be the only thing that can reach
port 7700. A Meilisearch instance on a public interface with a leaked admin key
is a full read and write of your search corpus.
Rotate the public search key on a schedule using expiresAt. A key with an
expiry that you renew is meaningfully safer than one that lives forever in a
JavaScript bundle.
Rate limit the search endpoint at the proxy. Meilisearch has no built-in rate limiting, and search is the cheapest possible thing for someone to abuse.
Back up the volume with the same job that backs up your databases — snapshots inside the container are not a backup until they are somewhere else.