Teable on Kubernetes (Helm)
August 5, 2026 · View on GitHub
The full Teable platform on an existing Kubernetes cluster: the app, its datastores, and the AI runtime plane — everything self-hosted inside your cluster, driven by one umbrella chart.
Prerequisites
- An ingress-nginx controller with a public IP
- cert-manager (or bring your own TLS certificates — see the values example)
- A default StorageClass (or set the
storageClassNamefields per component) - One base domain with DNS control -- typically a subdomain of yours (e.g.
teable.example.com): four records point at your ingress controller (listed in the values example), all derived from that single domain
Quick start
cp helm/examples/values.example.yaml my-values.yaml # set global.baseDomain, read the TLS section
helm dependency build helm/teable-infra
helm install teable helm/teable-infra -n opensandbox-system --create-namespace \
-f my-values.yaml
./helm/doctor.sh # all green = deployed
The first install pulls images, issues certificates and runs database
migrations — give it a few minutes (kubectl get pods -n opensandbox-system -w
to watch). Do not add --wait: the storage buckets are created by a
post-install hook, which Helm only runs after --wait would return, while
the app cannot become ready without them — --wait deadlocks and times out
on a first install.
Open https://<baseDomain> and register the first account (it becomes the
admin). The infra console is at https://infra.<baseDomain>.
If you know Kubernetes but not Helm
Helm here plays the role docker compose plays on the Docker path:
| docker compose | Helm |
|---|---|
docker compose up -d | helm install teable helm/teable-infra -f my-values.yaml ... |
edit .env, up -d again | edit my-values.yaml, helm upgrade teable helm/teable-infra -f my-values.yaml |
docker compose down | helm uninstall teable (PVCs and their data survive) |
Everything an install creates is readable up front in
teable-infra/manifests/crds.yaml and
manifests/default.yaml — read them
before installing, or diff them between releases before upgrading.
Applying those files directly with kubectl also works as an escape hatch:
create the namespace first (kubectl create namespace opensandbox-system),
replace the placeholder Secret values with your own random material, apply
crds.yaml then default.yaml — and accept that you lose Helm's release
management (upgrades become re-applies).
Pinning and upgrading images
examples/images.values.yaml carries the image
keys of the release and nothing else:
helm upgrade teable helm/teable-infra -n opensandbox-system \
--reuse-values -f helm/examples/images.values.yaml
Prefer this over kubectl set image: it updates the same containers and
keeps the Helm release in sync, so the next upgrade will not silently roll
your images back. (On Helm 4 add --server-side=true --force-conflicts if
images were previously swapped by hand.)
Storage
The git-registry and VictoriaMetrics data PVCs support three modes, per
component (gitRegistry.persistence / infraService.victoriaMetrics.persistentVolumeClaim):
- Dynamic (default): leave
volumeNameandexistingClaimblank; setstorageClassNameor leave it blank for the cluster default StorageClass. - Static PV binding: set
volumeNameto a pre-provisioned PV and keepstorageClassName: ""— the empty string is emitted on the PVC so the dynamic provisioner stays out of the way. AdjustaccessModesto match the PV. - Bring your own PVC: set
existingClaimto a PVC you created beforehand; the chart then creates no PVC at all. Use this when PVC lifecycle is owned by a storage/cluster admin rather than the deploy account.
Both PVCs carry helm.sh/resource-policy: keep, so helm uninstall leaves
the data in place; delete the PVC explicitly to discard it.
Sandbox capacity
How many sandboxes fit on a node is derived from one value —
global.sandboxScheduling.memoryRequest, the "price of a seat" the scheduler
subtracts from node allocatable memory. Set too low it oversubscribes nodes and
sessions get OOM-killed; set too high the cluster refuses new sandboxes while
nodes look idle. Start at 1300Mi for a mixed AI-session and app-build
workload, and re-derive it if yours differs — see
sandbox-capacity.md for the sizing method, a worked
per-node example, and the related limit/backpressure knobs.
External gateway entry (no ingress controller)
If an external SLB/nginx terminates TLS in front of the cluster, set:
global:
entry:
mode: external-nginx
The chart then renders no Ingress or Certificate objects (the ingress-nginx
and cert-manager prerequisites no longer apply) and renders a
<release>-nginx-routes ConfigMap instead — the host/path → Service routing
contract for your gateway team, including the entry requirements (preserve
Host, no path rewrite, longest-prefix path matching, WebSocket, long
timeouts). The ConfigMap declares routes; it does not configure the external
gateway by itself.
This mode requires appRuntime.ingress.mode: gateway and the chart refuses
to render otherwise: the default dynamic mode creates per-app Ingress
objects at runtime, which nothing would serve without an ingress controller.
Restricted deploy accounts
If your deploy account only holds namespace-scoped permissions, have a cluster admin apply the pre-rendered cluster half first:
kubectl apply -f helm/teable-infra/manifests/crds.yaml
kubectl apply -f helm/teable-infra/manifests/cluster-rbac.yaml
(Both are pre-rendered from the default profile for release name teable in
namespace opensandbox-system — the quick-start defaults. Installing under a
different release name or namespace, or enabling components that are off by
default (e.g. registryGc)? Re-render the cluster half from the chart with
rbac.namespaceScope.create: false set on infra-service and
opensandbox-server, and apply the resulting ClusterRole/ClusterRoleBinding
documents.)
Then install as the deploy account with:
# Namespaces are cluster-scoped: have the admin create the release namespace
# plus the two runtime namespaces below, and keep the chart from rendering them.
sandboxNamespace:
create: false # admin pre-creates teable-sandbox
appRuntime:
createNamespace: false # admin pre-creates the app-deploy namespace
infraService:
rbac:
clusterScope:
create: false # cluster admin pre-provisioned the ClusterRole/Binding
knativeCompat: false # only if the account cannot grant serving.knative.dev
registryGc:
rbac:
clusterScope:
create: false
opensandbox-server:
server:
rbac:
clusterScope:
create: false
gateway:
rbac:
clusterScope:
create: false
opensandbox-controller:
rbac:
clusterScope:
create: false # keeps the namespaced leader-election Role/RoleBinding
crds:
install: false
The chart then renders only namespace-scoped RBAC (ServiceAccounts, Roles,
RoleBindings) alongside the workloads; the workloads keep referencing the same
ServiceAccount names either way. Combine with the Storage section
(existingClaim against admin-provisioned PVs) and the external gateway entry
mode above when those restrictions apply too.
Health and drift
./helm/doctor.sh [release] [namespace] # defaults: teable opensandbox-system
./helm/doctor.sh --from vYYYY.M.N # + migrations pending since that release
Checks that every workload is ready, certificates are issued, and that the
images running in the cluster still match what the Helm release installed —
with the exact commands to reconcile if they drifted. It also compares what is
running against the platform release manifest (versions.yaml) and reports one
of three states: compatible, upgrade the Teable app, or an unknown (unverified)
component combination. Upgrading from an older platform release, pass
--from <the release you run today>: it reads migrationCatalog from
versions.yaml and prints the migrations your install still has to run, in
order (see also "Upgrading across releases" in VERSIONS.md).
Hardening sandboxes
Sandboxes run as root with an unconfined seccomp profile by default, because
the sandbox entrypoint historically fixed up volume ownership at startup. Two
switches tighten that; both only affect new sandboxes, and both override
the matching fields of a batchSandboxTemplate you provide:
global:
sandboxSecurity:
seccompProfile: RuntimeDefault # replaces the default Unconfined profile
nonRoot:
enabled: true # uid/gid 1000, no privilege escalation, all capabilities dropped
seccompProfile is safe to flip on its own. nonRoot needs preparation:
- It requires
opensandbox-server>=v0.2.0-fix7andopensandbox-execd>=v1.0.19-fix3-- the versions pinned by this release. With an older execd every command inside a non-root sandbox fails withoperation not permitted. The identity is fixed at uid/gid 1000, matching the agent image; a different uid would hit the same failure. - Agents can no longer install system packages (
sudo apt-get install); user-space installs (uv,pip,npm,pnpm) are unaffected, so pre-install the system packages your workloads need in the sandbox image. - On a shared sandbox volume (one PVC mounted into every sandbox under
per-sandbox
subPaths) two things need handling -- see below.
Rolling back means setting both switches back and pinning the previous image versions -- the template and the execd version are a matched pair.
Shared sandbox volumes
Skip this if your sandboxes only use the default emptyDir workspace.
Existing files written by earlier root sandboxes stay root-owned and become read-only. Chown them once, from any pod that mounts the volume:
find /mnt/agent-data -uid 0 | head # what would break
chown -R 1000:1000 /mnt/agent-data/teable # one-time migration
That covers what already exists. New subPath directories are created by the
kubelet as root:root whenever a new user gets their first sandbox, so an
unprivileged sandbox could not write them either. The server pre-creates them
instead -- mount the same PVC into the server and declare the mapping:
opensandbox-server:
server:
volumes:
- name: agent-data
persistentVolumeClaim:
claimName: <your sandbox PVC>
volumeMounts:
- name: agent-data
mountPath: /mnt/agent-data
configToml: |
... # keep the rest of your config
[kubernetes.volume_subpath_precreate]
uid = 1000
gid = 1000
[kubernetes.volume_subpath_precreate.mounts]
# sandbox-side claim name = where the server mounts that same volume
"<your sandbox PVC>" = "/mnt/agent-data"
The server runs in the control-plane namespace and creates each directory with the right owner before starting the sandbox; read-only mounts are skipped. Get the claim name wrong and pre-creation is silently skipped -- verify with a fresh user's first sandbox, not an existing one.
Storage owned by a different identity
Some storage platforms mandate their own owner uid on shared volumes and
disallow chown, while the sandbox identity is fixed at 1000. Do not try to
align the two uids -- either direction breaks: a sandbox uid other than 1000
fails every command (the execd credential match), and re-owning platform
storage violates its policy. Bridge them with a group instead. With 1001 as
the storage-owner identity, run the server as that identity and have it
create directories group-writable:
opensandbox-server:
server:
podSecurityContext: {runAsUser: 1001, runAsGroup: 1001, runAsNonRoot: true}
containerPort: 8080 # non-root cannot bind 80; mirror it in `[server] port`
volumes: # the same PVC mount as above -- pre-creation needs it
- name: agent-data
persistentVolumeClaim:
claimName: <your sandbox PVC>
volumeMounts:
- name: agent-data
mountPath: /mnt/agent-data
configToml: |
[server]
port = 8080
... # keep the rest of your config
# The metadata store defaults to a path under HOME, and a non-root uid
# has no writable HOME on the stock image -- without this the server
# exits at startup before serving anything.
[store]
path = "/tmp/opensandbox/opensandbox.db"
[kubernetes.volume_subpath_precreate]
uid = 1001 # the storage owner, not the sandbox uid
gid = 1001
dir_mode = 0o2775 # group-write + setgid; needs server >= v0.2.0-fix9
[kubernetes.volume_subpath_precreate.mounts]
"<your sandbox PVC>" = "/mnt/agent-data"
then give sandboxes that group: add supplementalGroups: [1001] to the
pod-level securityContext of your batchSandboxTemplate (the nonRoot
switch preserves it; container-level securityContext has no such field).
Directories come out 1001:1001 drwxrwsr-x: the server, already being the
owner, never calls chown, and sandboxes (uid 1000) write through the
supplementary group. On engines older than fix9 use dir_mode = 0o775 --
same write bridge, minus setgid group inheritance on new content. Pre-creation
sets mode only on directories it creates, so directories that already exist
with the wrong mode or owner must be fixed once by hand (or removed and left
for the server to recreate).
Private CA / self-signed certificates
If your Teable hosts serve certificates from a private/corporate CA, sandboxes
reject the callbacks (AI sessions fail to start, builds fail on git push)
until they trust that CA — see private-ca.md for the sandbox
template override that mounts your root CA.
When something fails
See ../TROUBLESHOOTING.md — the Kubernetes section
covers the failure modes we have actually hit, each with the first place to
look.