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.
Setup steps
Section titled “Setup steps”- Click Deploy. First boot takes ~3 minutes (Synapse generates signing keys, postgres initialises, Jitsi components register).
- Open
element.<your-domain>— the Element web client opens. Click Sign in with Keycloak. - 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. - (Optional) Enable SIP dial-in: fill
JIGASI_SIP_URI,JIGASI_SIP_PASSWORD,JIGASI_SIP_SERVERin 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. - (Optional) Open federation: edit
FEDERATION_DOMAIN_WHITELISTin the Environment tab (e.g."matrix.org","example.com") and redeploy. Default is empty (no federation — the homeserver only talks to itself).
End-to-end encryption
Section titled “End-to-end encryption”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.
Voice and video
Section titled “Voice and video”- 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 tomeet.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).
Mobile apps
Section titled “Mobile apps”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.
Environment variables
Section titled “Environment variables”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.
| Variable | Default |
|---|---|
ELEMENT_HOSTNAME | element.yourdomain.com |
MATRIX_HOSTNAME | matrix.yourdomain.com |
ELEMENT_JITSI_HOSTNAME | elementmeet.yourdomain.com |
DB_PASSWORD | auto-generated random value |
SYNAPSE_REGISTRATION_SHARED_SECRET | auto-generated random value |
SYNAPSE_MACAROON_SECRET | auto-generated random value |
SYNAPSE_FORM_SECRET | auto-generated random value |
ALLOW_PUBLIC_REGISTRATION | false |
FEDERATION_DOMAIN_WHITELIST | (set before deploy) |
OIDC_BASE_URL | https://auth.yourdomain.com |
OIDC_CLIENT_ID | element |
OIDC_CLIENT_SECRET | <your-element_oidc_client_secret> |
VPS_PUBLIC_IP | <your-server-public-ip> |
TURN_HOSTNAME | turn.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) |
Domain
Section titled “Domain”- 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.
Compose file
Section titled “Compose file”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