You are currently viewing Setup Matrix Server Docker

Setup Matrix Server Docker

More work than expected, Setup Matrix Server Docker — With integration with LINE Messenger, other messengers are also possible.

Why Matrix ?!

Matrix Messenger is a standalone, encrypted system. Messages are stored on your own server (the one you use) as long as they are sent directly via Matrix.

If, on the other hand, messages are sent via bridges—such as those for LINE, WhatsApp, Telegram, Facebook Messenger, or Signal—the data remains on the respective companies’ servers. They are therefore subject to those companies’ control, censorship, and respective legal guidelines.

For example, EU regulations have global implications and result in restrictions on infrastructure outside the EU. Legal actions are already underway in the U.S., as these requirements restrict the freedom of speech of U.S. citizens and thus conflict with American laws (though the U.S. is used here merely as an example).

So if your entire family exchanges messages freely via your own Matrix server, you’ll enjoy complete privacy. Matrix acts as a universal messenger: You can integrate services like LINE or WhatsApp via bridges and use them directly within the Matrix Messenger. (Data protection for WhatsApp and similar apps, of course, remains the responsibility of the respective companies, but you can delete those apps from your device 😁)

Video: Setup Matrix Server Docker

Language: 🇩🇪|🇬🇧
☝️ Use YouTube subtitles for all languages.
☝️ Automatic AI translations take 7 to 14 days.

Note:
This tutorial covers only the installation of the Matrix server, a web interface, and an initial bridge to the LINE messenger.
Audio and video calls, two-factor authentication (2FA), backups, and additional bridges will be covered in subsequent videos. The primary purpose of this guide is to get the Matrix system up and running smoothly and ready for use. Expansions can be added at any time afterward.

Self-Hosted Matrix Server with LINE Integration — A Docker Setup for Real Life

I wanted to run my own Matrix homeserver, bundled with Element Web as a client and a bridge that automatically mirrors my LINE contacts into Matrix rooms. Sounded like a nice Sunday-afternoon project. It turned into considerably more. In this post I’ll show the finished, production-ready Docker setup — and, just as importantly, the complete list of mistakes I stumbled over, so you don’t have to repeat them. There were a few.

The setup runs with Docker Compose and is AMD64-compatible — every image used (Postgres, Synapse, Element Web, Coturn, as well as the Go/Alpine base images for the bridge build) is a multi-arch image. Docker automatically pulls the variant matching the host architecture; you don’t need to change anything in docker-compose.yml, whether your server is ARM64 or AMD64.

All secrets and settings (domain, passwords, TURN credentials) live centrally in one .env file — and actually flow automatically into the running configs from there.

Architecture

ComponentPurpose
PostgreSQLDatabase — two separate databases: one for Synapse, one for the bridge
SynapseMatrix homeserver
Element WebWeb client
CoturnSTUN/TURN server for voice/video calls
LINE bridge (beeper/line)Mirrors LINE accounts as Matrix rooms

All placeholders in this post (matrix.example.com, passwords, IPs) are anonymized — replace them with your own values.

Ports & protocols — what needs to be opened on your router/firewall

PortProtocolWhat forMust be reachable from outside?
443TCPReverse proxy/tunnel → Element Web + Synapse Client API (HTTPS)Yes, mandatory
8448TCPSynapse Federation API (only if other Matrix servers should federate with yours)Only for federation
3478UDP + TCPCoturn STUN/TURN — connection setup for callsYes, for voice/video calls
5349UDP + TCPCoturn STUN/TURN over TLSRecommended, for voice/video calls
49152–49172UDPCoturn relay port range — the actual media stream during callsYes, for voice/video calls

Internally (only between containers), ports 8008 (Synapse Client API) and 29322 (LINE bridge appservice) are also used — these must and should not be reachable from outside.

443 is enough for plain chatting. Without the Coturn ports (3478, 5349, 49152–49172), voice and video calls between users behind different NATs won’t work reliably.

A word on mount design

The biggest time sink was one inconspicuous piece of Docker behavior: if a file specified as a bind mount doesn’t yet exist on the host, Docker automatically creates an empty directory with that name. For files that containers generate themselves at runtime (homeserver.yaml, config.yaml, registration.yaml), this reliably leads to is a directory errors.

The solution: directory-to-directory mounts only for anything generated dynamically. setup.sh therefore creates all required directories (synapse/, postgres_data/, line-bridge/data/) itself, beforehand — before any container can touch them. The one exception is the static Element configuration, which gets copied into the image via its own Dockerfile.

docker-compose.yml

networks:
  matrix-net:
    driver: bridge
services:
  postgres:
    image: postgres:16-alpine
    container_name: matrix-postgres
    restart: unless-stopped
    networks:
      - matrix-net
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-synapse}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB:-synapse}
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
    volumes:
      - ./postgres_data:/var/lib/postgresql/data
      - ./postgres-init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-synapse}"]
      interval: 10s
      timeout: 5s
      retries: 5
  synapse:
    image: matrixdotorg/synapse:latest
    container_name: matrix-synapse
    restart: unless-stopped
    # Runs with your host UID/GID (from .env) — more on this below.
    user: "${HOST_UID}:${HOST_GID}"
    networks:
      - matrix-net
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      SYNAPSE_SERVER_NAME: ${MATRIX_DOMAIN}
      SYNAPSE_REPORT_STATS: "no"
    volumes:
      - ./synapse:/data
      - ./line-bridge/data:/line-bridge-data:ro
    ports:
      - "8008:8008"
      - "8448:8448"
  element:
    build:
      context: ./element
      dockerfile: Dockerfile
    container_name: matrix-element
    restart: unless-stopped
    networks:
      - matrix-net
    depends_on:
      - synapse
    ports:
      - "8080:80"
  coturn:
    build:
      context: ./coturn
      dockerfile: Dockerfile
    container_name: matrix-coturn
    restart: unless-stopped
    network_mode: host
    environment:
      PUBLIC_IP: ${PUBLIC_IP}
      TURN_USER: ${TURN_USER:-turnuser}
      TURN_PASSWORD: ${TURN_PASSWORD}
      MATRIX_DOMAIN: ${MATRIX_DOMAIN}
  line-bridge:
    build:
      context: ./line-bridge/matrix-line-messenger
      dockerfile: Dockerfile
    container_name: matrix-line-bridge
    restart: unless-stopped
    # Fixes a known quirk of the bridge image — more on this below.
    entrypoint:
      - /bin/sh
      - -c
      - |
        if [ -f /data/config ] && [ ! -f /data/config.yaml ]; then
          mv /data/config /data/config.yaml
          echo "[entrypoint-fix] /data/config -> /data/config.yaml renamed"
        fi
        exec /docker-run.sh
    networks:
      - matrix-net
    depends_on:
      - synapse
    volumes:
      - ./line-bridge/data:/data

Three different UID conflicts on Linux (not relevant under Docker Desktop for Mac)

This was the most stubborn class of bugs in the whole project — three different variants of the same underlying problem, each with its own symptom:

1. Directories Docker creates automatically belong to root, if they didn’t exist at the very first bind mount. Fix: setup.sh always creates synapse/, postgres_data/, line-bridge/data/ itself beforehand.

2. The permanently running Synapse container needs write access to ./synapse, e.g. to create media_store — otherwise PermissionError: [Errno 13] Permission denied: ‘/data/media_store’. Reason: Synapse runs by default with an image-internal UID that doesn’t match the owner of the host directory. Fix: user: “${HOST_UID}:${HOST_GID}” in docker-compose.yml, with HOST_UID/HOST_GID that setup.sh automatically (via id -u/id -g) adds to .env.

3. Files generated by the LINE bridge (config.yaml, registration.yaml) belong to a third internal UID — the bridge actually runs as root internally. This explains two symptoms: root can write anywhere without issue (which is why the bridge works despite restrictive directory permissions), but Synapse can no longer read the bridge files that are protected against it afterward (PermissionError when loading registration.yaml). Fix: fix-permissions.sh, a small helper that resets ownership back to your user after every generation step and makes files readable by other container UIDs.

fix-permissions.sh is purely idempotent (only sets owner/permissions, changes no content) and may be run at any time, as often as you like — not just during initial setup. Rule of thumb: whenever a container has just written something new to synapse/ or line-bridge/data/ and you (or another container) want to access it afterward.

A genuine inconsistency in the LINE bridge image itself

After the bridge’s first start, a restart loop ran with this message:

/data/config already exists, please remove it if you want to generate a new example
Didn’t find a config file.
Copied default config file to /data/config.yaml

Investigating the actual docker-run.sh of related bridge projects (same author, same script template) shows: the script checks for /data/config.yaml, but actually writes /data/config — an inconsistency in the original project, the log message is misleading and copied from an older sibling project. As long as config.yaml never exists, the script copies the template again on every restart.

The fix now sits in the container start itself (entrypoint override in docker-compose.yml, see above) — no manual step to forget anymore. A restart loop still remains as long as config.yaml is still the unedited template: the service runs with restart: unless-stopped, so it restarts automatically after every failure, until you stop it, patch it, and start it again (see the workflow further down).

The configuration files at a glance

.env — the central source of values:

MATRIX_DOMAIN=matrix.example.com
POSTGRES_USER=synapse
POSTGRES_PASSWORD=CHANGE_ME_STRONG_DB_PASSWORD
POSTGRES_DB=synapse
TURN_USER=turnuser
TURN_PASSWORD=CHANGE_ME_TURN_PASSWORD
PUBLIC_IP=YOUR_PUBLIC_IP
ADMIN_USER=admin
Do NOT enter HOST_UID/HOST_GID yourself — setup.sh adds these automatically.

postgres-init/init-line-bridge.sql — automatically creates a second database (CREATE DATABASE line_bridge;). The Postgres image automatically runs everything under /docker-entrypoint-initdb.d/ on the very first start — no script to call separately. The reason this second database is needed at all: Synapse and the bridge must not share a database. The bridge detects this on startup (the database contains foreign tables) and consistently refuses to run.

element/config.json is automatically generated from .env by generate-element-config.sh and baked into the image via COPY by element/Dockerfile — not mounted.

synapse/homeserver.yaml gets generated on first start:

docker run -it --rm \
  -u "$(id -u):$(id -g)" \
  -v "$(pwd)/synapse:/data" \
  -e SYNAPSE_SERVER_NAME=matrix.example.com \
  -e SYNAPSE_REPORT_STATS=no \
  matrixdotorg/synapse:latest generate

Right after that, apply-env.py homeserver automatically patches: the database (SQLite→Postgres), bridge registration, disabled self-registration, TURN credentials, and public_baseurl (see the discovery section below) — all with real values from .env.

A detail that cost time in an earlier, manual version: the generated database block names the key database: — older examples online call it dbname:. psycopg2 only understands database; if both are present at once, Synapse fails to start with you can’t specify both ‘database’ and ‘dbname’ arguments. apply-env.py replaces the entire block in one go, so this problem can no longer occur.

line-bridge/data/config.yaml gets generated by the bridge itself. After that: python3 apply-env.py line-bridge — automatically patches homeserver address/domain, its own database URI, permissions, and three particularly tricky values:

  • appservice.hostname defaults to 127.0.0.1 — the bridge process then only listens on its own loopback interface and is not reachable from other containers (Connection refused). Must be 0.0.0.0.
  • appservice.address (where Synapse reaches the bridge) must be the Docker service name (http://line-bridge:29322), not localhost.
  • encryption.allow defaults to false. Element and other modern Matrix clients create new direct messages encrypted by default, though — without this value, the very first contact with the bot fails with room is encrypted, but bridge isn’t configured to support encryption, and the message never arrives. Must be true.

line-bridge/data/registration.yaml gets generated automatically on the bridge’s second start. A detail that cost a lot of time: the generated namespace regex was broken (@line_\.+:matrix.example.com — a literal escaped dot instead of a wildcard, no anchors). As a result, not a single LINE contact matched, every message failed with Invalid user localpart for this application service. After generation, verify that it reads:

regex: ‘^@line_.*:matrix\.example\.com$’

This one value currently isn’t patched automatically, since it’s only created once and may vary slightly depending on the bridge version.

Server discovery (.well-known) — crucial for mobile apps

Even if Element Web and curl work perfectly, the mobile app can fail with a vague message like “can’t reach account provider”. Reason: many apps first query https://MATRIX_DOMAIN/.well-known/matrix/client before connecting. Without public_baseurl in homeserver.yaml, Synapse only returns {“errcode”:”M_NOT_FOUND”} there. apply-env.py sets this automatically:

public_baseurl: “https://matrix.example.com”

Test:

curl https://matrix.example.com/.well-known/matrix/client

Should return valid JSON with base_url (Synapse appends a trailing slash when serving this, by the way, regardless of the value set here — that’s normal behavior, not a bug).

The TURN server

Coturn is not an external service — it’s your own, self-hosted server in the stack. Without it, text chat works, but calls often fail as soon as both participants sit behind different NAT.

coturn/turnserver.conf.template gets automatically filled with real .env values at container start (via entrypoint.sh, no manual editing):

listening-port=3478
tls-listening-port=5349
external-ip=__PUBLIC_IP__
min-port=49152
max-port=49172
lt-cred-mech
user=__TURN_USER__:__TURN_PASSWORD__
realm=__MATRIX_DOMAIN__
no-tcp-relay
no-multicast-peers
  • user=…: not a third-party account, but a freely invented, shared secret between Coturn and Synapse — automatically identical in both places via .env.
  • no-tcp-relay: connection setup to the TURN server allows both UDP and TCP (TCP as a fallback for restrictive networks). This line, however, specifically disables only the media relay over TCP — the actual audio/video stream then runs exclusively over UDP (better performance). For maximum compatibility with very restrictive networks, remove this line.

Limits of this setup: voice and video calls

Being fully honest here: the TURN server is completely set up and automated, but I haven’t gotten voice and video calls conclusively working in this setup yet — I’m saving that for one of the next posts, once I’ve tested and documented the port forwarding and the complete call path end-to-end. Until then, I wouldn’t classify live calls over this server as reliable.

Voice messages (voicemails), on the other hand, already work without issue — they’re simply transferred as an audio file, just like pictures or videos, and don’t need a live media stream over TURN. For everything that’s plain message exchange (text, pictures, voice messages, video files), the stack is therefore already fully functional — only the live call itself is still outstanding.

Access from outside: Cloudflare Tunnel

For access from anywhere (not just your own LAN), you need HTTPS under the real domain — I use Cloudflare Tunnel for this. A few points that cost time in practice:

Separate hostnames per service, not one hostname with multiple targets. Cloudflare Tunnel routes by hostname, not by port — one hostname can only point to one target:

  • matrix.example.com → http://<server-IP>:8008 (Synapse)
  • element.example.com → http://<server-IP>:8080 (Element Web)

Multiple entries under the same hostname for different ports don’t work — only one “wins”, the rest cause gateway errors.

TURN ports cannot run over the tunnel — Cloudflare Tunnel doesn’t support public UDP. For working calls, only a direct port forward on the router remains for 3478, 5349, and 49152–49172.

If the mobile app simply can’t find the server despite successful curl/browser tests: delete and recreate the public hostname entry in the Cloudflare dashboard once. In practice this resolved a case where web and curl worked perfectly, but the app kept failing — likely a stuck internal state that couldn’t be diagnosed from outside.

Browser quirks when testing

Two things that look like server errors but are pure browser restrictions:

  • Secure Context: modern browsers only provide the Web Crypto API Matrix needs over HTTPS or localhost. A LAN IP over plain HTTP (http://192.168.x.x:…) does not satisfy this — login fails with a silent black screen and console errors like Browser missing feature: ‘crypto’. For tests from another device, there’s no way around real HTTPS.
  • Private/incognito mode: Safari and other browsers completely disable IndexedDB there, which Element absolutely needs — same symptom, black screen.

Creating an admin user, disabling registration

apply-env.py sets enable_registration: false automatically. Create users via the CLI:

docker compose exec synapse register_new_matrix_user \
  -c /data/homeserver.yaml http://localhost:8008

Interactively asks for username, password, admin yes/no. Use the same name for the first admin as ADMIN_USER in .env — that way it matches the bridge permissions set automatically.

Connecting LINE for the first time

  1. Start a chat with @linebot:matrix.example.com (name is in registration.yaml under sender_localpart)
  2. Send the message login
  3. Follow the login link/QR code
  4. The LINE account must already have an email address set (LINE app → Settings → Account) — otherwise login reliably fails

Additional practical note: LINE apparently only allows one active Chrome extension session at a time. If you log in the bridge to LINE while a separately installed LINE Chrome extension is also logged in in parallel, one can automatically log the other out (and vice versa). If you want to use both in parallel, keep this in mind in case one side unexpectedly logs out.

Workflow in short

Known restart loops that you need to actively break out of at two points: The line-bridge service runs with restart: unless-stopped and restarts automatically after every failure as long as the config hasn’t been patched with your real values yet — both after the first config generation and after the registration generation. Both times: explicitly stop with docker compose stop line-bridge before continuing (marked as its own step below). Also use -d (detached) throughout — without -d the terminal hangs in the foreground, and you’d have to press Ctrl+C to work at all.

cp .env.example .env
# Fill in ALL values (domain, DB password, TURN credentials, public IP, admin name)

chmod +x setup.sh apply-env.py fix-permissions.sh generate-element-config.sh

./setup.sh
# clones the bridge repo, creates directories, adds HOST_UID/GID,
# generates & patches homeserver.yaml, generates element/config.json

docker compose up -d postgres synapse coturn

docker compose up -d --build line-bridge
# generates the bridge config (correctly named thanks to the entrypoint fix)

docker compose stop line-bridge   # <- actively break the restart loop here
./fix-permissions.sh
python3 apply-env.py line-bridge
# also patches encryption.allow: true, among others — without this the very
# first contact with the bot fails, because modern clients create new chats
# encrypted by default

docker compose up -d --build line-bridge
# now generates registration.yaml — check the namespace regex (see above)

docker compose stop line-bridge   # <- break the restart loop again here
./fix-permissions.sh
docker compose restart synapse
docker compose up -d

After that: create an admin user, set up the Cloudflare Tunnel hostnames, connect LINE.

Conclusion

Every single failure in this project ultimately traced back to one of four patterns: Docker creating directories instead of files; three different, overlapping UID conflicts between the host and several containers; a genuine inconsistency in the bridge image itself; and browser security rules that look like server errors. Knowing these four categories gets you through considerably faster than I got through.

The complete, referenced files from this post are available as a ZIP download.


Donate Bild

Support / Donation Link for the Channel
If my posts have been helpful or supported you in any way, I’d truly appreciate your support 🙏

PayPal Link
Bank transfer, Bitcoin and Lightning


#Synapse #Matrix #Docker #ChatControl #HomeLab #SaveForEU #DecentralizedMessaging

Leave a Reply