Skip to content

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:

  1. Prepare config (secrets, base URL).
  2. Bring up PostgreSQL.
  3. Boot the server in setup mode and register the GitHub App.
  4. Paste the App credentials into your config and restart.
  5. 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 it APP_BASE_URL below.
  • PostgreSQL 16+ with the pgvector extension. 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

Terminal window
# 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_SECRET

2. Write your config

Copy .env.example and fill it in. The required values for self-hosted:

VariableValue
DATABASE_URLpostgres://user:pass@host:5432/hekkos
MASTER_KEYfrom step 1
GITHUB_WEBHOOK_SECRETfrom step 1
APP_BASE_URLyour public base URL
DEPLOY_MODEself-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):

VariableWhy
JWT_EXPIRYSession lifetime (default 8h).
CONTENT_RETENTION_DAYSWindow (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_TIMEOUTBound external calls (Ollama 70B can be slow — the LLM default is generous).
RIVERUI_BASIC_AUTH_USER / _PASSWORDEnable + protect the job-queue UI at /admin/jobs/.
LICENSE_FILE_PATH / LICENSED_ORG_NAMEYour license (see Licensing below); LICENSE_SIGNING_KEY_PATH only on a deployment that issues licenses.
COMMUNITY_INGEST_ENABLED + CATALOG_REPO / CATALOG_GITHUB_TOKENOpt into community-catalogue ingestion (opens a review PR; never a live write).
TIERED_RETRIEVAL_ENABLEDDefaults 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.
OLLAMA_EMBED_MODELSelf-hosted embedder model (default jina-embeddings-v2-base-code, code-specialized 768-dim). ollama pull it first, or set nomic-embed-text to keep the older default.
CONTEXTUAL_RETRIEVAL_ENABLEDDefaults 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_CMDDefaults 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.

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-llm profile (docker compose --profile local-llm up) and the Helm chart’s ollama.enabled=true bring 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 with CLASSIFIER_PROVIDER=ollama. See Running LLM roles on a local model.

Set ALERT_WEBHOOK_URL to 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.

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=1 and 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

  1. Visit APP_BASE_URL/setup/github-app.

  2. Enter your GitHub org slug and click Continue to GitHub.

  3. GitHub creates the “Hekkos” App from a manifest (permissions, events, and — importantly — the Setup URL and OAuth callback are all pre-filled for you).

  4. 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=...
  5. Copy all five into your config (replacing the throwaway App id/key), keep your own GITHUB_WEBHOOK_SECRET from step 1, and restart the server.

GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET are 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

  1. Go to APP_BASE_URL, click Login with GitHub, authorize.
  2. 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.
  3. 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.)
  4. 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.
  5. 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.
  6. 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_URL set and publicly reachable over HTTPS.
  • All five GitHub App vars set; login works.
  • ALERT_WEBHOOK_URL set.
  • PostgreSQL has automated backups; you’ve tested a restore.
  • TLS terminated at your ingress (the Helm chart ships ingress.tls empty — supply your own).
  • /metrics gated: set METRICS_BASIC_AUTH_USER / METRICS_BASIC_AUTH_PASSWORD (or keep the endpoint network-isolated).
  • Smoke test: GET APP_BASE_URL/livez returns ok; GET /readyz returns ready.

Kubernetes

The helm/hekkos chart deploys the server + MCP with liveness/readiness probes, resource limits, a non-root 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. Keep replicaCount: 1 unless you’ve confirmed the concurrent-migration lock is in place (it is) for HA.