Self-Hosting Conductor With Kubernetes
Self-hosted Conductor is released under a proprietary license. Self-hosting Conductor for commercial or production use requires a license key.
Overview
This guide covers deploying DBOS Conductor on Kubernetes so your applications get durable workflow execution, automatic workflow recovery, workflow management and observability — all running on infrastructure you control.
The Kubernetes manifests are portable to any conformant cluster.
Deployments
Database — Conductor needs a PostgreSQL database, which we recommend configuring with a dedicated database role.
Conductor — A stateless, single-container Deployment listening on port 8090. All state lives in PostgreSQL: use a Deployment and not a StatefulSet. Required environment variables:
DBOS__CONDUCTOR_DB_URL(connection string to thedbos_conductordatabase)DBOS_CONDUCTOR_LICENSE_KEY(obtain a license key)
Conductor is out of the critical path and a single Conductor instance can serve tens of thousands of application servers.
You can still run multiple replicas for high availability; each pod must then advertise its own address, which conductor.yaml below does from the pod IP.
Console — A stateless, single-container Deployment listening on port 8080 (the Service in front of it publishes port 80.)
It connects to Conductor using the environment variable DBOS_CONDUCTOR_URL, set to a bare host:port (for example conductor.dbos.svc.cluster.local:8090).
Note that your applications also use a variable named DBOS_CONDUCTOR_URL, but it takes a full WebSocket URL (see Register applications below.)
Conductor is architecturally out-of-band — it is not on the critical path of your application.
To upgrade, update the container image tag in conductor.yaml and console.yaml, (latest by default) then kubectl rollout restart. Prefer updating both Conductor and the console together.
Applications seamlessly reconnect to the new Conductor version with no impact on their availability.
After deploying Conductor and Console, register your application, and generate an API key. The application connects to Conductor via WebSocket using this API key and the Conductor URL.
With the Ingress below, that URL is your Ingress hostname plus the /conductor-api prefix:
DBOS_CONDUCTOR_KEY=<the API key you generated in the Console>
DBOS_CONDUCTOR_URL=wss://<your-elb-hostname>/conductor-api
Because this is a wss:// connection, your application verifies the Ingress TLS certificate.
Authentication
Conductor supports OAuth 2.0 with any OIDC-compliant provider. See the authentication setup guide.
Conductor performs no authentication unless OAuth is enabled. Without it, all
API requests run as a built-in local organization admin, and Conductor does not
verify API keys on incoming WebSocket connections. Anyone who can reach the Ingress
can register applications, cancel, resume, fork, or delete workflows, and create API
tokens. Configure OAuth before exposing this deployment to any untrusted network.
When configuring your OAuth provider, the callback URL and allowed web origin are your Ingress hostname (https://<your-elb-hostname>/oauth/callback and https://<your-elb-hostname>).
The OAuth settings are not secrets, so they can be set directly in the Deployment manifests — Conductor and the Console each need their own set.
Ingress
In this guide, all external traffic enters through a reverse proxy that performs TLS termination, supports WebSockets, and routes by path: /conductor-api/... to Conductor, everything else to the Console.
This guide uses ingress-nginx, but any reverse proxy meeting those requirements will work. The ingress.yaml below defines the routing it must implement.
The DBOS SDK maintains a long-lived WebSocket connection to Conductor, so both the reverse proxy and any cloud load balancer in front of it (e.g., AWS ELB) should have idle timeouts high enough (this guide uses 3600s) to tolerate network hiccups. The DBOS SDK sends periodic pings to keep the connection alive, but a network hiccup that delays pings past the timeout will cause a disconnect. In case of disconnection, the DBOS SDK will reconnect automatically.
Security Best Practices
Secret management — Conductor deployments need credentials for PostgreSQL, a license key, and an API key.
Store these as Kubernetes Secrets and inject them via secretKeyRef.
For Git-safe storage, encrypt with Sealed Secrets, SOPS, or a cloud-native secrets manager (AWS Secrets Manager, Vault, etc.).
Network policies — Apply a default-deny ingress policy to the namespace, then add explicit allow rules for each pod. If Conductor and Console are co-located, allow traffic from the Console to Conductor on port 8090.
Conductor validates its license key against https://cloud.dbos.dev at startup and exits if it cannot reach it, so keep outbound HTTPS open from the Conductor pod (this also means its nodes need a route to the internet, such as a NAT gateway for private subnets).
RBAC — Restrict which ServiceAccounts can read Secrets in the namespace. Conductor credentials (database URLs, license key, API key) should only be accessible to the pods that need them.
Walkthrough (AWS EKS)
- EKS (AWS)
In addition to DBOS Conductor and the DBOS Console, the infrastructure includes the following components:
| Component | Role |
|---|---|
| RDS | Database for Conductor operating state |
| Reverse Proxy (Nginx Ingress) | TLS termination, path-based routing, WebSocket support |
| Sealed Secrets | Encrypts secrets at rest; decrypts them in-cluster |
Set environment variables
Set these variables before proceeding — replace the placeholder values with your own:
# Your AWS account ID (12-digit number)
AWS_ACCOUNT_ID=123456789012
# AWS region for all resources
AWS_REGION=us-west-2
# PostgreSQL admin password (used for the RDS master user)
POSTGRES_PASSWORD='choose-a-secure-password'
# Password for the Conductor database role
CONDUCTOR_ROLE_PASSWORD='choose-another-secure-password'
# Conductor license key (from DBOS Console or sales)
CONDUCTOR_LICENSE_KEY='your-license-key'
Infrastructure
CLI tools required on your workstation
| Tool | Purpose | Install |
|---|---|---|
| AWS CLI | AWS account access | Install guide |
| eksctl | Create and manage EKS clusters | Install guide |
| kubectl | Interact with Kubernetes | Included with eksctl, or install separately |
| Helm | Install cluster add-ons (Ingress, Sealed Secrets) | brew install helm or Install guide |
| kubeseal | Encrypt Kubernetes secrets | brew install kubeseal or Install guide |
| openssl | Generate self-signed TLS certificate | Pre-installed on macOS/Linux |
Verify your AWS credentials are configured:
aws sts get-caller-identity
DBOS Conductor License Key
Obtain a development license key from the DBOS Console or contact DBOS sales for a pro license key. You can follow this guide with a development license key for evaluation, but you will be limited to one executor per application.
Create an EKS Cluster
Create a managed EKS cluster with two nodes. This takes approximately 15 minutes.
Create EKS cluster
eksctl create cluster \
--name dbos-conductor \
--region $AWS_REGION \
--version 1.32 \
--nodegroup-name default \
--node-type t3.medium \
--nodes 2 \
--managed
eksctl automatically:
- Creates a VPC with public and private subnets
- Configures the Amazon VPC CNI, which supports NetworkPolicy enforcement
- Sets up your
~/.kube/configto point at the new cluster
Once complete, verify the cluster is ready:
kubectl get nodes
You should see two nodes in Ready status:
NAME STATUS ROLES AGE VERSION
ip-192-168-xx-xx.us-west-2.compute.internal Ready <none> 2m v1.32.x
ip-192-168-xx-xx.us-west-2.compute.internal Ready <none> 2m v1.32.x
Create a Namespace
All resources in this guide are deployed to a dedicated dbos namespace:
kubectl create namespace dbos
Provision an RDS PostgreSQL Instance
RDS provisioning commands
Find the VPC and private subnets that eksctl created:
# Get the VPC ID
VPC_ID=$(aws ec2 describe-vpcs \
--filters "Name=tag:alpha.eksctl.io/cluster-name,Values=dbos-conductor" \
--query "Vpcs[0].VpcId" --output text --region $AWS_REGION)
echo "VPC: $VPC_ID"
# Get the private subnets
PRIVATE_SUBNETS=($(aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=$VPC_ID" \
"Name=tag:aws:cloudformation:logical-id,Values=SubnetPrivate*" \
--query "Subnets[*].SubnetId" --output text --region $AWS_REGION))
echo "Private subnets: ${PRIVATE_SUBNETS[@]}"
Create a DB subnet group from the private subnets:
aws rds create-db-subnet-group \
--db-subnet-group-name dbos-conductor-db \
--db-subnet-group-description "DBOS Conductor RDS subnets" \
--subnet-ids "${PRIVATE_SUBNETS[@]}" \
--region $AWS_REGION
Create a security group that allows PostgreSQL access from the EKS nodes:
# Get the EKS cluster security group
EKS_SG=$(aws ec2 describe-security-groups \
--filters "Name=vpc-id,Values=$VPC_ID" \
"Name=tag:aws:eks:cluster-name,Values=dbos-conductor" \
--query "SecurityGroups[0].GroupId" \
--output text --region $AWS_REGION)
echo "EKS SG: $EKS_SG"
# Create a security group for RDS
RDS_SG=$(aws ec2 create-security-group \
--group-name dbos-conductor-rds \
--description "Allow PostgreSQL from EKS nodes" \
--vpc-id $VPC_ID \
--query "GroupId" --output text --region $AWS_REGION)
echo "RDS SG: $RDS_SG"
# Allow inbound PostgreSQL from EKS nodes
aws ec2 authorize-security-group-ingress \
--group-id $RDS_SG \
--protocol tcp --port 5432 \
--source-group $EKS_SG \
--region $AWS_REGION
Create the RDS instance:
aws rds create-db-instance \
--db-instance-identifier dbos-conductor-pg \
--db-instance-class db.t4g.micro \
--engine postgres \
--engine-version 16 \
--master-username postgres \
--master-user-password "$POSTGRES_PASSWORD" \
--allocated-storage 20 \
--db-subnet-group-name dbos-conductor-db \
--vpc-security-group-ids $RDS_SG \
--no-publicly-accessible \
--region $AWS_REGION
Wait for the instance to become available (this takes a few minutes):
aws rds wait db-instance-available \
--db-instance-identifier dbos-conductor-pg \
--region $AWS_REGION
Get the RDS endpoint:
RDS_ENDPOINT=$(aws rds describe-db-instances \
--db-instance-identifier dbos-conductor-pg \
--query "DBInstances[0].Endpoint.Address" \
--output text --region $AWS_REGION)
echo "RDS endpoint: $RDS_ENDPOINT"
Create the databases and roles from a pod inside the cluster (since the RDS instance is not publicly accessible):
Create databases and roles
kubectl run pg-setup --restart=Never \
--namespace dbos \
--image=postgres:16 \
--env="PGPASSWORD=$POSTGRES_PASSWORD" \
--command -- bash -c "
psql -h $RDS_ENDPOINT -U postgres -c 'CREATE DATABASE dbos_conductor;'
psql -h $RDS_ENDPOINT -U postgres -c \"CREATE ROLE dbos_conductor_role WITH LOGIN PASSWORD '$CONDUCTOR_ROLE_PASSWORD';\"
psql -h $RDS_ENDPOINT -U postgres -c 'GRANT ALL PRIVILEGES ON DATABASE dbos_conductor TO dbos_conductor_role;'
psql -h $RDS_ENDPOINT -U postgres -d dbos_conductor -c 'GRANT ALL ON SCHEMA public TO dbos_conductor_role;'
"
# Wait for the pod to finish, then clean up
sleep 15 && kubectl logs pg-setup -n dbos && kubectl delete pod pg-setup -n dbos
This creates:
dbos_conductor— Conductor's internal database (application registry, metadata)dbos_conductor_role— a dedicated role for Conductor's database access
Install Cluster Add-ons
We install two Helm charts that the later sections depend on.
Helm installs (Nginx Ingress, Sealed Secrets)
Nginx Ingress Controller — reverse proxy and TLS termination:
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.type=LoadBalancer
Sealed Secrets — encrypt secrets for safe Git storage:
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system
Verify all add-ons are running:
# Ingress controller
kubectl get pods -n ingress-nginx
# Sealed Secrets controller
kubectl get pods -n kube-system -l app.kubernetes.io/name=sealed-secrets
Secrets
Several components need sensitive credentials.
We use Bitnami Sealed Secrets: create a regular Secret, encrypt it with kubeseal, and apply the encrypted SealedSecret to the cluster.
The controller decrypts it in-cluster into a standard Kubernetes Secret that pods can reference.
The encrypted form is safe to commit to Git.
Secrets Inventory
| Secret | Keys | Used by |
|---|---|---|
conductor-db | database-url | Conductor — connection to dbos_conductor database |
conductor-license | license-key | Conductor — production license |
Create and Seal Secrets
kubeseal commands
Create each secret, pipe it through kubeseal, and save the encrypted form:
# 1. Conductor database credentials (dedicated role)
kubectl create secret generic conductor-db \
--namespace dbos \
--from-literal=database-url="postgresql://dbos_conductor_role:${CONDUCTOR_ROLE_PASSWORD}@${RDS_ENDPOINT}:5432/dbos_conductor?sslmode=require" \
--dry-run=client -o yaml | \
kubeseal --controller-name=sealed-secrets --controller-namespace=kube-system --format yaml \
> sealed-conductor-db.yaml
# 2. Conductor license key
kubectl create secret generic conductor-license \
--namespace dbos \
--from-literal=license-key="$CONDUCTOR_LICENSE_KEY" \
--dry-run=client -o yaml | \
kubeseal --controller-name=sealed-secrets --controller-namespace=kube-system --format yaml \
> sealed-conductor-license.yaml
Apply and Verify
kubectl apply -f sealed-conductor-db.yaml
kubectl apply -f sealed-conductor-license.yaml
Verify the controller has decrypted them into regular Kubernetes Secrets:
kubectl get secrets -n dbos
NAME TYPE DATA AGE
conductor-db Opaque 1 10s
conductor-license Opaque 1 10s
Ingress
With the Nginx Ingress Controller installed, you have a load balancer in front of the cluster. This section creates a TLS certificate and an Ingress resource so that all services are reachable over HTTPS.
This walkthrough uses a self-signed certificate on the load balancer's hostname. For production, use cert-manager with a real domain.
Get the load balancer hostname:
ELB_HOSTNAME=$(kubectl get svc -n ingress-nginx ingress-nginx-controller \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo $ELB_HOSTNAME
Save this value — you'll need it throughout the rest of the guide. It looks like xxxxxxxx.us-west-2.elb.amazonaws.com.
Create a self-signed TLS certificate
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout tls.key -out tls.crt \
-subj "/CN=dbos-conductor" \
-addext "subjectAltName=DNS:${ELB_HOSTNAME}"
kubectl create secret tls dbos-tls \
--cert=tls.crt --key=tls.key \
--namespace dbos
The CN is kept short because OpenSSL's CN field has a 64-character limit — the actual hostname is covered by the SAN extension. Your browser will show a certificate warning for the self-signed cert — accept it to proceed.
Applications connect to Conductor over wss://, so they verify the Ingress certificate and will fail the TLS handshake against one they do not trust.
For production, issue a certificate for a domain you control, for example with cert-manager.
Note that no public CA will issue a certificate for an *.elb.amazonaws.com hostname, so this requires your own domain pointed at the load balancer.
To evaluate with the self-signed certificate, your applications must be configured to trust it.
Distribute it using a ConfigMap and configure SSL_CERT_FILE (Go, Python) / NODE_EXTRA_CA_CERTS (TypeScript) or the JDK javax.net.ssl.trustStore (Java) accordingly.
ingress.yaml
The Ingress routes /conductor-api/... to the Conductor service and everything else to the Console.
A regex rewrite strips the /conductor-api prefix so Conductor sees requests at /.
Replace <your-elb-hostname> with the $ELB_HOSTNAME value you retrieved above.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: dbos-ingress
namespace: dbos
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- <your-elb-hostname>
secretName: dbos-tls
rules:
- host: <your-elb-hostname>
http:
paths:
# Both paths are regexes, so ordering matters: ingress-nginx sorts
# locations longest-path-first, which puts /conductor-api ahead of
# the Console catch-all.
- path: /conductor-api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: conductor
port:
number: 8090
- path: /()(.*)
pathType: ImplementationSpecific
backend:
service:
name: console
port:
number: 80
The host in both tls and rules must match — without it, Nginx serves its default fake certificate instead of dbos-tls.
| Request path | Backend |
|---|---|
/conductor-api/websocket/<app>/<key> | conductor:8090 → /websocket/<app>/<key> |
/conductor-api/healthz | conductor:8090 → /healthz |
/conductor-api/v1/metrics | conductor:8090 → /v1/metrics |
/ | console:80 |
/conductor/applications | console:80 (UI page) |
rewrite-target: /$2— strips the/conductor-apiprefix using the second capture group. The Console catch-all uses/()(.*)so$2passes the full path through unchanged.proxy-read-timeout/proxy-send-timeout— set to 3600s to keep Conductor's long-lived WebSocket connections alive.
Apply the Ingress
kubectl apply -f ingress.yaml
WebSocket Configuration
The application connects to Conductor via a long-lived WebSocket. Three layers must be configured to prevent idle connections from being dropped:
| Layer | Setting | Default | Suggested | Why |
|---|---|---|---|---|
| Nginx Ingress | proxy-read-timeout | 60s | 3600s | Prevents Nginx from closing an idle WebSocket |
| Nginx Ingress | proxy-send-timeout | 60s | 3600s | Same, for the send direction |
| AWS ELB | idle timeout | 60s | 3600s | Prevents the load balancer from closing an idle TCP connection |
The Nginx timeouts are already set via the Ingress annotations.
Nginx handles the Connection: Upgrade and Upgrade: websocket headers automatically — no additional annotation is needed for the protocol upgrade itself.
The AWS load balancer idle timeout is configured separately on the ingress-nginx-controller Service:
kubectl patch svc ingress-nginx-controller -n ingress-nginx -p \
'{"metadata":{"annotations":{"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout":"3600"}}}'
The DBOS SDK sends periodic ping frames that keep the connection active under normal conditions. Albeit the SDK will reconnect automatically, increasing the ELB idle timeout will prevent network hiccups from dropping the connection.
Deployments
Conductor is the core service that manages workflow recovery and the application registry.
It connects to the dbos_conductor database using the dbos_conductor_role credentials.
conductor.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: conductor
namespace: dbos
spec:
replicas: 1
selector:
matchLabels:
app: conductor
template:
metadata:
labels:
app: conductor
spec:
containers:
- name: conductor
# Untagged resolves to :latest. For production, pin an explicit
# version so rollouts are reproducible: dbosdev/conductor:<version>
image: dbosdev/conductor
env:
- name: DBOS__CONDUCTOR_DB_URL
valueFrom:
secretKeyRef:
name: conductor-db
key: database-url
- name: DBOS_CONDUCTOR_LICENSE_KEY
valueFrom:
secretKeyRef:
name: conductor-license
key: license-key
# Peers forward tasks to each other at this address. It defaults to
# 127.0.0.1, which only works for a single replica — set it to the
# pod IP before scaling up.
- name: DBOS__ADVERTISE_ADDRESS
valueFrom:
fieldRef:
fieldPath: status.podIP
ports:
- containerPort: 8090
readinessProbe:
httpGet:
path: /healthz
port: 8090
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8090
initialDelaySeconds: 15
periodSeconds: 30
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: conductor
namespace: dbos
spec:
selector:
app: conductor
ports:
- port: 8090
targetPort: 8090
Both sensitive values (DBOS__CONDUCTOR_DB_URL and DBOS_CONDUCTOR_LICENSE_KEY) are pulled from the Sealed Secrets created in the Secrets section.
The Console is the web UI for managing applications, monitoring workflows, and generating API keys. In this example, it connects to Conductor via internal cluster DNS.
console.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: console
namespace: dbos
spec:
replicas: 1
selector:
matchLabels:
app: console
template:
metadata:
labels:
app: console
spec:
containers:
- name: console
# As with Conductor, pin an explicit version in production and keep
# the two in step: dbosdev/console:<version>
image: dbosdev/console
env:
- name: DBOS_CONDUCTOR_URL
value: "conductor.dbos.svc.cluster.local:8090"
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: console
namespace: dbos
spec:
selector:
app: console
ports:
- port: 80
targetPort: 8080
Deploy both with:
kubectl apply -f conductor.yaml
kubectl apply -f console.yaml
Verify both pods are running:
kubectl get pods -n dbos
NAME READY STATUS RESTARTS AGE
conductor-xxxxxxxxx-xxxxx 1/1 Running 0 2m
console-xxxxxxxxx-xxxxx 1/1 Running 0 30s
Access the Console and Generate an API Key
At this point, your self-hosted Conductor deployment is fully operational! Open https://<your-elb-hostname>/ in your browser (accept the self-signed cert warning), then follow the Conductor setup instructions to:
- Register your application
- Generate an API key
Cleanup
To tear down all AWS resources when done, delete them in this order.
# 1. Delete the RDS instance and wait for it to be gone
aws rds delete-db-instance --db-instance-identifier dbos-conductor-pg \
--skip-final-snapshot --region $AWS_REGION
aws rds wait db-instance-deleted \
--db-instance-identifier dbos-conductor-pg \
--region $AWS_REGION
# 2. Delete the DB subnet group (must be empty)
aws rds delete-db-subnet-group --db-subnet-group-name dbos-conductor-db --region $AWS_REGION
# 3. Delete the RDS security group (no longer attached to any instance)
RDS_SG=$(aws ec2 describe-security-groups \
--filters "Name=group-name,Values=dbos-conductor-rds" \
--query "SecurityGroups[0].GroupId" --output text --region $AWS_REGION)
aws ec2 delete-security-group --group-id $RDS_SG --region $AWS_REGION
# 4. Delete the EKS cluster (includes VPC, security groups, and node group)
eksctl delete cluster --name dbos-conductor --region $AWS_REGION