Self-hosting a public instance
This guide covers deploying a Haft reader as a public website with automatic TLS, backed by QMD search. Written from a production deployment of a 15,000-file vault.
Architecture
Browser → Caddy (TLS, :443) → Haft server (:9001, API + SPA)
↕
QMD bridge (:9002)
↕
QMD (SQLite + vector index)
- Haft server serves both the API and the reader SPA
- QMD provides keyword and semantic search
- Bridge maps QMD file-path results to Haft page IDs
- Caddy handles TLS termination and reverse proxying
Prerequisites
- A Linux server with ≥2 vCPUs and ≥8 GB RAM
- A domain or subdomain pointing at the server
- Bun and Node.js 22 installed
- QMD installed (
npm install -g @tobilu/qmd) - The Haft codebase (built from source)
Step 1: Transfer your vault
rsync -az /local/vault/ user@server:/opt/haft/vault/
Step 2: Index with QMD
Configure the collection in ~/.config/qmd/index.yml:
collections:
- name: my-vault
path: /opt/haft/vault
glob: "**/*.md"
Build the index. For large vaults, build locally and transfer (see Search with QMD for details):
qmd index /opt/haft/vault
qmd embed /opt/haft/vault
Step 3: Import into Haft
cd /opt/haft/haft
bun run src/index.ts import /opt/haft/vault --recursive
This is long-running (8–10 hours for 15K files on 2 vCPUs). Run under systemd or tmux. The catalog commits only at the end.
Step 4: Run the Haft server
Run bun run src/index.ts from the repo root, not apps/server/src/index.ts. The repo-root entry point serves both the API and the reader SPA. The apps/server entry point is API-only — you'll get raw JSON instead of the reader UI.
Create a systemd unit at /etc/systemd/system/haft-server.service:
[Unit]
Description=Haft reader server
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/haft/haft
ExecStart=/home/ubuntu/.bun/bin/bun run src/index.ts
Environment=HAFT_PORT=9001
Environment=HAFT_HOST=127.0.0.1
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Step 5: Set up the private semantic-candidate provider
Haft ships the QMD provider source in apps/qmd-provider. Do not copy an unversioned bridge script onto the host. The provider accepts a strict versioned request at POST /v1/candidates, always runs QMD vector search with the requested -n bound, and listens on loopback only.
Create /etc/haft/qmd-provider.env:
HAFT_QMD_PROVIDER_SHA=<exact-reviewed-git-sha>
HAFT_QMD_COLLECTION=my-vault
HAFT_QMD_BINARY=/usr/bin/qmd
HAFT_QMD_WORKING_DIRECTORY=/opt/haft/vault
HOME=/var/lib/haft
QMD_CONFIG_DIR=/etc/haft/qmd
INDEX_PATH=/var/lib/haft/qmd/index.sqlite
HAFT_QMD_PROVIDER_TIMEOUT_MS=2000
HAFT_QMD_PROVIDER_TERMINATION_GRACE_MS=250
HAFT_QMD_PROVIDER_MAX_CONCURRENCY=2
HAFT_QMD_PROVIDER_MAX_QUEUE=4
Install the repository template deploy/systemd/haft-qmd-provider.service.example as /etc/systemd/system/haft-qmd-provider.service, adjusting its checkout path if necessary. Pin QMD's configuration and index selectors explicitly in the private environment file; do not rely on the service user's HOME or QMD's default cache path. The exact selector names must match the reviewed QMD runtime. The SHA reported by /health must match the reviewed provider checkout being run. Use /ready for readiness checks; it returns 503 when the configured QMD executable or working directory is unavailable.
Point Haft's private adapter at the provider endpoint:
HAFT_QMD_ENDPOINT=http://127.0.0.1:9002/v1/candidates
Keep HAFT_QMD_SEARCH disabled until provider containment, catalog identity resolution, and semantic-index freshness checks are configured for the deployment. Public clients must call Haft's search API; they must never call the QMD provider directly.
Step 6: Reverse proxy with Caddy
sudo apt install -y caddy
Write /etc/caddy/Caddyfile:
your-domain.com {
reverse_proxy localhost:9001
}
Caddy obtains a Let's Encrypt certificate automatically on first request.
sudo systemctl restart caddy
Step 7: DNS
Create an A record pointing your domain at the server's public IP.
If using Cloudflare, set the proxy status to DNS only (grey cloud). Cloudflare's proxy conflicts with Caddy's certificate management.
Step 8: Start everything
sudo systemctl daemon-reload
sudo systemctl enable --now haft-server haft-qmd-provider caddy
Verify
# Reader UI loads (should return HTML)
curl -s https://your-domain.com | head -5
# API responds
curl -s https://your-domain.com/api/health
# Private provider liveness (the SHA must match the deployed checkout)
curl -s http://127.0.0.1:9002/health
# Private provider readiness (must return HTTP 200 before enabling semantic search)
curl -i -s http://127.0.0.1:9002/ready
# Inspect QMD through the exact provider environment. Do not use a bare
# interactive `qmd status`, which may select a different per-user cache/index.
sudo sh -c '
set -a
. /etc/haft/qmd-provider.env
set +a
exec "$HAFT_QMD_BINARY" status
'
# Bounded semantic-candidate contract probe
curl -s http://127.0.0.1:9002/v1/candidates \
-H 'content-type: application/json' \
-d '{"contractVersion":1,"query":"your query","limit":3}'
Troubleshooting
Raw JSON instead of reader UI
You're running the wrong entry point. Use bun run src/index.ts from the repo root, not apps/server/src/index.ts.
Search returns zero results
Check that the collection name in ~/.config/qmd/index.yml matches what was used during indexing. A mismatch (e.g., obsidian-vault vs my-vault) causes silent failures.
Semantic search times out
First query after idle may load the embedding model into memory. Do not compensate for persistent stalls by raising Haft's caller timeout while provider-side deadline, cancellation, and child cleanup are absent. Keep semantic execution disabled and inspect the private provider service.
Provider can't find qmd
systemd services don't inherit your shell $PATH. Set HAFT_QMD_BINARY to the full path of the QMD binary, such as /usr/bin/qmd.
Reader shows zero pages
The Haft import commits the catalog only at the end. Check import progress with journalctl -u haft-server -f.
Resource requirements
| Component | 15K files |
|---|---|
| QMD index | ~2 GB |
| Haft catalog | ~500 MB |
| Embedding model (RAM) | ~1 GB |
| Total disk budget | ~5 GB |