# Install with Helm

> Install self-hosted MergeHarbor on Kubernetes (EKS, GKE, AKS, OpenShift) with the Helm chart, an external PostgreSQL database, your own registry and TLS.

The Helm chart installs the console, API, workers and review agent. PostgreSQL is yours: a managed service such as Amazon RDS or Aurora, Google Cloud SQL or Azure Database for PostgreSQL, or a database you run. The chart works on any conformant Kubernetes 1.27+ cluster, including EKS, GKE, AKS and OpenShift.

## Before you start

- **PostgreSQL 15 or later** with the `vector` (pgvector) extension **0.8.0 or later** available (code search stores 16-bit vectors and uses iterative index scans; the migration stops with instructions on an older version). The first migration creates the extension. On Azure, allow-list `vector`; RDS, Aurora and Cloud SQL include it.
- **Embeddings**: set `embedder.enabled: true` to run MergeHarbor's CPU embeddings server in the cluster (no GPU, no download, no per-token cost), or bring any endpoint that speaks the OpenAI `/v1/embeddings` API, such as Ollama, vLLM or the OpenAI API.
- **An ingress controller** and a TLS certificate: cert-manager, or a Secret you create.
- **A license file** (`mergeharbor.lic`) from MergeHarbor.

## 1. Create the secrets

```sh
kubectl create namespace mergeharbor
AGENT_SHARED_SECRET="$(openssl rand -hex 32)"

kubectl -n mergeharbor create secret generic mergeharbor-core \
  --from-literal=DATABASE_URL='postgres://mergeharbor:<password>@db.internal:5432/mergeharbor?sslmode=verify-full' \
  --from-literal=CONTEXT_SECRET="$(openssl rand -hex 32)" \
  --from-literal=AGENT_SHARED_SECRET="$AGENT_SHARED_SECRET" \
  --from-literal=ADMIN_TOKEN="$(openssl rand -hex 32)" \
  --from-literal=SECRETBOX_KEYS="k1:$(openssl rand -base64 32)" \
  --from-literal=AUTH_PEPPER="$(openssl rand -hex 32)"

kubectl -n mergeharbor create secret generic mergeharbor-agent \
  --from-literal=AGENT_SHARED_SECRET="$AGENT_SHARED_SECRET" \
  --from-literal=ANTHROPIC_API_KEY='<key>'          # or OPENAI_API_KEY / SELFHOSTED_LLM_API_KEY

kubectl -n mergeharbor create secret generic mergeharbor-license \
  --from-file=license.lic=./mergeharbor.lic
```

Add your code host's credentials to `mergeharbor-core` too: for a GitHub App, `GITHUB_PRIVATE_KEY` and `GITHUB_WEBHOOK_SECRET`. See [GitHub](https://mergeharbor.com/docs/github) and the other code host guides. With External Secrets Operator, set `externalSecrets.enabled: true` to sync these Secrets from AWS Secrets Manager or Vault instead.

## 2. Write a values file

```yaml
profile: small # small | medium | large
ingress:
  className: "" # your IngressClass; empty = the cluster default
  host: mergeharbor.example.com
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
  tls: { enabled: true } # secret <release>-tls, or set tls.secretName
embedder:
  enabled: true # in-cluster embeddings; or set config.EMBED_URL, EMBED_MODEL and EMBED_DIM for your own server
config:
  MODEL_PROVIDERS: anthropic # the providers the agent has credentials for
license:
  existingSecret: mergeharbor-license
agent:
  llmBackend: anthropic
```

The chart includes this as `examples/minimal-values.yaml`.

Changing `EMBED_URL`, `EMBED_MODEL` or `EMBED_DIM` later is safe: code search never
compares vectors from two models. Every repository is re-embedded in the background, and
until it is, its semantic search results are incomplete (exact-text search is unaffected).

## 3. Install

```sh
helm install mergeharbor oci://ghcr.io/mergeharbor/charts/mergeharbor \
  --version <version> -n mergeharbor -f values.yaml
```

On the first install, pods apply the database schema themselves, one at a time.

## 4. Create the first administrator

If you sign in through your identity provider, set `config.INSTANCE_ADMIN_EMAILS` to your address and sign in. Otherwise, create the first organization and its owner with the break-glass admin API, then turn the token off again:

1. Turn the token on and name yourself an instance administrator:

   ```sh
   helm upgrade mergeharbor oci://ghcr.io/mergeharbor/charts/mergeharbor --version <version> -n mergeharbor \
     -f values.yaml --set config.ADMIN_TOKEN_ENABLED=true --set config.INSTANCE_ADMIN_EMAILS=you@example.com
   ```

2. Forward the API's internal admin port, which no Service exposes:

   ```sh
   kubectl -n mergeharbor port-forward deploy/mergeharbor-api 8082:8082 &
   ADMIN_TOKEN="$(kubectl -n mergeharbor get secret mergeharbor-core -o jsonpath='{.data.ADMIN_TOKEN}' | base64 -d)"
   ```

3. Create the organization, then invite its owner:

   ```sh
   curl -s -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -d '{"name":"Example Corp"}' http://127.0.0.1:8082/api/v1/orgs
   curl -s -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -d '{"email":"you@example.com","role_id":"owner"}' \
     http://127.0.0.1:8082/api/v1/orgs/<handle>/invitations
   ```

   Use the `handle` returned by the first call in the second one.

4. Open the `accept_url` from the second response and choose a password.
5. Run `helm upgrade` again without `config.ADMIN_TOKEN_ENABLED=true`, and keep `INSTANCE_ADMIN_EMAILS` in your values file.

## Common settings

| Value                                                      | Default                  | Purpose                                                                                              |
| ---------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------- |
| `profile`                                                  | `medium`                 | Replicas, requests and limits, autoscaling and disruption budgets for every component                |
| `image.registry`                                           | `ghcr.io/mergeharbor`    | Pull from your own registry or mirror                                                                |
| `image.tag`                                                | the chart's app version  | Pin a release                                                                                        |
| `image.pullSecrets`                                        | `[]`                     | For example `[{name: regcred}]`. Applied to every pod, including the migration Job and sandbox pods. |
| `ingress.className`, `ingress.host`, `ingress.annotations` | `""`, example host, `{}` | Ingress. API paths go to the API; everything else goes to the console.                               |
| `ingress.tls.enabled`, `ingress.tls.secretName`            | `true`, `<release>-tls`  | TLS certificate Secret                                                                               |
| `license.existingSecret`, `license.key`                    | `""`, `license.lic`      | The license Secret, mounted read-only. A renewed Secret applies within about a minute.               |
| `config.MODEL_PROVIDERS`                                   | `anthropic`              | The providers routing may use                                                                        |
| `config.S3_ENABLED`                                        | `"false"`                | Optional review archives in S3-compatible storage                                                    |
| `airGapped`                                                | `false`                  | See [Air-gapped install](https://mergeharbor.com/docs/self-hosting-air-gapped)                                              |
| `openshift`                                                | `false`                  | Run under OpenShift's restricted SCC                                                                 |
| `api`, `agent`, `console`, `cells[]`                       | from `profile`           | Override `replicas`, `resources`, `hpa` and `pdb` for one component                                  |

Prometheus `ServiceMonitor` and `PrometheusRule` objects are off by default; turn on `serviceMonitor.enabled` and `prometheusRule.enabled` if you run the Prometheus Operator.

### Burst indexing capacity

Onboarding many repositories at once is mostly first-time indexing. With KEDA installed (`keda.enabled: true`), `burst.enabled: true` adds a second worker StatefulSet per cell, `<release>-worker-<cell>-burst`. It scales from 0 up to `burst.maxReplicas` while the cell has more first-time indexing work that can run than your regular workers take, and back to 0 about 15 minutes after that drains.

Burst pods only run first-time indexing. Every repository keeps its regular worker for reviews, search and incremental indexing, so reviews are not slowed down or moved while a burst runs, and nothing is handed back when burst pods stop. A burst pod keeps its own copy of the repositories it indexed, and its volume is kept when it scales in, so the next burst starts warm.

| Value                                                           | Default                        | Purpose                                                                                                                       |
| --------------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `burst.nodeSelector`, `burst.tolerations`, `burst.nodeAffinity` | none                           | Put burst pods on spot or other cheap nodes, for example `karpenter.sh/capacity-type: spot`                                   |
| `burst.maxReplicas`                                             | `10`                           | Upper bound on burst pods per cell                                                                                            |
| `burst.drainTimeout`                                            | `1m`                           | Shutdown budget. The grace period is this + 40 s, so 100 s fits a spot interruption notice.                                   |
| `burst.resources`, `burst.mirrors`                              | the cell's                     | Pod size and volume                                                                                                           |
| `burst.keda.batchesPerPod`                                      | `8`                            | Index batches one burst pod runs at once (its `INDEX_WORKERS`)                                                                |
| `burst.keda.coreSlots`                                          | `0`                            | First-time index batches your regular workers run at once (workers × `INDEX_WORKERS`); burst pods cover only the rest         |
| `burst.keda.orgConcurrency`                                     | `INDEX_ORG_CONCURRENCY` (16)   | One organization's batches that can run at once; a single organization's wave never asks for more pods than this allows       |
| `burst.keda.databaseUrlKey`                                     | `""` (the cell's database URL) | Core-secret key with a read-only database URL for KEDA's query                                                                |

`cells[].burst` overrides these values for one cell. Volumes kept at zero replicas are still billed. If a burst pod is reclaimed without warning, its unfinished batches run again on another worker within about two minutes. Details and trade-offs: `docs/SCALE.md`, "Burst indexing capacity (Kubernetes)".

## Model providers

| Provider                                  | Settings                                                                                                                                                                        |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Anthropic API                             | `agent.llmBackend: anthropic`, `ANTHROPIC_API_KEY` in `mergeharbor-agent`, `config.MODEL_PROVIDERS: anthropic`                                                                  |
| Amazon Bedrock                            | `agent.llmBackend: bedrock`, `agent.awsRegion`, an IAM role for the agent's service account, `config.MODEL_PROVIDERS: bedrock`                                                  |
| Your own model server (vLLM, Ollama, TGI) | `agent.selfHostedLLM.baseUrl`, a priced entry in `agent.modelRegistry`, `agent.reviewModel` and `agent.summaryModel` set to its id, `config.MODEL_PROVIDERS: openai_compatible` |

Models without prices in the registry are never chosen for routing. Price a self-hosted model at your effective GPU cost per million tokens (0 is allowed). `values.yaml` has a complete `modelRegistry` example.

## OpenShift

Set `openshift: true`. Pods then run with the namespace's arbitrary user and group IDs: the chart sets no `runAsUser`, `runAsGroup` or `fsGroup`. They still run as non-root, with a read-only root filesystem, all capabilities dropped and the `RuntimeDefault` seccomp profile, so they work under the `restricted-v2` SCC. The OpenShift router turns the Ingress into a Route: set `ingress.className: openshift-default` and the annotation `route.openshift.io/termination: edge`. The chart's `ci/openshift-values.yaml` is a complete example.

Proof runs (the optional sandbox) need gVisor and a fixed sandbox user, so on OpenShift they need a custom SCC and are not covered by `restricted-v2`.

## GKE and AKS

Use the managed PostgreSQL service (Cloud SQL for PostgreSQL, or Azure Database for PostgreSQL Flexible Server with the `vector` extension allow-listed), reached over private networking. Use the cloud's ingress class, or ingress-nginx, with cert-manager or your own certificate. For object storage, any S3-compatible endpoint works through `config.S3_ENDPOINT` and `config.S3_PATH_STYLE`. Terraform modules are provided for AWS only.

## AWS with Terraform

`deploy/terraform/aws` creates a VPC, an EKS cluster with node groups for workers (and optionally sandboxes), Aurora PostgreSQL, an S3 bucket encrypted with KMS, Secrets Manager entries, and IAM roles for the chart's service accounts. It does not install the chart.

```sh
cd deploy/terraform/aws
terraform init
terraform apply -var region=us-east-1 -var environment=prod -var public_url=https://mergeharbor.example.com
```

Then install External Secrets Operator using the `irsa_role_arns` output, and install the chart with `externalSecrets.enabled: true`, the service account role annotations from the outputs, `config.S3_BUCKET`, `ingress.className: alb` and your host.

## Network policy

`networkPolicy.enabled` (on by default) limits traffic between MergeHarbor's pods. For production, also turn on `networkPolicy.egress.enabled`. Then list in `networkPolicy.egress.allowCIDRs` the private ranges the API and workers must reach: database, code hosts, identity provider and model servers. Cloud metadata addresses are always blocked.
