Production-grade Helm charts + build pipelines for the Cube.js Community Edition semantic layer on Kubernetes.
This repo ships two charts, sharing common helpers via a Helm library chart:
| Chart | When to use it | Cube Store router |
|---|---|---|
cube-stack |
Vanilla community Cube.js — single-router metastore | 1 replica, no replication |
cube-stack-ha |
Production HA — survives a single router pod loss in <10 s | 3 replicas with embedded Raft (via the agriev/cube fork) |
Both charts cover the same components — Cube API, Cube Refresh Worker, Cube Store Router, Cube Store Workers, optional Redis. They differ only in the metastore replication story.
| Component | Image | Purpose |
|---|---|---|
| Cube API | cubejs/cube |
Request-serving Pods (REST/GraphQL/SQL APIs) — scaled by HPA |
| Cube Refresh Worker | cubejs/cube |
Background pre-aggregation builder (CUBEJS_REFRESH_WORKER=true) |
| Cube Store Router | cubejs/cubestore (vanilla) / cubestore-ha from agriev/cube (HA) |
Cluster metadata + query planner |
| Cube Store Workers | cubejs/cubestore |
Distributed query execution (StatefulSet) |
| Redis (optional) | bitnami/redis |
Legacy queue/cache driver — Cube Store handles this by default |
Pinned to Cube 1.6.41 (latest stable as of 2026-05-01). Override with one flag — see Versioning.
The fastest way to see Cube running on a cluster (zero external dependencies — uses an in-memory DuckDB datasource and a bundled "orders" demo cube):
helm dependency update charts/cube-stack
helm upgrade --install cube charts/cube-stack \
-f charts/cube-stack/values.yaml \
-f charts/cube-stack/values-quickstart.yaml \
-n cube --create-namespace --wait
kubectl -n cube port-forward svc/cube-cube-stack-api 4000
open http://localhost:4000 # Cube PlaygroundFor the HA variant (3-router Raft cluster, requires the agriev/cube fork's image — make ha-image builds it locally):
make ha-image # build cubestore-ha:vdev locally
make ha-deploy # 3-router cluster in namespace cube-ha
make ha-verify # election + single-failover smoke test
make ha-chaos # 5-round kill-leader chaos (~90 s)Standard deploy with overrides (the chart will refuse to install if you forget the datasource — see Will it boot out of the box?):
make deps # pull subcharts
make lint # validate values + templates
make render-prod # inspect what'll be applied
make install OVERLAY=my.yaml # helm upgrade --install
make test # /livez + /readyz smoke testsEnd-to-end on a local kind cluster (Postgres + dev overlay):
make kind-up
make kind-deploy
make test
make kind-downcubejs/cube ships multi-arch (linux/amd64 + linux/arm64).
cubejs/cubestore does NOT — its main tags are linux/amd64 only.
For ARM64 nodes (Apple Silicon kind/minikube, AWS Graviton, ARM clouds) use:
cubestoreImage:
archSuffix: "-arm64v8" # tag becomes v1.6.41-arm64v8For amd64 CPUs without AVX (some older CPUs / nested VMs):
cubestoreImage:
archSuffix: "-non-avx"The chart auto-fails the install on ImagePullBackOff so you'll know
within 30 seconds — but better to set the suffix up front. The
quickstart overlay does this automatically when it detects ARM during CI.
Short answer: no, by design — and that's the same trade-off the official Cube docs make.
helm install cube ./charts/cube-stack with default values fails with:
datasources.default.host is required (or enable quickstart.enabled=true to deploy with the bundled demo schema)
There's no sensible default for "which warehouse should Cube talk to" — silently starting a Cube that can't answer queries is worse than refusing to install. Three resolutions:
| Goal | Command |
|---|---|
Demo / smoke test (no DB, in-memory DuckDB, fake orders cube) |
helm install … -f values-quickstart.yaml |
| Local kind cluster against a real Postgres | make kind-deploy (applies examples/postgres-dev.yaml + values-dev.yaml) |
| Production | configure datasources.<name> (host + creds), schema source (schema.configMap / schema.git / baked image), and a remote storage backend for Cube Store |
The minimum must-set keys for a healthy production install:
quickstart: { enabled: false }
cube:
apiSecret: "<openssl rand -hex 64>" # or apiSecretFromSecret.name
preAggregationsSchema: "prod_pre_aggregations"
datasources:
default:
type: postgres
host: ... # required
database: ...
user: ...
existingSecret: ... # holds password
schema:
configMap: { enabled: true, inline: { ... } } # OR schema.git OR baked image
cubestore:
workers: { replicas: 2 } # ≥ 2 for cluster mode
remoteStorage:
type: s3 # OR gcs (NOT minio for prod)
s3: { bucket: ..., region: ..., existingSecret: ... }helm template … | kubeconform runs in CI for every overlay, so a
misconfigured release fails before it reaches the cluster.
Aligned with Cube's own production guide and environment variable reference.
- Cube Store cluster mode (
cubestore.workers.replicas ≥ 2). OSS Cube Store does not replicate — losing the router or any worker downs the cluster, so cluster mode is about throughput, not HA. (For genuine router HA, see HA mode below.) - Cube Store remote storage on S3 or GCS (not MinIO — community-supported, no consistency guarantees per Cube docs).
- Cube Store export bucket ≠ persistent bucket (
datasources.<n>.export.bucketandcubestore.remoteStorage.s3.bucketMUST differ). - Cube Store isolated network — no NetworkPolicy ingress from outside the cube namespace; Cube Store has no auth of its own.
- Pin both images to the same tag (
global.imageTag: vX.Y.Z) — Cube and Cube Store ship in lock-step.
- API Pods: 2+ replicas, 2 CPU + 3 GiB RAM minimum each (Cube docs).
- Refresh worker: 6 GiB RAM allocation; raise
refreshWorker.concurrencyfor partitioned schedules. - Cube Store router: 4 CPU + 8 GiB RAM (the docs' floor).
- Cube Store workers: 4 CPU + 8 GiB RAM each, ≥ 2 replicas. Partition count of any pre-agg should be ≤ workers.replicas for fan-out efficiency.
-
cube.preAggregationsSchemaset to a stable schema name in your warehouse. -
cube.dbQueryTimeout≥ longest pre-agg build time. -
cube.maxSessionscapped at the warehouse's connection budget. -
refreshWorker.queriesPerAppIdraised in multi-tenant deployments. -
cubestore.queryTimeoutandcubestore.jobRunnerstuned per workload. -
CUBEJS_DB_EXPORT_BUCKETconfigured for Snowflake / BigQuery / Redshift (setdatasources.<n>.export.*instead ofextraEnv).
-
cube.apiSecretfrom external secret (Sealed Secret / External Secrets / Vault), not inline. - JWT auth enabled (
cube.jwt.enabled=true) — never expose Cube to the internet without it. - SQL API gated by
cube.sqlAuthifpgSqlPort/sqlPortexposed. -
cube.devMode: false(CUBEJS_DEV_MODE=false) — default in production overlay. -
cube.telemetry: falsefor air-gapped clusters. -
networkPolicy.enabled: true— restrict ingress to dashboards / BI tools. - Cosign-signed images (the
docker.yamlworkflow does this) verified by an admission controller.
-
metrics.statsdExporter.enabled: true(Cube Store StatsD → Prometheus). -
metrics.serviceMonitor.enabled: truefor the Prometheus Operator. - Track key SLIs:
cubejs_pre_aggregation_build_duration, refresh worker queue depth, Cube Store query timeouts.
Upstream OSS Cube Store has a single-router single-point-of-failure: all
metadata lives in one RocksDB instance, and pod loss makes the cluster
unqueryable until k8s reschedules and re-attaches the PVC. The
agriev/cube fork adds embedded Raft
replication (no external coordinator) to the router. This chart wires that
fork in via cubestore.ha.enabled=true.
# Build the fork's image
make ha-image # → cubestore-ha:vdev
# Deploy a 3-router HA cluster (namespace cube-ha)
make ha-deploy
# Verify single-cycle failover (kill leader → new leader)
make ha-verify
# Multi-cycle chaos (3 rounds; recovers in ~6–10 s per round)
make ha-chaosWhat flips when cubestore.ha.enabled=true:
replicas: 3enforced (chart fails install on< 3or even values).- Per-pod headless DNS via the StatefulSet
serviceName. CUBESTORE_NODE_IDderived from pod ordinal in an init wrapper.CUBESTORE_RAFT_PEERSrendered from the replica count.- PDB
minAvailable = replicas - 1(quorum protection). - Raft port
9100opened.
Production caveats:
- Workers are unchanged — partition replication is Phase 2 in the fork.
- Reads route to the leader; followers don't serve queries in v1.
- The fork rebases on upstream Cube quarterly; pin both
image.tagandcubestoreImage.tagto the same release.
See the fork's HA.md
and migration guide
for protocol details.
| Anti-pattern | Why it's bad | Mitigation in this chart |
|---|---|---|
| Single Cube Store node in production | Single point of failure + bottleneck | cubestore.workers.replicas ≥ 2 enforced via NOTES.txt warning |
| Sharing export bucket and persistent bucket | Concurrent reads/writes corrupt state | datasources.<n>.export.bucket is separate from cubestore.remoteStorage.s3.bucket |
| MinIO for persistent storage | Community contribution, no consistency guarantees | values.yaml documents s3 / gcs as preferred; minio listed but flagged in docs |
| Cube Store exposed without network controls | No built-in auth | Optional networkPolicy.enabled: true |
| Memory cache driver in production | Loses queue on Pod restart | Default cube.cacheAndQueueDriver=cubestore |
CUBEJS_DEV_MODE=true in production |
Disables auth, exposes playground | Default cube.devMode: false; quickstart overlay opts in explicitly |
| Heavy non-partitioned pre-aggregations | Query timeouts, single-worker hotspot | cube.dbQueryTimeout, doc the partition rule |
| High-cardinality dimensions in pre-aggs | Inflates table size | Surfaced in docs |
The chart is engineered around swap points, not forks. Each scenario below is supported without modifying templates.
Build docker/Dockerfile and point image.repository at
your registry. The CI workflow handles multi-arch buildx + cosign + Trivy.
image:
repository: ghcr.io/my-org/cube
tag: v1.6.41-3Add npm deps in docker/package.json (e.g. a custom
auth provider, a patched driver, OpenTelemetry).
Mount your own cube.js via schema.configMap.inline:
schema:
configMap:
enabled: true
inline:
cube.js: |
const jwt = require('jsonwebtoken');
module.exports = {
checkAuth: async (req, auth) => {
const decoded = jwt.verify(auth, process.env.CUBEJS_JWT_KEY);
req.securityContext = { tenant_id: decoded.tenant };
},
};
model/orders.yml: |
...Refresh different tenants in parallel:
schema:
configMap:
enabled: true
inline:
cube.js: |
module.exports = {
contextToAppId: ({ securityContext }) => `app_${securityContext.tenant_id}`,
contextToOrchestratorId: ({ securityContext }) => `orch_${securityContext.tenant_id}`,
scheduledRefreshContexts: async () => [
{ securityContext: { tenant_id: 'acme' } },
{ securityContext: { tenant_id: 'globex' } },
],
};
refreshWorker:
replicas: 3 # one Pod per ~10 tenants is a good rule
concurrency: 6
queriesPerAppId: 10Two Cubes can read each other's data via the SQL API. Deploy two releases in different namespaces:
helm install cube-revenue ./charts/cube-stack -f revenue.yaml -n revenue
helm install cube-product ./charts/cube-stack -f product.yaml -n product
# In product's cube.js add the SQL connection to revenue's pgSqlPort.Drop them into docker/package.json:
{ "dependencies": { "@cubejs-backend/my-custom-driver": "0.1.0" } }Then in cube.js:
const Driver = require('@cubejs-backend/my-custom-driver');
module.exports = { driverFactory: () => new Driver(...) };api:
sidecars:
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.13.0
args: [my-project:us-east1:db, "--auto-iam-authn"]
securityContext: { runAsNonRoot: true }
extraVolumes: []
initContainers:
- name: vault-agent
image: hashicorp/vault:1.18
...Every credential field has an existingSecret companion:
cube:
apiSecretFromSecret: { name: cube-api, key: secret }
jwt:
keyFromSecret: { name: cube-jwt, key: public.pem }
datasources:
default:
existingSecret: cube-pg
export:
aws:
existingSecret: cube-awsThe chart only renders chart-managed Secrets when no existingSecret
is set — there's no double-allocation.
Cube Store remote storage is exhaustive: pick s3 / gcs / minio /
shared-pvc per the cubestore.remoteStorage.type block.
For Azure Blob, set cubestore.extraEnv with CUBESTORE_AZURE_* (Cube
docs cover the variable names).
metrics.serviceMonitor ships out of the box. For a Grafana dashboard,
toggle metrics.grafanaDashboard.enabled and provide your own JSON via
extraManifests (or import the official dashboard
through your Grafana provisioning).
The chart is a single rooted directory — point an Application /
HelmRelease at charts/cube-stack and supply your overlay via
spec.source.helm.valuesFiles. The OCI release workflow
(chart-vX.Y.Z tag) publishes signed packages to
oci://ghcr.io/<owner>/charts/cube-stack for declarative consumption.
.
├── charts/
│ └── cube-stack/ # the Helm chart
│ ├── Chart.yaml
│ ├── values.yaml # ★ single source of truth
│ ├── values-prod.yaml # production overlay
│ ├── values-dev.yaml # local kind/minikube overlay
│ └── templates/
├── docker/ # custom Cube image (model + extra deps)
│ ├── Dockerfile
│ ├── cube.js
│ └── model/
├── examples/ # ready-to-mix overlays per data source
├── .github/workflows/
│ ├── ci.yaml # lint + template + kubeconform + ct install on kind
│ ├── docker.yaml # multi-arch image build, signing, Trivy scan
│ ├── release.yaml # OCI chart push on `chart-vX.Y.Z` tag
│ └── security.yaml # weekly Trivy + kube-linter on rendered manifests
├── Makefile
└── README.md
┌────────────────────────────────────┐
│ ingress (optional) │
└─────────────┬──────────────────────┘
│ HTTPS / SQL
┌───────────────▼───────────────┐
│ Cube API (Deployment + HPA) │ ←─ /livez, /readyz
└─┬──────────┬─────────────────┬┘
queries via │ │ scheduled jobs │ env vars
gRPC/HTTP │ ▼ │
┌─▼─┐ ┌─────────────────┐ │
│ . │ │ Refresh Worker │ │
│ . │ └────────┬────────┘ │
│ . │ │ │
│ . │ ▼ ▼
│ . │ ┌─────────────────────────────────┐
│ . │ │ Cube Store Router (StatefulSet)│ — metadata + planner
│ . │ └────────┬────────────────────────┘
│ . │ │ control plane
│ . │ ┌────────▼────────────────────────┐
│ . │ │ Cube Store Workers (StatefulSet,│ — distributed exec
│ . │ │ headless service, N replicas) │
│ . │ └────────┬────────────────────────┘
│ . │ │
│ . │ ┌────────▼────────┐
│ . │ │ Remote storage │ — S3 / GCS / MinIO / shared PVC
│ . │ └─────────────────┘
│ . │
│ . │ ┌───────────────────────┐
│ . └─►│ Upstream data sources │ — Postgres / Snowflake / ClickHouse / ...
└─────►└───────────────────────┘
A single Cube Store node is a single point of failure and a hard bottleneck on sub-second analytics. Cluster mode (one router + N workers) is the official recommendation for production. This chart sets it up out of the box:
- the router is a StatefulSet exposing
meta,http,mysql,statusports via a regular ClusterIP Service so workers and the API can resolve it by name; - workers are a StatefulSet with a headless Service so each Pod gets a
stable FQDN of the form
<release>-cube-stack-cubestore-workers-<N>.<release>-cube-stack-cubestore-workers-headless; - the chart auto-fills
CUBESTORE_WORKERSandCUBESTORE_META_ADDRfor both sides so you never have to hand-build the comma-separated list.
Since Cube 0.32 the cache and queue can run on Cube Store itself, removing
Redis from the critical path. The chart defaults to that
(cube.cacheAndQueueDriver=cubestore). If you must keep Redis, set the value
to redis and either flip redis.enabled=true (bitnami subchart) or point
redis.external.url at your existing Redis.
Every component is parameterised in
charts/cube-stack/values.yaml. The most
relevant top-level keys:
| Key | What it controls |
|---|---|
global.imageTag |
Pin Cube + Cube Store to the same version everywhere |
global.topologySpread |
soft / hard — auto-generates topology spread constraints across zones |
cube.* |
Cube core settings (ports, secrets, cache driver, JWT, schema path) |
image.*, cubestoreImage.* |
Override registries (e.g. for the custom image built from docker/Dockerfile) |
schema.configMap / schema.git |
Where Cube finds your model files |
api.* |
Cube API replicas, resources, HPA, PDB, probes |
refreshWorker.* |
Refresh worker replicas, resources, HPA, PDB |
cubestore.router.* / cubestore.workers.* |
StatefulSet sizing, persistence, probes |
cubestore.remoteStorage.* |
S3 / GCS / MinIO / shared-PVC backend |
datasources.* |
One or more data sources (postgres / snowflake / bigquery / clickhouse / …) |
redis.* |
Bundled or external Redis (legacy driver only) |
ingress.* |
HTTP/REST ingress |
metrics.* |
StatsD-to-Prometheus sidecar + ServiceMonitor + Grafana dashboard |
networkPolicy.* |
NetworkPolicy locking down ingress between components |
Overlays:
values-prod.yaml— HA, multi-zone, S3 remote storage, ServiceMonitor, NetworkPolicyvalues-dev.yaml— single-replica, RWO PVC, kind/minikube friendlyexamples/values-*.yaml— drop-in overlays for Snowflake / ClickHouse / multi-datasource setups
Defaults: HPA on, min 2, max 10 replicas, target 70 % CPU + 80 % memory.
api:
replicas: 3 # baseline
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 30
targetCPUUtilizationPercentage: 60
behavior:
scaleDown:
stabilizationWindowSeconds: 600Refresh workers are stateful in practice (each one schedules a slice of pre-aggregations), so HPA is off by default. Enable when you have clearly partitioned schedules:
refreshWorker:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 6Scale by adding workers — the chart re-renders CUBESTORE_WORKERS for you:
cubestore:
workers:
replicas: 8
resources:
requests: { cpu: "4", memory: 16Gi }
limits: { cpu: "8", memory: 32Gi }Adding workers is a rolling restart of Cube Store. Drain queries first if you can — Cube Store is push-style and will retry, but in-flight queries may error during the restart window.
Three options — pick whichever fits your release process best.
-
Custom image (default). Build
docker/Dockerfile, pointimage.repositoryat your registry. CI in.github/workflows/docker.yamldoes multi-arch build, cosign sign, Trivy scan, and pushes tagsvX.Y.Z,vX.Y,vX,sha-…to GHCR. -
ConfigMap:
schema: configMap: enabled: true inline: cube.js: | module.exports = { schemaPath: 'model' }; model/orders.yml: | cubes: - name: orders sql_table: public.orders measures: [{ name: count, type: count }]
-
Git:
schema: git: enabled: true repoUrl: git@github.com:my-org/cube-models.git ref: main subPath: cube auth: existingSecret: cube-git-key sshKeyKey: id_rsa
| Knob | Effect |
|---|---|
global.imageTag |
Pin Cube + Cube Store to one version (e.g. v1.6.41) |
image.tag |
Override Cube image tag specifically |
cubestoreImage.tag |
Override Cube Store image tag specifically |
Chart.yaml's appVersion |
Default fallback for both |
Bump path:
# Update appVersion + global.imageTag in Chart.yaml + values.yaml,
# bump the chart version in Chart.yaml, then:
git tag chart-v1.0.1
git push --tags
# .github/workflows/release.yaml packages and pushes the chart to OCI GHCR.runAsNonRoot: true,runAsUser: 1000,fsGroup: 1000allowPrivilegeEscalation: false, drop all capabilities- Seccomp
RuntimeDefault - Read-only root FS is off (Cube writes to
/cube/confat startup) but is toggleable viacontainerSecurityContext.readOnlyRootFilesystem - Optional
NetworkPolicy(off by default) - Image SBOM + signature via cosign keyless (
docker.yamlworkflow) - Weekly Trivy + kube-linter SARIF uploads (
security.yamlworkflow)
brew install helm kubectl kind kubeconform
make deps lint kubeconform # offline checks
make kind-up # spin up kind cluster
make kind-deploy # postgres + cube-stack (dev overlay)
kubectl -n cube port-forward svc/cube-cube-stack-api 4000
open http://localhost:4000 # Cube playground
make test # /livez + /readyz smoke tests
make kind-down| Workflow | Trigger | What it does |
|---|---|---|
ci.yaml |
push / PR on charts/** |
helm lint + template + kubeconform; chart-testing lint + install on kind |
docker.yaml |
push / tag / dispatch | multi-arch buildx → GHCR; SBOM + cosign signing; Trivy scan |
release.yaml |
tag chart-vX.Y.Z |
helm package, push to oci://ghcr.io/<owner>/charts, GitHub Release |
security.yaml |
weekly + PR | Trivy config scan on rendered manifests + kube-linter SARIF |
The chart auto-translates datasources.<name>.* into CUBEJS_DB_* (for the
first entry — the default data source) or CUBEJS_DS_<NAME>_DB_* for
subsequent entries. Anything Cube needs that the chart doesn't know about
goes through extraEnv:
datasources:
default:
type: bigquery
extraEnv:
- name: CUBEJS_DB_BQ_PROJECT_ID
value: my-project
- name: CUBEJS_DB_BQ_KEY_FILE
value: /etc/cube/bq.json
# Mount the key via cube.extraEnvFrom or schema.configMap as needed.See Cube docs for the canonical list.