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