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.
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
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
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.
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
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.
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
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:
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
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:
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.
The in-app calling setup does not require a separate standalone Element Call
web container.
Use the Matrix federation tester 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:
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
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:
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 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:
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.
This is a setup guide, not a backup of the original server. See 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:
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.
Inside the bundle
The complete setup lives in a folder you can keep, inspect and change. No generated credentials or runtime data are included.
compose.ymlThe seven-service stack ↗configure.pyGenerate your own config ↗settings.env.exampleDomain, IP and email ↗images.jsonImage origins and digests ↗Archive SHA-256dfdb0a203861f119a6b21d503041a792a111374742a1b3cd48b9dbc06dca085f