celld-operator
Kubernetes operator for celld · alpha

Self-hosted Durable Objects, operated by Kubernetes.

celld-operator runs Cloudflare Workers and Durable Objects on your own cluster with celld, and supplies everything celld deliberately leaves out: fleet provisioning, ingress, gated rollouts, autoscaling, and tenancy. One custom resource, one complete platform.

Apache-2.0 Kubernetes 1.30+ Gateway API · Istio · Ingress KEDA autoscaling Go · Kubebuilder
Why celld-operator

Stateless self-hosted Workers already exist. This is the stateful part.

workerd gives you self-hosted stateless Workers. What nobody else offers is distributed, durable, self-hosted Durable Objects: your agents, rooms, documents, and tenants as named stateful cells on infrastructure you control, with RPO=0 durability against an object-storage bucket you own.

Every object is its own database

Each Durable Object ("cell") is a private SQLite database, replicated to your bucket before any write is acknowledged. Node loss cannot lose an acknowledged write.

One CRD, an entire fleet

A single WorkerApp resource reconciles the StatefulSet, Services, ingress, NetworkPolicies, PodDisruptionBudget, mesh policy, metrics, and autoscaler for one application.

Operational rules, encoded

celld's drain semantics, rollout gating, and memory bounds are enforced by the reconciler. There are no templates to misconfigure. The controller cannot be talked into unsafe states.

How it works

Nodes coordinate through a bucket. The operator does the rest.

celld nodes share one object-storage bucket (deployments, per-cell SQLite state, and ownership leases) with no consensus service. Object-storage compare-and-swap guarantees exactly one node owns a cell at a time.

The operator turns one WorkerApp into one fleet of celld pods sharing a bucket prefix: routed through your Gateway, guarded by network policy, scaled by KEDA on cell-utilization metrics, and rolled out with celld's documented fleet-wide restoring=0 gate.

Each application is its own fleet with its own bucket prefix and credentials, so the blast radius of even a full runtime compromise is that one app. Read the architecture →

namespace: tenant-acme Gateway TLS · shared edge HTTPRoute Service :8080 celld-0 celld-1 celld-2 peer :8081 object store s3://cells/apps/chat state · deploys · leases celld-operator control plane reconciles · gates rollouts · polls /state
The WorkerApp resource

One resource. A whole platform.

Declare hostnames, the app version, the bucket, sizing, and scaling targets. The operator reconciles the workload, networking, security policy, ingress, rollout gating, metrics, and autoscaling, then reports fleet health back in status.

 watch it converge
$ kubectl get workerapps -n tenant-acme
NAME   PHASE   APP          READY   RESTORING
chat   Ready   sha-abc123   3       0

Full WorkerApp reference →

apiVersion: celld-operator.io/v1alpha1
kind: WorkerApp
metadata:
  name: chat
  namespace: tenant-acme
spec:
  hostnames: ["chat.acme.example.com"]
  appVersion: sha-abc123   # or "auto": follow the bucket
  celld:
    image: ghcr.io/denoland/celld:v0.2.0
  replicas: 3
  bucket:
    name: s3://platform-cells/apps/chat
    credentialsFrom:
      iamRole: arn:aws:iam::123456789012:role/celld-chat
  resources:
    memoryGi: 8              # ~1,000 resident cells
    maxResidentCells: 1000
  websockets: true
  autoscaling:
    enabled: true
    minReplicas: 3
    maxReplicas: 10
    targets:
      residentCellUtilization: 70
  telemetry:
    enabled: true
    retention: 30d
Features

The control plane celld deliberately ships without

celld supplies the runtime and the data plane; the operator supplies the platform. Upstream stays unpatched: the operator's celld-facing surface is env vars, signals, and two HTTP endpoints.

Gated rollouts

Not a vanilla rolling update: the operator releases one pod at a time and steps only when the whole fleet reports restoring=0. The restore load lands on peers, which a stock rollout cannot see. Progress lives in status.rollout.waitingOn.

Custom-metrics autoscaling

CPU is the wrong signal for celld. The operator exports resident-cell utilization and pressure-shedding metrics from each pod's /state, and drives a KEDA ScaledObject with celld-aware policy: scale up early, scale down slowly, never during a rollout.

Ingress your way

Gateway API HTTPRoutes on a shared Gateway, classic Istio VirtualServices, or plain networking.k8s.io/v1 Ingress with cert-manager TLS. Drain 503s are retried at the edge so rollouts are invisible to clients.

Security by default

celld's unauthenticated internal listener is fenced by NetworkPolicy and Istio AuthorizationPolicy, never routed. One IAM role per fleet, scoped to that fleet's bucket prefix. Tenancy is enforced at the Kubernetes layer.

GitOps-native deploys

Pin appVersion in git and let your GitOps tool roll the fleet, or set appVersion: auto and a plain celld deploy rolls it within one poll interval. Version drift always surfaces as a status condition.

Built-in telemetry

One field enables celld's tracing: Parquet traces and logs in your own bucket (DuckDB-queryable, no services to run) or OTLP to your collector. W3C traceparent joins edge spans to Durable Object spans.

Encoded, not documented

Rules you cannot get wrong

celld has precise operational requirements. Most platforms put them in a runbook; this operator puts them in the reconciler. There are no templates to drift.

  • Liveness is TCP only. celld's health path answers 503 during a graceful drain; an HTTP liveness probe there would kill nodes mid-handoff.
  • Termination grace exceeds the drain bound, so the kubelet never SIGKILLs a draining node.
  • CELLD_MAX_RSS_MB is set explicitly to ~80% of the container memory limit, because the upstream default is not cgroup-aware.
  • The internal listener stays internal. Port 8081 is reachable only from fleet pods and the operator, and is never routed.
  • Peers can reach draining pods so cell handoff works while a node reports unready.
  • Breaking celld upgrades are refused unless the resource explicitly opts into a Recreate, so a GitOps diff alone can never create a mixed fleet.
Built on celld

The runtime underneath

celld is Deno Land's open-source (Apache-2.0) daemon that embeds V8 and executes Wrangler-built Workers. Your existing build pipeline is retained unchanged: esbuild, wrangler.jsonc, celld deploy.

  • RPO=0. A write is not acknowledged before it is durable in the bucket. Nodes are replaceable; the bucket is the source of truth.
  • One writer per cell. Object-storage compare-and-swap fences ownership, with no consensus service or membership protocol.
  • Cheap density. One 8 GB node holds ~1,000 resident cells, about $0.05 per resident cell per month. Inactive cells cost nearly nothing.
  • Real Durable Objects. SQLite storage, alarms, hibernatable WebSockets, JS RPC, service bindings, static assets, WebAssembly. See the compatibility matrix.

Run your first fleet in minutes

Install the operator, deploy a Wrangler project to your bucket, and create a WorkerApp. The operator converges the rest.