Skip to main content
The Helm chart in 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/resize and ValidatingAdmissionPolicy.
  • A CNI that enforces NetworkPolicy. Without one, the runner network policy is accepted but does nothing.
  • An installer that may create cluster-scoped ValidatingAdmissionPolicy objects. Turn this off with isolation.admissionPolicy: false only 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 in charts/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 in runner.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
Annotations and 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.
IPv6 egress is not covered. Every rule is IPv4. Read Security model for what these controls do and do not hold.

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

This writes secrets.env, which make start reads for the SECRETS_* values.
3

Start

It creates the kind cluster (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
Give the two executor values a tag. They are bare repository strings, and a tagless one reads :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

The release name 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.
Then add an executor in the app, queue a task, and confirm the runner pod lands on your runner nodes.
Use placeholders below for your own account, region and cluster.Cluster
  • 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: true in 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.nodeSelector and runner.tolerations, so agent code never shares a node with the dashboard that holds the App key.
Task volumes. Install the EBS CSI driver add-on, create this StorageClass, and set taskVolume.storageClass: ebs-gp3:
A 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 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
With no static keys in 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.
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 (default 600) 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.

Roll back

Migrations only move forward. The repository ships no down migrations, and a Helm rollback does not undo them. Roll back only to a version that works with the current schema, or restore the database backup you took before the upgrade.

Uninstall

Stop runner Jobs first, then remove the release. Task volumes have no owner and survive it.
Next: Create the GitHub App, Set up Jira, Security model.