aRustyDev / aRustyDev/mdbook-htmx
docs(examples): Secure Self-Hosted Meilisearch via Cloudflare
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# Example: Secure Self-Hosted Meilisearch via Cloudflare
This example demonstrates a production-grade architecture for serving an HTMX documentation site with a privately-hosted Meilisearch instance, using Cloudflare Workers as a secure proxy layer.
---
## Architecture Overview
```
Browser (HTMX)
↓
│ POST /search
↓
Cloudflare Worker (Policy Enforcement Layer)
↓ (internal CF network)
│ CF-Access-Client-Id / CF-Access-Client-Secret
↓
Cloudflare Tunnel (cloudflared)
↓
│ localhost:7700
↓
Private Meilisearch (LAN / VPC)
```
### Key Security Properties
- **Meilisearch is never internet-facing** - only accessible via Cloudflare Tunnel
- **Worker is the only public entry point** - acts as policy enforcement
- **API keys never reach the browser** - server-side only
- **Query constraints are enforced** - Worker sanitizes all inputs
---
## Why This Pattern?
Even with Meilisearch's "search-only" keys, direct exposure risks:
| Risk | Impact |
|------|--------|
| Index enumeration | Attackers can infer document structure |
| Filter abuse | Bypass intended visibility constraints |
| Query DoS | Expensive fuzzy searches can overload |
| Future config mistakes | Public surface stays exposed forever |
Workers provide:
- Frozen query shape (no arbitrary filters/sorts)
- Authentication enforcement
- Rate limiting per user
- Response caching
- Centralized query logging
---
## Component 1: Cloudflare Tunnel Configuration
### `config.yml`
```yaml
tunnel: meilisearch-tunnel
credentials-file: /etc/cloudflared/meili.json
ingress:
# Only this hostname routes to Meilisearch
- hostname: meili.internal.example.com
service: http://localhost:7700
# Everything else gets 404
- service: http_status:404
```
### Run the Tunnel
```bash
# As a service (recommended)
cloudflared service install
systemctl start cloudflared
# Or manually for testing
cloudflared tunnel run meilisearch-tunnel
```
### Cloudflare Access Policy
In Zero Trust Dashboard:
1. **Application**: `meili.internal.example.com`
2. **Policy Type**: Service Token
3. **Create Token** → Use `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET` in Worker
---
## Component 2: Cloudflare Worker (TypeScript)
### `wrangler.toml`
```toml
name = "docs-search"
main = "src/worker.ts"
compatibility_date = "2024-01-01"
[vars]
MEILI_HOST = "https://meili.internal.example.com"
MEILI_INDEX = "docs"
# Secrets (set via wrangler secret put)
# MEILI_SEARCH_KEY
# CF_ACCESS_CLIENT_ID
# CF_ACCESS_CLIENT_SECRET
```
### `src/worker.ts`
```typescript
import { Hono } from "hono";
import { cors } from "hono/cors";
// Environment bindings
interface Env {
MEILI_HOST: string;
MEILI_INDEX: string;
MEILI_SEARCH_KEY: string;
CF_ACCESS_CLIENT_ID: string;
CF_ACCESS_CLIENT_SECRET: string;
}
const app = new Hono<{ Bindings: Env }>();
// CORS for HTMX requests
app.use("/search", cors({ origin: "*" }));
// Health check (no auth required)
app.get("/health", (c) => c.json({ status: "ok" }));
// Search endpoint - returns HTML partial for HTMX
app.post("/search", async (c) => {
// ---- 1. Authentication gate ----
const user = await verifySession(c.req);
if (!user) {
return c.html(
`
401
);
}
// ---- 2. Parse + sanitize input ----
let body: { q?: string; scope?: string };
try {
body = await c.req.json();
} catch {
return c.html(`
}
const query = String(body.q ?? "").slice(0, 128).trim();
if (query.length < 2) {
return c.html(`
Type at least 2 characters
`);}
const scope = body.scope || "all";
// ---- 3. Build constrained Meilisearch payload ----
// IMPORTANT: Never expose raw user input to filters
const meiliPayload = {
q: query,
limit: 10,
attributesToRetrieve: ["title", "section", "path", "excerpt"],
attributesToHighlight: ["title", "excerpt"],
highlightPreTag: "",
highlightPostTag: "",
// Scope filter using approved values only
filter: scope !== "all" ? `scopes = "${escapeFilter(scope)}"` : undefined,
};
// ---- 4. Proxy to Meilisearch via Tunnel ----
const meiliResponse = await fetch(
`${c.env.MEILI_HOST}/indexes/${c.env.MEILI_INDEX}/search`,
{
method: "POST",
headers: {
Authorization: `Bearer ${c.env.MEILI_SEARCH_KEY}`,
"Content-Type": "application/json",
// Cloudflare Access → Tunnel authentication
"CF-Access-Client-Id": c.env.CF_ACCESS_CLIENT_ID,
"CF-Access-Client-Secret": c.env.CF_ACCESS_CLIENT_SECRET,
},
body: JSON.stringify(meiliPayload),
}
);
if (!meiliResponse.ok) {
console.error("Meilisearch error:", await meiliResponse.text());
return c.html(`
}
const data = (await meiliResponse.json()) as MeiliSearchResponse;
// ---- 5. Runtime authorization filter (defense in depth) ----
const authorizedHits = data.hits.filter((hit) => {
// Public pages always visible
if (hit.auth?.access === "public") return true;
// Authenticated pages require login
if (hit.auth?.access === "authenticated") return !!user;
// Role-based pages require matching role
if (hit.auth?.access === "roles") {
return hit.auth.roles?.some((role) => user.roles.includes(role));
}
return false;
});
// ---- 6. Return HTML partial for HTMX ----
return c.html(renderResults(authorizedHits));
});
// ----- Helper Types -----
interface User {
id: string;
roles: string[];
}
interface MeiliHit {
title: string;
section: string;
path: string;
excerpt: string;
auth?: {
access: "public" | "authenticated" | "roles";
roles?: string[];
};
_formatted?: {
title?: string;
excerpt?: string;
};
}
interface MeiliSearchResponse {
hits: MeiliHit[];
query: string;
processingTimeMs: number;
estimatedTotalHits: number;
}
// ----- Helper Functions -----
async function verifySession(req: Request): Promise {
// Option 1: Cloudflare Access JWT
const cfAccessJWT = req.headers.get("Cf-Access-Jwt-Assertion");
if (cfAccessJWT) {
// Validate JWT and extract user info
// See: https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/
return { id: "user-from-jwt", roles: ["authenticated"] };
}
// Option 2: Session cookie
const sessionCookie = req.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1];
if (sessionCookie) {
// Validate session and look up user
return { id: "user-from-session", roles: ["authenticated"] };
}
return null;
}
function escapeFilter(value: string): string {
// Whitelist valid scope names
const validScopes = ["all", "developers", "managers", "sre"];
return validScopes.includes(value) ? value : "all";
}
function escape(s: string = ""): string {
return s.replace(
/[&<>"']/g,
(m) =>
({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[m]!
);
}
function renderResults(hits: MeiliHit[]): string {
if (!hits.length) {
return `
No results found
`;}
return `
-
${hit._formatted?.title ?? escape(hit.title)}
${escape(hit.section)}
${hit._formatted?.excerpt ?? escape(hit.excerpt)}
${hits
.map(
(hit) => `
`
)
.join("")}
`;
}
export default app;
```
### Deploy
```bash
# Set secrets
wrangler secret put MEILI_SEARCH_KEY
wrangler secret put CF_ACCESS_CLIENT_ID
wrangler secret put CF_ACCESS_CLIENT_SECRET
# Deploy
wrangler deploy
```
---
## Component 3: Meilisearch Configuration
### Index Schema
```json
{
"primaryKey": "id"
}
```
### Index Settings
```bash
curl -X PATCH 'http://localhost:7700/indexes/docs/settings' \
-H 'Authorization: Bearer MASTER_KEY' \
-H 'Content-Type: application/json' \
-d '{
"searchableAttributes": [
"title",
"section",
"content"
],
"displayedAttributes": [
"title",
"section",
"path",
"excerpt",
"auth",
"scopes"
],
"filterableAttributes": [
"scopes",
"auth.access"
],
"rankingRules": [
"words",
"typo",
"proximity",
"attribute",
"exactness",
"desc(weight)"
],
"stopWords": ["the", "and", "or", "a", "an"],
"typoTolerance": {
"enabled": true,
"minWordSizeForTypos": {
"oneTypo": 4,
"twoTypos": 7
}
}
}'
```
### Document Shape
```json
{
"id": "getting-started/install#macos",
"title": "Installation",
"section": "Getting Started",
"path": "/docs/install#macos",
"content": "Full markdown-stripped content for searching...",
"excerpt": "How to install the CLI on macOS...",
"weight": 10,
"scopes": ["developers", "sre"],
"auth": {
"access": "public"
}
}
```
### Security Notes
- **`content`** is searchable but NOT displayed (prevents data leakage)
- **`excerpt`** is a curated summary, safe to display
- **`scopes`** and **`auth`** enable filtering
- **`weight`** allows boosting important pages
---
## Component 4: HTMX Integration
### Search Input
```html
All
Developers
Managers
SRE
```
### Progressive Enhancement (No-JS Fallback)
```html
All
Developers
Search
```
### CSS for Search Results
```css
.search-results {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.search-result {
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
}
.search-result a {
display: block;
text-decoration: none;
}
.result-title {
display: block;
color: var(--link-color);
}
.result-title mark {
background: var(--highlight-color);
padding: 0 2px;
}
.result-section {
font-size: 0.875rem;
color: var(--text-muted);
}
.result-excerpt {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.result-excerpt mark {
background: var(--highlight-color);
}
.search-empty {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
```
---
## Component 5: Build-Time Indexing
### `scripts/index-docs.ts`
```typescript
import { readFileSync } from "fs";
import { glob } from "glob";
interface SearchDocument {
id: string;
title: string;
section: string;
path: string;
content: string;
excerpt: string;
weight: number;
scopes: string[];
auth: {
access: "public" | "authenticated" | "roles";
roles?: string[];
};
}
async function indexDocuments() {
const MEILI_HOST = process.env.MEILI_HOST || "http://localhost:7700";
const MEILI_ADMIN_KEY = process.env.MEILI_ADMIN_KEY!;
// Load search index generated by mdbook-htmx
const searchIndex = JSON.parse(
readFileSync("book/htmx/search-index.json", "utf-8")
);
const documents: SearchDocument[] = searchIndex.documents.map(
(doc: any, idx: number) => ({
id: doc.path.replace(/\//g, "-").replace(/^-/, "") || `doc-${idx}`,
title: doc.title,
section: doc.headings?.[0]?.text || "Documentation",
path: doc.path,
content: stripMarkdown(doc.body),
excerpt: generateExcerpt(doc.body, 150),
weight: calculateWeight(doc),
scopes: doc.scopes || ["all"],
auth: doc.auth || { access: "public" },
})
);
// Batch update (replace all documents)
const response = await fetch(
`${MEILI_HOST}/indexes/docs/documents?primaryKey=id`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${MEILI_ADMIN_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(documents),
}
);
if (!response.ok) {
throw new Error(`Indexing failed: ${await response.text()}`);
}
const task = await response.json();
console.log(`Indexed ${documents.length} documents. Task: ${task.taskUid}`);
// Wait for task completion
await waitForTask(MEILI_HOST, MEILI_ADMIN_KEY, task.taskUid);
}
function stripMarkdown(text: string): string {
return text
.replace(/```[\s\S]*?```/g, "") // Code blocks
.replace(/`[^`]+`/g, "") // Inline code
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Links
.replace(/[#*_~`]/g, "") // Formatting
.replace(/\n+/g, " ") // Newlines
.trim();
}
function generateExcerpt(text: string, maxLength: number): string {
const stripped = stripMarkdown(text);
if (stripped.length <= maxLength) return stripped;
return stripped.slice(0, maxLength).replace(/\s+\S*$/, "") + "...";
}
function calculateWeight(doc: any): number {
// Boost getting started, overview pages
if (doc.path.includes("getting-started")) return 20;
if (doc.path.includes("overview")) return 15;
if (doc.path.includes("quickstart")) return 18;
return 10;
}
async function waitForTask(
host: string,
key: string,
taskUid: number
): Promise {
const maxAttempts = 30;
for (let i = 0; i < maxAttempts; i++) {
const response = await fetch(`${host}/tasks/${taskUid}`, {
headers: { Authorization: `Bearer ${key}` },
});
const task = await response.json();
if (task.status === "succeeded") {
console.log("Indexing completed successfully");
return;
}
if (task.status === "failed") {
throw new Error(`Indexing failed: ${task.error?.message}`);
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error("Indexing timed out");
}
indexDocuments().catch(console.error);
```
### CI/CD Integration
```yaml
# .github/workflows/deploy-docs.yml
name: Deploy Docs
on:
push:
branches: [main]
paths: ["docs/**"]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build mdbook-htmx
run: mdbook build
- name: Index to Meilisearch
env:
MEILI_HOST: ${{ secrets.MEILI_HOST }}
MEILI_ADMIN_KEY: ${{ secrets.MEILI_ADMIN_KEY }}
run: npx tsx scripts/index-docs.ts
- name: Deploy Worker
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
command: deploy
- name: Deploy Assets
run: |
# Deploy static files to R2 or Pages
wrangler r2 object put docs-bucket --file book/htmx/
```
---
## Performance Considerations
| Metric | Typical Value |
|--------|---------------|
| Worker → Tunnel latency | 5-20ms |
| Meilisearch search latency | <10ms |
| Total E2E latency | <50ms |
### Optimization Strategies
1. **Cache popular queries** in Worker KV:
```typescript
const cacheKey = `search:${scope}:${query}`;
const cached = await c.env.SEARCH_CACHE.get(cacheKey);
if (cached) return c.html(cached);
// ... perform search ...
await c.env.SEARCH_CACHE.put(cacheKey, html, { expirationTtl: 300 });
```
2. **Pre-warm common queries** on deploy
3. **Use Meilisearch's typo tolerance** instead of client-side fuzzy matching
---
## Security Checklist
- [ ] Meilisearch only accessible via Cloudflare Tunnel
- [ ] Tunnel protected by Service Token (not just IP allowlist)
- [ ] Worker validates authentication before searching
- [ ] API keys stored in Wrangler secrets, never in code
- [ ] Query input sanitized (length limit, whitelist filters)
- [ ] Results filtered by authorization at runtime
- [ ] `content` field searchable but not displayed
- [ ] No raw error messages exposed to clients
---
## Troubleshooting
### Worker can't reach Meilisearch
```bash
# Verify tunnel is running
cloudflared tunnel info meilisearch-tunnel
# Check Cloudflare Access logs in Zero Trust Dashboard
```
### Search returns empty results
```bash
# Check Meilisearch has documents
curl -H "Authorization: Bearer $MEILI_ADMIN_KEY" \
http://localhost:7700/indexes/docs/stats
# Check index settings
curl -H "Authorization: Bearer $MEILI_ADMIN_KEY" \
http://localhost:7700/indexes/docs/settings
```
### HTMX requests fail
```javascript
// Check browser console for CORS errors
// Verify hx-post URL matches Worker route
// Check HX-Request header is present
```
---
## References
- [Cloudflare Tunnel Documentation](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)
- [Cloudflare Access Service Tokens](https://developers.cloudflare.com/cloudflare-one/identity/service-tokens/)
- [Meilisearch Security Best Practices](https://docs.meilisearch.com/learn/security/basic_security.html)
- [HTMX Requests](https://htmx.org/docs/#requests)
- [Hono Framework](https://hono.dev/)
Contributor guide
Assessment
This issue has not been assessed yet.