Self-hosted install
Stand up Hekkos in your own infrastructure, end to end. Budget ~30 minutes. Hekkos is a Go server + an MCP server + PostgreSQL (with pgvector). It runs a GitHub App, so part of the setup happens on GitHub.
At a high level:
- Prepare config (secrets, base URL).
- Bring up PostgreSQL.
- Boot the server in setup mode and register the GitHub App.
- Paste the App credentials into your config and restart.
- Sign in and connect your first source.
0. Prerequisites
- A public HTTPS base URL for the deployment (e.g.
https://hekkos.example.com). GitHub must be able to reach it for webhooks and redirects. Call itAPP_BASE_URLbelow. - PostgreSQL 16+ with the
pgvectorextension. Hekkos runs migrations itself on startup — you just need an empty database it can connect to. Back it up; Hekkos does not manage backups for you. - Docker (the quickest path) or the built binaries.
1. Generate secrets
# 32-byte AES key that encrypts every secret at rest. Generate a REAL one —# a placeholder (all-identical bytes) is rejected at startup.openssl rand -hex 32 # -> MASTER_KEY
# Any strong random string; you'll give the same value to the GitHub App later.openssl rand -hex 20 # -> GITHUB_WEBHOOK_SECRET2. Write your config
Copy .env.example and fill it in. The required values for self-hosted:
| Variable | Value |
|---|---|
DATABASE_URL | postgres://user:pass@host:5432/hekkos |
MASTER_KEY | from step 1 |
GITHUB_WEBHOOK_SECRET | from step 1 |
APP_BASE_URL | your public base URL |
DEPLOY_MODE | self-hosted (the default) |
GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_CLIENT_ID, and
GITHUB_CLIENT_SECRET come from step 4 — leave them blank for now.
Useful optional self-hosted knobs (full annotated reference: docs/reference/configuration.md):
| Variable | Why |
|---|---|
JWT_EXPIRY | Session lifetime (default 8h). |
CONTENT_RETENTION_DAYS | Window (default 180 days) after which the retention sweep NULLs private stored doc content. Always bounded — 0/unset floors to 180 days; it does not disable retention or keep content forever. |
LLM_CALL_TIMEOUT / GITHUB_CALL_TIMEOUT | Bound external calls (Ollama 70B can be slow — the LLM default is generous). |
RIVERUI_BASIC_AUTH_USER / _PASSWORD | Enable + protect the job-queue UI at /admin/jobs/. |
LICENSE_FILE_PATH / LICENSED_ORG_NAME | Your license (see Licensing below); LICENSE_SIGNING_KEY_PATH only on a deployment that issues licenses. |
COMMUNITY_INGEST_ENABLED + CATALOG_REPO / CATALOG_GITHUB_TOKEN | Opt into community-catalogue ingestion (opens a review PR; never a live write). |
TIERED_RETRIEVAL_ENABLED | Defaults off for self-hosted (your orgs run their own embedder and get semantic chat). Set true if you want free-tier orgs on keyword search. |
EMBEDDINGS_MODEL | Embedding model for the resolved provider (default jina-embeddings-v2-base-code on self-hosted Ollama, code-specialized 768-dim). ollama pull it first, or set nomic-embed-text to keep the older default. The unified knob across providers (supersedes the legacy OLLAMA_EMBED_MODEL). |
EMBEDDINGS_GROUNDING_FLOOR | Overrides the RAG grounding-floor cosine distance (gates abstain + billing). Empty = the model’s calibrated default (0.85 for Ollama/OpenAI). Required when EMBEDDINGS_PROVIDER=deepinfra (its floor is uncalibrated until #711 — fatal at boot without it). |
CONTEXTUAL_RETRIEVAL_ENABLED | Defaults off. When true, the indexer adds a short LLM “situating context” per chunk (memoized, budget-metered) to lift retrieval recall — it runs only for paid/licensed orgs, so an unlicensed deployment indexes raw regardless. Adds per-chunk LLM cost on a doc’s first scan. |
CALL_EDGES_{TS,PY,RUST,RUBY,JAVA}_ENABLED + {LANG}_INDEXER_BIN + {LANG}_INDEXER_SANDBOX_CMD | Defaults off. Opt into a non-Go call graph (calls_scip* edges) for TypeScript / Python / Rust / Ruby / Java. Self-hosted uses the subprocess backend: point _BIN at the language’s SCIP indexer (scip-typescript / scip-python / rust-analyzer / scip-ruby / scip-java) and _SANDBOX_CMD at a sandbox wrapper (untrusted repo code runs under it — AGENTS.md #12). Go’s call graph is always on (in-process, no config). Fail-safe: a missing/failing indexer just yields no call edges for that language, never a broken scan. Full var list in the configuration reference. |
RERANKER_ENABLED + RERANKER_URL (+ _MODEL / _API_KEY / _TIMEOUT) | Defaults off. A cross-encoder reranker reorders the retrieval pool before the LLM (quality only, no user-facing change). It needs a served cross-encoder behind an https URL — Ollama CANNOT serve it (Ollama’s API is embeddings + generate only). D1 ships an HTTP backend only, and the zero-new-sidecar in-process ONNX path is deferred, so self-hosted defaults OFF until either that lands or you stand up a served endpoint yourself. SaaS runs a sealed Infinity CPU sidecar (michaelf34/infinity serving mixedbread-ai/mxbai-rerank-xsmall-v1, provisioned by Terraform) — a self-hosted operator can do the same (an Infinity or TEI container, a second sidecar) or point RERANKER_URL at a hosted rerank API. Fail-safe: an unset/unreachable/slow reranker degrades to RRF order, never breaks a query — and RERANKER_ENABLED=true with an empty/cleartext URL is caught loud at boot (rerank.ConfigWarning Warn + a rerank.Preflight check), not fatal. Full var list in the configuration reference. |
Scan coverage: cgo and SCIP
What a baseline scan actually covers depends on how the server is built and which indexers are enabled — and both are now disclosed per scan in a coverage manifest, so the Real-State Trust number is presented over a known slice rather than an undisclosed one.
- cgo build. In a
CGO_ENABLED=0build the tree-sitter languages — TypeScript/JavaScript, Python, Rust, Java, Ruby — are extracted at the low-confidence regex tier: no structured function/type/signature drift for them (only dead-reference findings). Go, Terraform/HCL, and YAML are pure-Go and stay structured regardless. The cgo server build lifts those five to structured extraction. - SCIP indexers. With the
CALL_EDGES_{TS,PY,RUST,RUBY,JAVA}_ENABLEDindexers off (the default — see the table above), there is no cross-language call graph for those languages. Go’s call graph is always on.
Both states are recorded on each scan’s coverage manifest. A degraded scan surfaces
verbatim warnings — e.g. “cgo off — python, typescript extracted at regex tier”
and “SCIP off — java call graph not indexed” — visible via the real-state metrics
response (coverage_manifest and the measured_note “structured extraction covered
X% of code files” qualifier, repo-scoped) and the MCP get_real_state tool’s
_Coverage: …_ note. Truncation at the fetch cap is disclosed the same way
(“scan truncated … Trust measured over the first N files”); the caps are not raised.
What a scan skips (denominator accuracy). Build/cache output directories
(target/, .venv/, __pycache__/, .tox/, .mypy_cache/, obj/) are never
scanned — their contents are machine output, not authored surface. testdata/ is
also skipped, by Go fixture convention. (bin/ is deliberately not skipped — it
often holds real committed scripts.) Generated files (.pb.go, and any file marked
@generated or Code generated … DO NOT EDIT) are excluded from the coverage
denominator so committed codegen does not read as undocumented surface.
For the LLM, self-hosted mode uses Ollama by default; set OLLAMA_BASE_URL
to your Ollama instance (it needs models like llama3.1:8b / llama3.1:70b
pulled).
Local models. You don’t have to run Ollama yourself — the compose
local-llmprofile (docker compose --profile local-llm up) and the Helm chart’sollama.enabled=truebring up an Ollama and pull the model for you. In saas mode you can also keep hosted generation but move just the cheap classifier/diff-summarizer role to a local model withCLASSIFIER_PROVIDER=ollama. See Running LLM roles on a local model.
Set
ALERT_WEBHOOK_URLto a Slack/Discord/PagerDuty-compatible incoming webhook. Without it, operational alerts (failed jobs, revoked LLM keys, budget exhaustion) are silently dropped. The server logs a warning at startup if it’s unset.
Changing the embedding provider
The embedding provider is chosen at boot from EMBEDDINGS_PROVIDER (or, unset, the
DEPLOY_MODE default — ollama self-hosted, openai saas). There are three
providers, each writing and reading a different vector column:
EMBEDDINGS_PROVIDER | Dim | Column | Key | Notes |
|---|---|---|---|---|
openai | 1536 | embedding_1536 | OPENAI_API_KEY | Calibrated floor 0.85 |
ollama | 768 | embedding_768 | — (local) | Calibrated floor 0.85 |
deepinfra | 1024 | embedding_1024 (migration 133) | DEEPINFRA_API_KEY | OpenAI-compatible, pooled; requires EMBEDDINGS_GROUNDING_FLOOR (floor uncalibrated until #711) |
The active column is picked from the current config, not from what’s stored.
Switching providers on an already-populated database makes RAG return empty over every previously-indexed chunk until you re-embed it — the old vectors sit in the now-inactive column and the query path skips them. This is a one-time, self-inflicted step, not a data-loss event: the content is still there, just embedded under the other model.
Two guards make the transition safe. The server detects a stranded column at boot and
logs a loud embedding dimension mismatch error naming the stranded chunk count
(it never refuses to start, so a deliberate swap-then-rescan isn’t blocked). And on the
query path #708’s per-chunk embed_model stamp makes RAG fail closed — returning a
re-index message rather than silently matching nothing — whenever the org’s stored stamp
differs from the resolved model. To resolve it:
- Re-scan every connected repo (a full scan re-embeds all chunks under the active provider; the delete-then-reindex wipes the stale column). Once every chunk carries an active-column vector under the active model, both guards clear.
- Or roll back
EMBEDDINGS_PROVIDERto the previous value and re-scan — because the re-scan wipes the old vectors, rollback is itself a re-scan under the old provider, not a column toggle.OPENAI_API_KEYstays wired across a flip either way.
Note that even a same-dimension model change (e.g. the self-hosted Ollama default
nomic-embed-text → jina-embeddings-v2-base-code, both 768-dim, via EMBEDDINGS_MODEL)
leaves un-rescanned chunks embedded under the old model — a query vector only matches
its own model’s chunks, so a full re-scan is the same remedy. (The boot dimension check
catches the cross-dimension case; a same-dimension model swap is caught by the
embed_model fail-closed gate and surfaced in the capability manifest / boot log — see
#650 / #708.)
DeepInfra is gated on #711. The DeepInfra embeddings backend is wired and dormant: the default (unset
EMBEDDINGS_PROVIDER) is unchanged, and the actual flip waits on #711’s eval, which picks the model (Qwen3 vs bge-m3) and each model’s calibrated grounding floor. Self-hosted Ollama’s 768 floor is still0.85and remains uncalibrated (#650 C2 is resolved for the SaaS-embed axis only).
3. Bring up PostgreSQL and the server (setup mode)
The GitHub App doesn’t exist yet, so GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY
are still blank — and the server won’t fully boot without them. To break this
chicken-and-egg, start the server in setup mode just long enough to register
the App:
- Provide a throwaway
GITHUB_APP_ID=1and a throwaway PEM (openssl genrsa 2048) so config validation passes, and start the server. - Everything that needs the real App will be non-functional, but the manifest setup route works.
docker compose up (using docker-compose.yml, which wires Postgres for you) is
the easiest way to get there.
4. Register the GitHub App
-
Visit
APP_BASE_URL/setup/github-app. -
Enter your GitHub org slug and click Continue to GitHub.
-
GitHub creates the “Hekkos” App from a manifest (permissions, events, and — importantly — the Setup URL and OAuth callback are all pre-filled for you).
-
GitHub redirects back and shows you the credentials once:
GITHUB_APP_ID=...GITHUB_WEBHOOK_SECRET=...GITHUB_APP_PRIVATE_KEY="..."GITHUB_CLIENT_ID=...GITHUB_CLIENT_SECRET=... -
Copy all five into your config (replacing the throwaway App id/key), keep your own
GITHUB_WEBHOOK_SECRETfrom step 1, and restart the server.
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETare what “Login with GitHub” uses. If you skip them, the App works for webhooks but no one can sign in.
5. Sign in and connect a source
- Go to
APP_BASE_URL, click Login with GitHub, authorize. - Create your org — name only; membership starts in Manual (invite-only) mode and you’re its admin. The onboarding wizard opens — four steps: Connect → Preferences → Team → Confirm.
- The wizard’s first step is Connect GitHub: install the App on the repos you want, and GitHub returns you to the wizard with the source connected. (If it doesn’t, see GitHub App configuration.)
- The initial scan starts automatically while you finish the wizard’s remaining steps — a scan strip shows the repo counts on every step, and Overview afterwards.
- Preferences shows the settled defaults (doc standards treated as recommended, pilot-first rollout; BYOK deployments also get an LLM-key row) — accept them as-is, or open a “Customize” link to adjust one. From this step onward, Finish with defaults ends the wizard early with the same safe values.
- The wizard’s Team step is where teammates come in: create an invite link (Manual mode), or — once the GitHub source is connected — switch to GitHub-org mirroring, where each member of your GitHub org joins at their first sign-in. Confirm recaps your choices (each with an edit jump), then lands you on Overview — its Setup checklist card (connect · first scan · adopt a doc standard · team) is the post-wizard guide, so anything you skipped stays one click away until all four items are done.
Teammates who sign in early. Self-hosted deployments are single-org: a second person who logs in before being invited can’t create another org — they see a screen explaining that this deployment already has an organization, with guidance on how to join (and the org’s name, if an invite is already pending for their email). Send them an invite link from the Team step or Settings → Members, or switch on mirroring and have them sign in again.
Licensing
Self-hosted Hekkos runs at free-tier limits with no license. To lift them,
set LICENSE_FILE_PATH (and LICENSED_ORG_NAME) to a license issued to you. A
missing or expired license never takes the server down — it degrades to
free-tier enforcement and logs a warning.
Production checklist
- Real
MASTER_KEY(not a placeholder), stored in your secret manager. -
APP_BASE_URLset and publicly reachable over HTTPS. - All five GitHub App vars set; login works.
-
ALERT_WEBHOOK_URLset. - PostgreSQL has automated backups; you’ve tested a restore.
- TLS terminated at your ingress (the Helm chart ships
ingress.tlsempty — supply your own). -
/metricsgated: setMETRICS_BASIC_AUTH_USER/METRICS_BASIC_AUTH_PASSWORD(or keep the endpoint network-isolated). - Smoke test:
GET APP_BASE_URL/livezreturnsok;GET /readyzreturnsready.
Kubernetes
The helm/hekkos chart deploys the server + MCP with liveness/readiness probes,
resource limits, a hardened securityContext, and secrets via a Kubernetes
Secret. Set your image tags and the required env/secret values in
values.yaml. Bring your own backed-up PostgreSQL (the chart does not ship one),
and supply TLS at the ingress.
Production hardening (chart defaults)
The chart ships production-hardened out of the box. What that means, and the knobs to adjust it:
-
High availability —
replicaCount: 2(default). Two replicas survive a rolling update and a single node loss without dropping the service. The concurrent-migration lock makes this safe (multiple replicas booting at once won’t race migrations). On a single-node dev cluster you can setreplicaCount: 1. -
PodDisruptionBudget —
podDisruptionBudget.maxUnavailable: 1(default on). During a voluntary disruption (node drain, cluster upgrade) at most one pod of each app goes down at a time. It’s written soreplicaCount: 1still drains cleanly (it never wedges an eviction) while giving real availability at 2+. -
Autoscaling —
autoscaling.enabled: false(opt-in). Set ittrueto ship a HorizontalPodAutoscaler for server + mcp (minReplicas/maxReplicas/targetCPUUtilizationPercentage). When enabled the HPA owns the replica count andreplicaCountis ignored. The CPU target is a percentage of the container CPU request, so setresourcessensibly first. -
NetworkPolicy —
networkPolicy.enabled: false(opt-in). Set ittruefor a starting policy that allows same-namespace ingress to the container ports and egress to DNS, HTTPS (GitHub / LLM providers), and Postgres. Whenollama.enabled=true, the policy also allows egress to the bundled in-cluster Ollama on 11434 — so the air-gapped pair (ollama.enabled+networkPolicy.enabled) no longer silently severs every embed/generation call. Caveats: it only takes effect if your CNI enforces NetworkPolicy, and it is a starting point you are expected to tighten (lock egress to your known provider/DB CIDRs) — leaving it default-off avoids silently breaking outbound calls on clusters where those endpoints don’t match the shipped assumptions. -
Container hardening (server + mcp). The containers run with
readOnlyRootFilesystem: true, all Linux capabilities dropped, no privilege escalation, theRuntimeDefaultseccomp profile, and as a non-root user (uid/gid 65532). That uid is contractually aligned with thehekkosuser baked into the images (Dockerfile.server/Dockerfile.mcp) — if you rebuild the images with a different uid, changepodSecurityContext.runAsUserto match. Because the root filesystem is read-only, each pod mounts a small writable/tmpemptyDirfor the Go runtime’s temp files. -
Ollama hardening (lighter). When
ollama.enabled: true, the bundled Ollama gets a lighter posture (drop all caps, no privilege escalation, seccompRuntimeDefault) but notreadOnlyRootFilesystemor forced non-root — the upstream image writes model blobs to/root/.ollamaand runs as its own user, and forcing either would break model storage.
Cloud Run (SaaS operators). The bespoke sealed services (the CPU reranker and the per-language SCIP indexers + practice-scan shim) now carry an ongoing liveness probe in addition to their startup probe, so a hung instance is restarted rather than left serving. This is Terraform-managed and needs no action on a self-hosted install.