Meilisearch is the Best Search You'll Never Need

Self-hosting Meilisearch with Docker, Traefik, and Astro - complete with API key management, content indexing, and rate limiting.

Pixel art of a ship with red sails floating on a sea under clouds.

This is the third post in my search implementation series. I previously covered Pagefind for static search and Orama for client-side full-text search. Now I’m exploring Meilisearch - a self-hosted search engine with typo tolerance, faceted filtering, vector search, and real-time indexing.

For a small blog like mine, it’s total overkill. But I learned a lot setting it up, so here’s the complete walkthrough.

Why Meilisearch Exists

Meilisearch is a production-grade search engine built in Rust. It’s designed for scenarios where client-side search falls short - large e-commerce catalogs, documentation sites with thousands of pages, or applications needing real-time search updates without rebuilds.

The key features that set it apart are typo tolerance that actually works, hybrid search combining keyword and semantic capabilities, and vector search for AI-powered recommendations. Unlike Pagefind or Orama, Meilisearch runs as a separate server, which means your search index never touches the client.

But these features come with a cost. You need to run and maintain a server, manage API keys, handle backups, and monitor performance. For my 50-post blog, this is like using a semi-truck to buy groceries.

Setting Up Meilisearch with Docker

The basic Docker setup is straightforward:

docker pull getmeili/meilisearch:latest

But running it properly requires more thought. Here’s a production-ready setup:

docker run -d \
  --name meilisearch \
  -p 7700:7700 \
  -e MEILI_MASTER_KEY='your-secure-master-key-here' \
  -v $(pwd)/meili_data:/meili_data \
  --restart unless-stopped \
  getmeili/meilisearch:latest

The MEILI_MASTER_KEY is critical for securing your Meilisearch instance. Without it, your Meilisearch server is completely open to the public. The key must be at least 16 bytes and should be a strong, random value.

You can generate a secure 32-character key using openssl rand -hex 16.

The volume mount -v $(pwd)/meili_data:/meili_data ensures your search index persists between container restarts. Without this, you’ll lose all your indexed data every time the container stops.

Using Docker Compose

For a more maintainable setup, use Docker Compose:

services:
  meilisearch:
    image: getmeili/meilisearch:latest
    container_name: meilisearch
    restart: unless-stopped

    environment:
      - MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
      - MEILI_ENV=production

    ports:
      - '7700:7700'

    volumes:
      - meilisearch_data:/meili_data

    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:7700/health']
      interval: 30s
      timeout: 10s
      retries: 3

    networks:
      - app-network

volumes:
  meilisearch_data:

networks:
  app-network:
    external: true

Integrating with Traefik

If you’re self-hosting Meilisearch behind Traefik (like I do), you need to consider security and rate limiting. The self-hosted version doesn’t include rate limiting or CORS handling by default, so you need to add middleware.

The best practice is to define middlewares in a separate static configuration file and reference them in your Docker Compose labels. This keeps your configuration organized and reusable.

Docker Compose Configuration

Here’s the Docker Compose setup that references middlewares from a static configuration file:

services:
  meilisearch:
    image: getmeili/meilisearch:latest
    container_name: meilisearch
    restart: unless-stopped

    environment:
      - MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
      - MEILI_ENV=production
      - MEILI_HTTP_ADDR=0.0.0.0:7700

    volumes:
      - meilisearch_data:/meili_data

    networks:
      - app-network

    labels:
      - 'traefik.enable=true'

      # Router for search endpoints (GET, POST, OPTIONS for CORS preflight)
      # Matches: /indexes/{index}/search and /health
      - 'traefik.http.routers.meilisearch-search.rule=Host(`search.yourdomain.com`) && (PathRegexp(`/indexes/[^/]+/search`) && (Method(`GET`) || Method(`POST`) || Method(`OPTIONS`)) || Path(`/health`))'
      - 'traefik.http.routers.meilisearch-search.entrypoints=websecure'
      - 'traefik.http.routers.meilisearch-search.tls.certresolver=letsencrypt'
      - 'traefik.http.routers.meilisearch-search.service=meilisearch'

      # Middleware chain: security headers, rate limiting, and CORS
      # The @file suffix references middlewares defined in static config
      - 'traefik.http.routers.meilisearch-search.middlewares=security-headers@file,meilisearch-ratelimit@file,meilisearch-cors@file'

      # Service configuration
      - 'traefik.http.services.meilisearch.loadbalancer.server.port=7700'

    healthcheck:
      test: ['CMD-SHELL', 'curl -f http://localhost:7700/health || exit 1']
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  meilisearch_data:

networks:
  app-network:
    external: true

Middleware Configuration

Create a middlewares.yml file (or add to your existing Traefik static configuration) with the middleware definitions:

http:
  middlewares:
    # Global security headers middleware
    security-headers:
      headers:
        stsSeconds: 315360000
        browserXssFilter: true
        contentTypeNosniff: true
        forceSTSHeader: true
        stsIncludeSubdomains: true
        stsPreload: true
        frameDeny: true
        referrerPolicy: 'strict-origin-when-cross-origin'

    # Rate limiting middleware for Meilisearch
    # Uses Redis/Valkey for distributed rate limiting across multiple instances
    meilisearch-ratelimit:
      rateLimit:
        average: 100
        burst: 200
        period: 1s
        redis:
          endpoints:
            - 'valkey:6379'
          readTimeout: 3s
          writeTimeout: 3s
          dialTimeout: 5s
        sourceCriterion:
          ipStrategy:
            depth: 0
            excludedIPs:
              - '173.245.48.0/20'
              - '103.21.244.0/22'

    # CORS middleware for Meilisearch search endpoints
    meilisearch-cors:
      headers:
        accessControlAllowMethods:
          - 'GET'
          - 'POST'
          - 'OPTIONS'
        accessControlAllowHeaders:
          - '*'
        accessControlAllowOriginList:
          - 'https://yourdomain.com'
        accessControlMaxAge: 86400
        addVaryHeader: true
        accessControlExposeHeaders:
          - 'Content-Type'

How It Works Together

  1. Router Matching: The Traefik router matches requests to /indexes/{index}/search and /health endpoints on your search domain.

  2. Middleware Chain: Requests flow through the middleware chain in order:

    • security-headers@file: Adds security headers (HSTS, XSS protection, etc.)
    • meilisearch-ratelimit@file: Enforces rate limits (100 req/s average, 200 burst)
    • meilisearch-cors@file: Handles CORS headers for cross-origin requests
  3. CORS Handling: The CORS middleware is essential for allowing your frontend to access the search API. Without it, browser security will block all search requests. The OPTIONS method is included to handle CORS preflight requests.

  4. Rate Limiting: The rate limiter prevents abuse and protects your server. If you’re running multiple Traefik instances, use Redis/Valkey for distributed rate limiting. Otherwise, you can remove the redis section for in-memory rate limiting.

  5. Service Routing: Finally, the request is forwarded to the Meilisearch service on port 7700.

Note: Admin endpoints (like creating indexes or managing API keys) should be accessed directly via your internal network or VPN, not through the public Traefik router. Only expose the search endpoints publicly.

Creating API Keys

Never expose your master key to the client. Instead, create a search-only key with restricted permissions.

Here’s how to create a proper search key using the Meilisearch API:

curl \
  -X POST 'http://localhost:7700/keys' \
  -H 'Authorization: Bearer YOUR_MASTER_KEY' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "description": "Public search key for blog",
    "actions": ["search"],
    "indexes": ["blog"],
    "expiresAt": null
  }'

Or create a script to generate it programmatically:

// scripts/create-search-key.ts
import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_URL || 'http://localhost:7700',
  apiKey: process.env.MEILISEARCH_MASTER_KEY,
});

async function createSearchKey() {
  const key = await client.createKey({
    description: 'Public search key for blog',
    actions: ['search'],
    indexes: ['blog'],
    expiresAt: null,
  });

  console.log('Search Key:', key.key);
  console.log('\nAdd this to your .env file:');
  console.log(`PUBLIC_MEILISEARCH_SEARCH_KEY=${key.key}`);
}

createSearchKey().catch(console.error);

Run it once with:

npx tsx scripts/create-search-key.ts

The generated key can only perform searches on the blog index. It can’t add, modify, or delete documents, making it safe to expose in your frontend code.

Indexing Your Content

The indexing script needs to parse your Astro content collections and send them to Meilisearch. Here’s a complete implementation:

// scripts/index-meilisearch.ts
import { MeiliSearch } from 'meilisearch';
import { getCollection } from 'astro:content';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkMdx from 'remark-mdx';
import { visit } from 'unist-util-visit';
import { SKIP } from 'unist-util-visit';
import fs from 'fs/promises';
import path from 'path';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_URL || 'http://localhost:7700',
  apiKey: process.env.MEILISEARCH_MASTER_KEY,
});

const mdxProcessor = unified().use(remarkParse).use(remarkMdx);

function extractTextFromAST(node: any): string {
  const texts: string[] = [];

  visit(node, (n: any) => {
    // Skip code blocks, inline code, and JSX elements
    if (
      n.type === 'code' ||
      n.type === 'inlineCode' ||
      n.type === 'mdxJsxFlowElement' ||
      n.type === 'mdxJsxTextElement'
    ) {
      return SKIP;
    }

    if (n.type === 'text') {
      texts.push(n.value);
    }
  });

  return texts.join(' ').trim().replace(/\s+/g, ' ');
}

async function indexPosts() {
  console.log('Starting indexing process...');

  // Get all blog posts
  const posts = await getCollection('blog');
  const documents = [];

  for (const post of posts) {
    // Skip drafts
    if (post.data.draft) {
      continue;
    }

    // Read the MDX file
    const filePath = path.join(
      process.cwd(),
      'src/content/blog',
      `${post.slug}.mdx`
    );
    const fileContent = await fs.readFile(filePath, 'utf-8');

    // Parse frontmatter and content
    const { content } = matter(fileContent);

    // Extract text from markdown AST
    const ast = mdxProcessor.parse(content);
    const textContent = extractTextFromAST(ast);

    documents.push({
      id: post.slug,
      path: `/blog/${post.slug}`,
      title: post.data.title,
      description: post.data.description,
      content: textContent,
      tags: post.data.tags || [],
      date: post.data.date.toISOString(),
      author: post.data.author || 'Sarthak Mishra',
    });
  }

  console.log(`Indexing ${documents.length} documents...`);

  // Get or create index
  const index = client.index('blog');

  // Configure index settings
  console.log('Configuring index settings...');

  // Set searchable attributes (order matters for relevance)
  await index.updateSearchableAttributes([
    'title',
    'description',
    'tags',
    'content',
  ]);

  // Set filterable attributes
  await index.updateFilterableAttributes(['tags', 'date']);

  // Set sortable attributes
  await index.updateSortableAttributes(['date']);

  // Add documents
  const task = await index.addDocuments(documents, {
    primaryKey: 'id',
  });

  console.log('Waiting for indexing to complete...');
  await client.waitForTask(task.taskUid);

  console.log('✓ Indexing complete!');
  console.log(`Indexed ${documents.length} posts`);
}

indexPosts().catch(console.error);

Add it to your package.json:

{
  "scripts": {
    "meilisearch:index": "tsx scripts/index-meilisearch.ts",
    "build": "astro build && pnpm meilisearch:index"
  }
}

The script extracts clean text from your MDX files, skipping code blocks and JSX components. It configures searchable attributes with proper weighting (title ranks higher than content) and enables filtering and sorting.

Using Meilisearch in Your Search Component

The TypeScript/JavaScript SDK is excellent and makes integration straightforward.

pnpm add meilisearch

Here’s a complete search component for Astro with Preact:

// src/components/SearchModal.tsx
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { MeiliSearch } from 'meilisearch';

interface SearchResult {
  id: string;
  path: string;
  title: string;
  description: string;
  tags: string[];
  date: string;
}

export default function SearchModal() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Initialize Meilisearch client
  const client = new MeiliSearch({
    host: import.meta.env.PUBLIC_MEILISEARCH_URL,
    apiKey: import.meta.env.PUBLIC_MEILISEARCH_SEARCH_KEY,
  });

  const index = client.index('blog');

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const searchTimeout = setTimeout(async () => {
      setLoading(true);
      setError(null);

      try {
        const searchResults = await index.search(query, {
          limit: 8,
          sort: ['date:desc'],
        });

        setResults(searchResults.hits as SearchResult[]);
      } catch (err) {
        console.error('Search error:', err);
        setError('Search failed. Please try again.');
      } finally {
        setLoading(false);
      }
    }, 300); // Debounce search

    return () => clearTimeout(searchTimeout);
  }, [query]);

  return (
    <div class="search-modal">
      <input
        type="text"
        placeholder="Search posts..."
        value={query}
        onInput={e => setQuery(e.currentTarget.value)}
        class="search-input"
      />

      {loading && <div class="search-loading">Searching...</div>}

      {error && <div class="search-error">{error}</div>}

      {results.length > 0 && (
        <div class="search-results">
          {results.map(result => (
            <a key={result.id} href={result.path} class="search-result">
              <h3>{result.title}</h3>
              <p>{result.description}</p>
              <div class="search-result-meta">
                <time>{new Date(result.date).toLocaleDateString()}</time>
                <div class="tags">
                  {result.tags.slice(0, 3).map(tag => (
                    <span key={tag} class="tag">
                      {tag}
                    </span>
                  ))}
                </div>
              </div>
            </a>
          ))}
        </div>
      )}

      {!loading && query && results.length === 0 && (
        <div class="no-results">No results found for "{query}"</div>
      )}
    </div>
  );
}

The client initialization uses your public search key, making it safe to expose. The SDK handles typo tolerance automatically - searching for “javascirpt” will still find “javascript” posts.

Advanced Features You Probably Don’t Need

Typo Tolerance

Meilisearch handles typos out of the box. No configuration needed. It just works. This is genuinely impressive - searching for “astr” finds “astro”, “javascirpt” finds “javascript”, and so on.

For most blogs, this is overkill. Your readers can spell.

Faceted Search and Filtering

Want to filter by tags or date ranges? Meilisearch makes it easy:

const results = await index.search(query, {
  filter: 'tags = "astro" AND date > 2024-01-01',
  limit: 10,
});

But honestly, for a blog, a simple tag page works fine. You don’t need real-time faceted filtering.

Meilisearch ships with a vector store, and recent releases made the newer store the default for newly created indexes. You can do semantic search by generating embeddings and mixing them with keyword relevance:

# Example from Meilisearch docs
results = client.index('books').search(query, opt_params={
    'hybrid': {
        'semanticRatio': 0.7,
        'embedder': 'openai'
    },
    'limit': 4
})

The semanticRatio controls the balance between keyword and semantic search. A value of 0.7 means 70% semantic, 30% keyword matching.

But here’s the catch - you need to generate embeddings for all your content. This requires an OpenAI API key, adds complexity to your indexing pipeline, and costs money for the API calls.

For a small blog, this is complete overkill.

Meilisearch added multimodal embeddings support, letting you search across text and images in newer releases. It’s impressive, but it also increases operational complexity and cost, so it’s hard to justify for a small site.

The Honest Assessment

After setting all this up, here’s my take on when you should actually use Meilisearch.

When Meilisearch Makes Sense

Use Meilisearch if you have:

  • 1000+ documents that need search
  • Real-time content updates without rebuilds (like a CMS)
  • Complex filtering requirements (e-commerce, job boards)
  • Multiple languages requiring sophisticated typo tolerance
  • The infrastructure to maintain a production search server

Good use cases include large documentation sites, e-commerce platforms, job boards, or any application where search is a core feature.

Why It’s Overkill for Small Blogs

On a sub-50-post blog, Meilisearch mostly adds a server to babysit, recurring infra cost, and a lot of configuration surface area while delivering little beyond what static search already gives you. If you rebuild on deploy and only need simple tag filtering, Pagefind or Orama stay simpler, cheaper, and good enough.

Conclusion

Meilisearch is powerful, impressive software that I genuinely enjoy using. But for a static blog, it violates the principle of “keeping it simple.”

By choosing a static library like Pagefind or Orama, you eliminate an entire class of maintenance tasks and security risks. You get search that is fast, smart, and effectively free.

Build this setup to learn. But deploy the static solution to production.


What’s next?

This is part of a series of posts on implementing search for static sites:

  • The Right Way to Add Orama Search to Astro — simple, zero-config search for small to medium sites
  • Why I Switched from Orama to Pagefind — chunked index for better scalability
  • Meilisearch is the Best Search You’ll Never Need (you are here) — server-side search with advanced features
  • Why I Didn’t Use Google Programmable Search (coming soon) — the hidden costs and indexing delays that make it impractical
  • I Tried 4 Search Engines So You Don’t Have To (coming soon) — comprehensive comparison from a small blog perspective

All with practical examples from a real production blog.

Subscribe to my newsletter

I send out a newsletter every week, usually on Thursdays, that's it!

You'll get two emails initially—a confirmation link to verify your subscription, followed by a welcome message. Thanks for joining!

You can read all of my previous issues here

Related Posts.