charts/factory deploys three things into one namespace: the dashboard (API and web app on one port), the driver, and an OTLP collector. The driver runs every task as a Kubernetes Job in that same namespace. There is no CRD.
The chart deploys no database. Bring your own PostgreSQL 17. The schema needs no extension, so RDS and Aurora both work.
Each Bellows release owns its namespace. The driver finds its runner pods by the release label, so run one board per namespace.
Before you start
- Kubernetes 1.33 or newer. The chart relies on native sidecars,
pods/resizeand ValidatingAdmissionPolicy. - A CNI that enforces NetworkPolicy. Without one, the runner network policy is accepted but does nothing.
- An installer that may create cluster-scoped
ValidatingAdmissionPolicyobjects. Turn this off withisolation.admissionPolicy: falseonly if your cluster enforces the same rules another way. - A PostgreSQL 17 database and its connection URL.
- A GitHub App and a GitHub OAuth App. See Create the GitHub App.
- An object store for task artifacts (S3, an S3-compatible store or Google Cloud Storage), unless this is a single-replica trial.
- A registry that holds the Bellows images, and a DNS name for the dashboard.
Values that matter
Every value is commented incharts/factory/values.yaml. These are the ones you set or review.
Required
The render refuses to start without them, and names the missing value.Common
Model credentials
The agent needs a model credential. The chart forwards the names inrunner.env (default CLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_API_KEY) to runners by reference. Put the values in runner.credentials, or in your own Secret named by runner.credentialsExistingSecret with one key per name. The value never appears in a pod spec.
Secret store
The secret store holds the secrets members save in Settings. Pick one backend.
Do not switch backends once secrets are stored. Only the dashboard container receives these keys.
With
gcp, bind the dashboard’s Google service account to the dashboard ServiceAccount only, so the driver and runner pods have no access. The backend calls secretmanager.secrets.create on the project, and secretmanager.versions.add, secretmanager.versions.access and secretmanager.secrets.delete on secrets whose names start with the prefix. It never lists secrets. Grant the three name-scoped permissions with an IAM condition resource.name.startsWith("projects/<project number>/secrets/<prefix>"): the condition sees the project number, while gcp.project takes the project id. secrets.create is checked on the project, so grant it without the condition.
Secrets to create
By default the chart creates its Secrets from your values. For production, create them yourself and reference them, so credentials never sit in a values file.1
Create the dashboard Secret
Set
secret.existingSecret to its name. Every key but database-url is optional in the pod spec, and the server names any missing one at boot. The driver needs job-board-token.2
Create the secret-store Secret
Set
secretStore.existingSecret to its name. It holds operation-key, fingerprint-key, and with the database backend also data-keys. secretStore.activeKeyId is not secret and always comes from values.3
Create the runner credentials Secret
Set
runner.credentialsExistingSecret. One key per name in runner.env.Ingress and TLS
The dashboard Service is ClusterIP, and the Ingress is off by default. The dashboard port carries the job board, so exposing it is a deliberate choice. Turn it on in values:values.yaml
tls pass through to the Ingress unchanged, so use whatever your controller expects. An empty className uses the cluster’s default IngressClass. GKE’s built-in controller has no IngressClass object: leave className empty and set the annotation kubernetes.io/ingress.class: gce. To attach a Cloud Armor policy on GKE, create a BackendConfig with securityPolicy and name it in dashboard.service.annotations as cloud.google.com/backend-config: '{"default":"<name>"}'. The render is refused if the host of auth.publicUrl is not in ingress.hosts, because sign-in would never return.
MCP needs only the one
/ Prefix rule the chart renders per host. It serves /.well-known/oauth-* and /api/mcp through it. Do not add a second path rule.Runners, RBAC and admission
Runners are Jobs in the release namespace. The chart sets up these guardrails:
Tune the runners:
blockedCidrs blocks your VPC too, so a database on a private address is unreachable from a runner unless you allow it.
Try it on kind
You need a checkout of the Bellows repository,docker, kind, helm, kubectl and node. This builds every image locally and installs the chart on one node.
1
Fill in .env
In the repository root, copy
.env.example to .env and set GITHUB_APP_ID, the private key (GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE), GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET, SESSION_SECRET and JOB_BOARD_TOKEN. Set a model credential too (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY). Register the OAuth App with the callback http://127.0.0.1:8081/api/auth/github/callback.2
Create the secret-store keys
secrets.env, which make start reads for the SECRETS_* values.3
Start
make cluster does that alone), builds and loads the images, installs the database release factory-state and the dev release, then port-forwards the dashboard. A cold node can take minutes to pull the database image.4
Sign in
Open
http://127.0.0.1:8081 and sign in with GitHub. Change the port with K8S_PORT.
The local profile keeps artifacts in the dashboard pod (
artifacts.backend: filesystem) and uses ReadWriteOnce volumes. Do not copy it to production.
Install in production
1
Get the images and chart
A release tag
v1.2.3 publishes the dashboard, driver, collector and both executor images, and the chart to oci://ghcr.io/<owner>/charts/factory. The chart version is the tag without the v. New registry packages start private, so make them public or add imagePullSecrets.2
Write values.yaml
values.yaml
:latest, so a node reuses its first cached pull. database.waitImage (default postgres:17-alpine) is the one image the registry prefix never touches. Mirror it and set it whole if you need to.3
Install
4
Check
bellows makes the dashboard bellows-factory and the driver bellows-factory-driver. The dashboard waits for the database with pg_isready before it starts, and GET /api/ready answers 200 once migrations have run. GET /api/health is the liveness check.Amazon EKS specifics
Amazon EKS specifics
Use placeholders below for your own account, region and cluster.ClusterA task’s claim binds once its first pod is scheduled. EBS volumes are zonal, so runner nodes must exist in the zone of the volume.Database. Use RDS or Aurora PostgreSQL 17 through With no static keys in
- EKS 1.33 or newer on EC2 Linux nodes. Fargate is out: it has no NetworkPolicy and no metrics API.
- VPC CNI add-on with
enableNetworkPolicy: truein its configuration values. Without it the NetworkPolicy is inert. - On the runner node group, set the instance metadata hop limit to 1 (
HttpPutResponseHopLimit: 1) or disable IMDS. This keeps agent code from reaching node credentials. - Install the metrics-server add-on, or a running task’s resource readings stay empty.
- A tainted runner node group, matched by
runner.nodeSelectorandrunner.tolerations, so agent code never shares a node with the dashboard that holds the App key.
taskVolume.storageClass: ebs-gp3:database.url. Do not use charts/factory-local-state: it is for kind only. A private-address database sits inside isolation.blockedCidrs, so add the database as a /32 in isolation.allowedCidrs if runners must reach it.Artifacts and secrets. Annotate the dashboard ServiceAccount with an IRSA role, or bind it with an EKS Pod Identity association:values.yaml
artifacts.credentialsExistingSecret, the dashboard uses Pod Identity, then IRSA. With secretStore.backend: aws, the role needs Secrets Manager Create, Get, Describe and DeleteSecret on arn:aws:secretsmanager:<region>:<account-id>:secret:<prefix>/*. After install, create, read and delete one secret in Settings to confirm the signed requests work.Ingress with an ALB. Install the AWS Load Balancer Controller, then:values.yaml
listen-ports must include the HTTP listener, or ssl-redirect has nothing to redirect. The ACM certificate and the DNS record live outside the chart.External Secrets. If you sync Secrets from a secret manager: a path outside the controller’s IAM scope fails with could not get secret data from provider, and the real AccessDeniedException is in the controller’s events. A missing remote key fails the whole sync, so default optional keys to empty. GITHUB_APP_PRIVATE_KEY may be raw or base64.Google GKE specifics
Google GKE specifics
Artifacts on Google Cloud Storage. Set
artifacts.backend: gcs and artifacts.gcs.bucket. The board never creates the bucket, so create it first. The board authenticates with Application Default Credentials only: there is no key-file option, and the cluster holds no storage secret. Bind the dashboard’s Kubernetes ServiceAccount (<release>-factory-dashboard) to a Google service account with Workload Identity, and give that account roles/storage.objectUser on the one bucket:values.yaml
artifacts.gcs.kmsKey is optional. Set it to a full Cloud KMS key name to encrypt new objects with a customer-managed key, and grant roles/cloudkms.cryptoKeyEncrypterDecrypter on that key to the Cloud Storage service agent of the bucket’s project. The dashboard’s own account needs no KMS role. The chart refuses artifacts.s3.* and artifacts.credentialsExistingSecret with gcs, and artifacts.gcs.* with another backend. Only the dashboard’s account is bound; runner pods hold no storage credential.Upgrade
- Back up the database first. The dashboard runs schema migrations at boot.
- The dashboard is always replaced by Recreate, never rolled, so two versions never share the schema. Expect a few seconds of downtime.
- The driver drains on shutdown. It stops claiming, and a minute before
driver.terminationGracePeriodSeconds(default600) it hands running tasks back. They are retried at once, and each runner Job keeps running for the next claim. Raise the value to let more runs finish in place. - Pin the executor image tags in values when you upgrade, since they are not derived from the chart version.