OpenTalk is an open-source video conferencing platform developed in Germany. This guide explains how to deploy OpenTalk v25.4.7 on a Kubernetes cluster step by step.
Component Versions
| Component | Version |
|---|---|
| PostgreSQL | 16-alpine |
| Redis | 8-alpine |
| MinIO | RELEASE.2025-06-13T11-33-47Z |
| LiveKit | v1.9.11 |
| Controller | v0.32.11 |
| Web Frontend | v2.7.6 |
You can find also the release notes and the official admin guide here:
- Release: https://docs.opentalk.eu/releases/25.4.7/
- Admin Guide: https://docs.opentalk.eu/25.4/admin/
So let’s start…
Prerequisites
- Kubernetes cluster with nginx-ingress and cert-manager
- Ceph RBD storage (or any other StorageClass)
- Keycloak instance (e.g.
sso.foo.com) - A domain (e.g.
opentalk.foo.com)
Architecture
Since OpenTalk 25.4.6 everything runs under a single domain — no separate controller subdomain needed. LiveKit signaling is proxied through the controller, so LiveKit itself does not need to be publicly reachable.
| Path | Target | Description |
|---|---|---|
/ | web-frontend | OpenTalk Web UI |
/v1 | controller | REST API |
/signaling | controller | WebSocket signaling for meetings |
/livekit | controller | LiveKit proxy (since 25.4.6) |
Firewall
LiveKit is pinned to a dedicated worker node and requires direct UDP access for WebRTC media streams. Open the following ports on that node:
| Port | Protocol | Purpose |
|---|---|---|
| 20000-25000 | UDP | WebRTC media |
| 7881 | TCP | LiveKit TCP fallback |
Keycloak Setup
Create two clients in your opentalk realm:
| Client | Type | Redirect URI |
|---|---|---|
Frontend | public | https://opentalk.foo.com/* |
opentalk | confidential | https://opentalk.foo.com/* |
For the opentalk client, enable Service Accounts (required for user search via the Keycloak Admin API) and copy the Client Secret from the Credentials tab.
Deployment Structure
opentalk/
├── 010-postgres.yaml
├── 020-redis.yaml
├── 030-minio.yaml
├── 040-livekit.yaml
├── 050-controller.yaml
├── 060-web-frontend.yaml
└── README.md
Secrets
All sensitive credentials are stored in a single Kubernetes secret. This avoids scattering credentials across multiple component-specific secrets.
Create the namespace and the central secret first:
kubectl create namespace opentalk
kubectl create secret generic opentalk-secrets \
--namespace opentalk \
--from-literal=POSTGRES_DB="opentalk" \
--from-literal=POSTGRES_USER="opentalk" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 20)" \
--from-literal=MINIO_ROOT_USER="opentalk" \
--from-literal=MINIO_ROOT_PASSWORD="$(openssl rand -hex 20)" \
--from-literal=LIVEKIT_API_KEY="controller_key" \
--from-literal=LIVEKIT_API_SECRET="$(openssl rand -hex 32)" \
--from-literal=KEYCLOAK_CLIENT_SECRET="<from-keycloak-admin-ui>" \
--dry-run=client -o yaml | kubectl apply -f -
Each deployment references only the keys it needs via secretKeyRef. For services like LiveKit that require a config file rather than environment variables, an init container uses envsubst to inject the secret values into the config template at startup.
010-postgres.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: opentalk-postgres
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 5Gi
csi:
driver: rbd.csi.ceph.com
fsType: ext4
nodeStageSecretRef:
name: csi-rbd-secret-coba
namespace: ceph-system
volumeAttributes:
clusterID: "<CEPH-CLUSTER-ID>"
pool: "kubernetes"
staticVolume: "true"
imageFeatures: "layering"
volumeHandle: "opentalk-postgres"
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: opentalk-postgres
namespace: opentalk
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
volumeMode: Filesystem
volumeName: opentalk-postgres
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: opentalk
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/part-of: opentalk
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: postgres
template:
metadata:
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/part-of: opentalk
spec:
containers:
- name: postgres
image: postgres:16-alpine
imagePullPolicy: IfNotPresent
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: POSTGRES_DB
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: POSTGRES_PASSWORD
ports:
- name: postgres
containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
subPath: postgres
livenessProbe:
exec:
command: [pg_isready, -U, $(POSTGRES_USER), -d, $(POSTGRES_DB)]
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command: [pg_isready, -U, $(POSTGRES_USER), -d, $(POSTGRES_DB)]
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: data
persistentVolumeClaim:
claimName: opentalk-postgres
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: opentalk
labels:
app.kubernetes.io/name: postgres
app.kubernetes.io/part-of: opentalk
spec:
selector:
app.kubernetes.io/name: postgres
ports:
- name: postgres
port: 5432
targetPort: postgres
020-redis.yaml
Redis is used for session synchronization only — no persistent storage needed. Since Redis is not exposed outside the cluster, no password is required.
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: opentalk
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: opentalk
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: redis
template:
metadata:
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: opentalk
spec:
containers:
- name: redis
image: redis:8-alpine
imagePullPolicy: IfNotPresent
ports:
- name: redis
containerPort: 6379
livenessProbe:
exec:
command: [redis-cli, ping]
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
exec:
command: [redis-cli, ping]
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: opentalk
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: opentalk
spec:
selector:
app.kubernetes.io/name: redis
ports:
- name: redis
port: 6379
targetPort: redis
030-minio.yaml
After the first deployment, the MinIO bucket must be created manually (see First Time Setup below).
apiVersion: v1
kind: PersistentVolume
metadata:
name: opentalk-minio
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 10Gi
csi:
driver: rbd.csi.ceph.com
fsType: ext4
nodeStageSecretRef:
name: csi-rbd-secret-coba
namespace: ceph-system
volumeAttributes:
clusterID: "<CEPH-CLUSTER-ID>"
pool: "kubernetes"
staticVolume: "true"
imageFeatures: "layering"
volumeHandle: "opentalk-minio"
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: opentalk-minio
namespace: opentalk
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
volumeMode: Filesystem
volumeName: opentalk-minio
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
namespace: opentalk
labels:
app.kubernetes.io/name: minio
app.kubernetes.io/part-of: opentalk
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: minio
template:
metadata:
labels:
app.kubernetes.io/name: minio
app.kubernetes.io/part-of: opentalk
spec:
containers:
- name: minio
image: minio/minio:RELEASE.2025-06-13T11-33-47Z
imagePullPolicy: IfNotPresent
args: [server, /data]
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: MINIO_ROOT_USER
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: MINIO_ROOT_PASSWORD
ports:
- name: minio
containerPort: 9000
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
httpGet:
path: /minio/health/live
port: 9000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /minio/health/ready
port: 9000
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: data
persistentVolumeClaim:
claimName: opentalk-minio
---
apiVersion: v1
kind: Service
metadata:
name: minio
namespace: opentalk
labels:
app.kubernetes.io/name: minio
app.kubernetes.io/part-of: opentalk
spec:
selector:
app.kubernetes.io/name: minio
ports:
- name: minio
port: 9000
targetPort: minio
040-livekit.yaml
LiveKit replaces the old Janus gateway. It requires hostNetwork: true on a dedicated node so WebRTC UDP ports are directly reachable.
Since LiveKit reads its configuration from a YAML file rather than environment variables, an init container uses envsubst to inject the LIVEKIT_API_SECRET from opentalk-secrets into the config template at startup.
Important for Kubernetes clusters using Calico networking: LiveKit must exclude Calico virtual interfaces (
cali*) from ICE candidate gathering, otherwise WebRTC connections will fail since those interfaces are not reachable from outside.
apiVersion: v1
kind: ConfigMap
metadata:
name: livekit-config-template
namespace: opentalk
labels:
app.kubernetes.io/name: livekit
app.kubernetes.io/part-of: opentalk
data:
livekit.yaml: |
port: 7880
rtc:
tcp_port: 7881
port_range_start: 20000
port_range_end: 25000
use_external_ip: false
interfaces:
excludes:
- cali*
- tunl*
- veth*
keys:
controller_key: "${LIVEKIT_API_SECRET}"
logging:
json: false
level: info
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: livekit
namespace: opentalk
labels:
app.kubernetes.io/name: livekit
app.kubernetes.io/part-of: opentalk
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: livekit
template:
metadata:
labels:
app.kubernetes.io/name: livekit
app.kubernetes.io/part-of: opentalk
spec:
nodeSelector:
kubernetes.io/hostname: worker-4-uxmal
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
initContainers:
- name: config-init
image: bhgedigital/envsubst:latest
command:
- sh
- -c
- envsubst < /template/livekit.yaml > /config/livekit.yaml
env:
- name: LIVEKIT_API_SECRET
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: LIVEKIT_API_SECRET
volumeMounts:
- name: config-template
mountPath: /template
- name: config
mountPath: /config
containers:
- name: livekit
image: livekit/livekit-server:v1.9.11
imagePullPolicy: IfNotPresent
args:
- --config
- /etc/livekit/livekit.yaml
- --node-ip
- "176.9.0.25"
ports:
- name: http
containerPort: 7880
protocol: TCP
- name: tcp-rtc
containerPort: 7881
protocol: TCP
volumeMounts:
- name: config
mountPath: /etc/livekit
livenessProbe:
tcpSocket:
port: 7880
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 7880
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
volumes:
- name: config-template
configMap:
name: livekit-config-template
- name: config
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: livekit-server
namespace: opentalk
labels:
app.kubernetes.io/name: livekit
app.kubernetes.io/part-of: opentalk
spec:
selector:
app.kubernetes.io/name: livekit
ports:
- name: http
port: 7880
targetPort: http
protocol: TCP
- name: tcp-rtc
port: 7881
targetPort: tcp-rtc
protocol: TCP
050-controller.yaml
The controller config uses the new [oidc] and [user_search] sections — the old [keycloak] section is deprecated. An init container injects secrets from opentalk-secrets via envsubst.
apiVersion: v1
kind: ConfigMap
metadata:
name: controller-config-template
namespace: opentalk
labels:
app.kubernetes.io/name: controller
app.kubernetes.io/part-of: opentalk
data:
controller.toml: |
[frontend]
base_url = "https://opentalk.foo.com"
[database]
url = "postgres://opentalk:${POSTGRES_PASSWORD}@postgres.opentalk.svc.cluster.local/opentalk"
[redis]
url = "redis://redis.opentalk.svc.cluster.local:6379/"
[oidc]
authority = "https://sso.foo.com/realms/opentalk"
[oidc.frontend]
client_id = "Frontend"
[oidc.controller]
client_id = "opentalk"
client_secret = "${KEYCLOAK_CLIENT_SECRET}"
[user_search]
backend = "keycloak_webapi"
api_base_url = "https://sso.foo.com/admin/realms/opentalk"
users_find_behavior = "from_user_search_backend"
[livekit]
public_url = "https://opentalk.foo.com/livekit"
service_url = "http://livekit-server.opentalk.svc.cluster.local:7880"
api_key = "controller_key"
api_secret = "${LIVEKIT_API_SECRET}"
[minio]
uri = "http://minio.opentalk.svc.cluster.local:9000"
bucket = "opentalk"
access_key = "opentalk"
secret_key = "${MINIO_ROOT_PASSWORD}"
[websocket_rate_limit]
disabled = false
tokens_per_second = 10
token_bucket_size = 30
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller
namespace: opentalk
labels:
app.kubernetes.io/name: controller
app.kubernetes.io/part-of: opentalk
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: controller
template:
metadata:
labels:
app.kubernetes.io/name: controller
app.kubernetes.io/part-of: opentalk
spec:
initContainers:
- name: config-init
image: bhgedigital/envsubst:latest
command:
- sh
- -c
- envsubst < /template/controller.toml > /config/controller.toml
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: POSTGRES_PASSWORD
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: KEYCLOAK_CLIENT_SECRET
- name: LIVEKIT_API_SECRET
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: LIVEKIT_API_SECRET
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: opentalk-secrets
key: MINIO_ROOT_PASSWORD
volumeMounts:
- name: config-template
mountPath: /template
- name: config
mountPath: /config
containers:
- name: controller
image: registry.opencode.de/opentalk/controller:v0.32.11
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 11311
volumeMounts:
- name: config
mountPath: /controller/controller.toml
subPath: controller.toml
readinessProbe:
httpGet:
path: /v1/auth/login
port: 11311
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
httpGet:
path: /v1/auth/login
port: 11311
initialDelaySeconds: 30
periodSeconds: 20
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
volumes:
- name: config-template
configMap:
name: controller-config-template
- name: config
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: controller
namespace: opentalk
labels:
app.kubernetes.io/name: controller
app.kubernetes.io/part-of: opentalk
spec:
selector:
app.kubernetes.io/name: controller
ports:
- name: http
port: 11311
targetPort: http
060-web-frontend.yaml
Frontend and controller share the same domain. The controller is accessed via path-based routing, so CONTROLLER_HOST points to the same domain as the frontend — no separate subdomain needed.
Two separate Ingress objects handle the routing: one for the frontend (/) and one for the controller paths (/v1, /signaling, /livekit) which need WebSocket upgrade headers.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-frontend
namespace: opentalk
labels:
app.kubernetes.io/name: web-frontend
app.kubernetes.io/part-of: opentalk
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: web-frontend
template:
metadata:
labels:
app.kubernetes.io/name: web-frontend
app.kubernetes.io/part-of: opentalk
spec:
containers:
- name: web-frontend
image: registry.opencode.de/opentalk/web-frontend:v2.7.6
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: PORT
value: "8080"
- name: CONTROLLER_HOST
value: "opentalk.foo.com"
- name: BASE_URL
value: "https://opentalk.foo.com"
- name: OIDC_ISSUER
value: "https://sso.foo.com/realms/opentalk"
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: web-frontend
namespace: opentalk
labels:
app.kubernetes.io/name: web-frontend
app.kubernetes.io/part-of: opentalk
spec:
selector:
app.kubernetes.io/name: web-frontend
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opentalk-frontend
namespace: opentalk
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
tls:
- hosts:
- opentalk.foo.com
secretName: opentalk-tls
rules:
- host: opentalk.foo.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-frontend
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opentalk-controller
namespace: opentalk
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
spec:
ingressClassName: nginx
tls:
- hosts:
- opentalk.foo.com
secretName: opentalk-tls
rules:
- host: opentalk.foo.com
http:
paths:
- path: /v1
pathType: Prefix
backend:
service:
name: controller
port:
number: 11311
- path: /signaling
pathType: Prefix
backend:
service:
name: controller
port:
number: 11311
- path: /livekit
pathType: Prefix
backend:
service:
name: controller
port:
number: 11311
First Time Setup
After the first deployment, create the MinIO bucket (only needed once — the bucket persists on the Ceph volume):
# Get the MinIO pod name
kubectl get pods -n opentalk -l app.kubernetes.io/name=minio
# Read the MinIO password
kubectl get secret opentalk-secrets -n opentalk \
-o jsonpath='{.data.MINIO_ROOT_PASSWORD}' | base64 -d && echo
# Open a shell in the MinIO pod
kubectl exec -it -n opentalk <minio-pod-name> -- sh
# Create the bucket
mc alias set local http://localhost:9000 opentalk <MINIO_ROOT_PASSWORD>
mc mb local/opentalk
exit
Then restart the controller (it fails on first start if the bucket is missing):
kubectl rollout restart deployment/controller -n opentalk
Managing the Deployment
# Deploy / update
kubectl apply -f apps/opentalk
# Check pod status
kubectl get pods -n opentalk
# Check controller logs
kubectl logs -n opentalk -l app.kubernetes.io/name=controller
# Remove (volumes are retained)
kubectl delete -f apps/opentalk
