Install with Helm

One Deployment, one PersistentVolumeClaim, three Services. No database chart, no cache chart, no message queue — the product is one process with one directory of state, and there is no dependencies: block in the chart.

Install

helm install searchcode ./deploy/helm/searchcode \
  --namespace searchcode --create-namespace \
  --values my-values.yaml

Check what it will render before you install it:

helm template searchcode ./deploy/helm/searchcode --values my-values.yaml

values.yaml in the chart is annotated field by field and is worth reading in full; what follows is the shape of a real deployment.

A minimal production values file

image:
  repository: ghcr.io/boyter/searchcode
  # tag is deliberately empty in the chart's own values: it resolves to the
  # chart's appVersion, so an install pins a build. Never set it to "latest" —
  # a node replacement then silently moves you to a different product build.

persistence:
  size: 200Gi
  storageClass: fast-ssd

license:
  existingSecret: searchcode-license   # created out of band; never inline

config:
  searchcodeYaml:
    auth:
      mode: proxy
      trustedProxyCIDRs: ["10.0.0.0/8"]
      sharedSecretFile: /etc/searchcode/secrets/proxy-secret
      sharedSecretHeader: X-Searchcode-Proxy-Secret
    authz:
      adminGroups: ["platform-admins"]
    repositoryGroups:
      - name: platform
      - name: payments
    bindings: []

  extraFiles:
    repositories.yaml: |
      repositories:
        - url: https://github.example.com/platform/api
          groups: [platform]

ingress:
  enabled: true
  className: nginx
  host: searchcode.example.com

Two keys are filled in for you when you leave them unset: instance.dataDir points at the mounted volume, and license.file points at the mounted licence secret when license.existingSecret is set.

config.searchcodeYaml is the whole searchcode.yaml, rendered into a ConfigMap and mounted as a directory at /etc/searchcode — never as a single file, because a bind-mounted file that does not exist becomes a directory and the pod crash-loops on something unattributable. config.extraFiles becomes conf.d/ fragments, merged in filename order. That is where searchcode discover output belongs; see Enumerating a code host with searchcode discover.

For GitOps setups where the config is rendered upstream of Helm, set config.existingConfigMap and everything above is ignored.

Every field, and validating it first

The config you put under config.searchcodeYaml is the file documented in Configuration reference. Validate it before you install rather than after:

helm template searchcode ./deploy/helm/searchcode --values my-values.yaml \
  --show-only templates/configmap.yaml > /tmp/rendered.yaml
# extract searchcode.yaml from it, then:
searchcode config validate -f searchcode.yaml

searchcode config validate prints every problem rather than the first and exits non-zero, so it belongs in the pipeline that renders your values.

Authentication

The default rendered config is auth.mode: local: built-in accounts, and nobody can sign in until you create somebody.

kubectl -n searchcode exec deploy/searchcode -- searchcode user add --admin alice

For SSO, use auth.mode: proxy and put your proxy in front of the UI listener — the -ui Service on port 8081.

There is no values key that would put an Ingress in front of the API listener, and that is deliberate. Agents present a searchcode-issued bearer credential your proxy did not issue and cannot validate, and the workaround people reach for next — a skip-auth route for /v1/mcp and /api/v1/* — is an authentication bypass. The listeners are separate ports and separate Services so that this cannot happen by accident.

auth.mode: open renders only when you also set auth.allowAnonymous: true. The render fails without it, naming the reason: that mode makes every caller one anonymous principal with access to the whole estate, which is fine for a trial and must not arrive in a cluster by way of a copy-pasted values file.

Three Services, not one

Service Port Who reaches it
-api 8080 Agents and API clients. Never behind an auth proxy.
-ui 8081 Humans, behind your proxy in proxy mode. Ingress terminates here.
-metrics 9090 Prometheus, and nothing else.

Only the -ui Service gets an Ingress. There is no values key that would put one in front of -api, deliberately. Ports, names and what sits in front of each listener covers the naming decision and what has to pass through untouched.

Metrics is separate because repository names appear in metric labels. A NetworkPolicy can express "this port, from this namespace"; it cannot express "this path".

Headless. --set ui.enabled=false runs the API and MCP with no UI. The rendered config gets server.uiListen: off, and the chart renders no -ui Service, UI container port, Ingress or NetworkPolicy rule for it. Set config.searchcodeYaml.mcp.auth.mode: apikey with it, because MCP OAuth needs the UI. ingress.enabled: true with the UI off is refused. Ports, names and what sits in front of each listener lists what works headless.

NetworkPolicy

networkPolicy.enabled defaults to true and the policy is generated from your own configuration: the egress ports come from the repository URLs you declared and from the OTLP endpoint if you enabled tracing, and nothing else is permitted. Hand the rendered policy to your security team — it is the proof that the product does not phone home, in the form they already read.

Kubernetes NetworkPolicy matches CIDRs and not hostnames, so egressCIDRs is how you say "port 443, but only to our GitHub Enterprise". With the list empty, each derived port is allowed to any destination and the rendered policy says so in a comment naming the hosts it was derived from.

allowDNS is on by default. Forgetting DNS is the classic "NetworkPolicy broke everything" outage.

Scaling

replicaCount is 1 and there is no HorizontalPodAutoscaler template. One process serves an instance: it owns a directory of clones and an in-process analysis store, so a second replica is a second copy of the estate on a second volume, not more capacity for the same one. Scale vertically — see Sizing.

Probes

Probes are not values. They come from files/probes.yaml, a byte copy of deploy/kubernetes/probes.yaml, which is the file the readiness behaviour is actually tested against.

/readyz returns 200 as soon as the server can serve and never waits for repositories. Gating readiness on ingestion means a 2,000-repository estate never passes its probe and Kubernetes recycles the pod forever. /livez touches no storage, because a liveness probe that stats a slow disk restarts the pod and makes the disk slower.

Private CA and egress proxies

caBundle:
  existingConfigMap: corporate-ca
  key: ca-bundle.crt

mounts the bundle and points GIT_SSL_CAINFO and SSL_CERT_FILE at it. You need this if your git host uses an internal CA or your egress proxy inspects TLS. Without it there is no fix available inside the pod: the root filesystem is read only, so appending to the system trust store is not an option.

proxy.httpProxy / httpsProxy / noProxy are passed through. Put your in-cluster service CIDR in noProxy, or clones to internal hosts get sent to the corporate proxy and fail in a way that reads like a DNS problem.

Directory sources

A directory source is a path this pod can read. The pattern worth copying is a ReadWriteMany PVC populated by a CronJob running your own export tooling, mounted read only through extraVolumes / extraVolumeMounts. See Source capability tiers for what a directory source can and cannot answer.

Monitoring

serviceMonitor.enabled, prometheusRule.enabled and grafanaDashboard.enabled ship the dashboard and alert rules the product is developed against, so the panels work on day one.

Uninstall

helm uninstall searchcode -n searchcode

The PersistentVolumeClaim is kept. Delete it deliberately.

Generated from the documentation that ships with searchcode enterprise. Questions: ben@boyter.org