Production Kubernetes Deployment
Deploy Sibyl to a production Kubernetes cluster using Helm.
Prerequisites
- Kubernetes cluster (1.27+)
- kubectl configured for your cluster
- Helm 3.x
- SurrealDB instance (in-cluster StatefulSet, Surreal Cloud, or another external host)
Architecture Overview
+---------------------------- Production Cluster -----------------------------+
| |
| +------------------+ +------------------+ +------------------+ |
| | Ingress/Gateway | | Backend (n) | | Frontend (n) | |
| | (Kong/Nginx) |---->| HPA: 2-10 | | HPA: 2-10 | |
| +------------------+ +--------+---------+ +------------------+ |
| | |
| +---------+--------+ |
| | Worker (n) | |
| | HPA: 1-5 | |
| +------------------+ |
| |
+------------------------------------------------------------------------------+
|
+----------+-----------+
| SurrealDB |
| graph + content + |
| auth (unified) |
+----------------------+SurrealDB is the only data store. See Storage Modes for connection options.
Quick Start
Provision the sibyl-validation-receipts persistent claim before installation. API and worker share it, along with the organization backup archives kept in its backups directory; multi-node replicas require ReadWriteMany storage. With receipts in S3, give archives a bucket prefix (backend.backupArchives.url) or a claim of their own; see Backup Archives.
# Add namespace
kubectl create namespace sibyl
# Create secrets (Surreal default)
kubectl create secret generic sibyl-secrets -n sibyl \
--from-literal=SIBYL_JWT_SECRET=$(openssl rand -hex 32) \
--from-literal=SIBYL_SETTINGS_KEY=$(openssl rand -base64 32 | tr '+/' '-_') \
--from-literal=SIBYL_OPENAI_API_KEY=sk-... \
--from-literal=SIBYL_ANTHROPIC_API_KEY=sk-ant-...
kubectl create secret generic sibyl-surreal -n sibyl \
--from-literal=password=<your-surreal-password>
# Install with Helm from the published repository
helm repo add sibyl https://raw.githubusercontent.com/hyperb1iss/sibyl/gh-pages
helm repo update
helm upgrade --install sibyl sibyl/sibyl \
-n sibyl \
-f values-production.yamlInstalling from a source checkout works the same way with the local chart path: replace sibyl/sibyl with ./charts/sibyl.
Values Configuration
Create a values-production.yaml (Surreal default):
# Coordination mode
coordinationBackend: "redis"
backend:
replicaCount: 2
image:
repository: ghcr.io/hyperb1iss/sibyl-api
tag: "1.4.10"
pullPolicy: Always
# Reference pre-created secrets
existingSecret: sibyl-secrets
validationReceipts:
existingClaim: sibyl-validation-receipts
# Or keep receipts in S3 and leave existingClaim empty:
# url: "s3://sibyl-receipts/prod?region=us-west-2"
# Archives default to the receipts claim; with receipts in S3:
# backupArchives:
# url: "s3://sibyl-backups/prod?region=us-west-2"
# Only the ingress controller pods' own range, so logins key on each user's
# address. Never the pod CIDR; see "Client Addresses Behind the Ingress".
forwardedAllowIps: "10.250.0.0/28"
# SurrealDB connection
surreal:
url: "ws://your-surrealdb.example.com:8000/rpc"
username: "root"
existingSecret: sibyl-surreal
namespacePrefix: "org_"
database: "graph"
# Valkey/Redis coordination for multi-replica deployments
redis:
host: "valkey.example.svc.cluster.local"
port: "6379"
jobsDb: "1"
rateLimitDb: "4"
existingSecret: sibyl-redis
secretKey: password
env:
SIBYL_SERVER_HOST: "0.0.0.0"
SIBYL_SERVER_PORT: "3334"
SIBYL_ENVIRONMENT: "production"
SIBYL_PUBLIC_URL: "https://sibyl.example.com"
SIBYL_LLM_PROVIDER: "anthropic"
SIBYL_LLM_MODEL: "claude-haiku-5-5"
# Enable autoscaling
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
# Enable PodDisruptionBudget
pdb:
enabled: true
minAvailable: 1
# Spread pods across nodes
podAntiAffinity:
enabled: true
type: soft
topologyKey: kubernetes.io/hostname
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 512Mi
frontend:
enabled: true
replicaCount: 2
image:
repository: ghcr.io/hyperb1iss/sibyl-web
tag: "1.4.10"
apiUrl: "http://sibyl-backend:3334/api"
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
pdb:
enabled: true
minAvailable: 1
worker:
enabled: true
replicaCount: 2
autoscaling:
enabled: true
minReplicas: 1
maxReplicas: 5
pdb:
enabled: true
minAvailable: 1
# Ingress (adjust for your ingress controller)
# ingress.hosts is the shared route table. Enable ingress.classic for a classic
# networking.k8s.io/v1 Ingress, or ingress.gatewayApi for a Gateway API HTTPRoute.
ingress:
hosts:
- host: sibyl.example.com
paths:
- path: /api
pathType: Prefix
service: backend
- path: /mcp
pathType: Prefix
service: backend
- path: /
pathType: Prefix
service: frontend
# Classic Ingress (NGINX and similar):
classic:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- secretName: sibyl-tls
hosts:
- sibyl.example.com
# Gateway API HTTPRoute (Kong and similar): enable this instead of classic.
gatewayApi:
enabled: false
parentRefs:
- name: production-gateway
namespace: kongDatabase Setup
SurrealDB Requirements (default)
- SurrealDB 3.x. Pin an explicit tested image tag for production instead of floating on
latest. - TiKV-backed storage for replicated SurrealDB pods, or RocksDB-backed storage for a single pod
- Root credentials set at start (
--user/--pass) - Persistence and backups live in the selected SurrealDB storage engine
For a scalable in-cluster shape, deploy the TiDB Operator, create a small TiKV/PD cluster, and point the official SurrealDB Helm chart at tikv://<pd-service>:2379. For single-pod installs, a RocksDB-backed PVC is simpler. Either way, tikv:// and rocksdb:// configure the SurrealDB server only. Point Sibyl's backend.surreal.url at that server with a ws://, wss://, http://, or https:// URL. The backend rejects unsupported schemes such as rocksdb:// and tikv:// at startup; the embedded forms have their own rules and are not meant for a cluster (see SurrealDB URL forms).
Coordination Requirements
Set coordinationBackend: "redis" when running multiple backend or worker replicas. Use Valkey or Redis for arq jobs, distributed locks, WebSocket pub/sub, and shared rate limits.
Secrets Management
Option 1: Kubernetes Secrets
# Create from literal values
kubectl create secret generic sibyl-secrets -n sibyl \
--from-literal=SIBYL_JWT_SECRET=$(openssl rand -hex 32) \
--from-literal=SIBYL_OPENAI_API_KEY=sk-... \
--from-literal=SIBYL_ANTHROPIC_API_KEY=sk-ant-...On AWS with the bedrock provider, leave the two provider keys out. Claude and Cohere embeddings then authenticate through the service account's IAM role; see Amazon Bedrock in the Helm chart guide.
Option 2: External Secrets Operator
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: sibyl-secrets
namespace: sibyl
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: sibyl-secrets
data:
- secretKey: SIBYL_JWT_SECRET
remoteRef:
key: sibyl/jwt-secret
- secretKey: SIBYL_OPENAI_API_KEY
remoteRef:
key: sibyl/openai-keyOption 3: Sealed Secrets
# Create SealedSecret
kubeseal --format=yaml < sibyl-secrets.yaml > sibyl-secrets-sealed.yaml
kubectl apply -f sibyl-secrets-sealed.yamlSchema Bootstrap
Sibyl bootstraps SurrealDB schema inline at startup. The Helm chart runs no pre-upgrade migration hook.
Ingress Configuration
The chart renders from the shared ingress.hosts route table. Set ingress.classic.enabled=true for a classic networking.k8s.io/v1 Ingress, or ingress.gatewayApi.enabled=true (with parentRefs) for a Gateway API HTTPRoute. The standalone manifests below are equivalent hand-written forms if you prefer to manage routing outside the chart.
Client Addresses Behind the Ingress
Every request the backend receives comes from an ingress controller or Gateway pod, so on its own the backend sees one address for all users. The five-per-minute login limit is keyed on that address, which means the sixth sign-in in a minute from anyone locks everyone out. Tell the backend which proxies to believe and it reads the real client from X-Forwarded-For instead:
backend:
# A range only the ingress controller pods draw their addresses from.
forwardedAllowIps: "10.250.0.0/28"Every trusted address must be a proxy, never something that could be a client. A trusted peer can name any client address, and an ingress that appends to X-Forwarded-For rather than replacing it (Envoy-based gateways, AWS ALB, and nginx's $proxy_add_x_forwarded_for all do) hands the same power to anyone who calls through it from a trusted address: the ingress appends the caller's own address, the backend skips it as trusted, and the caller's forged entry wins. A NetworkPolicy cannot close that path, because the request really does arrive from the controller. So do not trust the cluster pod CIDR, node ranges, or the VPC CIDR; each of them holds clients.
Recommended setup:
- Give the controller pods addresses nothing else draws from (a CNI IP pool selected by the controller's namespace, or a node pool reserved for the controller with its own pod range) and trust only that range.
kubectl get pods -n <controller-namespace> -o wideshows the addresses it has to cover. - Configure the controller to replace
X-Forwarded-Forfrom untrusted peers rather than append to it. ingress-nginx does by default, as long asuse-forwarded-headersandcompute-full-forwarded-forstay off. - Set
networkPolicy.enabled: truewith anetworkPolicy.ingress.fromthat selects the controller's namespace, so nothing can reach the backend directly.
The backend walks X-Forwarded-For from the right and stops at the first address it does not trust. Handle any hop in front of the controller at the controller, not in the backend's list:
- A cloud L7 load balancer in front of the controller: have the controller trust only the load balancer's dedicated subnets and resolve the client itself (in ingress-nginx,
use-forwarded-headers: truewithproxy-real-ip-cidrlimited to them, since it defaults to0.0.0.0/0), so the backend still receives one resolved address. Do not add the VPC CIDR toforwardedAllowIps. - An L4 load balancer or
externalTrafficPolicy: Cluster, where traffic reaches the controller from node addresses: do not add the node range, because the entry to its left is one the client wrote. SetexternalTrafficPolicy: Localon the controller's Service, or enable PROXY protocol, so the controller sees real client addresses.
Trusting a range lets anything inside it choose its client address, which also decides the break-glass allowlist, so list the controller and nothing else, and keep /api and /mcp routed straight to the backend service (the Next.js frontend passes a client's own X-Forwarded-For through untouched). After an upgrade, sign in and check that the backend's request log lines (kubectl logs -n sibyl deploy/sibyl-backend) carry your own address in client, not a controller pod's. The Helm reference covers the value's forms and the "*" escape hatch, and the environment reference explains how the address resolves.
Kong Gateway (standalone HTTPRoute)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: sibyl
namespace: sibyl
spec:
parentRefs:
- name: production-gateway
namespace: kong
hostnames:
- sibyl.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /api
- path:
type: PathPrefix
value: /mcp
backendRefs:
- name: sibyl-backend
port: 3334
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: sibyl-frontend
port: 3337NGINX Ingress (standalone manifest)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sibyl
namespace: sibyl
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- sibyl.example.com
secretName: sibyl-tls
rules:
- host: sibyl.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: sibyl-backend
port:
number: 3334
- path: /mcp
pathType: Prefix
backend:
service:
name: sibyl-backend
port:
number: 3334
- path: /
pathType: Prefix
backend:
service:
name: sibyl-frontend
port:
number: 3337Health Checks
The chart configures liveness and readiness probes:
backend:
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/health/ready
port: http
initialDelaySeconds: 5
periodSeconds: 10The readiness probe targets /api/health/ready, the deep readiness endpoint that returns 503 when SurrealDB is unreachable so the pod is pulled from service until it can serve. Liveness stays on /api/health.
Scaling
Manual Scaling
kubectl scale deployment sibyl-backend -n sibyl --replicas=5HPA Configuration
With autoscaling enabled:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5min before scaling down
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 15Monitoring
Check Deployment Status
# All resources
kubectl get all -n sibyl
# Pods status
kubectl get pods -n sibyl -o wide
# HPA status
kubectl get hpa -n sibyl
# Events
kubectl get events -n sibyl --sort-by='.lastTimestamp'View Logs
# Backend logs
kubectl logs -n sibyl -l app.kubernetes.io/component=backend -f
# Worker logs
kubectl logs -n sibyl -l app.kubernetes.io/component=worker -f
# All Sibyl logs
kubectl logs -n sibyl -l app.kubernetes.io/name=sibyl -fUpgrades
Keep image tags and security contexts in lockstep. Current Sibyl images run backend/worker as 10001:10001 and frontend as 10002:10002; older pinned images need matching overrides in backend.podSecurityContext, frontend.podSecurityContext, and worker.podSecurityContext.
# Update values (use ./charts/sibyl instead when installing from source)
helm repo update
helm upgrade sibyl sibyl/sibyl \
-n sibyl \
-f values-production.yaml \
--set backend.image.tag=1.4.10 \
--set frontend.image.tag=1.4.10
# Rollback if needed
helm rollback sibyl -n sibylUninstall
# Remove Helm release
helm uninstall sibyl -n sibyl
# Remove namespace (DELETES ALL DATA)
kubectl delete namespace sibylNext Steps
- Helm Chart Reference - Complete values documentation
- Environment Variables - All configuration options
- Monitoring - Observability setup
