Table of Contents

Setting up a Synapse + Element Matrix chat server


Firewall

The following ports have to be opened in your firewall:

80/tcp   # HTTP
443/tcp  # HTTPS


Domain setup

You will have to decide if you want Synapse to use your root domain or a subdomain, and on which subdomain you want to serve Element.

Examples are:

It doesn't really matter which you choose, but configuration will depend on it slightly. In this case, I decided to use quietlife.nl as the homeserver domain and chat.quietlife.nl to serve Element.

Note that Element is merely a web application talking to Synapse. It is not required to build a Matrix server. You can also use Synapse without Element, using locally installed Matrix client applications instead. So you are free to leave out Element entirely if you wish.



Docker

Unfortunately, Synapse and Element are not packaged for Debian, so you can't simply apt install them. For now, Docker is likely the easiest way to deploy this stack.

It is probably a good idea to first read Setting up IPv6 NAT in Docker in order to have IPv6 working for these containers.


Compose file

First create /var/opt/matrix:

sudo mkdir /var/opt/matrix

Then create docker-compose.yml there:

/var/opt/matrix/docker-compose.yml
services:
  postgres:
    image: postgres:17
    restart: always
    environment:
      POSTGRES_USER: "synapse"
      POSTGRES_PASSWORD: "5yN@Ps3"
      POSTGRES_DB: "synapse"
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "synapse"]
    networks:
      - network
    volumes:
      - /var/opt/matrix/postgres:/var/lib/postgresql/data

  synapse:
    image: matrixdotorg/synapse:latest
    restart: always
    depends_on:
      - postgres
    networks:
      - network
    ports:
      - "127.0.0.1:8008:8008"
      - "[::1]:8008:8008"
    volumes:
      - /var/opt/matrix/synapse:/data

Make sure to change POSTGRES_PASSWORD to something you generated yourself.


If you also want to serve Element, add this:

/var/opt/matrix/docker-compose.yml
  element:
    image: vectorim/element-web:latest
    restart: always
    networks:
      - network
    ports:
      - "127.0.0.1:8088:80"
      - "[::1]:8088:80"
    volumes:
      - /var/opt/matrix/element/config.json:/app/config.json


If you have an IPv6-enabled Docker setup, add this:

/var/opt/matrix/docker-compose.yml
networks:
  network:
    driver: bridge
    enable_ipv6: true
    ipam:
      driver: default
      config:
        - subnet: 2001:db8:db8:8008::/64


PostgreSQL

Start PostgreSQL:

cd /var/opt/matrix
sudo docker compose up -d postgres

The database created by PostgreSQL's init script upsets Synapse because of the collation.

So after starting PostgreSQL, drop the database and recreate it manually:

sudo docker exec -it matrix-postgres-1 dropdb -U synapse synapse
sudo docker exec -it matrix-postgres-1 createdb -U synapse -E UTF8 -l C -T template0 synapse


Synapse

For the Synapse container, create its data directory:

sudo mkdir /var/opt/matrix/synapse

Create its configuration file there:

/var/opt/matrix/synapse/homeserver.yaml
server_name: "quietlife.nl"
report_stats: false
media_store_path: "/data/media_store"
signing_key_path: "/data/signing.key"
form_secret: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
macaroon_secret_key: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
registration_shared_secret: "cccccccccccccccccccccccccccccccccccccccccccccccccc"

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

database:
  name: psycopg2
  args:
    host: "postgres"
    port: 5432
    user: "synapse"
    password: "5yN@Ps3"
    database: "synapse"

trusted_key_servers:
  - server_name: "matrix.org"

Make sure you change server_name and password under database.


For form_secret, macaroon_secret_key and registration_shared_secret you have to generate random password strings.
For example:

apg -m50 -n3


If you want to add e-mail support to Synapse, append this to homeserver.yaml:

/var/opt/matrix/synapse/homeserver.yaml
email:
  smtp_host: "quietlife.nl"
  smtp_port: 587
  smtp_user: "synapse@quietlife.nl"
  smtp_pass: "PASSWORD"
  require_transport_security: true
  enable_notifs: true
  notif_from: "Quiet Chat <synapse@quietlife.nl>"
  app_name: "Quiet Chat"
  client_base_url: "https://chat.quietlife.nl"

You will, of course, have to change the majority of fields to match your own mail setup and whether or not you also host Element.


Set the permissions to 991:991:

sudo chown -R 991:991 /var/opt/matrix/synapse

And start Synapse:

cd /var/opt/matrix
sudo docker compose up -d synapse


Element

(Feel free to skip this part if you do not want to host Element. You can still use the Element desktop application or other Matrix clients if you don't.)


For the Element container, create its data directory:

sudo mkdir /var/opt/matrix/element

Create its configuration file there:

/var/opt/matrix/element/config.json
{
  "default_server_config": {
    "m.homeserver": {
      "base_url": "https://quietlife.nl"
    }
  },
  "disable_custom_urls": true,
  "disable_3pid_login": true,
  "disable_guests": true,
  "brand": "Quiet Chat",
  "branding": {
    "auth_footer_links": [],
    "welcome_background_url": "https://quietlife.nl/resources/images/background.jpg"
  },
  "embedded_pages": {
    "login_for_welcome": true
  },
  "setting_defaults": {
    "UIFeature.registration": false,
    "UIFeature.passwordReset": false
  }
}

And start Element:

cd /var/opt/matrix
sudo docker compose up -d element


nginx

A reverse proxy will be needed to do the TLS termination and to forward incoming traffic to the Docker containers.

For that, install nginx:

sudo apt install nginx certbot python3-certbot-nginx


SSL certificate creation

Generate certificates with certbot for your homeserver domain and - if you want to host Element - for your Element domain:

sudo certbot certonly -d quietlife.nl
sudo certbot certonly -d chat.quietlife.nl

In this case, I decided to use separate certificates for both, but using a SAN for chat. would also work.
(Skip this step for the domains for which you already have certificates.)


Homeserver domain

For your homeserver domain, it's important to do two things:

There is no problem if you host another site on your root domain, as long as these three subpaths can be used for Matrix.

You have to define where clients and federating servers should connect to using two simple JSON arrays. It is probably easiest to put that in the nginx configuration rather than separate files in your web root.

/etc/nginx/sites-available/quietlife.nl
	location /.well-known/matrix/client {
	 add_header Access-Control-Allow-Origin * always;
	 add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
	 add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
	 add_header Access-Control-Allow-Credentials true always;
	 default_type application/json;
	 return 200 '{"m.homeserver":{"base_url":"https://quietlife.nl"}}';
	}
 
	location /.well-known/matrix/server {
	 add_header Access-Control-Allow-Origin * always;
	 add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
	 add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
	 add_header Access-Control-Allow-Credentials true always;
	 default_type application/json;
	 return 200 '{"m.server":"quietlife.nl:443"}';
	}

Additionally, you'll want to route Matrix traffic to Synapse rather than to your regular website:

/etc/nginx/sites-available/quietlife.nl
	location ~ ^(/_matrix|/_synapse/client) {
	 client_max_body_size 50M;
	 proxy_http_version 1.1;
	 proxy_pass http://localhost:8008;
	 proxy_set_header Host $host;
	 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	 proxy_set_header X-Forwarded-Proto $scheme;
	 proxy_set_header X-Real-IP $remote_addr;
	}


Full root domain configuration

An example of a full configuration file, with a regular website hosted at https://quietlife.nl/ would look like this:

/etc/nginx/sites-available/quietlife.nl
server {
	listen 80 default_server;
	listen [::]:80 default_server;
 
	listen 443 ssl default_server;
	listen [::]:443 ssl default_server;
	http2 on;
 
	server_name quietlife.nl www.quietlife.nl;
 
	ssl_certificate /etc/letsencrypt/live/quietlife.nl/fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/quietlife.nl/privkey.pem;
 
	access_log /var/log/nginx/quietlife.nl-access.log;
	error_log /var/log/nginx/quietlife.nl-error.log;
 
	root /var/www/quietlife.nl;
	index index.html index.php;
 
	if ($scheme != "https") {
	 return 301 https://$host$request_uri;
	}
 
	location ~ \.php$ {
	 include snippets/fastcgi-php.conf;
	 fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
	}
 
	location /.well-known/matrix/client {
	 add_header Access-Control-Allow-Origin * always;
	 add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
	 add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
	 add_header Access-Control-Allow-Credentials true always;
	 default_type application/json;
	 return 200 '{"m.homeserver":{"base_url":"https://quietlife.nl"}}';
	}
 
	location /.well-known/matrix/server {
	 add_header Access-Control-Allow-Origin * always;
	 add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
	 add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
	 add_header Access-Control-Allow-Credentials true always;
	 default_type application/json;
	 return 200 '{"m.server":"quietlife.nl:443"}';
	}
 
	location ~ ^(/_matrix|/_synapse/client) {
	 client_max_body_size 50M;
	 proxy_http_version 1.1;
	 proxy_pass http://localhost:8008;
	 proxy_set_header Host $host;
	 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	 proxy_set_header X-Forwarded-Proto $scheme;
	 proxy_set_header X-Real-IP $remote_addr;
	}
}

Make sure the site is enabled:

cd /etc/nginx/sites-enabled
sudo ln -s ../sites-available/quietlife.nl .
sudo systemctl reload nginx.service


Element domain

If you want to host Element, you'll most likely want that on a subdomain, such as chat.quietlife.nl.
Of course, skip this step if you don't run the Element container.

/etc/nginx/sites-available/chat.quietlife.nl
server {
	listen 80;
	listen [::]:80;
 
	listen 443 ssl;
	listen [::]:443 ssl;
	http2 on;
 
	server_name chat.quietlife.nl;
 
	access_log /var/log/nginx/chat.quietlife.nl-access.log;
	error_log /var/log/nginx/chat.quietlife.nl-error.log;
 
	ssl_certificate /etc/letsencrypt/live/chat.quietlife.nl/fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/chat.quietlife.nl/privkey.pem;
 
	if ($scheme != "https") {
	 return 301 https://$host$request_uri;
	}
 
	location / {
	 add_header X-Content-Type-Options nosniff;
	 add_header X-Frame-Options SAMEORIGIN;
	 add_header X-XSS-Protection "1; mode=block";
	 proxy_pass http://localhost:8088;
	 proxy_set_header Host $host;
	 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	 proxy_set_header X-Forwarded-Proto $scheme;
	 proxy_set_header X-Real-IP $remote_addr;
	}
}

Make sure the site is enabled:

cd /etc/nginx/sites-enabled
sudo ln -s ../sites-available/chat.quietlife.nl .
sudo systemctl reload nginx.service


Combined Synapse and Element domain

If you chose to use the same domain for your homeserver and for Element, you can use a single configuration file. In this example, everything would be hosted on chat.quietlife.nl:

/etc/nginx/sites-available/chat.quietlife.nl
server {
	listen 80;
	listen [::]:80;
 
	listen 443 ssl;
	listen [::]:443 ssl;
	http2 on;
 
	server_name chat.quietlife.nl;
 
	access_log /var/log/nginx/chat.quietlife.nl-access.log;
	error_log /var/log/nginx/chat.quietlife.nl-error.log;
 
	ssl_certificate /etc/letsencrypt/live/chat.quietlife.nl/fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/chat.quietlife.nl/privkey.pem;
 
	if ($scheme != "https") {
	 return 301 https://$host$request_uri;
	}
 
	location /.well-known/matrix/client {
	 add_header Access-Control-Allow-Origin * always;
	 add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
	 add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
	 add_header Access-Control-Allow-Credentials true always;
	 default_type application/json;
	 return 200 '{"m.homeserver":{"base_url":"https://chat.quietlife.nl"}}';
	}
 
	location /.well-known/matrix/server {
	 add_header Access-Control-Allow-Origin * always;
	 add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
	 add_header Access-Control-Allow-Headers "Authorization,Content-Type" always;
	 add_header Access-Control-Allow-Credentials true always;
	 default_type application/json;
	 return 200 '{"m.server":"chat.quietlife.nl:443"}';
	}
 
	location ~ ^(/_matrix|/_synapse/client) {
	 client_max_body_size 50M;
	 proxy_http_version 1.1;
	 proxy_pass http://localhost:8008;
	 proxy_set_header Host $host;
	 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	 proxy_set_header X-Forwarded-Proto $scheme;
	 proxy_set_header X-Real-IP $remote_addr;
	}
 
	location / {
	 add_header X-Content-Type-Options nosniff;
	 add_header X-Frame-Options SAMEORIGIN;
	 add_header X-XSS-Protection "1; mode=block";
	 proxy_pass http://localhost:8088;
	 proxy_set_header Host $host;
	 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	 proxy_set_header X-Forwarded-Proto $scheme;
	 proxy_set_header X-Real-IP $remote_addr;
	}
}

Make sure the site is enabled:

cd /etc/nginx/sites-enabled
sudo ln -s ../sites-available/chat.quietlife.nl .
sudo systemctl reload nginx.service


Apache2 equivalents

If you, for some reason, use Apache2 as a reverse proxy server, the configuration is of course completely different.

For the JSON files in /.well-known/:

/etc/apache2/sites-available/quietlife.nl.conf
    ProxyPass /.well-known/ !
    Alias /.well-known/matrix/client /var/www/quietlife.nl/.well-known/matrix/client.json
    Alias /.well-known/matrix/server /var/www/quietlife.nl/.well-known/matrix/server.json
 
    <Location "/.well-known/">
      Header always set Access-Control-Allow-Origin "*"
      Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
      Header always set Access-Control-Allow-Headers "Authorization,Content-Type"
      Header always set Access-Control-Allow-Credentials "true"
    </Location>

Then create those two JSON files:

/var/www/quietlife.nl/.well-known/matrix/client.json
{"m.homeserver":{"base_url":"https://quietlife.nl"}}
/var/www/quietlife.nl/.well-known/matrix/server.json
{"m.server":"quietlife.nl:443"}

For proxying Matrix traffic to Synapse:

/etc/apache2/sites-available/quietlife.nl.conf
    RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
    AllowEncodedSlashes NoDecode
    LimitRequestBody 52428800
    ProxyPreserveHost on
    ProxyPass /_matrix http://localhost:8008/_matrix nocanon
    ProxyPassReverse /_matrix http://localhost:8008/_matrix
    ProxyPass /_synapse/client http://localhost:8008/_synapse/client nocanon
    ProxyPassReverse /_synapse/client http://localhost:8008/_synapse/client

Make sure the site is enabled:

sudo a2ensite quietlife.nl.conf
sudo systemctl reload apache2.service


For Element:

/etc/apache2/sites-available/chat.quietlife.nl.conf
<VirtualHost *:80>
    ServerName chat.quietlife.nl
 
    RewriteEngine On
    RewriteCond %{HTTPS} off
    RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</VirtualHost>
 
<VirtualHost *:443>
    ServerName chat.quietlife.nl
 
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/chat.quietlife.nl/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/chat.quietlife.nl/privkey.pem
 
    CustomLog /var/log/apache2/chat.quietlife.nl-access.log combined
    ErrorLog /var/log/apache2/chat.quietlife.nl-error.log
 
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-XSS-Protection "1; mode=block"
 
    ProxyPass / http://localhost:8088/
    ProxyPassReverse / http://localhost:8088/
 
    RequestHeader set X-Forwarded-For %{REMOTE_ADDR}s
    RequestHeader set X-Forwarded-Proto https
    RequestHeader set Host %{HTTP_HOST}s
    RequestHeader set X-Real-IP %{REMOTE_ADDR}s
</VirtualHost>

Make sure the site is enabled:

sudo a2ensite chat.quietlife.nl.conf
sudo systemctl reload apache2.service


Database backups

It's probably a good idea to make regular backups of the Synapse database. You can use this script:

/usr/local/bin/backup-synapse.sh
#!/bin/bash
 
backup_directory="/var/backups/synapse_backups"
timestamp=$(date +"%Y-%m-%d_%H:%M")
 
# Create backup directory if not present
if [ ! -d $backup_directory ]; then
  mkdir -p $backup_directory;
fi
 
# Create a database dump and compress it
nice -n 19 docker exec matrix-postgres-1 pg_dump postgres://synapse@localhost:5432/synapse | nice -n 19 xz -T1 -1 > $backup_directory/synapse-postgres-$timestamp.sql.xz
 
# Delete backups older than one week
find $backup_directory -mtime +7 -exec rm {} \;
 
exit 0

Make it executable:

sudo chmod +x /usr/local/bin/backup-synapse.sh

And run it as a cronjob for the root user:

# Run hourly backups of Synapse
30 * * * * /usr/local/bin/backup-synapse.sh


Automatic updates

Because Synapse and Element run in Docker, you can't update them with unattended-upgrades.
You can use Watchtower to automate container updates, with this line in root's crontab:

# Check for container updates every day at 21:00
0 21 * * * /usr/bin/docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once --cleanup


Creating user accounts

The quickest way to create user accounts is to talk to Synapse directly on http://localhost:8008.

Within the container, there is a register_new_matrix_user command:

docker exec -it matrix-synapse-1 register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008


Eventually, you'll probably want to use synadm instead.

Unlike Synapse, this is packaged in Debian, and also talks to Synapse over http://localhost:8008, which is bound to the host.