Running OpenTalk in Kubernetes

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

ComponentVersion
PostgreSQL16-alpine
Redis8-alpine
MinIORELEASE.2025-06-13T11-33-47Z
LiveKitv1.9.11
Controllerv0.32.11
Web Frontendv2.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.

PathTargetDescription
/web-frontendOpenTalk Web UI
/v1controllerREST API
/signalingcontrollerWebSocket signaling for meetings
/livekitcontrollerLiveKit 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:

PortProtocolPurpose
20000-25000UDPWebRTC media
7881TCPLiveKit TCP fallback

Keycloak Setup

Create two clients in your opentalk realm:

ClientTypeRedirect URI
Frontendpublichttps://opentalk.foo.com/*
opentalkconfidentialhttps://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

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.