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
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:
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:
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:
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
Resilience — continue-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.