I self-hosted Meilisearch in production mode — it refused to start without a master key

I self-hosted Meilisearch in production mode — it refused to start without a master key

I wanted search for docs and internal tools without standing up Elasticsearch. Meilisearch on Ubuntu 24.04 with Docker refused to boot in production until MEILI_MASTER_KEY was a real secret of at least 16 bytes.

 Meilisearch private search service

Caption: Indexes and keys on my VPS. Nginx on 443. Port 7700 never faces the internet.

Why I wanted this on my server

I needed typo-tolerant search for articles and small catalogs. Elasticsearch is a second career. A hosted search vendor means copying private docs off-box.

Meilisearch is an HTTP search engine: push JSON, tune fields, query. I wanted it on Ubuntu 24.04, production mode, HTTPS, keys I can rotate, dumps I can restore.

It is not a database. Postgres (or the CMS) stays source of truth. Meilisearch is the index I can rebuild.

What I actually installed

Docker Compose under /opt/meilisearch, getmeili/meilisearch bound to 127.0.0.1:7700, data in a named volume, Nginx + Certbot on search.example.com. 1 GB RAM for a docs index; 2–4 GB if indexing jobs overlap. SSD.

Where it broke

On a fresh Ubuntu 24.04 box this install is famous for dying in production with no master key.

I set MEILI_ENV=production because I did not want an open API on a VPS. The container would not stay up until MEILI_MASTER_KEY was a UTF-8 secret of at least 16 bytes. After .env was fixed and I restarted, /health answered and /indexes without a Bearer token was rejected — which is the point.

If unauthenticated /indexes still works, you are in development mode or the key never loaded. Stop, fix .env, start again. The master key does not go in browser JavaScript. Search-only keys go to the frontend.

 Meilisearch Docker stack architecture

Caption: Public HTTPS to Nginx. Meilisearch, dumps, and snapshots stay on localhost and a volume.

Prerequisites

Docker + Compose v2, Nginx, Certbot, curl, jq, openssl. Whatever app will push documents.

sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg jq openssl ufw nginx certbot python3-certbot-nginx

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

The working install

1. Install Docker Engine

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

2. Create the project directory

sudo mkdir -p /opt/meilisearch/backups
sudo chown -R "$USER":"$USER" /opt/meilisearch
chmod 700 /opt/meilisearch/backups
cd /opt/meilisearch

3. Generate secrets and create .env

cd /opt/meilisearch

MEILI_MASTER_KEY_VALUE="$(openssl rand -base64 48 | tr -d '\n')"

cat > .env <<EOF
MEILI_ENV=production
MEILI_MASTER_KEY=${MEILI_MASTER_KEY_VALUE}
MEILI_HTTP_ADDR=0.0.0.0:7700
MEILI_DB_PATH=/meili_data/data.ms
MEILI_DUMP_DIR=/meili_data/dumps
MEILI_SNAPSHOT_DIR=/meili_data/snapshots
MEILI_SCHEDULE_SNAPSHOT=86400
MEILI_NO_ANALYTICS=true
MEILI_LOG_LEVEL=INFO
EOF

chmod 600 .env

MEILI_SCHEDULE_SNAPSHOT=86400 is roughly daily same-version recovery. Dumps are what I take before a version jump.

4. Create compose.yaml

services:
  meilisearch:
    image: getmeili/meilisearch:latest
    restart: unless-stopped
    env_file: ./.env
    ports:
      - "127.0.0.1:7700:7700"
    volumes:
      - meili_data:/meili_data
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

volumes:
  meili_data:

Docs show latest. I pin a release tag after I like a boot.

5. Start Meilisearch

cd /opt/meilisearch
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --since=2m meilisearch
curl -s http://127.0.0.1:7700/health | jq
curl -i http://127.0.0.1:7700/indexes

source .env
curl -s http://127.0.0.1:7700/indexes \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" | jq

The first /indexes call should fail. The second should return a list, empty on a new instance.

6. Configure Nginx and HTTPS

Create /etc/nginx/sites-available/search.example.com:

server {
    listen 80;
    listen [::]:80;
    server_name search.example.com;

    client_max_body_size 100M;

    location / {
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://127.0.0.1:7700;
    }
}
sudo ln -s /etc/nginx/sites-available/search.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d search.example.com
curl -s https://search.example.com/health | jq

Very important to keep in mind

Documents are tasks. A 200 from POST /documents does not mean search can see the rows yet. I poll /tasks until succeeded. If a misspelled query returns nothing, I check failed tasks before I blame typo tolerance.

 Meilisearch indexing and query flow

Caption: App writes documents, waits on tasks, frontend searches with a scoped key and filters.

First index

cd /opt/meilisearch
source .env
export MEILISEARCH_URL="https://search.example.com"

curl -s -X POST "${MEILISEARCH_URL}/indexes" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"uid":"articles","primaryKey":"id"}' | jq

curl -s -X POST "${MEILISEARCH_URL}/indexes/articles/documents" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" \
  -H "Content-Type: application/json" \
  -d '[
    {"id":1,"title":"Docker backup guide","category":"infrastructure","published":true},
    {"id":2,"title":"Private search with Meilisearch","category":"search","published":true},
    {"id":3,"title":"Draft incident report","category":"internal","published":false}
  ]' | jq
curl -s "${MEILISEARCH_URL}/tasks?limit=5" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" | jq
curl -s -X PATCH "${MEILISEARCH_URL}/indexes/articles/settings" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "searchableAttributes": ["title", "category"],
    "displayedAttributes": ["id", "title", "category", "published"],
    "filterableAttributes": ["category", "published"],
    "sortableAttributes": ["id"]
  }' | jq
curl -s -X POST "${MEILISEARCH_URL}/indexes/articles/search" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"q":"privte serch","filter":"published = true"}' | jq

If unpublished rows show up in a public UI, published is not filterable or the query omitted the filter. I rotate any key that leaked too much access.

API keys

curl -s "${MEILISEARCH_URL}/keys" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" | jq
curl -s -X POST "${MEILISEARCH_URL}/keys" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Public articles search key",
    "actions": ["search"],
    "indexes": ["articles"],
    "expiresAt": null
  }' | jq

Admin keys stay on the backend. The frontend gets search-only plus filter: published = true.

Backup, expose, next step

Snapshots for same-version. Dumps before upgrades. Meilisearch will not happily import a dump over a live database without a deliberate empty/replace of the data path.

cd /opt/meilisearch
source .env
BACKUP_DATE="$(date +%F-%H%M%S)"

curl -s -X POST "http://127.0.0.1:7700/dumps" \
  -H "Authorization: Bearer ${MEILI_MASTER_KEY}" | jq

tar czf "backups/meilisearch-config-${BACKUP_DATE}.tar.gz" compose.yaml .env
sha256sum backups/meilisearch-config-${BACKUP_DATE}.tar.gz > "backups/meilisearch-${BACKUP_DATE}.sha256"

Wait for the dump task, then copy dumps and snapshots off the volume:

docker run --rm -v meilisearch_meili_data:/meili_data -v "$PWD/backups:/backup" alpine \
  sh -c 'cp -av /meili_data/dumps /meili_data/snapshots /backup/'

rsync -avh /opt/meilisearch/backups/ backup-user@backup.example.com:/srv/backups/meilisearch/

 Meilisearch backup and restore routine

Caption: Snapshot schedule, upgrade dump, .env, off-server copy. The index is rebuildable if the app still has source rows.

Optional metrics (keep /metrics off the public internet):

MEILI_EXPERIMENTAL_ENABLE_METRICS=true

What I have running now is Meilisearch in production mode behind Nginx, one articles index, a search-only key, daily snapshots scheduled. Next I wire one Laravel (or CMS) indexer and prove I can rebuild the index from SQL if the volume dies.

Did you hit the same wall?

I got stuck on production mode refusing to start until MEILI_MASTER_KEY was set to at least 16 bytes. Did you hit the same thing, or a different one? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.