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.
topologySpreadConstraints to distribute pods across nodes. See the topologySpreadConstraints guide and the Descheduler guide for the complete HA picture.
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)
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.
Port Reference
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.
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.
Chart.yaml
Defines the chart identity and version. The appVersion tracks the ejabberd image tag you want to deploy.
apiVersion: v2
name: ejabberd
description: Helm chart to deploy Ejabberd XMPP server with PostgreSQL backend
type: application
version: 1.0.0
appVersion: "latest"
values.yaml
All environment-specific values live here. Change these without touching any template file.
# ─── 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"
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.
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.
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
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.
apiVersion: v1
kind: ConfigMap
metadata:
name: ejabberd-hosts
namespace: {{ .Release.Namespace }}
data:
hosts.yml: |
hosts:
{{- range .Values.dynamicHosts }}
- {{ . }}
{{- end }}
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.
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.
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 }}
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 }}
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.
{{- 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 }}
PostgreSQL — Database Setup
ejabberd requires its schema to be pre-created in PostgreSQL. The official ejabberd SQL schema file handles all the required tables.
Create the Database and User
# 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
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.
# 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
Verify the Schema
\c ejabberd
\dt # should list: users, roster_version, rosterusers, archive, etc.
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.
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.
Create the OpenSSL Config File
Replace 192.168.0.100 with your actual LoadBalancer IP on all three lines.
[ 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
CN = chat.yourdomain.com and subjectAltName = DNS:chat.yourdomain.com. Use a proper CA-signed cert for internet-facing deployments.
Generate the Certificate and Key
# 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.
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
# 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.
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.
# 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
echo -n to avoid writing a trailing newline into the file.
Create the Kubernetes Secret from the File
# 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
--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.
Verify Both Secrets Are Ready
# 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
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
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.
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:
# 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
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.
Jenkins Credentials Setup
Before creating the pipeline, add these three credentials in Jenkins. Go to Manage Jenkins → Credentials → Global → Add Credentials.
| Credential ID | Type | What 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 |
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.
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.
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 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.
Kubernetes — Pre-Deploy Checklist
Before running the pipeline, confirm all cluster-side resources exist.
# 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
Run the Jenkins Pipeline
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.
Watch Pod Startup
# 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
Confirm Service is Exposed
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
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.
Exec into the ejabberd Pod
kubectl exec -it ejabberd-0 -n chat-system -- /bin/sh
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.
# 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
Register Additional Users (optional)
# 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
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.
Verify the Deployment
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.
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.
Confirm Data Persists in PostgreSQL
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;
Confirm HPA is Active
kubectl get hpa -n chat-system
# Should show minReplicas: 2, maxReplicas: 5, TARGETS: cpu%/70%
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
⚙️ Infrastructure
Key Takeaways
- No Mnesia — all data lives in PostgreSQL. Pod restarts and rescheduling are safe.
- Admin user is manual — always run
ejabberdctl registerafter first deploy - Config changes → edit
values.yamlorconfigmap.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.maxReplicasandcpuThresholdin values.yaml as load grows
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.