Aller au contenu

Self-hosted install

Ce contenu n’est pas encore disponible dans votre langue.

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.
EMBEDDINGS_MODELEmbedding 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_FLOOROverrides 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_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.

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=0 build 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}_ENABLED indexers 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-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.

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_PROVIDERDimColumnKeyNotes
openai1536embedding_1536OPENAI_API_KEYCalibrated floor 0.85
ollama768embedding_768— (local)Calibrated floor 0.85
deepinfra1024embedding_1024 (migration 133)DEEPINFRA_API_KEYOpenAI-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:

  1. 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.
  2. Or roll back EMBEDDINGS_PROVIDER to 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_KEY stays wired across a flip either way.

Note that even a same-dimension model change (e.g. the self-hosted Ollama default nomic-embed-textjina-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 still 0.85 and 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=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 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 set replicaCount: 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 so replicaCount: 1 still drains cleanly (it never wedges an eviction) while giving real availability at 2+.

  • Autoscaling — autoscaling.enabled: false (opt-in). Set it true to ship a HorizontalPodAutoscaler for server + mcp (minReplicas / maxReplicas / targetCPUUtilizationPercentage). When enabled the HPA owns the replica count and replicaCount is ignored. The CPU target is a percentage of the container CPU request, so set resources sensibly first.

  • NetworkPolicy — networkPolicy.enabled: false (opt-in). Set it true for a starting policy that allows same-namespace ingress to the container ports and egress to DNS, HTTPS (GitHub / LLM providers), and Postgres. When ollama.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, the RuntimeDefault seccomp profile, and as a non-root user (uid/gid 65532). That uid is contractually aligned with the hekkos user baked into the images (Dockerfile.server / Dockerfile.mcp) — if you rebuild the images with a different uid, change podSecurityContext.runAsUser to match. Because the root filesystem is read-only, each pod mounts a small writable /tmp emptyDir for 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, seccomp RuntimeDefault) but not readOnlyRootFilesystem or forced non-root — the upstream image writes model blobs to /root/.ollama and 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.