Skip to content
Hogin Hogin
Go back

Self-hosted Matrix + Element: a messenger that's actually yours

11 мин чтения

Telegram, WhatsApp, Signal — they all share one flaw: your account, your messages and the rules of the game live on someone else’s server. Matrix changes the design itself: it’s an open protocol where you run your own server and talk to anyone, much like email. Let’s see why that beats the usual messengers, and how to stand up Matrix with the Element web client in Docker over one evening.

Table of contents

Open Table of contents

What we usually pick from

Almost every popular messenger is built the same way — centralized. Telegram keeps your chats (except secret ones) on its servers, unencrypted from its own point of view. WhatsApp encrypts content end-to-end, but it belongs to Meta, is tied to a phone number and harvests metadata: who messaged whom and when. Signal is a gold standard for crypto, but it’s still one central server with mandatory phone-number binding. Slack and Discord are handy for teams, but they’re closed SaaS: your data, history and access are entirely under their control.

What unites them is that you’re a guest in someone else’s house. An account can be banned, a service can leave your country, the pricing can change — and there’s no way to take your chat history and move it elsewhere.

What “self-hosted” and “federated” mean

Matrix is not an app but an open protocol for messaging (the way HTTP is the protocol of the web). A server that implements it is called a homeserver; the most popular implementation is Synapse. matrix.org is just one server among thousands — not a “main” one.

The key word is federation. The best analogy is email: you have an address @your-domain, you run your own mail server, yet you exchange messages with anyone on gmail, on corporate domains and so on. Matrix works the same way: your ID looks like @you:your-domain.com, the server is yours, but you share rooms with people on matrix.org, mozilla.org and any other server.

A centralized messenger vs Matrix federation

The core problem with centralized messengers

When a service is centralized, you have neither ownership nor guarantees. It comes down to a few very practical things:

Self-hosted Matrix removes each of these: control, data and identifier all stay on your side.

What Matrix changes

Where messages and keys live: the messenger's cloud vs your homeserver with E2EE

Bridges deserve their own note — they’re what defuses the main objection, “but all my contacts are on Telegram.” Stand up a bridge (e.g. mautrix-telegram) and your Telegram chats show up in Element as ordinary rooms. One client, every network at once.

Matrix bridges: one client for Telegram, WhatsApp, Signal, Discord and IRC

A one-table comparison

Matrix (self-hosted)TelegramSignalSlack / Discord
Who controls the serveryouthe companythe companythe company
Where data is storedwith youwith themwith themwith them
E2EEyes, by default”secret” chats onlyyesno
Phone number requirednoyesyesemail
Federationyesnonono
Bridges to other networksyesnonono
Open sourceyespartiallyyesno
Costyour serverfreefreesubscription

The price of freedom here is exactly one thing: you have to stand the server up and maintain it yourself. Everything else is yours.

Clients and ecosystem maturity

It’s worth knowing that Matrix is not a niche experiment. The protocol is backed by the non-profit Matrix.org Foundation, the standard is open, and it’s implemented by dozens of independent projects. Matrix was adopted where the cost of failure is high: internal communications of the French government (the Tchap project), the German armed forces and healthcare, a number of universities. If state institutions trust the protocol, it’s more than mature enough for a personal server.

And because the client is decoupled from the server, you’re not locked into one app:

They all talk to the same homeserver of yours — you can use different ones on different devices at once.

What you need to run your own server

A minimal production set: a domain, a small VPS (2 GB RAM is enough), a reverse proxy with TLS, and three containers — Synapse, PostgreSQL and Element. Step by step.

1. Generate the Synapse config. This one-off run creates homeserver.yaml and keys in ./synapse-data:

docker run -it --rm \
  -v $(pwd)/synapse-data:/data \
  -e SYNAPSE_SERVER_NAME=your-domain.com \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse:latest generate

Note: SYNAPSE_SERVER_NAME is the part after : in your ID (@you:your-domain.com), not the host the server physically runs on. The two can be split via .well-known (see below).

2. Switch Synapse to PostgreSQL. By default it generates SQLite, which is no good for production. Open synapse-data/homeserver.yaml and replace the database block:

database:
  name: psycopg2
  args:
    user: synapse
    password: change-me-please
    database: synapse
    host: postgres
    cp_min: 5
    cp_max: 10

3. Describe docker-compose.yml — three services on one network:

services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: synapse
      POSTGRES_PASSWORD: change-me-please
      POSTGRES_DB: synapse
      # Synapse needs exactly this locale:
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
    volumes:
      - ./postgres:/var/lib/postgresql/data

  synapse:
    image: matrixdotorg/synapse:latest
    restart: unless-stopped
    depends_on: [postgres]
    volumes:
      - ./synapse-data:/data
    ports:
      - "8008:8008"   # client HTTP — hide it behind the reverse proxy

  element:
    image: vectorim/element-web:latest
    restart: unless-stopped
    volumes:
      - ./element-config.json:/app/config.json:ro
    ports:
      - "8080:80"

4. Configure the Element web client. A minimal element-config.json points the client at your homeserver:

{
  "default_server_config": {
    "m.homeserver": {
      "base_url": "https://matrix.your-domain.com",
      "server_name": "your-domain.com"
    }
  },
  "brand": "My Chat"
}

5. Provide TLS via a reverse proxy. Caddy fetches Let’s Encrypt certificates automatically — two blocks are enough (a subdomain for the API and one for the web client):

matrix.your-domain.com {
    reverse_proxy localhost:8008
}

chat.your-domain.com {
    reverse_proxy localhost:8080
}

6. Enable .well-known delegation. To keep IDs pretty (@you:your-domain.com) while the server lives on the matrix. subdomain, serve two files from the root domain. At https://your-domain.com/.well-known/matrix/server:

{ "m.server": "matrix.your-domain.com:443" }

And at https://your-domain.com/.well-known/matrix/client (with an Access-Control-Allow-Origin: * header):

{ "m.homeserver": { "base_url": "https://matrix.your-domain.com" } }

7. Start it and create an administrator:

docker compose up -d

# create the first user (the -a flag = admin)
docker compose exec synapse \
  register_new_matrix_user -u admin -a -c /data/homeserver.yaml http://localhost:8008

Done: open https://chat.your-domain.com, log in as admin, and you have your own messenger.

A mini example: a Telegram bridge

The main objection to switching is “but all my contacts are on Telegram.” A bridge solves it: Telegram chats arrive in Element as ordinary rooms, and you can reply right from there. Let’s take mautrix-telegram — add a fourth container to the same docker-compose.yml:

  mautrix-telegram:
    image: dock.mau.dev/mautrix/telegram:latest
    restart: unless-stopped
    depends_on: [postgres, synapse]
    volumes:
      - ./mautrix-telegram:/data

The steps are short. The first run of the container creates config.yaml — fill in api_id and api_hash (from my.telegram.org) and the homeserver address. The second run generates registration.yaml, the file the bridge uses to introduce itself to Synapse. Wire it into homeserver.yaml:

app_service_config_files:
  - /data/telegram-registration.yaml

Restart Synapse, message the bot @telegrambot:your-domain.com with login, and your Telegram chats pull into Element. Bridges to WhatsApp, Signal and Discord install the same way — each is a separate appservice following the same scheme.

How to verify it works

First, confirm the client API answers from outside:

curl https://matrix.your-domain.com/_matrix/client/versions
# {"versions":["r0.6.1","v1.1", ... ]}

Then check that .well-known is served from the root domain:

curl https://your-domain.com/.well-known/matrix/server
# {"m.server":"matrix.your-domain.com:443"}

And the main test — federation. Open federationtester.matrix.org, enter your-domain.com and make sure every check is green. That means other Matrix servers can see yours and talk to it. After that, log in to Element, create a room and invite someone like @user:matrix.org — if messages flow, federation is up.

Pitfalls and maintenance

A server of your own isn’t “set it and forget it.” A few things to keep in mind:

None of this is hard, but it’s the maintenance cost a cloud messenger doesn’t have. Then again, neither is the dependence on someone else’s cloud.

Bottom line

Matrix isn’t “yet another messenger” — it’s a change of model: instead of renting space in someone’s cloud, you get your own node in a shared federated network. The price is a server you must stand up and maintain. In return: ownership of your data, end-to-end encryption by default, an identifier with no phone-number binding, and bridges that keep you in touch even with the people who stayed on Telegram and WhatsApp. For a team — or a family that cares about privacy — that trade is almost always worth it.


Share this post:

Next Post
Deploy Astro to Cloudflare Pages: Git Integration and GitHub CI/CD