> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuneplane.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Install with Helm

> The same Kubernetes deployment, values-driven — for several environments or a GitOps pipeline.

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
helm install tuneplane deploy/helm/tuneplane-server \
  --namespace tuneplane --create-namespace \
  --set image.repository=registry.company.com/tuneplane/tuneplane-server \
  --set config.TUNEPLANE_INGEST_URL=http://tuneplane-server.tuneplane.svc.cluster.local \
  --set secrets.existingSecret=tuneplane-server-secrets
```

The chart renders the same objects as [the kustomize manifests](/en/ops/install-kubernetes):
ServiceAccount, ConfigMap, PVC, Role, RoleBinding, Service, Deployment, and optionally an Ingress
and a Secret.

Pick the chart when you run several environments or drive deployment from values. Pick the manifests
when you want to read exactly what will be applied.

## A values file worth starting from

```yaml values.yaml theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
image:
  repository: registry.company.com/tuneplane/tuneplane-server
  tag: "0.3.0"
imagePullSecrets:
  - name: tuneplane-registry

storage:
  accessMode: ReadWriteMany     # not negotiable, see below
  size: 2Ti
  storageClassName: nfs-client

config:
  TUNEPLANE_DEFAULT_FLEET_KIND: kuberay
  TUNEPLANE_STORAGE_ROOT: /tuneplane
  TUNEPLANE_PUBLIC_URL: https://tuneplane.company.com
  TUNEPLANE_INGEST_URL: http://tuneplane-server.tuneplane.svc.cluster.local
  TUNEPLANE_KUBERAY_RAY_VERSION: "2.55.1"
  TUNEPLANE_K8S_SHM_SIZE: 64Gi
  TUNEPLANE_KUBERAY_PRERUNNING_DEADLINE_S: "1800"

secrets:
  existingSecret: tuneplane-server-secrets

ingress:
  enabled: true
  className: nginx
  host: tuneplane.company.com
  tls:
    enabled: true
    secretName: tuneplane-server-tls
```

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
helm upgrade --install tuneplane deploy/helm/tuneplane-server \
  -n tuneplane --create-namespace -f values.yaml
```

## Three values decide whether it works

<ParamField path="storage.accessMode" type="ReadWriteMany" required>
  An RWO claim **does not fail**. Kubernetes gives each pod its own volume, so a multi-node job's
  checkpoint shards split across pods and the run cannot be resumed — with nothing in the logs to
  say why. The chart defaults to RWX; the StorageClass has to actually support it.
</ParamField>

<ParamField path="config.TUNEPLANE_INGEST_URL" type="url" required>
  Training pods POST their metrics to it. Use the in-cluster service name. An external ingress
  address leaves the cluster and comes back in; `127.0.0.1` is the worker's own loopback.
</ParamField>

<ParamField path="secrets.existingSecret" type="string" required>
  Without `TUNEPLANE_WEB_JWT_SECRET` each process signs with its own key: every restart logs everyone
  out, and more than one replica cannot work at all. The chart's install notes warn when neither
  this nor `secrets.create` is set.
</ParamField>

## Secrets

`secrets.create: true` renders a Secret from `secrets.data` — convenient on a test cluster, wrong in
production, because the values land in your release history.

Point `secrets.existingSecret` at a Secret from `kubectl create secret`, Sealed Secrets, or the
External Secrets Operator:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
kubectl -n tuneplane create secret generic tuneplane-server-secrets \
  --from-literal=TUNEPLANE_WEB_JWT_SECRET="$(openssl rand -hex 32)" \
  --from-literal=TUNEPLANE_DB_URL='postgresql+psycopg://tuneplane:...@postgres:5432/tuneplane' \
  --from-literal=TUNEPLANE_REDIS_URL='redis://:...@redis:6379/0' \
  --from-literal=TUNEPLANE_S3_SECRET_KEY='...' \
  --from-literal=TUNEPLANE_SECRET_ENC_KEY='...'
```

## One thing the chart does for you

`TUNEPLANE_K8S_STORAGE_PVC` is filled in from the claim the chart creates, so the executor is always
told about the claim that actually exists. That pairing is easy to break by hand — the executor
checks a claim that is not the one mounted, reports healthy, and the mismatch only surfaces when a
multi-node run fails to resume.

Override it only when reusing a claim the chart does not own:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
helm install tuneplane deploy/helm/tuneplane-server --set storage.existingClaim=my-nfs-claim
```

## Verify before you apply

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
helm lint deploy/helm/tuneplane-server
helm template tuneplane deploy/helm/tuneplane-server -f values.yaml | kubectl apply --dry-run=client -f -
```

## Confirm it worked

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
kubectl -n tuneplane rollout status deploy/tuneplane-server
kubectl -n tuneplane get pvc -o custom-columns=NAME:.metadata.name,MODES:.spec.accessModes
kubectl -n tuneplane port-forward svc/tuneplane-server 8080:80
curl -s localhost:8080/api/cluster/health | jq .storage
```

That last call is the one worth reading: it compares the `TUNEPLANE_K8S_STORAGE_SHARED` declaration
against the claim's real access modes, which is the check that catches an RWO claim before a job
does.

<Note>
  The chart is verified with `helm lint` and `helm template` on every change. Installing it against a
  live cluster is not something CI here can do — no runner has one — so the first install on your
  cluster is the first real test. Run the dry-run above first.
</Note>
