Kubernetes
Deploy deco Studio and agent sandboxes on Kubernetes using Helm
This guide deploys deco Studio and its hosted agent sandboxes on Kubernetes.
Chart sources:
Easiest path: for a one-command install with bundled dependencies (local or a POC cluster), use the umbrella chart or selfhost/scripts/local-k8s.sh (see Quickstart). There’s also a guide for coding agents ( SKILL.md ) that interviews you and writes a reusable install directory — an umbrella chart with pinned OCI dependencies plus your values. This page is the chart reference for installing directly and for production — the full dependency guide (managed vs in-cluster Postgres/S3/ClickHouse, secrets with/without ESO, ingress, sandbox) lives in selfhost/production .
For the runtime architecture (web/API/worker split, Postgres, NATS, and AgentSandbox), see Architecture.
Deployment model
A production deployment uses three Helm releases:
| Chart | Scope | Purpose |
|---|---|---|
chart-deco-studio | Once per Studio environment | Runs an nginx front door with two API containers per pod, queue workers, NATS, persistence, and optional observability components |
sandbox-operator | Once per Kubernetes cluster | Installs the upstream Kubernetes SIGs agent-sandbox controller and CRDs |
sandbox-env | Once per Studio environment | Installs the environment’s SandboxTemplate , runner RBAC, sentinel Secret, and optional warm pool, preview Gateway, and idle housekeeper |
The operator is shared cluster infrastructure. Each sandbox-env release is isolated by envName , so production, staging, and other Studio releases can share the operator and the agent-sandbox-system namespace without resource-name collisions.
When a hosted agent needs a sandbox, Studio creates a SandboxClaim . The operator turns that claim into an ephemeral sandbox pod from the environment’s SandboxTemplate . Studio reaches the daemon through Kubernetes port-forward for control traffic. If preview routing is enabled, Studio also creates one HTTPRoute per claim so the wildcard Gateway routes directly to the sandbox Service on port 9000.
Prerequisites
- Kubernetes 1.32+ and Helm 3
kubectlconfigured for the cluster- A PostgreSQL database; the current API/worker topology requires PostgreSQL
- A StorageClass for Studio and the chart-managed NATS JetStream PVCs
- An S3-compatible object store and credentials for the organization filesystem (org-fs)
- Support for
spec.hostUsers: trueand privileged sidecars inagent-sandbox-system; the mandatory org-fs sidecar uses FUSE and bidirectional mount propagation - For public preview URLs only: Gateway API CRDs, a Gateway controller, wildcard DNS, and either cert-manager with a DNS-01
ClusterIssueror load-balancer TLS termination
For production, schedule sandbox pods on a dedicated, tainted node pool. Sandbox pods run user-authored code and should not share nodes with Studio, Postgres, NATS, or observability workloads.
Install Studio with agent sandboxes
The commands below pin chart versions explicitly. Pin them in your own deployment too, instead of tracking whatever is newest.
export STUDIO_CHART_VERSION=0.13.1
export SANDBOX_OPERATOR_CHART_VERSION=0.1.4
export SANDBOX_ENV_CHART_VERSION=0.15.3
The charts are published as public OCI artifacts, so no registry credentials are needed. To pick a newer version, list what is published:
helm show chart oci://ghcr.io/decocms/chart-deco-studio | grep '^version:'
helm show chart oci://ghcr.io/decocms/studio/charts/sandbox-operator | grep '^version:'
helm show chart oci://ghcr.io/decocms/studio/charts/sandbox-env | grep '^version:'
Every published version is also listed on the package pages: chart-deco-studio, sandbox-operator, sandbox-env.
1. Install the cluster-wide sandbox operator
The operator chart has no configurable values and must be installed in agent-sandbox-system because the vendored controller manifest targets that namespace.
helm install sandbox-operator \
oci://ghcr.io/decocms/studio/charts/sandbox-operator \
--version "$SANDBOX_OPERATOR_CHART_VERSION" \
--namespace agent-sandbox-system \
--create-namespace
Verify the controller and CRDs:
kubectl rollout status deployment/agent-sandbox-controller \
-n agent-sandbox-system --timeout=300s
kubectl get crd sandboxclaims.extensions.agents.x-k8s.io \
sandboxtemplates.extensions.agents.x-k8s.io \
sandboxwarmpools.extensions.agents.x-k8s.io
2. Configure and install Studio
Hosted sandboxes require S3-compatible object storage. Studio considers it configured only when all four required variables are present:
| Variable | Required | Description |
|---|---|---|
S3_ENDPOINT | Yes | Full S3-compatible API endpoint, such as https://s3.us-east-1.amazonaws.com |
S3_BUCKET | Yes | Bucket that backs org-fs |
S3_ACCESS_KEY_ID | Yes | Access key used by Studio |
S3_SECRET_ACCESS_KEY | Yes | Secret key used by Studio |
S3_REGION | Provider-specific | Defaults to auto ; set the actual region for AWS S3 |
S3_FORCE_PATH_STYLE | Provider-specific | Set false for AWS S3 virtual-hosted addressing; commonly true for MinIO and other path-style providers |
The credentials need s3:ListBucket on the bucket and s3:GetObject , s3:PutObject , and s3:DeleteObject on its objects. These variables configure the org filesystem and are separate from the optional s3Sync.* backup sidecar and MONITORING_S3_* settings.
Store the S3 variables alongside DATABASE_URL , BETTER_AUTH_SECRET , and ENCRYPTION_KEY in the Kubernetes Secret consumed by Studio. Do not put credentials in configMap.meshConfig or in the sandbox-env release; sandbox pods access org-fs through Studio’s authenticated API and do not receive the S3 keys directly.
Create studio-values.yaml . envName , the template suffix, Studio namespace, release name, and service-account name must agree with the sandbox-env release installed in the next step.
database:
engine: postgresql
url: "" # supplied as DATABASE_URL by the existing Secret
serviceAccount:
create: true
name: deco-studio
automount: true
configMap:
meshConfig:
BETTER_AUTH_URL: "https://studio.example.com"
BASE_URL: "https://studio.example.com"
STUDIO_AGENT_SANDBOX_ENABLED: "true"
STUDIO_ENV: "production"
STUDIO_SANDBOX_TEMPLATE_NAME: "studio-sandbox-production"
secret:
secretName: deco-studio-secrets
configMap.meshConfig is a deprecated chart-key name retained for upgrade
compatibility. Its values configure the Studio API.
The existing deco-studio-secrets Secret must contain DATABASE_URL , BETTER_AUTH_SECRET , ENCRYPTION_KEY , S3_ENDPOINT , S3_BUCKET , S3_ACCESS_KEY_ID , and S3_SECRET_ACCESS_KEY , plus provider-specific S3_REGION and S3_FORCE_PATH_STYLE . Prefer externalSecret or another secret manager in production; do not commit these values to Git.
Provision deco-studio-secrets in the deco-studio namespace before running the Helm install below. Alternatively, configure the chart’s externalSecret values to create it from AWS Secrets Manager.
Install the application chart:
helm install deco-studio \
oci://ghcr.io/decocms/chart-deco-studio \
--version "$STUDIO_CHART_VERSION" \
--namespace deco-studio \
--create-namespace \
--values studio-values.yaml
A first install against an empty database migrates before it serves. Studio takes a Postgres advisory lock around the DBOS system-schema migration, so whichever process starts first migrates and the others wait — no replica-count juggling is needed, with or without Argo CD. The chart also ships a single-writer migration Job ( migrateJob.enabled , on by default). It does not provide the guarantee — the lock does — but it gives the migration one named place that either succeeded or failed, instead of whichever pod happened to win. Under Argo CD the sync wave holds the Deployments until the Job finishes; under plain helm install the Job starts alongside the first pods, which is harmless once the pods carry the lock.
The main Deployment contains nginx and two API containers. The separate worker Deployment is required and enabled by default. The chart also enables its NATS dependency by default and requires PostgreSQL so API and worker pods share the DBOS run queue.
3. Install the per-environment sandbox resources
helm install sandbox-env-production \
oci://ghcr.io/decocms/studio/charts/sandbox-env \
--version "$SANDBOX_ENV_CHART_VERSION" \
--namespace agent-sandbox-system \
--set envName=production \
--set mesh.namespace=deco-studio \
--set mesh.serviceAccountName=deco-studio
This creates SandboxTemplate/studio-sandbox-production and grants only the named Studio service account permission to manage claims, read sandboxes, port-forward to sandbox pods, patch their Services, and manage per-claim HTTPRoute objects in agent-sandbox-system .
The sandbox chart’s mesh.* values key is a deprecated name retained for
upgrade compatibility; it points to the Studio release. mesh.serviceName ,
mesh.servicePort , and mesh.podSelectorLabels are deprecated and unused by
the current per-claim preview routing design. New installations do not need
to set those three fields.
4. Verify the complete deployment
helm status deco-studio -n deco-studio
helm status sandbox-operator -n agent-sandbox-system
helm status sandbox-env-production -n agent-sandbox-system
kubectl get deployments,pods -n deco-studio
kubectl get deployment -n agent-sandbox-system
kubectl get sandboxtemplate,sandboxwarmpool -n agent-sandbox-system
kubectl auth can-i create sandboxclaims.extensions.agents.x-k8s.io \
--as=system:serviceaccount:deco-studio:deco-studio \
-n agent-sandbox-system
To access Studio before configuring ingress:
kubectl port-forward svc/deco-studio 8080:80 -n deco-studio
Studio is then available at http://localhost:8080 .
5. Expose Studio
The chart’s Service is ClusterIP and the Ingress is optional and off by default, because the controller, class, TLS issuer, and annotations differ per cluster. Pick the option that matches what you already run.
Ingress (chart-managed). Set ingress.enabled and the chart renders an Ingress backed by this release’s Service — you don’t hardcode the generated Service name, and the object joins the Helm release, so helm rollback and helm uninstall cover it:
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt
hosts:
- host: studio.example.com
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- studio.example.com
secretName: studio-tls
Studio serves the whole app from the root, so one / Prefix rule per host is the normal shape. With ingress.enabled=true , the chart fails to render if hosts is empty or a host has no paths — an Ingress without rules is accepted by controllers and silently routes nothing.
LoadBalancer. No controller in the cluster: set service.type: LoadBalancer and point DNS and TLS at the load balancer.
Gateway API. Leave ingress.enabled: false and create a Gateway and HTTPRoute targeting the same Service. This is separate from the sandbox preview Gateway, which the sandbox-env chart configures.
Whichever you choose, set BASE_URL and BETTER_AUTH_URL to the same public https:// URL. Auth callbacks are built from those values, not from the incoming request host, so a mismatch produces sign-in redirects that fail.
6. Connect an AI provider
A self-hosted deployment ships with no model access. Agents and Decopilot stay unusable until an organization owner connects a provider under Settings → AI Providers with a key you own — Anthropic, Google Gemini, OpenRouter, or any OpenAI-compatible endpoint, including a self-hosted vLLM server. The hosted deco AI Gateway and its starter credits are a feature of the managed offering; do not plan a self-hosted rollout around them. See AI Providers.
Egress note: unless you run the models in your own network, Studio pods need outbound HTTPS to the provider’s API.
7. Configure authentication
Studio uses Better Auth. For Kubernetes, configure it with environment variables in the same Secret or ConfigMap the chart already injects — there is nothing extra to mount:
| Method | Variables |
|---|---|
| Social login | AUTH_GOOGLE_CLIENT_ID / AUTH_GOOGLE_CLIENT_SECRET , AUTH_GITHUB_CLIENT_ID / AUTH_GITHUB_CLIENT_SECRET |
| Deployment-wide SSO | AUTH_SSO_DOMAIN plus the Microsoft Entra ID or Google Workspace variables |
| Magic link, email OTP, email/password | AUTH_MAGIC_LINK_ENABLED , AUTH_EMAIL_OTP_ENABLED , AUTH_EMAIL_PASSWORD_ENABLED , plus a mail provider ( AUTH_RESEND_API_KEY or AUTH_SENDGRID_API_KEY ) |
See Authentication for the full reference.
There’s no auth-config.json file anywhere — auth is configured entirely through the AUTH_* environment variables above. Keep provider secrets in the Secret, not in a ConfigMap.
Sandbox configuration
Isolation and egress
The sandbox container runs as a non-root user with privilege escalation disabled, all Linux capabilities dropped, a RuntimeDefault seccomp profile, and a read-only root filesystem. Writable emptyDir volumes are mounted where the runtime needs them.
The org-fs sidecar is mandatory: it uses privileged FUSE mounts to expose organization skills, uploads, outputs, and home directories under /app/org . Only this sidecar is privileged. disableFsSidecar: true is a debugging escape hatch, not a supported production mode.
By default, a short-lived NET_ADMIN init container installs iptables rules and exits before user code starts. It blocks private, link-local, and shared-address IPv4/IPv6 ranges—which cover the pod and Service CIDRs on most clusters—and allows outbound DNS plus HTTPS. Override the CIDR lists when your cluster uses different ranges. The chart does not currently create a Kubernetes NetworkPolicy . If your CNI or external firewall owns sandbox egress, set netinit.enabled: false and supply equivalent controls.
Recommended production scheduling:
nodeSelector:
workload: sandbox
tolerations:
- key: workload
operator: Equal
value: sandbox
effect: NoSchedule
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: studio-sandbox-production
Warm pool
Warm pools are disabled by default because every idle warm pod consumes the full sandbox resource request. Enabling one also requires Studio and the sandbox template to share a sentinel token. Studio uses it only for the first daemon configuration request and immediately rotates the daemon to a per-claim token.
Generate the token once, store it in the Studio Secret as STUDIO_SANDBOX_SENTINEL_TOKEN , and pass the same value to sandbox-env :
export SANDBOX_SENTINEL_TOKEN="$(openssl rand -base64 48)"
helm upgrade sandbox-env-production \
oci://ghcr.io/decocms/studio/charts/sandbox-env \
--version "$SANDBOX_ENV_CHART_VERSION" \
--namespace agent-sandbox-system \
--reuse-values \
--set-string sentinel.token="$SANDBOX_SENTINEL_TOKEN" \
--set warmPool.enabled=true \
--set warmPool.size=2
If sentinel.token is omitted, the chart generates and preserves a Secret for sandbox pods, but Studio still needs the same value before it can consume warm-pool pods. For GitOps, generate the value in your secret manager and deliver it to both namespaces. Do not place it in configMap.meshConfig .
The optional warm-pool HPA requires warmPool.enabled: true , warmPool.autoscaling.enabled: true , and at least one explicit autoscaling/v2 metric. The chart intentionally provides no default metric.
Idle housekeeper
Studio refreshes claim activity, while the optional housekeeper CronJob cleans up idle or unrecoverable claims and orphaned routes/pods. Its defaults run every five minutes and reap claims idle for 15 minutes.
housekeeper:
enabled: true
schedule: "*/5 * * * *"
idleTtlSeconds: 900
STUDIO_ENV is required when the housekeeper is enabled because its default selectors are environment-scoped. Without the matching label, it intentionally selects no claims.
Preview Gateway
Treat public preview URLs as a later phase. They need Gateway API CRDs, a Gateway controller, wildcard DNS, and a wildcard certificate via DNS-01 — dependencies that usually belong to another team and another schedule. Studio, agents, and code execution all work without them; only shareable preview links are missing.
Without a preview Gateway, Studio can use its legacy in-process preview proxy when a preview URL pattern and the required external routing are provided. The current production design uses Gateway API and routes each preview directly to its sandbox daemon:
Browser -> load balancer -> Gateway -> per-claim HTTPRoute -> sandbox Service:9000
Configure sandbox-env :
previewGateway:
enabled: true
gatewayClassName: istio
namespace: istio-system
domain: preview.example.com
tlsTermination: gateway
clusterIssuer: letsencrypt-dns
Then add the matching values to Studio:
configMap:
meshConfig:
STUDIO_SANDBOX_PREVIEW_URL_PATTERN: "https://{handle}.preview.example.com"
STUDIO_SANDBOX_PREVIEW_GATEWAY_NAME: "agent-sandbox-preview-production"
STUDIO_SANDBOX_PREVIEW_GATEWAY_NAMESPACE: "istio-system"
The Gateway name and namespace must either both be set or both be absent. Create wildcard DNS for *.preview.example.com pointing to the Gateway load balancer. With tlsTermination: gateway , the ClusterIssuer must support DNS-01 because wildcard certificates cannot use HTTP-01. Set tlsTermination: loadBalancer instead when the external load balancer owns the certificate.
The sandbox handle in the hostname is the preview route’s only built-in authorization. Treat preview URLs as secrets: handles can appear in CDN, load-balancer, and proxy logs. Add Gateway-level authorization (for example, Istio AuthorizationPolicy or Envoy external auth) when hostname secrecy is not sufficient.
Each environment that enables previews needs a distinct previewGateway.domain ; two Gateways cannot safely bind the same wildcard hostname.
Main Studio values
These defaults come from deploy/helm/studio/values.yaml :
| Parameter | Description | Default |
|---|---|---|
replicaCount | API pod replicas | 1 |
image.repository / image.tag | Studio API/worker image (tags) | ghcr.io/decocms/studio/studio / unset |
nginx.image.repository / nginx.image.tag | Front-door image (tags) | ghcr.io/decocms/studio/studio-nginx / unset |
service.type / service.port | Service exposure | ClusterIP / 80 |
ingress.enabled / ingress.className / ingress.hosts | Optional chart-managed Ingress | false / "" / [] |
database.engine | Database engine | postgresql |
worker.enabled / worker.replicaCount | Queue worker Deployment | true / 2 |
worker.autoscaling.enabled | Worker HPA | true |
nats.enabled | NATS subchart with JetStream | true |
persistence.enabled / persistence.size | Studio PVC | true / 10Gi |
autoscaling.enabled | API HPA | false |
metrics.scrape | Prometheus pod annotations | true |
image.tag and nginx.image.tag are unset by default, so both fall back to the chart’s appVersion , which is latest . Set both explicitly in production to a released version (for example 4.152.1 ) — otherwise a pod restart can silently pull a different build than the one you validated. A digest ( sha256:… ) is also accepted. The sandbox chart already pins its own image; do not override it with latest .
Main sandbox values
| Parameter | Description | Default |
|---|---|---|
envName | DNS-label suffix for environment resources | Required |
image.repository / image.tag | Sandbox daemon image (tags) | ghcr.io/decocms/studio/studio-sandbox-go / pinned by the chart |
resources.requests | Per-sandbox request | 500m CPU / 2Gi memory |
resources.limits | Per-sandbox limit | 2 CPU / 4Gi memory / 10Gi ephemeral storage |
mediumResources | Overrides for the second, roomier -medium SandboxTemplate (headless agent-loop claims) | 3Gi request / 6Gi limit |
terminationGracePeriodSeconds | Time for final git sync and unmount | 90 |
netinit.enabled | Install default iptables egress policy | true |
readOnlyRootFilesystem | Read-only sandbox root filesystem | true |
depsCache.enabled / depsCache.golden | Node-local dependency caches | false / false |
warmPool.enabled / warmPool.size | Pre-warmed sandboxes | false / 0 |
previewGateway.enabled | Wildcard preview Gateway | false |
housekeeper.enabled | Idle-claim cleanup CronJob | false |
Upgrades and removal
Render and lint all three charts before applying changes:
helm lint deploy/helm/studio \
--set database.url=postgresql://user:pass@postgres.example.com:5432/studio
helm lint deploy/helm/sandbox-operator \
--namespace agent-sandbox-system
helm lint deploy/helm/sandbox-env \
--namespace agent-sandbox-system \
--set envName=production
helm template deco-studio deploy/helm/studio --values studio-values.yaml
helm template sandbox-operator deploy/helm/sandbox-operator \
--namespace agent-sandbox-system
helm template sandbox-env-production deploy/helm/sandbox-env \
--namespace agent-sandbox-system \
--set envName=production
Files under a Helm chart’s crds/ directory are installed once but are not upgraded by helm upgrade . When the sandbox operator’s upstream appVersion changes, apply the new CRDs before upgrading the operator:
kubectl apply -f deploy/helm/sandbox-operator/crds/agent-sandbox-crds.yaml
helm upgrade sandbox-operator deploy/helm/sandbox-operator \
--namespace agent-sandbox-system
Uninstall environment resources before the shared operator. Removing the operator first leaves claims and custom resources without a controller.
helm uninstall sandbox-env-production -n agent-sandbox-system
helm uninstall deco-studio -n deco-studio
# Only after every environment has removed sandbox-env:
helm uninstall sandbox-operator -n agent-sandbox-system
Per-PR preview environments
The chart carries an optional preview block. It turns a release into a
short-lived, self-contained environment: an ephemeral Postgres in the same
namespace, migrations run once by a Job instead of racing across pods, and an
HTTPRoute onto a shared wildcard Gateway . It is meant for reviewing a change
before it merges — one throwaway Studio per pull request.
preview.enabled is false by default and every preview template is gated on
it, so a normal install renders exactly what it rendered before. You do not need
this to self-host Studio, and you should leave it off unless you are building a
review pipeline.
Turning it on requires an orchestrator to create and destroy an environment per
pull request. The suggested one is Argo CD, and the chart-deco-studio-previews
chart in this repository ships that half: an ApplicationSet whose pullRequest
generator watches a repository for a label, plus the shared Gateway . Point it
at your own repository, domain and object storage — it has no defaults.
helm template studio-previews deploy/helm/studio-previews \
--set domain=pr.example.com \
--set applicationSet.repo.owner=your-org \
--set applicationSet.repo.name=your-fork \
--set applicationSet.repo.tokenSecret.name=preview-github-token \
--set studioChart.repoURL=oci://ghcr.io/decocms \
--set studioChart.version="$STUDIO_CHART_VERSION" \
--set studioChart.valuesRepo.url=https://github.com/your-org/your-fork.git \
--set images.api=ghcr.io/your-org/studio-preview \
--set images.nginx=ghcr.io/your-org/studio-nginx-preview \
--set studioValues.externalSecret.secretPath=preview/studio \
--set studioValues.externalSecret.secretStoreName=your-cluster-store \
--set studioValues.objectStorage.endpoint=https://your-endpoint \
--set studioValues.objectStorage.bucket=your-preview-bucket
A preview is not a small production. It does not cover hosted agent sandboxes, OAuth sign-in, billing, the monitoring dashboard, outbound email, or anything that depends on more than one pod. It also authenticates nobody by itself — put an authorization policy on the Gateway listener before exposing it, because a preview accepts email/password signup and can drive LLM agents.
See deploy/helm/studio-previews/README.md for the full contract.
Found an error or want to improve this page?
Edit this page