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