# Set up your own Matrix server This folder is a self-contained starting point for a small, single-server Matrix installation: **Synapse + PostgreSQL, Element Web, Coturn, LiveKit, MatrixRTC JWT service, and Traefik with Let's Encrypt**. Copy this folder to the new server; it does not depend on any other part of the original repository. The templates are adapted from a running deployment captured on **2026-10-04**. They contain no original passwords, signing keys, domains or server IPs. Run the setup script on your own server to generate your own secrets and config files. ## 1. Prepare the server and DNS Use a **Linux server with a static public IPv4 assigned directly to it**, Docker Engine, the Compose plugin (`docker compose`) and Python **3.9+**. The source deployment is Linux/amd64. This guide uses Linux host networking for Coturn; Docker Desktop, rootless Docker and a server behind router/cloud NAT need networking changes and are outside this recipe. Ports 80, 443 and 8448 must be available for this stack's Traefik. If another reverse proxy already owns them, integrate the routes into that proxy before starting. There is no Tailscale dependency. Docker installation instructions: [Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/). For `DOMAIN=example.com`, create these **DNS A records**, all pointing to your server's public IPv4: | Name | Purpose | | --- | --- | | `matrix.example.com` | Synapse API, federation, client discovery | | `chat.example.com` | Element Web | | `rtc.example.com` | LiveKit signaling and MatrixRTC authorization | | `turn.example.com` | Coturn | Use direct DNS records, without a CDN HTTP proxy in front of TURN or LiveKit's media ports. Publish AAAA records only after configuring and testing IPv6. **Choose your Matrix identity now.** This recipe uses `server_name: matrix.example.com`, producing IDs such as `@alice:matrix.example.com`. Treat that name as permanent once the server is in use. If you want `@alice:example.com`, arrange root-domain delegation and adjust the server-name references before initialization; this recipe deliberately uses the simpler subdomain layout. [Synapse server names](https://element-hq.github.io/synapse/latest/setup/installation.html) Allow the following incoming traffic in the provider firewall and host firewall: | Port | Protocol | Purpose | | --- | --- | --- | | 80 | TCP | Let's Encrypt HTTP-01 and HTTPS redirect | | 443 | TCP | Matrix, Element and RTC HTTPS/WebSocket | | 8448 | TCP | Federation fallback/direct access | | 3478 | TCP + UDP | Coturn client connections | | 61000–65535 | UDP | Coturn relay allocations | | 7881 | TCP | LiveKit ICE/TCP fallback | | 7882 | UDP | LiveKit ICE/UDP mux | Keep SSH access available. The stack does not expose PostgreSQL or LiveKit's internal API port. Synapse's port 8008 binds only to `127.0.0.1` for local admin access. Docker-published ports can bypass ordinary UFW rules; account for Docker's firewall behavior rather than relying on UFW alone. [Docker firewall behavior](https://docs.docker.com/engine/network/packet-filtering-firewalls/) Coturn's relay pool assumes the host's ephemeral port range ends below 61000. Check `cat /proc/sys/net/ipv4/ip_local_port_range` if your host uses a custom range. ## 2. Generate this server's config Work as your normal Linux user with Docker access. Do not run `configure.py` with `sudo`; Synapse will run with this user's UID/GID so it can write its data. ```sh cd matrix-setup cp settings.env.example settings.env nano settings.env python3 configure.py docker compose config --quiet ``` Set your real `DOMAIN`, `PUBLIC_IPV4` and `ACME_EMAIL` in `settings.env`. Use plain `KEY=value` lines without quotes. The script generates six independent random secrets, copies matching values wherever needed, and creates: | Path | Contents | | --- | --- | | `.env` | Compose variables, credentials and Synapse UID/GID | | `generated/homeserver.yaml` | Synapse database, identity, TURN and MatrixRTC configuration | | `generated/log.config` | Synapse console logging | | `generated/element.json` | Element homeserver selection | | `generated/livekit.yaml` | LiveKit IP, media ports and API key | | `generated/turnserver.conf` | Coturn authentication, listeners and relay policy | | `data/` | Persistent PostgreSQL, Synapse and Traefik state | `configure.py` starts no containers and refuses to overwrite `.env`, `generated/` or `data/`. It is a **one-time initializer**, not a reconfiguration or secret rotation tool. After initialization, edit generated files deliberately and keep shared values in sync. Editing `settings.env` alone does not reconfigure a server. The script protects `.env` and the `generated/` directory with private host permissions. Individual config files are readable by the differing image users through their bind mounts. These paths, `settings.env`, `data/` and `backups/` are ignored by the included `.gitignore`. They contain private installation state and should not be included when sharing this guide. ## 3. Generate the signing key and start ```sh docker compose pull docker compose run --rm --no-deps --entrypoint python synapse \ -m synapse.app.homeserver --generate-keys -c /data/homeserver.yaml docker compose up -d docker compose ps docker compose logs --tail=100 traefik postgres synapse livekit lk-jwt-service coturn ``` The key command creates `data/synapse/server.signing.key` without starting the database. Preserve that key for the lifetime of the server. The image's generation behavior is documented in the [Synapse Docker instructions](https://github.com/element-hq/synapse/blob/v1.159.0/docker/README.md). Traefik obtains certificates for Matrix, Element and RTC on port 80 and serves HTTPS on 443. Synapse serves `/.well-known/matrix/client` itself; its additional RTC content points to the JWT service. `serve_server_wellknown: true` advertises federation on 443, while 8448 is also served with the same certificate. [Synapse delegation](https://element-hq.github.io/synapse/latest/delegate.html) Resource caps are inherited from the source stack and total about **4.75 GiB** across these services. They are limits, not reserved host memory. Leave RAM for the OS and adjust limits to the number of users and media activity you observe. ## 4. Create accounts Public registration is disabled. Create the first account interactively: ```sh docker compose exec synapse register_new_matrix_user \ -c /data/homeserver.yaml http://localhost:8008 ``` Enter a local username and password; answer `yes` to the admin prompt for the first operator account. Repeat with `no` for ordinary accounts. Keeping the password interactive avoids putting it in shell history. [Account creation](https://github.com/element-hq/synapse/blob/v1.159.0/docker/README.md) Open `https://chat.example.com` using your actual domain and sign in. Other compatible clients can use `https://matrix.example.com` as the homeserver. This bundle uses Synapse's local authentication; it does not include Matrix Authentication Service, SMTP/password-reset mail or an identity server. The public reverse-proxy route blocks `/_synapse/admin`. For an admin tool, forward port 8008 over SSH to the server's loopback address, or run the required admin operation locally. No public admin dashboard is included. ## 5. Verify the new deployment Substitute your chosen domain in these commands, and run the HTTPS checks from a machine outside the server: ```sh curl -fsS https://matrix.example.com/_matrix/client/versions curl -fsS https://matrix.example.com/.well-known/matrix/client curl -fsS https://matrix.example.com/.well-known/matrix/server curl -fsS https://matrix.example.com:8448/_matrix/federation/v1/version curl -fsS https://rtc.example.com/livekit/jwt/healthz ``` Check that discovery returns your own homeserver and `https://rtc.example.com/livekit/jwt`. The modern authenticated MatrixRTC transport registry and the older well-known announcement are both configured, following the [Element Call prerequisites](https://github.com/element-hq/element-call/blob/main/docs/self_hosting.md). The in-app calling setup does not require a separate standalone Element Call web container. Use the [Matrix federation tester](https://federationtester.matrix.org/) for your server name, then exchange messages with an account on another homeserver. An API returning 200 is not a federation or call test. For calling, use two accounts on different external networks, for example home broadband and mobile data. Check both audio directions, video, joining/leaving and an encrypted call. In Chromium, `chrome://webrtc-internals` can show the selected ICE candidate pair. LiveKit also logs the selected transport: ```sh docker compose logs --since=10m livekit ``` A normal direct media path should select **your public IP on UDP 7882**. TCP 7881 can work as fallback, so a successful call alone does not establish that UDP is reachable. [LiveKit port reference](https://docs.livekit.io/transport/self-hosting/ports-firewall/) Coturn serves the TURN URLs advertised by Synapse for clients/call modes that use them. It is not automatically a TURN relay for LiveKit just because it is listed in Synapse. This recipe has no TURN/TLS on 443 or embedded LiveKit TURN; networks that block the configured media paths need a separately designed relay setup. Do not change the UDP mapping to 7881: LiveKit listens on **7882**. ## Files to keep and maintenance Back up `.env`, `settings.env`, `generated/`, Synapse's signing key and media, Traefik ACME state, and PostgreSQL. A database dump alone does not preserve the whole server. For a small server, this creates a database/media-consistent backup by stopping Synapse while PostgreSQL remains available: ```sh umask 077 backup_dir="backups/$(date -u +%Y%m%dT%H%M%SZ)" mkdir -p "$backup_dir" docker compose stop synapse docker compose exec -T postgres pg_dump -U synapse -d synapse -Fc \ > "$backup_dir/synapse.dump" # Continue only if pg_dump succeeded: sudo tar -czf "$backup_dir/config-media.tar.gz" \ .env settings.env generated data/synapse data/traefik docker compose start synapse ``` If a backup step fails, restart Synapse and treat that backup as incomplete. Copy the dump and archive to private off-server storage. Test restoration on an isolated host: restore the files with their permissions and the same server identity, start an empty PostgreSQL 15 service, load the dump with `pg_restore`, then start Synapse. Do not run `configure.py` over restored data or allow two servers with the same identity to federate at once. All seven image references are digest-pinned to the source snapshot, including ones whose tag text says `latest`. `docker compose pull` therefore does not silently upgrade them. [images.json](images.json) records their origins. Review upstream releases, back up first, and update both tag and digest deliberately. Do not change the PostgreSQL major version without a database migration plan. After changing generated configuration, recreate the affected service so that individual file bind mounts also pick up atomically replaced files: ```sh docker compose config --quiet docker compose up -d --force-recreate --no-deps synapse ``` Replace `synapse` with the service you changed; LiveKit recreation interrupts active calls. Never rerun the initializer to change passwords on an existing database. Keep `.env` and `generated/homeserver.yaml` aligned when changing the PostgreSQL password, and both LiveKit/JWT or Coturn/Synapse copies when changing their shared secrets. ## Differences from the source deployment - The relevant services are combined into one Compose project with relative paths and project-owned networks. Existing VPS Compose files are untouched. - Tailscale addresses, the source domain/IP, the Traefik dashboard and unrelated workloads are removed. Local admin access uses loopback port 8008. - Fresh secrets replace all source credentials. Synapse runs as the initializing user; filenames do not contain a particular domain. - Logging is bounded to three 10 MB Docker log files per service. - The unused Redis service is omitted: the captured single-node LiveKit config does not reference Redis. Distributed LiveKit needs separate planning. - Current MatrixRTC transport discovery/rate settings supplement the source's well-known announcement. LiveKit room auto-creation is disabled so the JWT service's local-homeserver allowlist controls creation, as required by the [JWT service v0.4.1 instructions](https://github.com/element-hq/lk-jwt-service/blob/v0.4.1/README.md). This is a setup guide, not a backup of the original server. See [VALIDATION.md](VALIDATION.md) for what was actually checked and what still needs verification on your own domain and server. ## Share a clean copy From this folder, build an archive containing only the guide and templates: ```sh tar -czf ../matrix-setup-guide.tar.gz \ README.md VALIDATION.md compose.yml configure.py settings.env.example \ .gitignore images.json templates ``` This explicit file list excludes installation credentials, runtime data and backups even after you have used the guide yourself.