Skip to Content

Matrix deployment on Arch Linux LXC behind Nginx

This guide describes a native, systemd-managed deployment of Synapse and Element Web in an Arch Linux LXC that already runs Odoo and PostgreSQL. An existing Nginx reverse proxy on the Proxmox side terminates public HTTPS. Docker and client-side software installation are not required.

The conferencing section adds native LiveKit and MatrixRTC authorization services. Element Web supplies the browser interface; Synapse alone does not supply the conferencing backend. Odoo loads the separately hosted Element interface in an iframe and manages reviewed identities and memberships through HTTPS APIs.

This is an operator runbook, not an automatic installer. Commands below are to be run on the identified deployment machine after replacing examples. Writing this guide does not install services, change existing databases, or establish live acceptance. Sources were reviewed on October 12, 2026; package versions and experimental MatrixRTC interfaces must be checked when deploying.

Contents

1. Deployment layout

1.1 Example names and addresses

All names, addresses, account names, and secret placeholders in this guide are synthetic. Substitute actual deployment values before using any configuration.

Example Purpose
example.com Permanent Matrix server name; accounts are @alice:example.com
matrix.example.com Public Synapse HTTPS API
element.example.com Public Element Web HTTPS interface
rtc.example.com Public LiveKit signaling and legacy RTC authorization endpoints
turn.example.com TURN hostname, covered by its own TLS certificate
odoo.example.com Existing Odoo HTTPS interface
10.20.0.1 Front Nginx address on the private network
10.20.0.20 Arch LXC address on the private network
203.0.113.10 Example public IPv4 address; replace this documentation-only address

The Matrix server name is an account identity domain, not necessarily the hostname serving the API. Choose it before initializing Synapse and retain it through upgrades and restores. The examples use example.com with discovery delegating connections to matrix.example.com.

See Synapse server-name selection and delegation.

1.2 Services and traffic

Internet browsers and Matrix peers
                  |
                  | HTTPS :443
                  v
Front Nginx on the Proxmox side
  example.com         -> Matrix discovery responses
  matrix.example.com  -> LXC 10.20.0.20:8008, Synapse
  element.example.com -> LXC 10.20.0.20:8081, internal Nginx
  rtc.example.com     -> LXC 10.20.0.20:7880, LiveKit signaling
                      -> LXC 10.20.0.20:8080, legacy authorization
  odoo.example.com    -> existing Odoo upstream, unchanged

Arch LXC
  Odoo                -> existing Odoo databases and existing database role
  Synapse             -> new matrix_synapse database and matrix_synapse role
  internal Nginx      -> /usr/share/webapps/element/
  LiveKit             -> WebRTC media and embedded TURN
  MatrixRTC service   -> LiveKit authorization; optional Synapse appservice wiring

Browser WebRTC media -> direct routed/NATed media ports -> LiveKit in LXC

The second Nginx serves static Element files inside the LXC. The front Nginx handles TLS and hostname routing. An alternative is to serve Element files directly from the front host; that moves Element installation and updates there. The main instructions keep all application packages in the LXC.

HTTP proxying covers API requests and WebSocket signaling. It does not carry ordinary UDP WebRTC media or TURN traffic. Configure media routing separately.

1.3 DNS and routing

  1. Point matrix.example.com, element.example.com, and rtc.example.com at the public address of the front proxy. Point turn.example.com at the address that exposes the TURN listener.
  2. Ensure example.com serves HTTPS and can answer both Matrix discovery paths. If it already hosts a website, add the locations to its existing server block.
  3. Publish AAAA records only if IPv6 routing, firewall rules, listeners, and media connectivity actually work. An unusable AAAA record can break discovery or calls.
  4. From the LXC, ensure the public Matrix and RTC HTTPS names are reachable. If public-address hairpin NAT is unavailable, use split DNS resolving those names to 10.20.0.1 internally. Keep the same HTTPS names and valid certificates.
  5. Reserve the LXC's address and confirm front-proxy connectivity to its private service ports. Restrict these HTTP ports to the proxy and authorized local use.

2. Prepare the existing LXC

Warning: this LXC already contains Odoo databases. Do not run initdb, replace its PostgreSQL data directory, or follow a fresh-cluster initialization tutorial. Matrix uses an additional database in the existing PostgreSQL cluster.

Before installing packages:

  1. Record the current Odoo, PostgreSQL, and Python versions and the Odoo interpreter path. Confirm Odoo uses its intended virtual environment.
  2. Take current backups through the existing Odoo/PostgreSQL backup procedure. Include Odoo filestores. A running-container snapshot alone is not a substitute for a coherent database backup.
  3. Schedule the Arch upgrade as maintenance. A full upgrade can affect the existing PostgreSQL major version, Python interpreter, and Odoo services.
  4. Reserve RAM, database connections, disk space, and upload bandwidth for Synapse and conferences. They share the LXC's resource limits with Odoo.
  5. Keep existing Odoo service units, virtual environments, Nginx routes, and database authentication rules intact.

Run these read-only inspections inside the LXC:

systemctl status postgresql
sudo -u postgres psql -X -d postgres -c 'SELECT version();'
sudo -u postgres psql -X -d postgres -c 'SHOW hba_file;'
sudo -u postgres psql -X -d postgres -c 'SHOW max_connections;'
ss -lntup

If PostgreSQL runs through a custom unit, socket, port, or operating-system user, adapt the commands to that installation. Do not start a second cluster to make the examples work.

3. Install native Arch packages

Run as root inside the LXC, during the maintenance window:

pacman -Syu matrix-synapse python-psycopg2 element-web nginx sudo curl jq

PostgreSQL is already installed and running. The command intentionally does not initialize it. Review the proposed package transaction before accepting it; complete any required PostgreSQL major-version upgrade through the existing cluster's maintenance procedure first.

Synapse uses the distribution's Python packages. Do not install its dependencies into Odoo's virtual environment. Element Web is prebuilt static content; no Node.js build environment is needed to serve the Arch package.

Element Web is an external frontend dependency, not an asset copied into the Odoo addon or a Python package. See Element Web setup and updates for the package ownership, embedding boundary, and frontend update procedure.

Confirm the installed layout:

pacman -Q matrix-synapse element-web postgresql
pacman -Ql matrix-synapse
pacman -Ql element-web
getent passwd synapse
systemctl cat synapse.service

The documented Arch layout uses the synapse service user, synapse.service, /etc/synapse/homeserver.yaml, and /var/lib/synapse. Element files are under /usr/share/webapps/element/. If the installed package changes these paths, use its actual layout rather than creating a competing service.

Sources: ArchWiki Matrix, Synapse package, and Element file list.

4. Add a dedicated Synapse database

4.1 Check names before creating anything

Inside the LXC, verify the proposed names are unused:

sudo -u postgres psql -X -d postgres -c "SELECT datname FROM pg_database WHERE datname = 'matrix_synapse';"
sudo -u postgres psql -X -d postgres -c "SELECT rolname FROM pg_roles WHERE rolname = 'matrix_synapse';"

If either exists, identify its owner and purpose. These instructions are for a new Synapse database, not for replacing or adopting an existing unknown database.

4.2 Create the role and database

Use an independently generated database password and save it in the deployment's secret store. The password prompt avoids putting it in shell arguments/history.

sudo -u postgres createuser --login --no-superuser --no-createdb --no-createrole --pwprompt matrix_synapse
sudo -u postgres createdb --owner=matrix_synapse --encoding=UTF8 --locale=C --template=template0 matrix_synapse
sudo -u postgres psql -X -d postgres -c 'REVOKE CONNECT ON DATABASE matrix_synapse FROM PUBLIC;'
sudo -u postgres psql -X -d postgres -c 'GRANT CONNECT ON DATABASE matrix_synapse TO matrix_synapse;'

The new database uses UTF-8 and C collation/ctype. Existing Odoo databases retain their own locales and owners. If template0 uses ICU or another locale provider, select --locale-provider=libc when creating the new database on PostgreSQL 15 or newer; check createdb --help for the installed version's options. Confirm only the new database's settings:

sudo -u postgres psql -X -d postgres -c "SELECT datname, pg_encoding_to_char(encoding), datcollate, datctype FROM pg_database WHERE datname = 'matrix_synapse';"

See Synapse PostgreSQL requirements.

4.3 Restrict authentication to the new database

Edit the existing pg_hba.conf identified by SHOW hba_file. Insert these role-specific entries before broader matching entries; leave existing Odoo rules in place:

host    matrix_synapse    matrix_synapse    127.0.0.1/32    scram-sha-256
host    all               matrix_synapse    0.0.0.0/0       reject
host    all               matrix_synapse    ::/0            reject
local   all               matrix_synapse                   reject

The Synapse connection below uses TCP 127.0.0.1, not a Unix socket. These rules allow its dedicated role only into matrix_synapse through that connection. If the cluster still generates MD5 password verifiers, configure SCRAM for this role and reset its password interactively before using scram-sha-256.

Check the edited rules and reload PostgreSQL configuration without restarting the whole cluster:

sudo -u postgres psql -X -d postgres -c 'SELECT line_number, error FROM pg_hba_file_rules WHERE error IS NOT NULL;'
sudo -u postgres psql -X -d postgres -c 'SELECT pg_reload_conf();'
psql -X -h 127.0.0.1 -U matrix_synapse -d matrix_synapse -W -c 'SELECT current_database(), current_user;'

Do not reload until the rule inspection reports no errors. HBA rule order is significant; PostgreSQL uses the first matching rule. PostgreSQL authentication reference.

5. Configure and start Synapse

5.1 Generate a fresh configuration

Run as root inside the LXC. Confirm there is no existing Synapse configuration or signing key to preserve before running these fresh-install commands:

install -d -o root -g synapse -m 0750 /etc/synapse
install -d -o synapse -g synapse -m 0750 /var/lib/synapse
generate_config --server-name example.com --config-dir /etc/synapse --data-dir /var/lib/synapse --report-stats no --generate-secrets --output-file /etc/synapse/homeserver.yaml
generate_log_config -o /etc/synapse/example.com.log.config -f /var/lib/synapse/homeserver.log
generate_signing_key --output_file /etc/synapse/example.com.signing.key
chown root:synapse /etc/synapse/homeserver.yaml /etc/synapse/example.com.log.config /etc/synapse/example.com.signing.key
chmod 0640 /etc/synapse/homeserver.yaml /etc/synapse/example.com.log.config /etc/synapse/example.com.signing.key

The generated signing key is permanent server identity material. Back it up and never regenerate it as an ordinary upgrade step.

5.2 Edit the generated configuration

Edit /etc/synapse/homeserver.yaml. Replace the corresponding sections with the following values; do not append duplicate YAML keys or discard generated secrets. Replace the database-password placeholder privately.

server_name: "example.com"
public_baseurl: "https://matrix.example.com/"
report_stats: false
log_config: "/etc/synapse/example.com.log.config"
signing_key_path: "/etc/synapse/example.com.signing.key"
media_store_path: "/var/lib/synapse/media"
enable_registration: false
url_preview_enabled: false
max_upload_size: "50M"

listeners:
  - port: 8008
    type: http
    tls: false
    bind_addresses:
      - "127.0.0.1"
      - "10.20.0.20"
    x_forwarded: true
    resources:
      - names: [client, federation]
        compress: false

database:
  name: psycopg2
  args:
    host: "127.0.0.1"
    port: 5432
    user: "matrix_synapse"
    password: "<REPLACE_WITH_THE_DATABASE_PASSWORD>"
    database: "matrix_synapse"
    cp_min: 2
    cp_max: 10

Keep the generated registration_shared_secret for the administrative account creation step. Do not expose it in Element configuration, source control, or Odoo settings. Public self-registration remains disabled.

Port 8008 must be reachable from the front proxy at 10.20.0.1, but not from arbitrary public clients. Loopback-only binding would prevent the separate proxy from reaching Synapse. x_forwarded: true assumes only trusted proxies can reach the listener and supply forwarded headers.

The example enables federation APIs for discovery, federation, and Matrix OpenID validation used by calling services. If public federation is intentionally restricted, retain the required OpenID endpoints and apply the deployment's explicit peer policy; merely removing all server-server endpoints can break calls.

Proxy behavior is described in the Synapse reverse-proxy guide.

5.3 Start the packaged service

systemctl enable --now synapse.service
systemctl status synapse.service
curl --fail http://127.0.0.1:8008/_matrix/client/versions

A successful versions response establishes local API reachability, not completed public federation or calling setup. If startup fails, inspect locally:

journalctl -u synapse.service -n 100 --no-pager

The package's service sandbox may restrict writes to /var/lib/synapse. Keeping media and logs there avoids widening its writable paths. Resolve any package-unit differences through a narrow systemd drop-in rather than disabling its sandbox.

6. Deploy Element Web inside the LXC

6.1 Maintain configuration separately from package assets

Element has no backend service process. Nginx serves the prebuilt Arch files. Create /etc/webapps/element/config.json if it does not already exist, using the packaged sample as a starting point:

install -d -m 0755 /etc/webapps/element
if [ ! -e /etc/webapps/element/config.json ]; then
    cp /etc/webapps/element/config.sample.json /etc/webapps/element/config.json
fi

Preserve an existing configuration. A minimal public configuration is:

{
  "default_server_config": {
    "m.homeserver": {
      "base_url": "https://matrix.example.com",
      "server_name": "example.com"
    }
  },
  "disable_custom_urls": true,
  "disable_guests": true,
  "brand": "Example Matrix",
  "default_country_code": "SI",
  "element_call": {
    "disable": false,
    "use_exclusively": true
  }
}

use_exclusively selects Element Call instead of relying on legacy one-to-one or Jitsi calling. Apply it when completing the RTC section; chat can be commissioned first. Check supported options against the installed Element release.

This file is publicly downloadable. It must contain no passwords, access tokens, OIDC client secrets, LiveKit API secrets, or recovery keys. Do not place local overrides in the package-managed webroot; the Nginx alias below serves the separately maintained configuration instead.

chmod 0644 /etc/webapps/element/config.json
jq empty /etc/webapps/element/config.json

See Element configuration.

6.2 Configure the internal Nginx

Inside the LXC, inspect /etc/nginx/nginx.conf. In its existing http block, include the directory used below if it is not already included:

include /etc/nginx/conf.d/*.conf;

Also retain the usual include /etc/nginx/mime.types; in the http block. Confirm the installed MIME map includes application/wasm for .wasm files and a JavaScript MIME type for .js files. Incorrect types can prevent the browser's encryption or calling code from loading, especially with nosniff.

Create the directory and /etc/nginx/conf.d/element.conf. If another Nginx already runs in the LXC, add this server to that instance rather than replacing its main configuration. The example uses private port 8081 to avoid existing listeners.

server {
    listen 10.20.0.20:8081;
    server_name element.example.com;

    root /usr/share/webapps/element;
    index index.html;

    allow 10.20.0.1;
    allow 127.0.0.1;
    deny all;

    location = /config.json {
        alias /etc/webapps/element/config.json;
        default_type application/json;
        add_header Cache-Control "no-store" always;
    }

    location ~ ^/config\.[^/]+\.json$ {
        return 404;
    }

    location / {
        try_files $uri $uri/ =404;
        add_header Cache-Control "no-cache" always;
    }
}

This deliberately simple caching policy also covers /, /index.html, /version, and language files. A missing host-specific config.element.example.com.json returns 404 so Element falls back to /config.json. Hash-based room navigation does not require rewriting missing asset URLs to HTML.

install -d -m 0755 /etc/nginx/conf.d
nginx -t
systemctl enable --now nginx.service

If Nginx was already running, use systemctl reload nginx.service after the successful configuration check. From the front proxy, inspect the upstream:

curl --fail -H 'Host: element.example.com' http://10.20.0.20:8081/config.json

Review Element's deployment and caching requirements when optimizing the caching policy later.

7. Configure the front Nginx

All examples in this section belong to the existing front proxy, not the LXC. Keep Proxmox management routes and the existing Odoo server block intact. Add dedicated Matrix virtual hosts using the proxy's established include layout.

7.1 Obtain HTTPS certificates

Use the front proxy's existing ACME/certificate-management workflow. Issue a certificate covering matrix.example.com, element.example.com, and rtc.example.com. The examples below use one certificate named matrix.example.com. Adjust paths if separate certificates are used.

For an existing Certbot webroot workflow, first create an HTTP server with the following location and a configured /var/lib/acme webroot:

server {
    listen 80;
    server_name matrix.example.com element.example.com rtc.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/lib/acme;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

Enable that HTTP configuration before obtaining the certificate. Do not enable TLS server blocks referencing certificate files that do not yet exist. If Certbot is already the deployed ACME client:

install -d -m 0755 /var/lib/acme
nginx -t
systemctl reload nginx
certbot certonly --webroot -w /var/lib/acme --cert-name matrix.example.com -d matrix.example.com -d element.example.com -d rtc.example.com

Manage example.com through its existing certificate workflow. Obtain a separate turn.example.com certificate when adding TURN/TLS. Configure renewal and Nginx reload through the existing certificate manager; do not install a competing ACME service on the same host. See Certbot instructions.

7.2 Matrix API virtual host

Create a TLS server for matrix.example.com:

server {
    listen 443 ssl;
    server_name matrix.example.com;

    ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    client_max_body_size 50m;
    access_log off;

    location ^~ /_matrix/ {
        proxy_pass http://10.20.0.20:8008;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
    }

    location ^~ /_synapse/client/ {
        proxy_pass http://10.20.0.20:8008;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        access_log off;
    }

    location ^~ /_synapse/admin/ {
        allow 10.20.0.20;
        deny all;
        proxy_pass http://10.20.0.20:8008;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        return 404;
    }
}

There is intentionally no trailing slash on the Synapse proxy_pass URLs. Preserve Matrix URI encoding; rewriting or normalizing signed federation paths can break authentication. The request body limit matches Synapse's upload limit.

The admin API is reachable only from the example Odoo LXC address. Add another explicit management source only if needed. With NAT or another trusted proxy in front, confirm the actual source Nginx sees before relying on this allowlist. Do not make the admin API globally reachable to simplify setup.

OIDC callback access logging is disabled here to avoid retaining authorization codes in ordinary URL logs. Apply an equivalent redacted-log policy if request logs are required. Keep application diagnostics private as well.

7.3 Element virtual host and embedding policy

server {
    listen 443 ssl;
    server_name element.example.com;

    ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "no-referrer" always;
    add_header Content-Security-Policy "frame-ancestors 'self' https://odoo.example.com" always;
    add_header Permissions-Policy 'camera=(self), microphone=(self), display-capture=(self), fullscreen=(self), autoplay=(self)' always;

    location / {
        proxy_pass http://10.20.0.20:8081;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_hide_header X-Frame-Options;
        access_log off;
    }
}

Element should retain its own browser origin, separate from Odoo. The CSP permits embedding only by itself and the named Odoo origin. A conflicting X-Frame-Options: SAMEORIGIN or DENY header prevents cross-origin embedding. proxy_hide_header removes an upstream header; it does not remove headers added by the front Nginx. Check global includes and final responses for conflicting policies.

If a stronger existing CSP is present, amend its frame-ancestors directive while preserving the other protections. Multiple CSP headers are enforced together, so adding a second permissive header cannot override a restrictive first one. Do not include a generic security snippet that reintroduces a conflicting frame policy. Nginx add_header inheritance also requires attention when adding more location-level headers.

Sources: Element security notes and Nginx header inheritance.

7.4 Matrix discovery on the identity domain

Add these two locations to the existing HTTPS server for example.com:

location = /.well-known/matrix/server {
    default_type application/json;
    add_header Cache-Control "public, max-age=3600" always;
    return 200 '{"m.server":"matrix.example.com:443"}';
}

location = /.well-known/matrix/client {
    default_type application/json;
    add_header Access-Control-Allow-Origin "*" always;
    add_header Cache-Control "public, max-age=300" always;
    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"}}';
}

The wildcard CORS header exposes public discovery metadata, not credentials. Preserve any necessary site security headers when merging these locations. Federation delegates to HTTPS port 443, so this layout does not require a separate public 8448 listener. MatrixRTC OpenID validation can follow this delegation too. This differs from deployment playbooks that assume port 8448.

7.5 Odoo's parent-page media policy

If the existing Odoo server sends a restrictive Permissions-Policy, update it to delegate media to Element while retaining Odoo's own permissions:

add_header Permissions-Policy 'camera=(self "https://element.example.com"), microphone=(self "https://element.example.com"), display-capture=(self "https://element.example.com"), fullscreen=(self "https://element.example.com"), autoplay=(self "https://element.example.com")' always;

Merge this into the existing policy, rather than emitting duplicate incompatible headers. If Odoo already has a CSP, its frame-src must permit https://element.example.com; preserve other existing frame sources. For a calling widget hosted on a different origin, add that actual UI origin to the policies and to the addon's Element Call Origin field. The SFU/backend URL is not a calling-widget UI origin.

7.6 Activate and inspect

On the front proxy:

nginx -t
systemctl reload nginx
curl --fail https://matrix.example.com/_matrix/client/versions
curl --fail https://example.com/.well-known/matrix/server
curl --fail https://example.com/.well-known/matrix/client
curl --fail https://element.example.com/config.json
curl --head https://element.example.com/

From an external browser, open https://element.example.com. Confirm that it selects the intended homeserver. Repeat the public API checks from inside the LXC to catch split-DNS or hairpin-routing failures. Never use curl -k as the normal solution to certificate errors.

8. Create initial Matrix accounts

Run inside the LXC as the Synapse user, with interactive password prompts:

sudo -u synapse register_new_matrix_user -c /etc/synapse/homeserver.yaml http://127.0.0.1:8008

Create a dedicated administrator for server administration. Repeat the command to create non-admin accounts for initial standalone chat/call checks and a non-admin Odoo room-management integration account. Choose administrator access only for the account that genuinely needs the Synapse admin API.

Do not supply passwords on command lines or put tokens in URLs. Keep account credentials in a secret store. The generated registration shared secret grants powerful account-creation authority even when public registration is disabled.

Sign two ordinary users into Element, create an encrypted private room, invite the other user, and exchange messages. Complete Element's device verification and recovery setup. This checks the independent Matrix deployment before linking Odoo's reviewed identities; a manually created @alice:example.com account is not automatically the addon's UUID-derived person account.

For SMTP/password reset, configure Synapse's email section using the deployed mail service's documented settings. Until SMTP or an alternative recovery process is configured, password-reset emails are unavailable. Encryption recovery keys remain separate from account-password recovery.

9. Add WebRTC and conferencing

9.1 Choose a compatible version set

Deploy a maintained LiveKit release and MatrixRTC authorization service alongside Synapse and Element. The native release examples below use LiveKit v1.13.9 and lk-jwt-service v0.8.0, available when this guide was written. These are explicit download examples, not a claim that this complete deployment has been tested or that these versions should remain pinned indefinitely.

The authorization service's v0.8.0 release includes an OpenID validation fix; do not select an older affected release simply to match an older tutorial. Release notes.

There are two RTC authorization interfaces:

  • Current clients can use homeserver-proxied application-service endpoints and the transport's url value.
  • Older clients use livekit_service_url and the legacy /sfu/get endpoint. Upstream deprecates this interface but retains it for compatibility.

The example wires the application service and advertises both transport values. An older packaged Element can use the legacy path while newer clients use the homeserver-mediated path. Verify the selected Synapse version supports MSC4502 and MSC4512; do not assume arbitrary older versions support these flags. See Synapse MatrixRTC configuration.

The inspected Synapse v1.162.0 sources support both transport fields and the appservice flags used here: transport schema and experimental configuration. This establishes configuration support, not live interoperability of the complete version set.

9.2 Install native release binaries

Inside the LXC, confirm the machine architecture with uname -m. The commands below target x86-64; choose matching upstream assets for ARM. Download into a fresh operator-owned staging directory:

mkdir -m 0700 matrix-rtc-downloads
cd matrix-rtc-downloads
curl --fail --location --output livekit.tar.gz https://github.com/livekit/livekit/releases/download/v1.13.9/livekit_1.13.9_linux_amd64.tar.gz
curl --fail --location --output lk-jwt-service https://github.com/element-hq/lk-jwt-service/releases/download/v0.8.0/lk-jwt-service_linux_amd64
tar -tzf livekit.tar.gz

Before installation, verify the files using the selected release's published checksums/signatures where available and inspect the archive paths. Download directly from the release pages; do not pipe an unreviewed remote script to a root shell. Extract the verified archive into this staging directory, then:

tar -xzf livekit.tar.gz
sudo install -o root -g root -m 0755 livekit-server /usr/local/bin/livekit-server
sudo install -o root -g root -m 0755 lk-jwt-service /usr/local/bin/lk-jwt-service

Adjust the extracted binary path if the archive's layout differs. Use a separate operator-managed update procedure for these binaries; pacman does not manage files installed under /usr/local/bin.

Release sources: LiveKit and MatrixRTC authorization service.

9.3 Create service users and configuration directories

As root inside the LXC, first confirm these names are unused, then create them:

getent passwd livekit
getent passwd matrixrtc
getent group matrixrtc-registration

For a new setup:

useradd --system --user-group --home-dir /var/lib/livekit --shell /usr/bin/nologin livekit
useradd --system --user-group --home-dir /var/lib/matrixrtc --shell /usr/bin/nologin matrixrtc
groupadd --system matrixrtc-registration
usermod -aG matrixrtc-registration synapse
usermod -aG matrixrtc-registration matrixrtc
install -d -o root -g livekit -m 0750 /etc/livekit
install -d -o root -g matrixrtc-registration -m 0750 /etc/matrix-rtc
install -d -o livekit -g livekit -m 0750 /var/lib/livekit
install -d -o matrixrtc -g matrixrtc -m 0750 /var/lib/matrixrtc

The shared group permits reading only the appservice registration. Do not add the RTC service to Synapse's general group, which could expose its database credentials.

9.4 Configure LiveKit and TURN

Generate a unique LiveKit API key and a strong independent API secret through the deployment's secret-management workflow. Use the same pair in LiveKit and the authorization service. These are backend secrets, never Element configuration.

Create /etc/livekit/livekit.yaml:

port: 7880
rtc:
  tcp_port: 7881
  udp_port: 7882
  use_external_ip: false
  node_ip: "203.0.113.10"

keys:
  "<REPLACE_WITH_LIVEKIT_API_KEY>": "<REPLACE_WITH_LIVEKIT_API_SECRET>"

room:
  auto_create: false

webhook:
  api_key: "<REPLACE_WITH_LIVEKIT_API_KEY>"
  urls:
    - "http://127.0.0.1:8080/sfu_webhook"

turn:
  enabled: true
  domain: "turn.example.com"
  udp_port: 3478
  tls_port: 5349
  cert_file: "/etc/livekit/turn.fullchain.pem"
  key_file: "/etc/livekit/turn.key.pem"
  relay_range_start: 30000
  relay_range_end: 30100
  allow_restricted_peer_cidrs:
    - "10.20.0.20/32"

Replace node_ip with the publicly reachable media address; the example disables automatic public-IP discovery and explicitly advertises the selected NAT address. Forward the listed ports without translating their port numbers. Do not also set port_range_start/port_range_end under rtc when using this UDP mux configuration.

The TURN allowlist is limited to the exact private SFU address for this layout; it is not permission to relay to all private networks. Adapt it only if the actual SFU candidates require another reviewed address. The relay-range size is an example capacity choice and may need expansion for simultaneous calls.

Install a valid turn.example.com certificate and its private key at the two configured LXC paths, readable by livekit but not world-readable. Transfer and renew these through the existing certificate-management system; do not copy the front proxy's entire private certificate store into the LXC. LiveKit terminates TURN/TLS itself in this example. Restart it after certificate renewal.

chown root:livekit /etc/livekit/livekit.yaml /etc/livekit/turn.fullchain.pem /etc/livekit/turn.key.pem
chmod 0640 /etc/livekit/livekit.yaml /etc/livekit/turn.fullchain.pem /etc/livekit/turn.key.pem

The webhook is signed using the configured API key/secret and stays on loopback. Disabling automatic room creation leaves authorization-controlled creation to the RTC service. Review the selected release's LiveKit configuration reference for changes to these options.

9.5 Configure the MatrixRTC authorization service

Create /etc/matrix-rtc/livekit-key and /etc/matrix-rtc/livekit-secret, each containing the corresponding value from LiveKit. Store them as root:matrixrtc, mode 0640. Use a private editor/secret manager; do not include real values in shell history or repository files.

Create /etc/matrix-rtc/registration.yaml, replacing the two token placeholders with two distinct strong random tokens:

id: "matrixrtc-livekit"
as_token: "<REPLACE_WITH_APPSERVICE_TOKEN>"
hs_token: "<REPLACE_WITH_HOMESERVER_TOKEN>"
sender_localpart: "_matrixrtc_service"
namespaces:
  users:
    - exclusive: false
      regex: ".*"
url: null
io.element.msc4502.scopes:
  - "urn:matrix:client:io.element.msc4502:rooms:is_joined"
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://127.0.0.1:8080"

The namespace and scope let this appservice inspect local room membership; registration tokens therefore require privileged custody. The null event URL avoids subscribing it to room-event traffic.

Create /etc/matrix-rtc/service.env:

LIVEKIT_URL=wss://rtc.example.com/livekit/sfu
LIVEKIT_KEY_FROM_FILE=/etc/matrix-rtc/livekit-key
LIVEKIT_SECRET_FROM_FILE=/etc/matrix-rtc/livekit-secret
LIVEKIT_JWT_BIND=127.0.0.1:8080
LIVEKIT_FULL_ACCESS_HOMESERVERS=example.com
LIVEKIT_HS_SERVER_NAME=example.com
LIVEKIT_AS_REGISTRATION_FILE=/etc/matrix-rtc/registration.yaml
LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=30
LIVEKIT_LOG_LEVEL=info

The full-access value is the permanent Matrix server name, not the API hostname. It restricts creation through the legacy interface; appservice endpoints use their homeserver authorization instead. Do not use * or disable TLS verification.

chown root:matrixrtc-registration /etc/matrix-rtc/registration.yaml
chmod 0640 /etc/matrix-rtc/registration.yaml
chown root:matrixrtc /etc/matrix-rtc/livekit-key /etc/matrix-rtc/livekit-secret
chmod 0640 /etc/matrix-rtc/livekit-key /etc/matrix-rtc/livekit-secret
chown root:root /etc/matrix-rtc/service.env
chmod 0600 /etc/matrix-rtc/service.env

Systemd reads the environment file before dropping privileges. The service reads the key and registration files through its restricted groups. Without a configured Redis-compatible store, authorization-service pending leave state is in memory and is lost on restart. For persistent delegated-leave handling, configure a dedicated authenticated Redis-compatible endpoint with LIVEKIT_REDIS_URL using the selected release's persistence instructions; do not expose that endpoint publicly or silently adopt Odoo's existing cache instance.

Configuration source: lk-jwt-service v0.8.0.

9.6 Add systemd units

Create /etc/systemd/system/livekit.service:

[Unit]
Description=LiveKit MatrixRTC SFU
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=livekit
Group=livekit
WorkingDirectory=/var/lib/livekit
ExecStart=/usr/local/bin/livekit-server --config /etc/livekit/livekit.yaml
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/livekit
UMask=0077
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Create /etc/systemd/system/matrixrtc.service:

[Unit]
Description=MatrixRTC LiveKit Authorization Service
Wants=network-online.target
After=network-online.target livekit.service

[Service]
Type=simple
User=matrixrtc
Group=matrixrtc
SupplementaryGroups=matrixrtc-registration
WorkingDirectory=/var/lib/matrixrtc
EnvironmentFile=/etc/matrix-rtc/service.env
ExecStart=/usr/local/bin/lk-jwt-service
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/matrixrtc
UMask=0077

[Install]
WantedBy=multi-user.target

These units run without root and without privileged network ports. Their filesystem restrictions must be compatible with the LXC's systemd configuration. Diagnose specific denial messages rather than making the LXC privileged or broadly removing service protections.

9.7 Add RTC routing on the LXC Nginx

The authorization service remains loopback-only. Add this separate server in the LXC's Nginx http configuration:

server {
    listen 10.20.0.20:8082;
    server_name rtc.example.com;

    allow 10.20.0.1;
    deny all;

    location ^~ /livekit/jwt/ {
        proxy_pass http://127.0.0.1:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $http_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
        access_log off;
    }

    location / {
        return 404;
    }
}

The trailing slash here intentionally strips the public /livekit/jwt/ prefix. This behavior is different from Synapse's signed-path proxying. The front proxy overwrites forwarded headers; only that trusted proxy can reach this internal port.

9.8 Add RTC routing on the front Nginx

In the front proxy's http context, define this map once if no equivalent exists:

map $http_upgrade $matrix_connection_upgrade {
    default upgrade;
    '' close;
}

Add the RTC TLS server:

server {
    listen 443 ssl;
    server_name rtc.example.com;

    ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location ^~ /livekit/sfu/ {
        proxy_pass http://10.20.0.20:7880/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $matrix_connection_upgrade;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        access_log off;
    }

    location ^~ /livekit/jwt/ {
        proxy_pass http://10.20.0.20:8082;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        access_log off;
    }

    location / {
        return 404;
    }
}

The SFU prefix is stripped before reaching LiveKit; requests such as /livekit/sfu/rtc reach its /rtc route. The JWT prefix remains intact until the internal Nginx strips it. Do not independently change these prefixes without updating the advertised URLs. WebSocket forwarding needs the explicit upgrade headers. Nginx WebSocket reference.

9.9 Configure Synapse RTC discovery and appservice wiring

Merge the following into the existing /etc/synapse/homeserver.yaml sections:

experimental_features:
  msc3266_enabled: true
  msc4143_enabled: true
  msc4222_enabled: true
  msc4502_enabled: true
  msc4512_enabled: true

max_event_delay_duration: 24h

rc_message:
  per_second: 0.5
  burst_count: 30

rc_delayed_event_mgmt:
  per_second: 1
  burst_count: 20

app_service_config_files:
  - "/etc/matrix-rtc/registration.yaml"

matrix_rtc:
  transports:
    - type: livekit
      url: "wss://rtc.example.com/livekit/sfu"
      livekit_service_url: "https://rtc.example.com/livekit/jwt"

Preserve other appservice registrations if present. The url and livekit_service_url are two interfaces to the same transport, not two separate conferences. Experimental features, endpoint schemas, and client support evolve; review the installed version's notes before changing them.

For clients still using well-known focus discovery, replace the client discovery response in the existing example.com HTTPS server with:

location = /.well-known/matrix/client {
    default_type application/json;
    add_header Access-Control-Allow-Origin "*" always;
    add_header Cache-Control "public, max-age=300" always;
    return 200 '{"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://rtc.example.com/livekit/jwt"}]}';
}

The appservice integration and webhook allow delegated leave handling. Reviewed Matrix room membership controls calling access; Odoo has no separate call approvals or invitation leases. Refer to Element Call hosting requirements when checking discovery and delayed-event behavior.

9.10 Route media and TURN ports

For the exact LiveKit configuration above, configure these routes through the upstream router/Proxmox networking and the LXC firewall:

Public destination Destination inside LXC Purpose
7882/UDP 10.20.0.20:7882/UDP Direct WebRTC media, UDP mux
7881/TCP 10.20.0.20:7881/TCP Direct ICE/TCP fallback
3478/UDP 10.20.0.20:3478/UDP Embedded TURN/UDP
5349/TCP 10.20.0.20:5349/TCP Embedded TURN/TLS
30000–30100/UDP Same ports on 10.20.0.20 Configured TURN relay range

Use destination-NAT port forwards if the LXC has only a private address. If the public address belongs to the Proxmox host, that host needs the corresponding forwarding/NAT rules as well as firewall permission. Preserve existing bridge, management, and Odoo rules; do not replace the Proxmox firewall with a generic internet example. Ensure return routing and LAN hairpin access work.

Restrict private HTTP ports 8008, 8081, 8082, and 7880 to the front proxy and necessary local connections. Keep PostgreSQL 5432 and authorization-service 8080 off the public network. TURN/media ports need actual client reachability.

Port 5349 works only on networks that permit it. To support networks allowing only TCP 443, give TURN/TLS a separate public IP on port 443, or deliberately configure an L4/SNI routing arrangement. Ordinary Nginx HTTP proxy_pass cannot proxy TURN. Do not bind LiveKit's TURN listener to a port already owned by the front HTTPS proxy. If changing the public TURN port, update the listener and advertised configuration coherently.

See LiveKit ports and firewall.

9.11 Start and commission conferencing

After completing certificate, proxy, and firewall configuration:

  1. Inside the LXC, run nginx -t and reload its Nginx.
  2. On the front proxy, run nginx -t and reload its Nginx.
  3. Inside the LXC, start the new native services and restart Synapse to load the registration and updated group membership:
systemctl daemon-reload
systemctl enable --now livekit.service matrixrtc.service
systemctl restart synapse.service
systemctl status livekit.service matrixrtc.service synapse.service
  1. Check Matrix discovery again from both outside and inside the LXC. Confirm the authorization service can reach the public Matrix and SFU URLs with valid TLS; local health alone does not prove this.
  2. In Element, use two separate user sessions/devices to join an encrypted room call. Check audio, video, screen sharing, mute, leave, and reconnect.
  3. Repeat from a different network, such as mobile data. A same-LAN call alone does not exercise public NAT and TURN connectivity.
  4. Check that participants disappearing after an interrupted connection do not leave permanently stuck call membership.

The Arch Element package inspected for this guide includes widgets/element-call/. For calls inside Element, use its packaged widget; there is no need to deploy an unrelated standalone guest-call frontend. If its version cannot use the selected transport, upgrade the client through a maintained release path and recheck compatibility instead of inventing unsupported Labs flags.

10. Connect the Odoo addon

Server deployment does not automatically create Odoo federation identities or grant users company access. Configure the installed addon through its reviewed workflows; see the module README.

10.1 Configure the target and company connection

  1. Create the verified Matrix target under Federation > Matrix.
  2. Set Server Name to example.com, Homeserver URL to https://matrix.example.com, and Element URL to https://element.example.com.
  3. Select the reviewed instance and Directory Owner company.
  4. For the packaged same-origin Element Call widget, leave Element Call Origin empty. Set it only if the actual calling UI is hosted on another HTTPS origin; do not put https://rtc.example.com there merely because it hosts the SFU.
  5. In that company's Settings > Matrix, enable integration, select the target, and enter the full dedicated integration user ID and its personal Matrix client access token. This account manages rooms and memberships; an ordinary person's session must not be reused for it.
  6. Enter a separate administrator token only for account administration. Retrieve tokens through a trusted Matrix client/login workflow and store them directly in the protected settings; do not put them in this guide, logs, jobs, or URLs.
  7. If split DNS resolves the HTTPS API to 10.20.0.1, enter that exact address in Approved Matrix IP Addresses. Approve the resolved HTTPS destination, not the LXC's plain-HTTP upstream address. TLS hostname verification remains mandatory. Include every intended resolved address if using dual-stack DNS.
  8. Save and choose Test Saved Connection.

10.2 Configure shared login and provisioning deliberately

The current connector provisions permanent user IDs derived from verified person UUIDs and binds them to an existing Synapse OIDC provider. It does not automatically adopt manually named accounts or configure Synapse's login provider.

To provision people through the addon:

  1. Configure the actual OIDC provider in Synapse and verify its issuer, client registration, and subject mapping. The subject must match the reviewed person UUID used by the federation provider.
  2. If using pmis_federation_provider, register the Matrix login client with the exact callback https://matrix.example.com/_synapse/client/oidc/callback and the target's reviewed instance. Follow the existing shared-login contract for provider/client setup and key custody.
  3. Enter the actual existing Synapse OIDC Provider ID in company settings. Determine the identifier used by Synapse for external account bindings; do not assume it equals a display label or arbitrarily invent it.
  4. Create and provision the verified Matrix person identity. Then sign into Element through the configured provider as that same reviewed person.
  5. Provision organization Spaces/rooms and separately approve each membership. Use Open in Odoo or Matrix Client after native mappings and provisioning are current.

Consult Synapse OIDC configuration for the server-side provider parameters. This deployment guide intentionally does not guess an issuer URL or client secret from an Odoo hostname.

10.3 Understand the current integration boundary

The embedded client retains its own Matrix session and encryption storage. Odoo cannot inspect the cross-origin Element account, and loading the iframe does not automatically log that account in. Verify the displayed identity and complete authentication/recovery inside Element.

The addon checks Odoo identity/company scope periodically and unloads the iframe when checks fail. This does not revoke the independent Matrix session or enforce an SFU-side admission lease. Element and the MatrixRTC deployment own call admission, moderation, and disconnection. Matrix room withdrawal does not guarantee immediate removal of an already connected SFU participant. The encrypted Discuss relay uses a dedicated nio device, independently of Element and LiveKit. Configure its token, verified device fingerprints, and protected persistent key store through the module's bridge setup; installing dependencies alone does not configure it.

11. Operational checks

Perform these checks before making the deployment available to ordinary users:

Check Expected result
Existing Odoo login and application access Continue working after host maintenance
PostgreSQL Existing Odoo databases unchanged; dedicated Synapse role/database
Public Matrix API Valid TLS; versions response through front Nginx
Identity-domain discovery Correct API hostname and federation delegation
Element configuration Intended homeserver; no embedded secrets
Element direct browser login Two users can exchange encrypted text
Odoo iframe Loads with allowed framing/media headers; no conflicting X-Frame-Options
Synapse admin API Denied from unapproved public sources
Conferencing Works across different networks, with TURN fallback where required
Interrupted calls Membership converges after connection loss/reconnect
Addon connection Saved connection check succeeds for the intended company
Recovery Verified device/recovery workflow restores encrypted history

Keep logs and credentials local to the administrator. Redact tokens, usernames, message content, authorization codes, and private infrastructure details before sharing diagnostics. Do not treat an HTTP 200 response from a health endpoint as proof of working encrypted chat or WebRTC media.

12. Updates, backups, and recovery

12.1 Native Arch updates

Review relevant Arch news and Synapse/Element release notes, make backups, then perform a planned full system upgrade inside the LXC:

pacman -Syu

Arch does not support partial system upgrades. Review .pacnew files and merge changes without discarding local configuration. Recheck the Odoo interpreter and PostgreSQL version as part of this shared-host maintenance.

After compatible package/configuration updates, restart Synapse and reload Nginx. Element has no separate process to restart; browser reloads fetch the new assets and public configuration. The conservative cache policy above makes those files revalidate. Odoo does not install, update, or pin the separately hosted Element.

12.2 RTC binary updates

Download and verify explicit maintained releases. Install replacement binaries through the same operator-controlled process and restart the corresponding services. Review RTC endpoint changes together with Synapse and Element compatibility. Retain the previous binaries and version inventory until the new deployment is accepted; do not assume a schema-changing upgrade is reversible by binary rollback.

12.3 PostgreSQL major upgrades

A PostgreSQL major upgrade affects the shared cluster, including Odoo and Synapse. Plan it as one coordinated database maintenance operation using the deployment's approved PostgreSQL upgrade procedure. Stop dependent application services as required, preserve all databases, and validate both applications afterward. Do not point new PostgreSQL binaries at an old data directory or run initdb over it. See Arch PostgreSQL.

12.4 Backups

Include the following in protected backups:

  • The dedicated Synapse PostgreSQL database and the required role definitions.
  • Synapse configuration, permanent signing key, and media store.
  • Element's separately maintained configuration and the deployed version inventory.
  • Front/LXC Nginx configuration, certificate-management configuration, and the exact networking/NAT rules.
  • LiveKit configuration, secrets, authorization-service registration, and any configured persistent authorization-service state store.
  • Existing Odoo database/filestore backups through their existing procedure.

Use PostgreSQL's supported backup tools. For a consistent Synapse database/media backup, pause Synapse writes for the chosen backup window or use a documented coordinated backup method. LXC snapshots and database dumps need a coherent restore plan; test that plan on an isolated target.

Restore the same Matrix server name and signing key. Do not bring an old cloned server online beside its original with the same identity and independent history. Server backups do not replace users' end-to-end encryption recovery material. Odoo's Matrix credentials are also database-bound; copied Odoo databases require explicit administrator reconfiguration before connector jobs can execute.

13. Troubleshooting

Element returns 502

From the front proxy, request http://10.20.0.20:8081/ with the Element Host header. Check the LXC address, internal Nginx listener, allowed proxy source, and firewall. Do not point the Element vhost at Synapse port 8008; Synapse does not serve the Element application.

Element loads directly but not inside Odoo

Inspect the browser console and final Element response headers. Check frame-ancestors, Odoo's frame-src, and any inherited X-Frame-Options. Inspect both Nginx layers. If third-party storage/authentication restrictions interfere, use the view's Open Element link to authenticate in a top-level tab and assess the selected browser's behavior. Do not disable browser security globally to make the embedded client work.

Device recovery fails

Verify device cross-signing, key backup, and recovery setup inside Element. Odoo's recovery reminder records acknowledgment only; it does not hold the key or create a usable backup. Signing into an account alone does not establish access to its historical encrypted messages.

Synapse reports database or locale errors

Check the dedicated connection, HBA rule order, password verifier, UTF-8 encoding, and C collation/ctype. Fix only the Matrix database configuration. Do not change Odoo's database locale or enable an unsafe-locale bypass as a shortcut.

Calls have no transport or authorization fails

Check homeserver transport discovery, legacy well-known metadata where used, selected client support, and the configured RTC URLs. Check matching LiveKit API secrets and protected appservice tokens without printing them. Inspect the authorization service's ability to reach the public homeserver and SFU names.

Call connects but has no audio/video

Check browser media permissions first, then ICE candidates, public media IP, UDP/TCP forwards, TURN certificate, and TURN relay connectivity. Test from an external network. A working HTTPS/WebSocket handshake does not prove that media packets can reach LiveKit. For embedded calls, check both the Odoo parent policy and Element's policy.

Call remains visible after a participant disappears

Check delayed-event support, appservice registration, signed webhook delivery, and authorization-service restart/state behavior. Confirm all components use compatible endpoint versions rather than combining unrelated tutorial snippets.

Odoo's saved connection test fails

Check the exact company/target, integration token owner, TLS certificate, DNS, and approved resolved IP addresses. On this split-DNS layout, the approved destination is the front proxy. Check the admin-source allowlist separately when account provisioning fails but client API health succeeds.

14. References