Self-Hosting Meilisearch Search for a Hugo Blog on k3s

Adding search to a static Hugo blog without relying on Algolia or any third-party service. Everything runs in the cluster — Meilisearch as a pod, secrets managed via HashiCorp Vault and External Secrets Operator, the index populated by the CI pipeline on every deploy, and the search endpoint exposed through Cloudflare Tunnel.

Why Meilisearch

Hugo is a static site generator — there is no server-side logic, no database, no search. The common solutions are either paying for Algolia or embedding a client-side library like Lunr.js that downloads the entire index to the browser.

Meilisearch is a better fit for a self-hosted setup:

  • Runs as a single binary / container with minimal resources
  • Returns results in under 50ms
  • Supports typo tolerance, highlighting, and tag filtering out of the box
  • Has a clean REST API with scoped API keys
  • Integrates naturally with a Kubernetes-native stack

Architecture

Hugo CI build
    ↓
Extract public/index.json from Docker image
    ↓
POST to Meilisearch API (CI indexer key)
    ↓
Meilisearch pod (k3s, local-path PVC)
    ↑
Blog frontend JS (read-only search key)
    ↑
Visitor types in search box

Secrets flow:

Vault KV store
    ↓
External Secrets Operator
    ↓
Kubernetes Secret (in-cluster only)
    ↓
Meilisearch pod reads MEILI_MASTER_KEY
Master key     → Vault + ExternalSecret → in-cluster Secret only
CI indexer key → Vault + ExternalSecret → Forgejo repo secret
Frontend key   → config.toml (safe, read-only)

Step 1 — Hugo JSON Output

Hugo does not generate a search index by default. Add the JSON output format to config.toml:

[outputs]
  home = ["HTML", "RSS", "JSON"]

[params]
  meilisearchHost  = "https://search.yourdomain.com"
  meilisearchKey   = "<your-frontend-read-only-key>"
  meilisearchIndex = "posts"

Create layouts/index.json.json — note the double extension, the first .json is the output format name, the second is the Go template extension:

{{- $.Scratch.Add "index" slice -}}{{- range .Site.RegularPages -}}{{- $.Scratch.Add "index" (dict "id" .File.UniqueID "title" .Title "summary" .Summary "content" .Plain "url" .RelPermalink "date" (.Date.Format "2006-01-02") "tags" .Params.tags "series" .Params.series) -}}{{- end -}}{{- $.Scratch.Get "index" | jsonify -}}

Keep it on a single line to avoid whitespace being emitted before the JSON array. Verify it works locally:

hugo server -D
# visit http://localhost:1313/index.json

You should see a JSON array of all your posts.

Step 2 — Deploy Meilisearch on k3s

Managed via Flux HelmRelease. Key configuration decisions:

  • storageClass: local-path — single node, no Longhorn needed
  • existingMasterKeySecret — master key comes from an ExternalSecret synced from Vault, never hardcoded in values
  • serviceMonitor.enabled: true — Prometheus scrapes metrics for free
# helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: meilisearch
  namespace: meilisearch
spec:
  interval: 1h
  chart:
    spec:
      chart: meilisearch
      version: "0.x"
      sourceRef:
        kind: HelmRepository
        name: meilisearch
        namespace: meilisearch
  values:
    environment:
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: true
    auth:
      existingMasterKeySecret: meilisearch-master-key
    persistence:
      enabled: true
      size: 2Gi
      storageClass: local-path
      accessMode: ReadWriteOnce
    resources:
      requests:
        cpu: 50m
        memory: 128Mi
      limits:
        cpu: 500m
        memory: 512Mi
    service:
      type: ClusterIP
      port: 7700
    serviceMonitor:
      enabled: true
      interval: 1m
      additionalLabels:
        release: kube-prometheus-stack

Step 3 — Store the Master Key in Vault

Generate a strong key and store it in Vault:

openssl rand -base64 32

vault kv put -mount=apps meilisearch-master-key \
  MEILI_MASTER_KEY='<generated-key>'

Then create an ExternalSecret that syncs it into the cluster:

# external-secret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: meilisearch-master-key
  namespace: meilisearch
spec:
  refreshInterval: "15s"
  secretStoreRef:
    name: vault-backend-global
    kind: ClusterSecretStore
  target:
    name: meilisearch-master-key
    creationPolicy: Owner
  data:
    - secretKey: MEILI_MASTER_KEY
      remoteRef:
        key: meilisearch-master-key
        property: MEILI_MASTER_KEY

The ExternalSecret is safe to commit to Git — it contains only a reference to the Vault path, never the secret value itself.

Step 4 — API Key Hierarchy

Meilisearch uses a three-key model. Generate the CI and frontend keys after the first deploy using port-forward:

kubectl port-forward svc/meilisearch 8000:7700 -n meilisearch

CI indexer key — can write documents, cannot manage indexes or keys:

curl -X POST "http://localhost:8000/keys" \
  -H "Authorization: Bearer <master-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CI indexer key",
    "actions": ["documents.add", "documents.delete", "indexes.create", "tasks.get"],
    "indexes": ["posts"],
    "expiresAt": null
  }'

Frontend search key — read-only, safe to embed in browser JavaScript:

curl -X POST "http://localhost:8000/keys" \
  -H "Authorization: Bearer <master-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Frontend search key",
    "actions": ["search"],
    "indexes": ["posts"],
    "expiresAt": null
  }'

Store the CI key in Vault and sync it with another ExternalSecret:

vault kv put -mount=apps meilisearch-ci-key \
  MEILI_API_KEY='<ci-indexer-key>'
# external-secret-ci-key.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: meilisearch-ci-key
  namespace: meilisearch
spec:
  refreshInterval: "15s"
  secretStoreRef:
    name: vault-backend-global
    kind: ClusterSecretStore
  target:
    name: meilisearch-ci-key
    creationPolicy: Owner
  data:
    - secretKey: MEILI_API_KEY
      remoteRef:
        key: meilisearch-ci-key
        property: MEILI_API_KEY

Also add MEILI_API_KEY as a Forgejo repository secret so the CI pipeline can use it. The frontend key goes directly into config.toml — it can only search, nothing else.

Key Scope Lives in
Master key Admin Vault + ExternalSecret → in-cluster Secret
CI indexer key Write documents Vault + ExternalSecret + Forgejo secret
Frontend key Search only config.toml

Step 5 — Create the Index

Before the CI pipeline can push documents, the index must exist:

kubectl port-forward svc/meilisearch 8000:7700 -n meilisearch

curl -X POST "http://localhost:8000/indexes" \
  -H "Authorization: Bearer <master-key>" \
  -H "Content-Type: application/json" \
  -d '{"uid": "posts", "primaryKey": "id"}'

Step 6 — Expose via Cloudflare Tunnel

Add a route to the cloudflared ConfigMap in the Flux repo:

ingress:
  - hostname: search.yourdomain.com
    service: http://meilisearch.meilisearch.svc.cluster.local:7700

Commit, push, then restart cloudflared to pick up the new config:

kubectl rollout restart deployment cloudflared -n cloudflared

Also add a CNAME DNS record in Cloudflare pointing search to your tunnel’s .cfargotunnel.com address. Verify the tunnel is working:

curl -s "https://search.yourdomain.com/health"
# {"status":"available"}

Step 7 — CI Pipeline Indexer Step

The Hugo site is already built into the Docker image during the CI pipeline. Rather than running Hugo a second time, extract public/index.json directly from the built image:

- name: Index posts to Meilisearch
  continue-on-error: true
  run: |
    docker create --name extract ${{ env.IMAGE_WITH_DIGEST }}
    docker cp extract:/usr/share/nginx/html/index.json ./index.json
    docker rm extract

    curl -X POST \
      "https://search.yourdomain.com/indexes/posts/documents" \
      -H "Authorization: Bearer ${{ secrets.MEILI_API_KEY }}" \
      -H "Content-Type: application/json" \
      --data @./index.json \
      --fail \
      --silent \
      --show-error

continue-on-error: true ensures a Meilisearch hiccup never blocks the actual deployment. The index gets updated on every push to main.

Step 8 — Search UI

Create content/search.md:

---
title: "Search"
layout: "search"
---

Create layouts/_default/search.html. The page inherits the theme’s base template so it matches the site’s look. The JavaScript uses the Meilisearch SDK to query the API as the user types, with highlighted matches returned in under 50ms:

{{ define "main" }}
<div class="search-page">
  <div id="searchbox"></div>
  <div id="hits"></div>
</div>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/meilisearch.umd.min.js"></script>
<script>
const client = new MeiliSearch({
  host: '{{ .Site.Params.meilisearchHost }}',
  apiKey: '{{ .Site.Params.meilisearchKey }}'
});
const index = client.index('{{ .Site.Params.meilisearchIndex }}');

const searchInput = document.createElement('input');
searchInput.setAttribute('type', 'text');
searchInput.setAttribute('placeholder', 'Search posts...');
document.getElementById('searchbox').appendChild(searchInput);

async function search(query) {
  const hitsEl = document.getElementById('hits');
  if (!query) { hitsEl.innerHTML = ''; return; }
  const results = await index.search(query, {
    attributesToHighlight: ['title', 'summary'],
    highlightPreTag: '<em>',
    highlightPostTag: '</em>',
    limit: 20
  });
  hitsEl.innerHTML = results.hits.map(hit => `
    <div class="hit">
      <a href="${hit.url}">${hit._formatted.title}</a>
      <div class="hit-summary">${hit._formatted.summary
        .replace(/<[^>]+>/g, '').substring(0, 200)}...</div>
      <div class="hit-tags">${(hit.tags || []).join(' #')}</div>
    </div>
  `).join('');
}

searchInput.addEventListener('input', e => search(e.target.value));
</script>
{{ end }}

Add the search page to the navigation in config.toml:

[[menus.main]]
name    = 'Search'
pageRef = '/search'
weight  = 30

Verification

Check the index stats after the first successful CI run:

curl "https://search.yourdomain.com/indexes/posts/stats" \
  -H "Authorization: Bearer <master-key>"

Expected output shows all your posts indexed with every field present:

{
  "numberOfDocuments": 79,
  "isIndexing": false,
  "fieldDistribution": {
    "content": 79,
    "date": 79,
    "id": 79,
    "series": 79,
    "summary": 79,
    "tags": 79,
    "title": 79,
    "url": 79
  }
}

What This Demonstrates

From a DevOps perspective this project covers several distinct areas working together:

  • GitOps — Meilisearch deployed and configured entirely through Flux HelmRelease, no manual kubectl apply
  • Secret hygiene — three-tier API key model, master key stored in Vault, synced into the cluster via External Secrets Operator, never touches Git
  • Supply chain — index populated from the already-built and signed image, not a separate Hugo process
  • Zero-trust networking — Meilisearch has no public port, only reachable through the Cloudflare Tunnel
  • Observability — ServiceMonitor wired up so Prometheus scrapes Meilisearch metrics automatically
  • Resiliencecontinue-on-error: true on the indexer step means search degradation never causes a deployment failure

The search endpoint at https://search.yourdomain.com is the only public surface. The master key has never touched Git, the CI runner, or any external system — it lives in Vault and is injected into the cluster exclusively through External Secrets Operator.

my DevOps Odyssey

“Σα βγεις στον πηγαιμό για την Ιθάκη, να εύχεσαι να ‘ναι μακρύς ο δρόμος, γεμάτος περιπέτειες, γεμάτος γνώσεις.” - Kavafis’ Ithaka.