Lattice is a self-hosted personal knowledge system for mirroring an Obsidian vault from S3 onto a Raspberry Pi, indexing it with QMD, and exposing a secure web UI for grounded retrieval.
The repository is the single source of truth for application code, container orchestration, sync scripts, deployment notes, and operational runbooks.
- Pulls a read-only mirror of an Obsidian vault from S3 into a dedicated local directory on the Pi
- Runs
qmd updateafter sync and optionallyqmd embedbased on change strategy - Persists vault, QMD index, and status data across restarts
- Exposes a simple web UI for questions, sources, health, and manual sync
- Supports both QMD retrieval and an OpenCode-backed grounded query path over the mirrored vault
- Publishes only the web app through Cloudflare Tunnel and protects it with Cloudflare Access
- Keeps QMD, OpenCode query, and the sync worker on the internal Docker network only
Obsidian / Remotely Save
-> AWS S3 bucket + prefix
-> sync-worker container
-> local vault mirror volume
-> QMD index volume
-> qmd service container
-> opencode-query service container
-> Next.js web app
-> Cloudflare Tunnel + Access
-> phone / laptop browser
Recurring sync is handled inside the container stack. A lightweight scheduler container calls the sync worker at a fixed interval. Host-level systemd is used only to keep Docker Compose up across Raspberry Pi reboots.
lattice/
apps/web/ Next.js App Router UI and route handlers
services/qmd/ Internal QMD-backed retrieval service
services/opencode-query/ Internal OpenCode-backed grounded query service
services/sync-worker/ Sync + index orchestration API
services/scheduler/ Interval trigger container
infra/docker/ Docker Compose stack
infra/aws/ Read-only IAM policy
infra/cloudflare/ Tunnel and Access notes
infra/systemd/ Bootstrapping unit for Docker Compose
scripts/ Shell scripts for sync, update, embed, health
docs/ Product, architecture, and deployment docs
data/ Local development bind mount root placeholder
- Raspberry Pi with a recent 64-bit Linux distribution
- Docker Engine with Compose plugin
- AWS account with an S3 bucket already fed by Remotely Save
- Cloudflare account with Zero Trust enabled
- A domain managed in Cloudflare
- Enough local disk for:
- full vault mirror
- QMD index database and model artifacts
- sync logs and status metadata
- Copy
.env.exampleto.env. - Set S3 bucket, prefix, region, and read-only credentials.
- Set
LATTICE_DATA_ROOTto an absolute path or leave the local default../../data/runtime. The value is consumed by Compose frominfra/docker/docker-compose.yml, so relative paths are resolved frominfra/docker/. - For ChatGPT subscription models, place your OpenCode
auth.jsonat the repository root asopencode-auth.jsonand leaveOPENCODE_OPENAI_AUTH_HOST_FILE=../../opencode-auth.jsonin.env. - Start the stack:
make up- Open
http://localhost:${WEB_PORT}if you are running locally without Cloudflare.
For app-only iteration:
cd apps/web
npm install
npm run devNode-based backend services now compile TypeScript source from src/ into dist/ before runtime. For local backend iteration, build the service first and then run its compiled entrypoint via the service-local start script.
- Install Docker and Compose on the Raspberry Pi.
- Clone this repository to a stable path such as
/opt/lattice. - Create
.envfrom.env.example. - Put a ChatGPT/OpenAI OAuth auth file at
/opt/lattice/opencode-auth.jsonand keepOPENCODE_OPENAI_AUTH_HOST_FILE=../../opencode-auth.jsonin.env. - Create the runtime directories:
mkdir -p /srv/lattice/{vault,qmd,status,logs,chat}- Point
LATTICE_DATA_ROOT=/srv/lattice. - Start the stack:
docker compose --env-file .env -f infra/docker/docker-compose.yml up --build -d- Install the systemd unit in
infra/systemd/lattice-compose.serviceso the stack comes back after reboot.
| Variable | Purpose |
|---|---|
LATTICE_PUBLIC_URL |
External URL used by the UI and Cloudflare |
LATTICE_DATA_ROOT |
Host directory for persistent bind mounts |
CHAT_DB_PATH |
Local SQLite path used for persisted chat history |
WEB_AUTH_MODE |
Web auth mode: dev is the default local setup, cloudflare requires the Cloudflare Access email header, and auto is an optional hybrid mode that prefers the Cloudflare header and otherwise uses WEB_DEV_USER_EMAIL when configured |
WEB_DEV_USER_EMAIL |
Development identity used when WEB_AUTH_MODE=dev, and as the fallback identity in WEB_AUTH_MODE=auto when Cloudflare headers are absent |
OPENROUTER_API_KEY |
OpenRouter API key used by the OpenCode query service for non-OpenAI models |
OPENCODE_OPENAI_AUTH_HOST_FILE |
Explicit host path to the OpenCode auth.json file for ChatGPT subscription-backed openai/* models. The default ../../opencode-auth.json resolves to the repository-root opencode-auth.json from the Compose file location. |
SYNC_S3_BUCKET |
S3 bucket containing the mirrored vault |
SYNC_S3_PREFIX |
Bucket prefix used for the Obsidian vault |
SYNC_AWS_REGION |
AWS region for the S3 bucket |
SYNC_DELETE |
Whether local mirror deletes files removed from S3 |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
Read-only AWS credentials for the Pi |
QMD_COLLECTION |
Single collection name used in v1 |
OPENCODE_MODEL |
Default OpenCode model identifier. Supported values: anthropic/claude-sonnet-4.6, anthropic/claude-opus-4.6, openai/gpt-5.5, or google/gemini-2.5-pro. Falls back to GPT-5.5 if unset or invalid. |
OPENCODE_QUERY_TIMEOUT_MS |
OpenCode inactivity timeout in milliseconds. Resets whenever the worker emits progress. Set to 0 to disable. Defaults to 120000. |
OPENCODE_PROMPT_HEARTBEAT_MS |
Heartbeat interval in milliseconds while an OpenCode prompt is still running. Defaults to 15000. |
OPENCODE_WORKER_SHUTDOWN_GRACE_MS |
Grace period in milliseconds before a timed-out worker and its OpenCode process group are force-killed. Defaults to 5000. |
QMD_EMBED_STRATEGY |
on-change, always, never, or manual |
CLOUDFLARE_TUNNEL_TOKEN |
Token for the optional cloudflared service |
Use a dedicated IAM principal with read-only permissions scoped to the exact bucket and prefix. The starter policy lives at infra/aws/readonly-iam-policy.json.
Recommended permissions:
s3:ListBucketon the bucket with a prefix conditions3:GetObjecton the relevant object ARN prefix
Do not grant write or delete permissions from the Raspberry Pi side.
Only the web service should be exposed. QMD and the sync worker remain internal-only.
- Create a Cloudflare Tunnel for the app hostname.
- Point the public hostname at
http://web:${WEB_INTERNAL_PORT}inside the stack. - Add a Cloudflare Access policy requiring your identity provider before the web app is reachable.
- Put the generated tunnel token into
CLOUDFLARE_TUNNEL_TOKEN.
For local development without Cloudflare, use WEB_AUTH_MODE=dev and set WEB_DEV_USER_EMAIL=you@example.com. For production behind Cloudflare Access, set WEB_AUTH_MODE=cloudflare so requests must include the Cloudflare Access email header. Use WEB_AUTH_MODE=auto only if you intentionally want one config that accepts Cloudflare-authenticated traffic and also falls back to WEB_DEV_USER_EMAIL in non-Cloudflare environments.
More detail is in infra/cloudflare/tunnel-notes.md.
The ChatGPT subscription flow requires an OpenCode auth.json file. Lattice intentionally does not read OpenAI OAuth credentials from .env; the auth file must be an explicit repository-root file so setup failures are obvious and token refreshes can persist.
- On a machine where OpenCode is already logged in, copy
~/.local/share/opencode/auth.jsonto the Lattice repository root asopencode-auth.json. - Keep
OPENCODE_OPENAI_AUTH_HOST_FILE=../../opencode-auth.jsonin.env. This path is relative toinfra/docker/docker-compose.yml. - Start or recreate the stack with
make up.
The file is ignored by git via .gitignore. Do not commit, paste, or log opencode-auth.json; it contains live OAuth refresh and access tokens. The opencode-query container bind-mounts this file at /app/opencode-data/opencode/auth.json, sets XDG_DATA_HOME=/app/opencode-data, and sets OPENCODE_OPENAI_AUTH_FILE to the same auth path. This makes Lattice and OpenCode use the same file, so OpenCode's OAuth token refreshes persist back to the repository-root opencode-auth.json.
Persistent directories expected under LATTICE_DATA_ROOT:
vault/for the local mirrorqmd/for the QMD SQLite store and model cachechat/for the web app SQLite chat historystatus/forstatus.jsonlogs/for per-run sync and indexing logs
These directories must not be committed. They are bind-mounted into the containers.
schedulerposts tosync-workerevery 5 minutes by default.sync-workerrunsscripts/sync-vault.sh.- On successful sync, it runs
scripts/run-qmd-update.sh. - It optionally runs
scripts/run-qmd-embed.shbased onQMD_EMBED_STRATEGY. - It writes human-readable and machine-readable status for the UI.
- The web UI can also trigger the same
/runpipeline manually.
docker compose logs -ffor service logsscripts/healthcheck.shfor lightweight HTTP health probesstatus.jsonunder the status volume for last sync and indexing state
The web UI surfaces:
- last sync time
- last successful sync time
- last index update time
- current run state
- embeddings state
- QMD, OpenCode, and sync worker health
Common failure modes:
- AWS auth errors: verify credentials, region, bucket, and prefix
- Empty results after sync: confirm the vault actually landed in the mirror path
- QMD failures: check whether the collection exists and whether the database path is writable
- Cloudflare 502/Access errors: verify the tunnel token and origin service hostname
- Manual sync stuck in running state: inspect sync-worker logs for a failed child process or lock cleanup issue
Useful commands:
docker compose --env-file .env -f infra/docker/docker-compose.yml ps
docker compose --env-file .env -f infra/docker/docker-compose.yml logs -f sync-worker
curl -fsS http://localhost:4000/status | jq- Rebuild after code changes with
make up - Back up the
vault/,qmd/,chat/, andstatus/directories before major upgrades - If QMD schema changes, stop the stack, snapshot the
qmd/directory, then rebuild - Rotate the AWS key and Cloudflare tunnel token periodically
- AWS CLI is the default sync implementation
- One QMD collection for the vault
- Retrieval-first answering, with synthesized-answer support left as a future layer
- Containerized scheduling outside the Next.js process
- Systemd only for machine bootstrapping, not recurring jobs