💬 XMPP Infrastructure Series

Deploy ejabberd XMPP on Kubernetes
with PostgreSQL + CI/CD Pipeline

Skip Mnesia. Run ejabberd with PostgreSQL as the backend, custom Helm charts, TLS certificates, and a full Jenkins pipeline — production-ready from day one.

ejabberd XMPP Helm Chart Jenkins CI/CD PostgreSQL StatefulSet TLS / self-signed Longhorn PVC

End-to-End Architecture

The full stack: a custom Helm chart defines all Kubernetes resources. Jenkins pulls the chart from GitHub and deploys it. ejabberd runs as a StatefulSet backed by PostgreSQL — no Mnesia. TLS is handled via a self-signed cert stored as a Kubernetes secret. A LoadBalancer service exposes the XMPP ports to clients.

🐙
GitHub
Helm chart source
🔧
Jenkins
CI/CD pipeline
Kubernetes
StatefulSet + PVC
💬
ejabberd
XMPP server
🐘
PostgreSQL
External DB backend
💡
SECTION 01

Architecture Overview

ejabberd normally uses Mnesia (its built-in distributed database) for storing users, rosters, messages, and MUC rooms. For production Kubernetes deployments, Mnesia creates headaches — it ties data to specific pods and breaks cleanly when pods restart or reschedule. We replace it entirely with PostgreSQL, which is external, persistent, and survives pod churn.

💬 ejabberd

An industrial-strength XMPP server. Runs as a Kubernetes StatefulSet with persistent storage via Longhorn PVC.

🐘 PostgreSQL

External database — not Mnesia. Stores users, rosters, MAM archives, MUC rooms, offline messages, and vCards.

⎈ Helm Chart

Defines all K8s resources — StatefulSet, ConfigMaps, Service, HPA, and TLS secret references — fully parameterised via values.yaml.

🔧 Jenkins CI/CD

Pulls the Helm chart from GitHub, runs helm lint, and deploys to the cluster. Any config change is a Git commit + pipeline run.

🔗
Complement with pod spreading If you run multiple ejabberd replicas, add topologySpreadConstraints to distribute pods across nodes. See the topologySpreadConstraints guide and the Descheduler guide for the complete HA picture.
📋
SECTION 02

Prerequisites

  • Kubernetes cluster — v1.24+ with kubectl access (cluster-admin or equivalent)
  • Helm 3 — installed on the Jenkins agent and on your local machine for linting
  • Longhorn — installed in the cluster as the storage class for persistent volumes
  • PostgreSQL — reachable from inside the cluster; note the IP, port, DB name, user, and password
  • Jenkins — with the Kubernetes CLI plugin and Git plugin installed
  • GitHub repository — where the Helm chart will live; a Personal Access Token with repo scope
  • LoadBalancer IP — a MetalLB or cloud LB IP reserved for the ejabberd service
  • OpenSSL — on any server that has kubectl access (for TLS cert generation)
⚠️
Install Longhorn first The PersistentVolumeClaim in the StatefulSet uses storageClass: longhorn. If Longhorn isn't installed, the pod will stay Pending with an unbound PVC. Install it via Helm: helm install longhorn longhorn/longhorn --namespace longhorn-system.
🔌
SECTION 03

Port Reference

Port
Name
Purpose
5222
XMPP Client (C2S)
Client-to-Server — your XMPP app connects here. STARTTLS required.
5269
XMPP Server (S2S)
Server-to-Server federation — for connecting to other XMPP servers.
5280
HTTP / Admin
Web admin panel, BOSH, WebSocket (/ws), HTTP-API, and mod_bosh endpoints.

📁
PHASE 01

Helm Chart — Directory Structure

Create the following directory layout in your repository. Every file under templates/ is a Kubernetes manifest rendered by Helm at deploy time.

ejabberd/ # root of the Helm chart
Chart.yaml # chart metadata
values.yaml # all configurable parameters
templates/
configmap.yaml # ejabberd.yml main config
ejabberd-hosts-configmap.yaml # virtual hosts list
statefulset.yaml # pod + volume definitions
service.yaml # LoadBalancer service
hpa.yaml # Horizontal Pod Autoscaler
💡
Initialise with helm create Run helm create ejabberd to get the scaffold, then replace the generated templates with the files below. Delete the unused ingress.yaml and serviceaccount.yaml from the templates folder.
📄
PHASE 01 — File 1

Chart.yaml

Defines the chart identity and version. The appVersion tracks the ejabberd image tag you want to deploy.

ejabberd/Chart.yaml
apiVersion: v2
name: ejabberd
description: Helm chart to deploy Ejabberd XMPP server with PostgreSQL backend
type: application
version: 1.0.0
appVersion: "latest"
⚙️
PHASE 01 — File 2

values.yaml

All environment-specific values live here. Change these without touching any template file.

ejabberd/values.yaml
# ─── Replica count ─────────────────────────────────────────
# Set to 1 to start; HPA will scale from hpa.minReplicas
replicaCount: 1

# ─── Container Image ───────────────────────────────────────
image:
  repository: ejabberd/ecs
  tag: "latest"
  pullPolicy: IfNotPresent

# ─── Image Pull Secrets (if using private registry) ────────
imagePullSecrets: []
# - name: my-registry-secret

# ─── Kubernetes Service ─────────────────────────────────────
service:
  name: ejabberd
  type: LoadBalancer
  loadBalancerIP: "192.168.0.100"  # your MetalLB / cloud LB IP
  ports:
    xmppClient: 5222
    xmppS2S: 5269
    httpAdmin: 5280

# ─── Persistent Storage (requires Longhorn installed) ───────
persistence:
  enabled: true
  storageClass: longhorn
  accessMode: ReadWriteOnce
  size: 5Gi

# ─── PostgreSQL Backend ─────────────────────────────────────
# DB must be reachable from inside the cluster
postgresql:
  host: "192.168.0.0"       # DB server IP reachable from cluster
  port: 5432
  user: "postgres"
  password: "postgrespassword"  # use a K8s secret in production
  database: "ejabberd"

# ─── Admin User ─────────────────────────────────────────────
# Used in ejabberd ACL. Create manually post-deploy (see Phase 5).
admin:
  username: admin
  password: adminpassword

# ─── Resources ──────────────────────────────────────────────
resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 1
    memory: 1Gi

# ─── Horizontal Pod Autoscaler ──────────────────────────────
hpa:
  enabled: true
  minReplicas: 2
  maxReplicas: 5
  cpuThreshold: 70     # scale up when CPU > 70%

# ─── TLS ────────────────────────────────────────────────────
tls:
  enabled: true
  secretName: ejabberd-tls  # created in Phase 3

# ─── Virtual Hosts ──────────────────────────────────────────
# Add your XMPP domain(s) — must match your TLS cert CN/SAN
dynamicHosts:
  - local           # e.g. "chat.yourdomain.com" or just "local"
⚠️
Never commit plain-text passwords to Git Move postgresql.password and admin.password to a Kubernetes Secret and reference them in the StatefulSet as secretKeyRef. For now they're in values.yaml for clarity — harden before pushing to a shared repo.
📝
PHASE 01 — File 3

templates/configmap.yaml — ejabberd.yml

This is the main ejabberd configuration file, rendered as a ConfigMap. Helm injects values.yaml variables directly into the YAML at deploy time.

ejabberd/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: ejabberd-config
  namespace: {{ .Release.Namespace }}
data:
  ejabberd.yml: |
    loglevel: info

    # Hosts defined in a separate ConfigMap (ejabberd-hosts)
    include_config_file:
      - "/home/ejabberd/conf/hosts.yml"

    # TLS certificate — created in Phase 3
    certfiles:
      - /home/ejabberd/certs/server.pem

    listen:
      # Port 5222 — XMPP Client connections with STARTTLS
      - port: 5222
        module: ejabberd_c2s
        starttls: true
        starttls_required: true
        shaper: normal
        max_stanza_size: 262144

      # Port 5280 — Web admin, BOSH, WebSocket, HTTP-API
      - port: 5280
        module: ejabberd_http
        tls: true
        custom_headers:
          "Access-Control-Allow-Origin": "*"
          "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS"
          "Access-Control-Allow-Headers": "*"
          "Access-Control-Max-Age": "86400"
        request_handlers:
          /admin: ejabberd_web_admin
          /api:   mod_http_api
          /http-bind: mod_bosh
          /ws:    ejabberd_http_ws
          /bosh:  mod_bosh

    # ─── AUTHENTICATION ─────────────────────────
    # jwt: validates token-based logins first, sql: fallback for password auth
    auth_method: [jwt, sql]

    # JWT secret key file — mounted from K8s secret (ejabberd-jwt-secret)
    jwt_key: /home/ejabberd/conf/jwt_secret.txt

    # ─── DATABASE ───────────────────────────────
    default_db: sql
    sql_type: pgsql
    sql_server:   {{ .Values.postgresql.host }}
    sql_port:     {{ .Values.postgresql.port }}
    sql_database: {{ .Values.postgresql.database }}
    sql_username: {{ .Values.postgresql.user }}
    sql_password: {{ .Values.postgresql.password }}
    sql_ssl: true

    # ─── TRAFFIC SHAPING ────────────────────────
    shaper:
      normal: 1000
      fast: 5000

    # ─── ACCESS CONTROL ─────────────────────────
    acl:
      admin:
        user:
          - admin@local     # matches dynamicHosts[0]

    access_rules:
      configure:
        allow: admin
      c2s:
        allow: all
      shared_roster_access:
        allow: admin

    api_permissions:
      "registration":
        who: admin
        what: "*"
      "console commands":
        from: ejabberd_ctl
        who: all
        what: "*"
      "public commands":
        who: admin
        what:
          - status

    # ─── MODULES ────────────────────────────────
    # Add or remove modules based on your application needs
    modules:
      mod_adhoc: {}
      mod_admin_extra: {}
      mod_caps: {}
      mod_client_state:
        queue_presence: true
      mod_stream_mgmt:
        resend_on_timeout: true
      mod_shared_roster:
        db_type: sql
      mod_disco: {}
      mod_bosh: {}
      mod_roster:
        db_type: sql
      mod_offline:
        db_type: sql
        access_max_user_messages: all
      mod_mam:
        db_type: sql
        default: always
        request_activates_archiving: true
        assume_mam_usage: true
        cache_size: 1000
      mod_vcard:
        db_type: sql
        search: true
      mod_muc:
        db_type: sql
        host: "conference.@HOST@"
        access: all
        access_create: all
        access_persistent: all
        access_admin: admin
        history_size: 0
        default_room_options:
          mam: true
      mod_pubsub:
        db_type: sql
        plugins:
          - flat
          - pep
      mod_last:
        db_type: sql
🌐
PHASE 01 — File 4

templates/ejabberd-hosts-configmap.yaml

A separate ConfigMap for the hosts list, pulled in by ejabberd via include_config_file. Separating hosts from the main config makes it easy to add new virtual hosts without touching the main config.

ejabberd/templates/ejabberd-hosts-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: ejabberd-hosts
  namespace: {{ .Release.Namespace }}
data:
  hosts.yml: |
    hosts:
    {{- range .Values.dynamicHosts }}
      - {{ . }}
    {{- end }}
💡
Adding more virtual hosts Simply add entries to dynamicHosts in values.yaml and re-run the pipeline. ejabberd will pick them up on next restart. Make sure your TLS certificate has all hostnames in its SAN list, or use a wildcard cert.
PHASE 01 — File 5

templates/statefulset.yaml

A StatefulSet is used instead of a Deployment because ejabberd needs stable network identity and persistent storage per pod replica. The checksum/config annotation ensures pods auto-restart when the ConfigMap changes.

ejabberd/templates/statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: ejabberd
  namespace: {{ .Release.Namespace }}
spec:
  serviceName: {{ .Values.service.name }}
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: ejabberd
  template:
    metadata:
      labels:
        app: ejabberd
      annotations:
        # Triggers pod restart when configmap content changes
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      imagePullSecrets:
        {{- toYaml .Values.imagePullSecrets | nindent 8 }}

      containers:
        - name: ejabberd
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}

          resources:
            {{- toYaml .Values.resources | nindent 12 }}

          ports:
            - name: c2s
              containerPort: 5222
            - name: s2s
              containerPort: 5269
            - name: http
              containerPort: 5280

          # Readiness: wait for HTTP port to accept connections
          readinessProbe:
            tcpSocket:
              port: 5280
            initialDelaySeconds: 30
            periodSeconds: 15
            timeoutSeconds: 5
            failureThreshold: 10

          # Liveness: confirm XMPP port is alive
          livenessProbe:
            tcpSocket:
              port: 5222
            initialDelaySeconds: 30
            periodSeconds: 20
            failureThreshold: 3

          volumeMounts:
            # Main ejabberd config file
            - name: ejabberd-config
              mountPath: /home/ejabberd/conf/ejabberd.yml
              subPath: ejabberd.yml
            # Hosts file
            - name: ejabberd-hosts
              mountPath: /home/ejabberd/conf/hosts.yml
              subPath: hosts.yml
              readOnly: true
            # TLS certificate secret
            {{- if .Values.tls.enabled }}
            - name: tls
              mountPath: /home/ejabberd/certs
              readOnly: true
            {{- end }}
            # JWT secret key file for token-based authentication
            - name: jwt-secret
              mountPath: /home/ejabberd/conf/jwt_secret.txt
              subPath: jwt_secret.txt
              readOnly: true
            # Persistent data volume
            - name: data
              mountPath: /var/lib/ejabberd

      volumes:
        - name: ejabberd-config
          configMap:
            name: ejabberd-config
        - name: ejabberd-hosts
          configMap:
            name: ejabberd-hosts
        {{- if .Values.tls.enabled }}
        - name: tls
          secret:
            secretName: {{ .Values.tls.secretName }}
        {{- end }}
        # JWT key secret — must be created before deploying (see Phase 3 Step 2)
        - name: jwt-secret
          secret:
            secretName: ejabberd-jwt-secret

  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - {{ .Values.persistence.accessMode }}
        resources:
          requests:
            storage: {{ .Values.persistence.size }}
        storageClassName: {{ .Values.persistence.storageClass }}
🔌
PHASE 01 — File 6

templates/service.yaml

ejabberd/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ .Values.service.name }}
  namespace: {{ .Release.Namespace }}
spec:
  loadBalancerIP: {{ .Values.service.loadBalancerIP }}
  type: {{ .Values.service.type }}
  selector:
    app: ejabberd
  ports:
    - name: xmpp-client
      port: {{ .Values.service.ports.xmppClient }}
      targetPort: {{ .Values.service.ports.xmppClient }}
    - name: xmpp-s2s
      port: {{ .Values.service.ports.xmppS2S }}
      targetPort: {{ .Values.service.ports.xmppS2S }}
    - name: http-admin
      port: {{ .Values.service.ports.httpAdmin }}
      targetPort: {{ .Values.service.ports.httpAdmin }}
📈
PHASE 01 — File 7

templates/hpa.yaml

Scales ejabberd replicas automatically based on CPU utilisation. The HPA watches the StatefulSet and triggers scale-out when average CPU exceeds cpuThreshold.

ejabberd/templates/hpa.yaml
{{- if .Values.hpa.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: ejabberd-hpa
  namespace: {{ .Release.Namespace }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: ejabberd
  minReplicas: {{ .Values.hpa.minReplicas }}
  maxReplicas: {{ .Values.hpa.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.hpa.cpuThreshold }}
{{- end }}
💡
Push the chart to GitHub now Once all 7 files are ready, commit and push to your GitHub repo. Jenkins will clone this repository in the pipeline. Make sure the repo is accessible with the Personal Access Token you'll add to Jenkins credentials in Phase 4.

🐘
PHASE 02

PostgreSQL — Database Setup

ejabberd requires its schema to be pre-created in PostgreSQL. The official ejabberd SQL schema file handles all the required tables.

01

Create the Database and User

psql — as postgres superuser
# Connect to PostgreSQL
psql -U postgres

# Create database and user
CREATE DATABASE ejabberd;
CREATE USER ejabberd_user WITH PASSWORD 'strongpassword';
GRANT ALL PRIVILEGES ON DATABASE ejabberd TO ejabberd_user;

# Connect to the ejabberd database
\c ejabberd
02

Import the ejabberd PostgreSQL Schema

Download and run the official schema. This creates all required tables for auth, roster, MAM, MUC, offline messages, vCards, and pubsub.

Shell
# Download the official ejabberd PostgreSQL schema
wget https://raw.githubusercontent.com/processone/ejabberd/master/sql/pg.sql

# Import into the ejabberd database
psql -U postgres -d ejabberd -f pg.sql
03

Verify the Schema

psql
\c ejabberd
\dt   # should list: users, roster_version, rosterusers, archive, etc.
💡
Verify cluster connectivity Before deploying ejabberd, confirm the DB is reachable from inside your cluster: kubectl run pg-test --rm -it --image=postgres -- psql -h 192.168.0.0 -U postgres -d ejabberd. If this connects, your sql_server value in values.yaml is correct.

🔐
PHASE 03 — Step 1

Generate a Self-Signed TLS Certificate

Run this on any server that has openssl and kubectl access. The certificate is tied to your LoadBalancer IP (or domain name if you have one). The SAN (Subject Alt Name) must match the IP/hostname clients use to connect.

01

Create the OpenSSL Config File

Replace 192.168.0.100 with your actual LoadBalancer IP on all three lines.

ip-cert.cnf
[ req ]
default_bits       = 2048
prompt             = no
default_md         = sha256
req_extensions     = req_ext
distinguished_name = dn

[ dn ]
CN = 192.168.0.100       # ← your LoadBalancer IP

[ req_ext ]
subjectAltName = IP:192.168.0.100  # ← same IP

[ v3_ext ]
subjectAltName = IP:192.168.0.100  # ← same IP
🌐
Using a domain instead of an IP? Replace all IP references with your domain: CN = chat.yourdomain.com and subjectAltName = DNS:chat.yourdomain.com. Use a proper CA-signed cert for internet-facing deployments.
02

Generate the Certificate and Key

Shell
# Generate private key + self-signed cert (valid 10 years)
openssl req -x509 -nodes -days 3650 \
  -newkey rsa:2048 \
  -keyout server.key \
  -out server.crt \
  -config ip-cert.cnf \
  -extensions v3_ext

# Combine key + cert into a single .pem file (ejabberd requires this)
cat server.key server.crt > server.pem

# Verify the certificate shows your IP in the SAN
openssl x509 -in server.crt -text -noout | grep -A1 "Subject Alt"

You should now have three files in the current directory: server.key, server.crt, server.pem.

🔑
PHASE 03 — Step 2

Create Kubernetes Secrets — TLS & JWT

Two secrets must be created in the cluster before the pipeline runs. Both must exist in the same namespace as ejabberd. The pipeline does not create these — they are one-time manual steps.

Secret 1 — TLS Certificate

kubectl — Create TLS secret from .pem file
# Create the namespace first if it doesn't exist
kubectl create namespace chat-system

# Create the TLS secret from the combined PEM file (generated in Step 1)
kubectl create secret generic ejabberd-tls \
  --from-file=server.pem \
  -n chat-system

# Verify it was created
kubectl get secret ejabberd-tls -n chat-system

Secret 2 — JWT Signing Key

The JWT key is a plain-text secret string that ejabberd uses to verify signed JWT tokens from your application. Place the key in a file called jwt_secret.txt on the server where you have kubectl access, then create the secret.

01

Create the JWT Key File on the Server

SSH into the server that has kubectl access. Create jwt_secret.txt with your chosen secret key. This should be a long random string — the same key your application uses when signing JWTs.

Shell — on the server with kubectl access
# Option A: generate a strong random key automatically
openssl rand -hex 64 > jwt_secret.txt

# Option B: use your own existing application JWT secret
echo -n "your-existing-jwt-secret-here" > jwt_secret.txt

# Confirm the file content looks correct (no trailing newline issues)
cat jwt_secret.txt
⚠️
This key must match your application ejabberd uses this key to verify JWT tokens — your backend application uses the same key to sign them. If they differ, all JWT logins will fail with an auth error. Use echo -n to avoid writing a trailing newline into the file.
02

Create the Kubernetes Secret from the File

kubectl — Create JWT secret
# Create the JWT secret — the key inside the secret is named jwt_secret.txt
kubectl create secret generic ejabberd-jwt-secret \
  --from-file=jwt_secret.txt=jwt_secret.txt \
  -n chat-system

# Verify the secret exists
kubectl get secret ejabberd-jwt-secret -n chat-system

# Inspect the secret keys (does not reveal the value)
kubectl describe secret ejabberd-jwt-secret -n chat-system
💡
Why --from-file=jwt_secret.txt=jwt_secret.txt? The format is --from-file=KEY_NAME=FILE_PATH. This sets the key name inside the secret to exactly jwt_secret.txt, which matches the subPath in the StatefulSet volumeMount. If the names don't match, the file won't be projected into the pod correctly.
03

Verify Both Secrets Are Ready

kubectl — Final secrets check
# Both secrets must appear before running the pipeline
kubectl get secrets -n chat-system

# Expected output:
NAME                    TYPE     DATA   AGE
ejabberd-tls            Opaque   1      2m
ejabberd-jwt-secret     Opaque   1      30s
🔒
Delete the local key file when done Once the secret is in the cluster, remove jwt_secret.txt and server.pem from the server to avoid leaving credentials on disk: rm jwt_secret.txt server.pem server.key server.crt
⚠️
Both secrets must exist before the pipeline runs The StatefulSet references both ejabberd-tls and ejabberd-jwt-secret as volumes. If either is missing when the pod starts, it will fail to mount and stay in a Pending or ContainerCreating state. Always create the secrets first.
🐳
PHASE 03 — Step 3

Trust the Certificate in Your Application

If your client application connects to ejabberd from another container, it needs to trust the self-signed cert. Add these lines to your application's Dockerfile:

Dockerfile — client application
# Copy the ejabberd self-signed cert into the image
COPY path/to/server.crt /usr/local/share/ca-certificates/ejabberd.crt

# Register it as a trusted CA
RUN update-ca-certificates
💡
Where to put server.crt Copy server.crt (not server.pem) into the root of your client app's source repository. The COPY instruction in the Dockerfile will pick it up during the Docker build. This makes the cert part of your CI/CD workflow — no manual steps needed on the container.

🔧
PHASE 04 — Step 1

Jenkins Credentials Setup

Before creating the pipeline, add these three credentials in Jenkins. Go to Manage Jenkins → Credentials → Global → Add Credentials.

Credential IDTypeWhat It Contains
github-creds Username/Password Your GitHub username + Personal Access Token (repo scope)
kubeconfig Secret file Your cluster's kubeconfig file (from ~/.kube/config)
jwt-token-ejabberd Secret file Optional JWT secret file if your setup uses JWT auth
📋
Getting your kubeconfig On the machine with cluster access, run: cat ~/.kube/config. Copy the content, save it to a file, and upload it as a Secret File credential in Jenkins with the ID kubeconfig.
🚀
PHASE 04 — Step 2

Jenkinsfile — CI/CD Pipeline

Create a new Jenkins Pipeline job and paste the following script. Update CHART_DIR to the path of your Helm chart relative to the repo root.

Jenkinsfile
pipeline {
  agent any

  environment {
    NAMESPACE    = "chat-system"         // must match the NS used for the TLS secret
    HELM_RELEASE = "ejabberd"
    CHART_DIR    = "infra/ejabberd"         // path inside the cloned repo
    GIT_TOKEN    = credentials('github-creds')
  }

  stages {

    stage('Clean Workspace') {
      steps { cleanWs() }
    }

    stage('Checkout Helm Chart Repo') {
      steps {
        checkout([
          $class: 'GitSCM',
          branches: [[name: '*/main']],
          userRemoteConfigs: [[
            url: 'https://github.com/YOUR_ORG/helm.git',
            credentialsId: 'github-creds'
          ]]
        ])
      }
    }

    stage('Clone Repos') {
      steps {
        sh """
          git clone https://${GIT_TOKEN_USR}:${GIT_TOKEN_PSW}@github.com/YOUR_ORG/helm.git infra
        """
      }
    }

    stage('Helm Lint') {
      steps {
        dir("${CHART_DIR}") {
          sh 'helm lint .'
        }
      }
    }

    stage('Helm Deploy') {
      steps {
        script {
          withCredentials([
            file(credentialsId: 'kubeconfig',           variable: 'KUBECONFIG'),
            file(credentialsId: 'jwt-token-ejabberd',   variable: 'JWT_TOKEN')
          ]) {
            dir("${CHART_DIR}") {
              sh """
                echo "🔧 Deploying Ejabberd Helm chart to namespace: ${NAMESPACE}"
                helm upgrade --install ${HELM_RELEASE} . \\
                  --namespace ${NAMESPACE} \\
                  --create-namespace \\
                  --wait --timeout=300s
                echo "✅ Deployment complete"
              """
            }
          }
        }
      }
    }

  }

  post {
    success {
      script {
        echo "🚀 Ejabberd deployed successfully!"
        withCredentials([file(credentialsId: 'kubeconfig', variable: 'KUBECONFIG')]) {
          sh "kubectl get pods -n ${NAMESPACE}"
          sh "kubectl get svc  -n ${NAMESPACE}"
        }
      }
    }
    failure {
      echo "❌ Deployment failed — check Helm lint output and pod events above"
    }
  }
}
💡
--create-namespace flag The --create-namespace flag in helm upgrade --install means Jenkins will create the chat-system namespace if it doesn't already exist. Keep it — it makes the pipeline idempotent.

PHASE 05 — Step 1

Kubernetes — Pre-Deploy Checklist

Before running the pipeline, confirm all cluster-side resources exist.

kubectl — Pre-flight checks
# 1. Confirm namespace exists (or let the pipeline create it)
kubectl get namespace chat-system

# 2. Confirm TLS secret is present
kubectl get secret ejabberd-tls -n chat-system

# 3. Confirm JWT secret is present
kubectl get secret ejabberd-jwt-secret -n chat-system

# 3. Confirm Longhorn storage class is available
kubectl get storageclass | grep longhorn

# 4. Confirm DB is reachable from cluster
kubectl run pg-test --rm -it --image=postgres --restart=Never -n chat-system \
  -- psql -h 192.168.0.0 -U postgres -d ejabberd -c '\dt'

# 5. Dry-run the Helm chart locally before triggering Jenkins
helm install ejabberd ./ejabberd --namespace chat-system --dry-run --debug
▶️
PHASE 05 — Step 2

Run the Jenkins Pipeline

01

Trigger the Build

In Jenkins, open the pipeline job and click Build Now. Watch the console output — the stages will run in sequence: Clean → Checkout → Lint → Deploy.

02

Watch Pod Startup

kubectl — Watch pod status
# Watch pods come up in real-time
kubectl get pods -n chat-system -w

# Check events if pod is stuck (CrashLoopBackOff / Pending)
kubectl describe pod ejabberd-0 -n chat-system

# Stream pod logs
kubectl logs -f ejabberd-0 -n chat-system
03

Confirm Service is Exposed

kubectl
kubectl get svc -n chat-system

# Expected output — EXTERNAL-IP should show your LoadBalancer IP
NAME       TYPE           CLUSTER-IP     EXTERNAL-IP      PORT(S)
ejabberd   LoadBalancer   10.96.45.12    192.168.0.100    5222:.../TCP,5269:.../TCP,5280:.../TCP
👤
PHASE 05 — Step 3

Create the Initial Admin User

The admin user defined in values.yaml is referenced in the ACL config — but the user account itself does not get created automatically. You must exec into the pod and create it using ejabberdctl. This is a one-time step.

01

Exec into the ejabberd Pod

kubectl
kubectl exec -it ejabberd-0 -n chat-system -- /bin/sh
02

Register the Admin User

Inside the pod, use ejabberdctl to register the user. The username and host must match what's in the ACL config — admin@local in our case.

ejabberdctl — inside pod
# Register the admin user (username, host, password)
ejabberdctl register admin local adminpassword

# Verify user was created
ejabberdctl check_account admin local

# List all registered users
ejabberdctl registered_users local

# Verify ejabberd is running and connected to the DB
ejabberdctl status
03

Register Additional Users (optional)

ejabberdctl — more users
# Register more users the same way
ejabberdctl register alice local alicepassword
ejabberdctl register bob   local bobpassword

# Change a user's password
ejabberdctl change_password admin local newpassword

# Delete a user
ejabberdctl unregister bob local

# Exit the pod
exit
⚠️
Admin user must exist for the web panel to work The ACL in ejabberd.yml grants admin access to admin@local — but if the user doesn't exist in the database, the web admin panel at https://LB_IP:5280/admin will deny all logins. Always run the register command after the first deployment.
🧪
PHASE 05 — Step 4

Verify the Deployment

T1

Access the Web Admin Panel

Open a browser and navigate to https://192.168.0.100:5280/admin. Accept the self-signed cert warning. Log in with admin@local and the password you registered.

💡
The web admin shows connected users, registered accounts, MUC rooms, and server stats in real time.
T2

Test XMPP Client Connection

Use any XMPP client (Conversations, Gajim, or Monal) to connect to the server. Configure it with: Server: 192.168.0.100, Port: 5222, STARTTLS: required. Log in with admin@local.

T3

Confirm Data Persists in PostgreSQL

psql — verify data in DB
psql -U postgres -d ejabberd

# Should show the admin user you registered
SELECT username, created_at FROM users;

# After any chat, MAM archive entries should appear
SELECT COUNT(*) FROM archive;
T4

Confirm HPA is Active

kubectl
kubectl get hpa -n chat-system
# Should show minReplicas: 2, maxReplicas: 5, TARGETS: cpu%/70%

SECTION FINAL

What You Have Now

If all tests passed, you have a fully production-grade XMPP server running on Kubernetes, backed by PostgreSQL, deployed via a repeatable Jenkins pipeline.

💬 ejabberd Capabilities

XMPP C2S on port 5222 with STARTTLS
Server-to-server federation (5269)
BOSH + WebSocket for browser clients
MUC (group chat) with MAM history
Web admin panel at :5280/admin
REST HTTP-API at :5280/api

⚙️ Infrastructure

🐘 PostgreSQL — no Mnesia dependency
📦 StatefulSet with Longhorn PVC
📈 HPA auto-scales 2 → 5 replicas
🔐 TLS self-signed cert mounted as secret
🚀 Jenkins CI/CD — deploy = git push
🔁 ConfigMap checksum triggers auto-restart

Key Takeaways

  • No Mnesia — all data lives in PostgreSQL. Pod restarts and rescheduling are safe.
  • Admin user is manual — always run ejabberdctl register after first deploy
  • Config changes → edit values.yaml or configmap.yaml, commit, re-run pipeline
  • TLS & JWT secrets must pre-exist before the pipeline runs — create both once, they persist across upgrades
  • Helm checksum annotation ensures pods reload when ejabberd.yml changes — no manual restarts needed
  • Scale HPA — adjust hpa.maxReplicas and cpuThreshold in values.yaml as load grows
🔜
Next Steps Add topologySpreadConstraints to the StatefulSet spec for multi-node HA pod spreading. Add a PodDisruptionBudget to prevent all ejabberd pods from being evicted simultaneously. Consider adding the Descheduler to automatically rebalance pods if a node fills up — see the companion guides linked in Section 01.