Databases and Search Deployment guide Intermediate

How to Run Meilisearch in Production with Docker

A Meilisearch deployment that survives a real workload — master key handling, scoped API keys, the index settings you must set before loading data, snapshots, and what its memory usage actually means.

AI-assisted draft

The first draft of this article was generated with AI, then fact-checked, edited and verified by a human editor before publication.

A Meilisearch container serving search requests behind a reverse proxy

Tested with

OperatingSystem
Ubuntu 24.04 LTS
Docker
27.x
Meilisearch
v1.13
Server
2 vCPU / 4 GB RAM
Documents
~120,000 indexed documents

Before you start

  • A Docker host with at least 2 GB RAM free
  • A reverse proxy already terminating TLS
  • Documents with a stable, unique id field
On this page

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

/opt/search/compose.yaml
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:
/opt/search/.env
MEILI_MASTER_KEY=paste-a-32-byte-random-string-here
bash
openssl rand -base64 32
chmod 600 /opt/search/.env
docker compose up -d

MEILI_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.

bash
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:

bash
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}'
Expected output
bash
{
  "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:

bash
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.

bash
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 }
  }'
Expected output
bash
{"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.

bash
curl -sS "http://127.0.0.1:7700/tasks/12" -H "Authorization: Bearer $ADMIN" | jq
Expected output
bash
{
  "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:

bash
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:

bash
curl -sS -X POST 'http://127.0.0.1:7700/dumps' -H "Authorization: Bearer $MASTER"
docker compose exec meilisearch ls -lh /meili_data/dumps

An upgrade, in order:

  1. Create a dump and copy it off the host.
  2. docker compose down.
  3. Change the image tag in compose.yaml.
  4. Start with MEILI_IMPORT_DUMP=/meili_data/dumps/<file>.dump set.
  5. Confirm document counts against /stats before 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

bash
curl -sS 'http://127.0.0.1:7700/health'
Expected output
bash
{"status":"available"}
bash
curl -sS 'http://127.0.0.1:7700/stats' -H "Authorization: Bearer $ADMIN" | jq
Expected output
bash
{
  "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 foundMEILI_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.

Frequently asked questions

Why is Meilisearch using so much memory?

It memory-maps its database, so resident memory tracks how much of the index the kernel has paged in. That memory is reclaimable page cache, not an allocation, and a high RSS on an otherwise idle host is expected. Watch search latency and swap activity instead.

Can I expose Meilisearch directly to the browser?

Only through a search-only API key scoped to the indexes that should be public. Never the master key or an admin key — those allow document writes and key creation. Use tenant tokens if different users must see different subsets of one index.

What happens if I change searchableAttributes after loading data?

Meilisearch reindexes every document in that index. On a large index that is minutes of elevated CPU and disk during which search still works but results reflect the old settings until the task completes.

Snapshots or dumps for backups?

Snapshots for fast recovery on the same version — they are a binary copy and restore quickly. Dumps for version upgrades and migrations, because they are version-independent but much slower to import.

Do I need to stop Meilisearch to upgrade it?

Yes, briefly. Take a dump, stop the container, change the image tag, start it with the dump import, and verify. Read the release notes for the versions you are skipping — Meilisearch occasionally changes the database format between minors.

Sources

  1. Install Meilisearch Meilisearch Primary source Accessed 15 Jul 2026
  2. Configure Meilisearch at launch Meilisearch Primary source Accessed 15 Jul 2026
  3. Keys API reference Meilisearch Primary source Accessed 15 Jul 2026
  4. Settings API reference Meilisearch Primary source Accessed 15 Jul 2026
  5. Tasks API reference Meilisearch Primary source Accessed 15 Jul 2026
4 min read

Part of a learning path

  • Docker intermediate

    How to Back Up Docker Volumes Automatically

    A nightly backup for a self-hosted Docker host: database dumps taken with the right tools, file volumes snapshotted with restic, a systemd timer to run it, and a restore test that proves it works.

    4 min AI-assisted
  • Security intermediate

    How to Secure a VPS That Runs Docker Containers

    SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.

    4 min
  • Data advanced

    How to Run PostgreSQL Securely on a VPS

    Keep the listener off the public internet, get pg_hba.conf right, require TLS for remote connections, and give the application a role that cannot drop your tables — on PostgreSQL 17 and Ubuntu 24.04.

    4 min