Setup MeiliSearch trên VPS: search engine ngon cho app startup

Chia sẻ bài viết

Mục lục
TL;DR
  • MeiliSearch là search engine viết bằng Rust, full-text typo-tolerant, response dưới 50ms cho 1 triệu document trên VPS 4GB RAM.
  • Cài qua Docker Compose hoặc binary, mở port 7700, secure bằng MEILI_MASTER_KEY 32 ký tự.
  • Tạo tenant token theo user_id để frontend search trực tiếp mà không lộ master key.
  • Index batch 10k document mỗi lần, dùng searchableAttributes và filterableAttributes để tối ưu relevancy.
  • Cloud VPS TND từ 199k/tháng đủ chạy MeiliSearch + app Node cho startup giai đoạn MVP đến 50k user.

Algolia tính tiền theo request, ElasticSearch ăn RAM khủng khiếp, còn database full-text search thì chậm và relevancy kém. Nếu bạn đang build app startup cần search ngon, dưới 100ms, có typo tolerance, faceted filtering, mà không muốn tốn 99 USD/tháng cho Algolia plan Pro, thì MeiliSearch là lựa chọn tuyệt vời. Viết bằng Rust, single binary 50MB, chạy mượt trên Cloud VPS 2GB RAM, và scale lên 10 triệu document chỉ cần 8GB RAM.

Mình đã chạy MeiliSearch production cho 3 app startup trong 2 năm qua: 1 marketplace giày, 1 platform khoá học, 1 SaaS quản lý tài liệu. Tổng index hơn 4 triệu document, latency p95 ổn định 30-60ms. Bài này mình chia sẻ workflow đầy đủ từ cài đặt, secure, indexing, đến tích hợp frontend, kèm code thật mình copy ra từ production.

1. MeiliSearch là gì và tại sao chọn nó thay Algolia/ElasticSearch

MeiliSearch là search-as-a-service open source viết bằng Rust, do team Pháp phát triển từ 2018. Triết lý của họ: ra kết quả ngay sau khi gõ ký tự đầu tiên, không cần config phức tạp, default settings đã đủ tốt cho 95% use case. So với competitor:

Tiêu chíMeiliSearchElasticSearchAlgolia
Ngôn ngữRustJava (JVM)Proprietary cloud
RAM tối thiểu512MB4GBN/A (cloud)
Setup time5 phút1-2 giờ30 phút (signup)
Typo toleranceMặc định bậtCần config fuzzyMặc định bật
Faceted searchCó (aggregation)
Chi phí 1M docFree (self-host)Free (self-host)~99 USD/tháng
LicenseMITSSPL/Elastic LicenseClosed

Điểm yếu của MeiliSearch: không scale horizontal như ElasticSearch (chỉ chạy 1 node, có sharding nhưng còn beta), không có analytics phức tạp như Algolia dashboard. Nhưng cho startup giai đoạn 0 đến 100k user, MeiliSearch là sweet spot tuyệt vời.

2. Chọn cấu hình VPS phù hợp cho MeiliSearch

RAM là yếu tố quyết định. MeiliSearch load index vào memory, cho phép search siêu nhanh, đổi lại nếu index lớn hơn RAM thì performance tụt thê thảm. Công thức ước lượng: 1MB raw JSON sau index sẽ chiếm 2-3MB RAM (tùy số field searchable).

Quy mô appSố documentRAM khuyến nghịCloud VPS TND
MVP demo10k - 50k1GBCloud VPS 20 (199k)
Startup giai đoạn 1100k - 500k2GBCloud VPS 40 (399k)
Startup tăng trưởng500k - 2M4GBCloud VPS 80 (799k)
Production lớn2M - 10M8-16GBCloud VPS 160/240

Mình thường khuyên chạy MeiliSearch chung VPS với app Node.js cho startup nhỏ, tách riêng VPS khi index vượt 1 triệu document. Disk SSD CEPH của TND đủ nhanh, không cần NVMe local.

3. Cài đặt MeiliSearch trên VPS bằng Docker Compose

Mình chọn Docker Compose vì dễ backup volume, dễ upgrade version, và dễ thêm reverse proxy Caddy hoặc Nginx. Đầu tiên tạo thư mục project và file docker-compose.yml:

mkdir -p /opt/meilisearch/data
cd /opt/meilisearch
openssl rand -hex 32
# Lưu output làm MEILI_MASTER_KEY, ví dụ: a3f2c1e8d9b4...

Tiếp theo tạo file docker-compose.yml với nội dung:

services:
  meilisearch:
    image: getmeili/meilisearch:v1.10
    container_name: meilisearch
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: "your_32_char_random_key_here"
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: "true"
      MEILI_HTTP_ADDR: "0.0.0.0:7700"
    volumes:
      - ./data:/meili_data
    ports:
      - "127.0.0.1:7700:7700"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:7700/health"]
      interval: 30s
      timeout: 5s
      retries: 3

Lưu ý port bind 127.0.0.1 chứ không phải 0.0.0.0, để không expose ra public internet. Frontend sẽ truy cập qua reverse proxy có HTTPS. Chạy:

docker compose up -d
docker logs meilisearch
curl -s http://127.0.0.1:7700/health

Nếu thấy {"status":"available"} là OK. Nếu lỗi permission volume, chmod 700 thư mục data và thử lại.

4. Cấu hình reverse proxy với Caddy để có HTTPS auto

Caddy auto Let's Encrypt, config 3 dòng là xong. Cài Caddy:

dnf install caddy -y   # AlmaLinux 9
systemctl enable --now caddy

File /etc/caddy/Caddyfile:

search.your-domain.com {
    reverse_proxy 127.0.0.1:7700
    header {
        Strict-Transport-Security "max-age=31536000;"
        X-Content-Type-Options "nosniff"
    }
    log {
        output file /var/log/caddy/meilisearch.log
        format json
    }
}

Reload caddy: systemctl reload caddy. Trỏ A record search.your-domain.com tới IP VPS, đợi 30 giây Caddy sẽ tự issue cert. Test:

curl -s https://search.your-domain.com/health

5. Tạo index đầu tiên và push document từ Node.js

Cài client SDK:

npm install meilisearch

File seed.js push 10k product vào index:

import { MeiliSearch } from 'meilisearch'
import fs from 'node:fs'

const client = new MeiliSearch({
  host: 'https://search.your-domain.com',
  apiKey: process.env.MEILI_MASTER_KEY
})

const products = JSON.parse(fs.readFileSync('./products.json', 'utf-8'))

async function seed() {
  const index = client.index('products')
  await index.updateSettings({
    searchableAttributes: ['name', 'description', 'brand', 'tags'],
    filterableAttributes: ['category', 'price', 'in_stock'],
    sortableAttributes: ['price', 'created_at'],
    rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
    typoTolerance: {
      enabled: true,
      minWordSizeForTypos: { oneTypo: 4, twoTypos: 8 }
    }
  })

  const batchSize = 1000
  for (let i = 0; i < products.length; i += batchSize) {
    const batch = products.slice(i, i + batchSize)
    const task = await index.addDocuments(batch)
    console.log(`Pushed batch ${i / batchSize + 1}, task ${task.taskUid}`)
  }
}

seed().catch(console.error)

Mỗi document cần 1 field id unique (primary key). MeiliSearch tự detect nếu document có field tên id, hoặc bạn truyền explicit qua primaryKey option khi tạo index.

6. Tenant token cho phép frontend search trực tiếp

Anti-pattern phổ biến: frontend gọi backend, backend gọi MeiliSearch. Mỗi request thêm 50ms latency và load không cần thiết lên Node server. MeiliSearch giải bài này bằng tenant token: backend gen JWT short-lived (1 giờ), frontend dùng JWT đó gọi thẳng MeiliSearch.

// backend Node.js
import { MeiliSearch } from 'meilisearch'

const client = new MeiliSearch({
  host: 'https://search.your-domain.com',
  apiKey: process.env.MEILI_MASTER_KEY
})

export async function genSearchToken(userId, role) {
  const rules = {
    products: {
      filter: role === 'admin' ? '' : 'in_stock = true'
    }
  }
  const apiKey = await client.getKey('search-only-key-uid')
  const token = await client.generateTenantToken(
    apiKey.uid,
    rules,
    { expiresAt: new Date(Date.now() + 3600 * 1000), apiKey: apiKey.key }
  )
  return token
}

Trước đó cần tạo 1 API key chỉ có quyền search:

curl -X POST 'https://search.your-domain.com/keys' 
  -H "Authorization: Bearer $MEILI_MASTER_KEY" 
  -H 'Content-Type: application/json' 
  --data '{"description":"Search only key","actions":["search"],"indexes":["products"],"expiresAt":null}'

Frontend dùng token tạm:

// frontend React + meilisearch instant-search
import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'
import { InstantSearch, SearchBox, Hits } from 'react-instantsearch'

const { searchClient } = instantMeiliSearch(
  'https://search.your-domain.com',
  userSearchToken  // fetch from backend on login
)

export default function Search() {
  return (
    
      
      
    
  )
}

7. Tối ưu relevancy với searchableAttributes và ranking rules

Mặc định MeiliSearch search trên tất cả field, nhưng bạn nên giới hạn rõ ràng. Thứ tự trong searchableAttributes có ý nghĩa: field đầu tiên có trọng số cao nhất.

await index.updateSearchableAttributes([
  'title',         // weight cao nhất
  'subtitle',
  'brand',
  'description',   // weight thấp nhất
])

Ranking rules quyết định thứ tự kết quả khi nhiều document match query. 6 rule mặc định theo thứ tự: words, typo, proximity, attribute, sort, exactness. Bạn có thể thêm custom rule như sales:desc để ưu tiên sản phẩm bán chạy.

await index.updateRankingRules([
  'words', 'typo', 'proximity', 'attribute',
  'sales:desc',     // custom rule
  'sort', 'exactness'
])

8. Sync database với MeiliSearch bằng webhook hoặc cron

Có 3 chiến lược sync:

  • Realtime hook: trong controller create/update/delete của app, sau khi commit DB thì push vào MeiliSearch queue. Đơn giản nhất, hợp với app dưới 100k document.
  • Background job: dùng BullMQ hoặc Sidekiq, mỗi lần DB thay đổi thì enqueue 1 job sync. Tránh block request user.
  • Cron full reindex: 1 lần/ngày chạy job export DB ra JSON và push toàn bộ. An toàn nhất, dùng cho app có nhiều admin sửa data thẳng vào DB.

Mình prefer kết hợp: realtime hook + cron đêm full reindex backup. Code mẫu cho realtime hook (Express):

// services/search-sync.js
import { meili } from './meili-client.js'

export async function syncProduct(product) {
  try {
    await meili.index('products').addDocuments([{
      id: product.id,
      name: product.name,
      brand: product.brand,
      price: product.price,
      in_stock: product.stock > 0,
      tags: product.tags,
      created_at: product.created_at.toISOString()
    }])
  } catch (e) {
    console.error('Meili sync failed:', e.message)
    // gửi vào dead letter queue retry sau
  }
}

9. Backup và restore index MeiliSearch

MeiliSearch có 2 dạng backup: snapshot (binary, restore nhanh) và dump (JSON, portable cho version khác).

# Tạo dump
curl -X POST 'https://search.your-domain.com/dumps' 
  -H "Authorization: Bearer $MEILI_MASTER_KEY"

# Dump file sẽ ở thư mục data/dumps/ trong container
# Copy ra host và backup lên R2 hoặc storage box
docker cp meilisearch:/meili_data/dumps/ /opt/meilisearch/backups/

Cron job backup hằng ngày 3h sáng, push lên S3-compatible storage. Snapshot của Cloud VPS TND cũng giúp rollback nhanh khi MeiliSearch upgrade fail.

10. Monitor MeiliSearch với Grafana + Prometheus

MeiliSearch expose metrics qua endpoint /metrics theo format Prometheus. Bật bằng env MEILI_EXPERIMENTAL_ENABLE_METRICS=true. Quan trọng theo dõi:

  • meilisearch_db_size_bytes: kích thước index trên disk
  • meilisearch_used_db_size_bytes: phần index đang dùng
  • meilisearch_nb_indexes: số lượng index
  • meilisearch_http_requests_total: số request, phân theo status code
  • meilisearch_http_response_time_seconds: latency p50/p95/p99

Alert khi p95 > 200ms hoặc db_size tăng đột biến. Mình từng phát hiện 1 bug trong app push duplicate document, index phình từ 800MB lên 5GB trong 2 ngày nhờ alert này.

11. Bài học production: 5 lỗi mình từng gặp

  1. Quên set MEILI_MASTER_KEY production: MeiliSearch chạy mode no-auth, ai cũng đọc/ghi được. Hậu quả: bị xóa hết index. Phải set ngay từ lần chạy đầu.
  2. Index quá nhiều field không cần search: searchableAttributes mặc định là tất cả field, làm index phình to. Giới hạn rõ ràng 3-5 field thật sự cần search.
  3. Push document size > 100MB: MeiliSearch reject, app crash. Batch nhỏ 1000 document một lần là sweet spot.
  4. Không bật typo tolerance cho ngôn ngữ tiếng Việt: tiếng Việt có dấu, user gõ không dấu thường xuyên. Set typoTolerance minWordSizeForTypos thấp xuống và normalize chữ trước khi index.
  5. Quên backup trước khi upgrade version: MeiliSearch v1.x to v2.x có breaking change index format. Luôn dump trước upgrade.

12. Tối ưu thêm: faceted search và highlighting

Faceted search cho phép user filter theo category, brand, price range. Setup:

const results = await index.search('iphone', {
  filter: ['category = phone', 'price 5000000 TO 30000000'],
  facets: ['brand', 'category', 'price'],
  attributesToHighlight: ['name', 'description'],
  highlightPreTag: '',
  highlightPostTag: ''
})

console.log(results.facetDistribution)
// { brand: { Apple: 12, Samsung: 8 }, category: { phone: 20 } }

Highlighting trả về text có thẻ mark bao quanh từ khoá match, frontend render thẳng vào HTML, hiệu ứng Algolia-style chuyên nghiệp.

Cloud VPS cho vibe coder

VPS chạy MeiliSearch + Node API mượt từ 199k/tháng

Cloud VPS TND sẵn AlmaLinux 9, Ubuntu 22/24, Debian 12/13. SSD CEPH, snapshot 1-click, backup hằng ngày, network 200Mbps trong nước. MeiliSearch + app Node + Postgres chạy ngon trên cấu hình 4GB RAM, đủ cho startup 50k user đầu tiên.

Xem 8 cấu hình Cloud VPS →

FAQ

MeiliSearch có chạy được trên VPS 1GB RAM không?

Được, với index dưới 100k document. Cloud VPS 20 của TND (1GB RAM, 199k/tháng) đủ cho MVP và staging. Khi production traffic tăng, upgrade lên Cloud VPS 40 (2GB) hoặc 80 (4GB) tuỳ index size.

So với ElasticSearch thì MeiliSearch yếu hơn ở điểm nào?

MeiliSearch không có aggregation phức tạp như ElasticSearch, không scale horizontal nhiều node, không có log analytics tích hợp. Nếu use case bạn cần ELK stack hay big data analytics, dùng ElasticSearch. Nếu chỉ cần search nhanh cho app, MeiliSearch thắng tuyệt đối về DX và chi phí.

Tenant token có an toàn cho frontend production không?

An toàn. Tenant token là JWT signed bởi API key của backend, có expiresAt và rules giới hạn index/filter. Token chỉ có quyền search read-only, không write được index. Đây là pattern Algolia và MeiliSearch đều khuyến nghị cho instant search frontend.

Làm sao sync MeiliSearch với PostgreSQL realtime?

3 cách: (1) hook trong app code sau commit DB thì push vào Meili, (2) PostgreSQL LISTEN/NOTIFY trigger gọi worker, (3) tool như pg2es hoặc Debezium đọc WAL. Cách 1 đơn giản nhất, cách 3 mạnh nhất nhưng phức tạp. Cho startup mình khuyên dùng cách 1 + cron backup full reindex hằng ngày.

MeiliSearch hỗ trợ tiếng Việt có dấu tốt không?

Tốt, từ v1.6 hỗ trợ tokenization tiếng Việt qua charabia engine. User gõ có dấu hoặc không dấu đều ra kết quả đúng nếu bạn normalize text trước khi index (lowercase, strip dấu tuỳ chọn). Mình recommend giữ nguyên dấu khi index, để MeiliSearch tự xử lý.