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.