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 neededexistingMasterKeySecret— master key comes from an ExternalSecret synced from Vault, never hardcoded in valuesserviceMonitor.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
- Resilience —
continue-on-error: trueon 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.