Skip to main content

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
caution

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​

Critical: use the correct entry point

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​

Component15K files
QMD index~2 GB
Haft catalog~500 MB
Embedding model (RAM)~1 GB
Total disk budget~5 GB