Skip to content

Element / Matrix

Self-hosted Element + Matrix homeserver — federated-capable team chat with end-to-end encryption, voice, group video (bundled Jitsi), and SIP dial-in.

  • Upstream project: https://element.io/
  • Replaces: Slack, Microsoft Teams, Signal (for team use), Zoom (for small group calls)
  • Sign-in (SSO): Pre-wired — the login page shows ‘Sign in with Keycloak’ out of the box, no post-deploy step.
  1. Click Deploy. First boot takes ~3 minutes (Synapse generates signing keys, postgres initialises, Jitsi components register).
  2. Open element.<your-domain> — the Element web client opens. Click Sign in with Keycloak.
  3. The first Keycloak user lands as a regular Matrix user. To promote them to homeserver admin, open synapseadmin.<your-domain> (operator-only, gated by Keycloak admin group), find the user, and toggle the admin flag.
  4. (Optional) Enable SIP dial-in: fill JIGASI_SIP_URI, JIGASI_SIP_PASSWORD, JIGASI_SIP_SERVER in the Environment tab with your SIP provider’s credentials, then redeploy. Without these, chat / voice / video still work — only dial-in from a phone is off.
  5. (Optional) Open federation: edit FEDERATION_DOMAIN_WHITELIST in the Environment tab (e.g. "matrix.org","example.com") and redeploy. Default is empty (no federation — the homeserver only talks to itself).

New direct messages and new invite-only rooms are encrypted by default. Public rooms stay unencrypted (E2EE in large public rooms hurts mobile sync UX). Each user is prompted to set up Secure Backup the first time they log in — this is a 24-character recovery key that lets them read encrypted history from a new device. Losing the key locks the user out of old encrypted messages; back it up the same way you back up a password manager.

  • 1:1 calls use the bundled Element/Matrix call stack and the shared TURN/STUN server at turn.<your-domain> for restrictive-network media relay.
  • Group video calls open in an embedded Jitsi widget at elementmeet.<your-domain> (the bundled Jitsi instance). Calls never leave your server — there is no fallback to meet.jit.si.
  • SIP dial-in (jigasi) lets a regular phone dial a SIP number to join a Jitsi room. Enable by filling the JIGASI_SIP_* env vars (see step 4 above).

Element’s iOS and Android apps connect straight to your homeserver. Users tap Use custom server at first launch and enter matrix.<your-domain>. SSO via Keycloak works in-app.

These values live in the Dokploy compose’s Environment tab. Random secrets are minted automatically when the template is first seeded — you don’t need to generate them yourself.

VariableDefault
ELEMENT_HOSTNAMEelement.yourdomain.com
MATRIX_HOSTNAMEmatrix.yourdomain.com
ELEMENT_JITSI_HOSTNAMEelementmeet.yourdomain.com
DB_PASSWORDauto-generated random value
SYNAPSE_REGISTRATION_SHARED_SECRETauto-generated random value
SYNAPSE_MACAROON_SECRETauto-generated random value
SYNAPSE_FORM_SECRETauto-generated random value
ALLOW_PUBLIC_REGISTRATIONfalse
FEDERATION_DOMAIN_WHITELIST(set before deploy)
OIDC_BASE_URLhttps://auth.yourdomain.com
OIDC_CLIENT_IDelement
OIDC_CLIENT_SECRET<your-element_oidc_client_secret>
VPS_PUBLIC_IP<your-server-public-ip>
TURN_HOSTNAMEturn.yourdomain.com
TURN_STATIC_AUTH_SECRET<your-turn_static_auth_secret>
JITSI_JICOFO_AUTH_PASSWORD<your-element_jitsi_jicofo_auth_password>
JITSI_JICOFO_COMPONENT_SECRET<your-element_jitsi_jicofo_component_secret>
JITSI_JVB_AUTH_PASSWORD<your-element_jitsi_jvb_auth_password>
JIGASI_XMPP_PASSWORD<your-element_jigasi_xmpp_password>
JIGASI_SIP_URI(set before deploy)
JIGASI_SIP_PASSWORD(set before deploy)
JIGASI_SIP_SERVER(set before deploy)
  • Service and port: element-web:80
  • Hostname: element.yourdomain.com

The hostname is attached automatically when the template is seeded; change it in the Domains tab before clicking Deploy if you want something else.

For reference — this is what the template deploys. Do not paste this anywhere. The compose is seeded into Dokploy automatically; the client-facing adjustments you make happen in the Environment and Domains tabs (described above), never in the compose itself.

# Element / Matrix (Synapse) -- chat, voice, video, SIP, E2EE.
#
# What's in the box:
# - synapse : Matrix homeserver (chat + voice signalling + E2EE).
# - element-web : Element web client.
# - synapse-admin : admin UI for Synapse (user mgmt, room mgmt).
# - postgres : Synapse's DB.
# - prosody / jicofo / jvb / jitsi-web : bundled Jitsi for group video.
# - jigasi : SIP <-> Jitsi gateway (dial-in from a SIP phone).
#
# Shared infrastructure consumed (not in this compose):
# - coturn at turn.<base>:5349 -- shared TURN/STUN for Matrix-native
# 1:1 voice + Jitsi restrictive-network fallback. Same vault secret
# as Nextcloud Talk + Rocket.Chat / Jitsi (see roles/coturn).
# - Keycloak at auth.<base> -- OIDC IdP for Synapse + Element.
#
# E2EE: encryption is on-by-default in Element for DMs and private
# rooms (Synapse default since 1.0). Cross-signing + key backup work
# out of the box; users opt in to key backup at first login.
#
# Federation: disabled by default
# (federation_domain_whitelist=[]) so the homeserver does not talk
# to the public Matrix network without an explicit operator decision.
# To open federation, edit /etc/synapse-template.yaml on the host or
# set FEDERATION_DOMAIN_WHITELIST in the Environment tab.
x-synapse-image: &synapse_image
image: matrixdotorg/synapse:v1.153.0
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: synapse
POSTGRES_USER: synapse
POSTGRES_PASSWORD: ${DB_PASSWORD}
# Synapse requires C collation on the DB. See
# https://element-hq.github.io/synapse/latest/postgres.html
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
volumes:
- element-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"]
interval: 10s
start_period: 30s
timeout: 5s
retries: 5
labels:
- "vps.auto-update=patch"
networks:
default:
aliases:
- postgres
synapse:
<<: *synapse_image
restart: unless-stopped
# Custom entrypoint: render homeserver.yaml from /etc/synapse-template.yaml
# by expanding ${ENV} placeholders, then exec synapse. Matrix-org's
# stock start.py only templates a fixed subset of env vars; we need
# arbitrary OIDC + TURN + federation config, so we drive it ourselves.
entrypoint: ["python3", "/etc/catena-synapse-init.py"]
environment:
SYNAPSE_SERVER_NAME: ${MATRIX_HOSTNAME}
SYNAPSE_REPORT_STATS: "no"
MATRIX_HOSTNAME: ${MATRIX_HOSTNAME}
ELEMENT_HOSTNAME: ${ELEMENT_HOSTNAME}
DB_PASSWORD: ${DB_PASSWORD}
SYNAPSE_REGISTRATION_SHARED_SECRET: ${SYNAPSE_REGISTRATION_SHARED_SECRET}
SYNAPSE_MACAROON_SECRET: ${SYNAPSE_MACAROON_SECRET}
SYNAPSE_FORM_SECRET: ${SYNAPSE_FORM_SECRET}
OIDC_BASE_URL: ${OIDC_BASE_URL}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
TURN_HOSTNAME: ${TURN_HOSTNAME}
TURN_STATIC_AUTH_SECRET: ${TURN_STATIC_AUTH_SECRET}
ALLOW_PUBLIC_REGISTRATION: ${ALLOW_PUBLIC_REGISTRATION}
FEDERATION_DOMAIN_WHITELIST: ${FEDERATION_DOMAIN_WHITELIST}
JITSI_PREFERRED_DOMAIN: ${ELEMENT_JITSI_HOSTNAME}
depends_on:
postgres:
condition: service_healthy
volumes:
- element-synapse-data:/data
configs:
- source: synapse-init
target: /etc/catena-synapse-init.py
mode: 0755
- source: synapse-homeserver-template
target: /etc/synapse-template.yaml
mode: 0644
- source: synapse-log-config
target: /etc/synapse-log.config
mode: 0644
labels:
- "vps.auth.mode=public"
- "vps.auth.oidc=true"
- "vps.auth.groups=staff"
- "vps.auth.oidc.redirect_uris=https://${MATRIX_HOSTNAME}/_synapse/client/oidc/callback"
- "vps.auth.oidc.scopes=openid email profile groups"
- "vps.auto-update=patch"
networks:
dokploy-network:
aliases:
- synapse
default: {}
element-web:
image: vectorim/element-web:v1.12.18
restart: unless-stopped
environment:
ELEMENT_WEB_PORT: "80"
configs:
- source: element-web-config
target: /app/config.json
mode: 0644
labels:
- "vps.auth.mode=public"
- "vps.auto-update=patch"
networks:
dokploy-network:
aliases:
- element-web
default: {}
synapse-admin:
image: awesometechnologies/synapse-admin:0.11.4
restart: unless-stopped
environment:
# Restricts the admin UI to managing THIS homeserver only.
REACT_APP_SERVER: https://${MATRIX_HOSTNAME}
depends_on:
- synapse
labels:
# Admin tier: gated by oauth2-proxy admin instance. Operators
# log in via Keycloak; only members of the operator group can
# reach the UI. Mirrors the catena-admin gating posture.
- "vps.auth.mode=admin"
- "vps.auto-update=patch"
networks:
dokploy-network:
aliases:
- synapse-admin
default: {}
# === JITSI BEGIN -- bundled on-server video conferencing =================
# Always-on, mirrors the Rocket.Chat pattern. Element's web client
# opens video calls in an embedded Jitsi widget pointing at the
# bundled instance at elementmeet.<base>. Uses the same shared coturn
# at turn.<base>:5349 for restrictive-network fallback.
#
# Hostname is intentionally elementmeet.<base> (not meet.<base>) to
# avoid collision with the Rocket.Chat bundled Jitsi when both
# templates are deployed side-by-side.
prosody:
image: jitsi/prosody:stable-10888
restart: unless-stopped
expose:
- "5222"
- "5347"
- "5280"
environment:
AUTH_TYPE: internal
ENABLE_AUTH: "1"
ENABLE_GUESTS: "1"
GLOBAL_MODULES: ""
GLOBAL_CONFIG: ""
LDAP_URL: ""
LDAP_BASE: ""
XMPP_DOMAIN: meet.jitsi
XMPP_AUTH_DOMAIN: auth.meet.jitsi
XMPP_GUEST_DOMAIN: guest.meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
XMPP_MODULES: ""
XMPP_MUC_MODULES: ""
XMPP_INTERNAL_MUC_MODULES: ""
XMPP_RECORDER_DOMAIN: recorder.meet.jitsi
JICOFO_AUTH_USER: focus
JICOFO_AUTH_PASSWORD: ${JITSI_JICOFO_AUTH_PASSWORD}
JICOFO_COMPONENT_SECRET: ${JITSI_JICOFO_COMPONENT_SECRET}
JVB_AUTH_USER: jvb
JVB_AUTH_PASSWORD: ${JITSI_JVB_AUTH_PASSWORD}
# jigasi (SIP gateway) needs an XMPP account on prosody to join
# rooms when a SIP call dials in. Auth user / password are
# consumed by the jigasi service below.
JIGASI_XMPP_USER: jigasi
JIGASI_XMPP_PASSWORD: ${JIGASI_XMPP_PASSWORD}
TZ: Etc/UTC
labels:
- "vps.auto-update=patch"
networks:
default:
aliases:
- meet.jitsi
- auth.meet.jitsi
- guest.meet.jitsi
- muc.meet.jitsi
- internal-muc.meet.jitsi
- recorder.meet.jitsi
jicofo:
image: jitsi/jicofo:stable-10888
restart: unless-stopped
environment:
XMPP_DOMAIN: meet.jitsi
XMPP_AUTH_DOMAIN: auth.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_SERVER: prosody
JICOFO_COMPONENT_SECRET: ${JITSI_JICOFO_COMPONENT_SECRET}
JICOFO_AUTH_USER: focus
JICOFO_AUTH_PASSWORD: ${JITSI_JICOFO_AUTH_PASSWORD}
# Tell jicofo about the SIP gateway so it routes dial-in
# requests to jigasi instead of dropping them.
JIGASI_SIP_URI: jigasi.meet.jitsi
TZ: Etc/UTC
depends_on:
- prosody
labels:
- "vps.auto-update=patch"
networks:
- default
jvb:
image: jitsi/jvb:stable-10888
restart: unless-stopped
# Media UDP MUST be host-published. mode: host bypasses Swarm's
# routing mesh so packets carry the real public source IP and
# JVB's ICE candidates point at a routable address. Uses port
# 10010 (not 10000) to avoid collision with the Rocket.Chat
# bundled JVB on the same host.
ports:
- target: 10010
published: 10010
protocol: udp
mode: host
environment:
XMPP_AUTH_DOMAIN: auth.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
XMPP_SERVER: prosody
JVB_AUTH_USER: jvb
JVB_AUTH_PASSWORD: ${JITSI_JVB_AUTH_PASSWORD}
JVB_BREWERY_MUC: jvbbrewery
JVB_PORT: "10010"
JVB_ADVERTISE_IPS: ${VPS_PUBLIC_IP}
JVB_TURN_HOST: ${TURN_HOSTNAME}
JVB_TURN_PORT: "5349"
JVB_TURN_TRANSPORT: tcp
JVB_TURN_SECRET: ${TURN_STATIC_AUTH_SECRET}
TZ: Etc/UTC
depends_on:
- prosody
labels:
- "vps.auto-update=patch"
networks:
- default
jitsi-web:
image: jitsi/web:stable-10888
restart: unless-stopped
expose:
- "80"
environment:
ENABLE_LETSENCRYPT: "0"
ENABLE_HTTP_REDIRECT: "0"
ENABLE_HSTS: "0"
DISABLE_HTTPS: "1"
PUBLIC_URL: https://${ELEMENT_JITSI_HOSTNAME}
XMPP_DOMAIN: meet.jitsi
XMPP_AUTH_DOMAIN: auth.meet.jitsi
XMPP_BOSH_URL_BASE: http://prosody:5280
XMPP_GUEST_DOMAIN: guest.meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_RECORDER_DOMAIN: recorder.meet.jitsi
TZ: Etc/UTC
depends_on:
- prosody
labels:
# Public by-link rooms; participants do not need an Element
# account to join (Jitsi rooms are by-URL).
- "vps.auth.mode=public"
- "vps.auto-update=patch"
networks:
dokploy-network:
aliases:
- jitsi-web
default: {}
jigasi:
image: jitsi/jigasi:jigasi-1.1-412-ge9a3acc-1
restart: unless-stopped
# SIP signalling. Port range is the standard Jigasi default; UDP
# for RTP, TCP/UDP for SIP. mode: host so the SIP provider sees
# the real VPS public IP in Via headers.
ports:
- target: 5060
published: 5060
protocol: udp
mode: host
- target: 5060
published: 5060
protocol: tcp
mode: host
environment:
XMPP_SERVER: prosody
XMPP_DOMAIN: meet.jitsi
XMPP_AUTH_DOMAIN: auth.meet.jitsi
XMPP_MUC_DOMAIN: muc.meet.jitsi
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
XMPP_GUEST_DOMAIN: guest.meet.jitsi
JIGASI_XMPP_USER: jigasi
JIGASI_XMPP_PASSWORD: ${JIGASI_XMPP_PASSWORD}
# SIP trunk credentials -- operator fills these in the
# Environment tab once they have a SIP provider account
# (Twilio Programmable Voice, OVH Telephony, Bandwidth, etc.).
# Empty values leave jigasi unregistered; the rest of the
# stack still works (chat, video, E2EE), only SIP dial-in is
# off. To enable dial-in: fill these three vars and redeploy.
JIGASI_SIP_URI: ${JIGASI_SIP_URI}
JIGASI_SIP_PASSWORD: ${JIGASI_SIP_PASSWORD}
JIGASI_SIP_SERVER: ${JIGASI_SIP_SERVER}
JIGASI_SIP_PORT: "5060"
JIGASI_SIP_TRANSPORT: UDP
ENABLE_SIP_TRANSCRIBER: "0"
ENABLE_SIP_VISUAL_NOTIFICATIONS: "1"
TZ: Etc/UTC
depends_on:
- prosody
labels:
- "vps.auto-update=patch"
networks:
- default
# === JITSI END ============================================================
configs:
# Custom Synapse entrypoint -- expands ${ENV} placeholders in
# /etc/synapse-template.yaml into /data/homeserver.yaml, then exec's
# synapse. Necessary because matrix-org's stock start.py only
# templates a fixed subset of env vars (SERVER_NAME, REPORT_STATS,
# postgres) -- not OIDC, TURN, federation, presence, etc.
synapse-init:
content: |
#!/usr/bin/env python3
import os, sys, pathlib, subprocess
template = pathlib.Path("/etc/synapse-template.yaml").read_text()
rendered = os.path.expandvars(template)
out = pathlib.Path("/data/homeserver.yaml")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(rendered)
pathlib.Path("/etc/synapse-log.config").chmod(0o644)
# First-boot: generate signing keys if missing. Idempotent --
# synapse refuses to overwrite existing keys.
keys = pathlib.Path("/data/keys")
keys.mkdir(parents=True, exist_ok=True)
if not any(keys.glob("*.signing.key")):
subprocess.check_call([
"python3", "-m", "synapse.app.homeserver",
"--config-path=/data/homeserver.yaml",
"--generate-keys",
])
os.execvp("python3", [
"python3", "-m", "synapse.app.homeserver",
"--config-path=/data/homeserver.yaml",
])
synapse-homeserver-template:
content: |
# Templated by /etc/catena-synapse-init.py at container start.
# ${VAR} placeholders are expanded from the container environment.
server_name: "${MATRIX_HOSTNAME}"
public_baseurl: "https://${MATRIX_HOSTNAME}/"
pid_file: /data/homeserver.pid
log_config: "/etc/synapse-log.config"
report_stats: false
signing_key_path: "/data/keys/signing.key"
trusted_key_servers: []
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
bind_addresses: ["0.0.0.0"]
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: synapse
password: "${DB_PASSWORD}"
database: synapse
host: postgres
port: 5432
cp_min: 5
cp_max: 10
media_store_path: /data/media_store
max_upload_size: 100M
enable_registration: ${ALLOW_PUBLIC_REGISTRATION}
enable_registration_without_verification: false
registration_shared_secret: "${SYNAPSE_REGISTRATION_SHARED_SECRET}"
macaroon_secret_key: "${SYNAPSE_MACAROON_SECRET}"
form_secret: "${SYNAPSE_FORM_SECRET}"
# E2EE: encrypted DMs + private rooms by default. Public rooms
# stay unencrypted (E2EE in large public rooms hurts UX).
encryption_enabled_by_default_for_room_type: invite
# Federation: closed by default. FEDERATION_DOMAIN_WHITELIST="" means
# the whitelist is the empty list -> no federation. To open
# federation to specific peers, set the var to a comma-separated
# list like "matrix.org,example.com" -- the YAML below uses an
# explicit list rendered from the env var.
federation_domain_whitelist: [${FEDERATION_DOMAIN_WHITELIST}]
# Presence / typing notifications: keep on for the team-chat UX.
use_presence: true
# TURN via the shared coturn at turn.<base>. Same static-auth-secret
# as Nextcloud Talk and Rocket.Chat / Jitsi. Synapse mints per-call
# HMAC-SHA1 credentials (RFC 7635), same scheme JVB uses.
turn_uris:
- "turn:${TURN_HOSTNAME}:3478?transport=udp"
- "turn:${TURN_HOSTNAME}:3478?transport=tcp"
- "turns:${TURN_HOSTNAME}:5349?transport=tcp"
turn_shared_secret: "${TURN_STATIC_AUTH_SECRET}"
turn_user_lifetime: 86400000
turn_allow_guests: true
# OIDC -- Keycloak. The realm + client live in the operator's
# Keycloak; ops/ converge mints the client (env_managed_keys
# re-injects OIDC_CLIENT_SECRET on every converge). Users land
# on Synapse's /_synapse/client/oidc/callback; Synapse maps the
# `preferred_username` claim to the local part of the Matrix ID.
oidc_providers:
- idp_id: keycloak
idp_name: "Keycloak"
discover: true
issuer: "${OIDC_BASE_URL}/realms/vps"
client_id: "${OIDC_CLIENT_ID}"
client_secret: "${OIDC_CLIENT_SECRET}"
scopes: ["openid", "profile", "email"]
user_mapping_provider:
config:
localpart_template: "{{ user.preferred_username }}"
display_name_template: "{{ user.name }}"
email_template: "{{ user.email }}"
allow_existing_users: true
# Disable the legacy password login UI; users sign in via
# Keycloak. (Bootstrap admin still works via registration-shared-
# secret + register_new_matrix_user CLI when needed.)
password_config:
enabled: false
# Pre-populate the conference widget so Element's /jitsi command
# and "video conference" button open elementmeet.<base> instead
# of the public meet.jit.si fallback.
app_service_config_files: []
synapse-log-config:
content: |
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
level: INFO
root:
level: INFO
handlers: [console]
disable_existing_loggers: false
element-web-config:
# Element's runtime config. Points the client at our Synapse
# homeserver, pre-fills the Jitsi widget with the bundled instance
# so video calls don't leak to meet.jit.si, and tightens defaults
# (no telemetry, no integration manager).
content: |
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://${MATRIX_HOSTNAME}",
"server_name": "${MATRIX_HOSTNAME}"
}
},
"brand": "Element",
"disable_custom_urls": true,
"disable_guests": true,
"disable_login_language_selector": false,
"disable_3pid_login": true,
"default_country_code": "CA",
"show_labs_settings": false,
"default_federate": false,
"default_theme": "light",
"room_directory": { "servers": ["${MATRIX_HOSTNAME}"] },
"enable_presence_by_hs_url": { "https://${MATRIX_HOSTNAME}": true },
"settingDefaults": {
"UIFeature.urlPreviews": true,
"UIFeature.feedback": false,
"UIFeature.registration": false,
"UIFeature.passwordReset": false,
"UIFeature.deactivate": false,
"UIFeature.shareQrCode": true,
"UIFeature.shareSocial": false,
"UIFeature.identityServer": false,
"UIFeature.thirdPartyId": false,
"UIFeature.advancedSettings": true,
"UIFeature.voip": true,
"UIFeature.widgets": true
},
"jitsi": {
"preferred_domain": "${ELEMENT_JITSI_HOSTNAME}"
},
"features": {
"feature_element_call_video_rooms": true
},
"element_call": {
"url": "https://${ELEMENT_JITSI_HOSTNAME}",
"use_exclusively": false
},
"posthog": null,
"analytics_owner": null,
"privacy_policy_url": null
}
volumes:
element-postgres-data:
element-synapse-data:
networks:
dokploy-network:
external: true

<- Back to all pre-configured apps