- Shell 100%
| templates | ||
| .env.example | ||
| .gitignore | ||
| backup.sh | ||
| docker-compose.yml | ||
| install.sh | ||
| LICENSE | ||
| README.md | ||
| setup.sh | ||
| update.sh | ||
Matrix Stack
A production-ready, self-hosted Matrix homeserver stack managed via a single .env file.
Includes: Synapse · PostgreSQL 16 · Element Web · Coturn (TURN/STUN) · Traefik integration · Well-known delegation
Table of Contents
- Overview
- Requirements
- Quick Start
- Configuration Reference
- Architecture
- Federation & Well-Known Delegation
- First Admin User
- TURN Server (VoIP/Video)
- Email Notifications
- Prometheus Metrics
- User Management
- Maintenance
- Firewall / Port Reference
- Troubleshooting
- Security Considerations
- License
Overview
This stack deploys a fully functional Matrix homeserver using Docker Compose. All service configuration is driven by a single .env file — no need to manually edit YAML files. Config files are generated from templates at install time.
.env ──▶ install.sh ──▶ homeserver.yaml
──▶ turnserver.conf
──▶ element/config.json
──▶ docker-compose up -d
Included Services
| Container | Image | Purpose |
|---|---|---|
matrix-synapse |
matrixdotorg/synapse:latest |
Matrix homeserver |
matrix-postgres |
postgres:16-alpine |
Synapse database |
matrix-element |
vectorim/element-web:latest |
Web client |
matrix-coturn |
coturn/coturn:latest |
TURN/STUN for VoIP/video |
matrix-well-known |
nginx:alpine |
Federation delegation (optional) |
Requirements
Software
| Requirement | Version | Notes |
|---|---|---|
| Docker Engine | ≥ 24 | |
| Docker Compose plugin | ≥ 2.20 | docker compose version |
envsubst |
any | Part of gettext-base |
curl |
any | For health checks |
Install on Debian/Ubuntu:
sudo apt install docker.io docker-compose-plugin gettext-base curl
sudo usermod -aG docker $USER # log out and back in
Reverse Proxy
This stack is designed to work with Traefik as the reverse proxy (v2 or v3). Traefik must:
- Have an external Docker network (default name:
traefik) - Have a configured Let's Encrypt cert resolver (default name:
letsencrypt) - Forward ports 80 and 443 from your router/firewall
No Traefik? See Using a Different Reverse Proxy.
DNS Records
You need DNS A records (or CNAME to your server) for:
| Record | Points to | Purpose |
|---|---|---|
matrix.example.com |
Your server IP | Matrix homeserver |
chat.example.com |
Your server IP | Element Web client |
example.com |
Your server IP | Well-known delegation (if using a separate server name) |
Quick Start
# 1. Clone the repository
git clone https://your-forgejo.example.com/jonny/matrix-stack.git
cd matrix-stack
# 2. Create your .env from the template
cp .env.example .env
# 3. Fill in all values — especially secrets and domains
nano .env
# 4. Generate secrets for all SECRET fields:
# (run this command 5 times, once per secret)
openssl rand -hex 32
# 5. Run the installer
./install.sh
# 6. Create your first admin user
docker exec -it matrix-synapse register_new_matrix_user \
-c /data/homeserver.yaml \
-u admin -p 'YourSecurePassword' --admin \
https://matrix.example.com
That's it. Open https://chat.example.com and sign in.
Configuration Reference
All configuration lives in .env. Never commit this file.
Domains
| Variable | Example | Description |
|---|---|---|
SYNAPSE_SERVER_NAME |
example.com |
Server name in MXIDs (@user:example.com) |
MATRIX_DOMAIN |
matrix.example.com |
Homeserver public URL |
ELEMENT_DOMAIN |
chat.example.com |
Element Web URL |
Tip: If you want simple MXIDs like
@alice:example.com, setSYNAPSE_SERVER_NAME=example.comandMATRIX_DOMAIN=matrix.example.com. This requires well-known delegation (see below).For the simplest setup with no delegation needed: set both to the same value, e.g.,
SYNAPSE_SERVER_NAME=matrix.example.com.
Secrets
Generate each with openssl rand -hex 32:
| Variable | Description |
|---|---|
POSTGRES_PASSWORD |
PostgreSQL password |
SYNAPSE_REGISTRATION_SHARED_SECRET |
Used with register_new_matrix_user |
SYNAPSE_MACAROON_SECRET_KEY |
Synapse session tokens |
SYNAPSE_FORM_SECRET |
Synapse form CSRF protection |
COTURN_AUTH_SECRET |
Shared between Synapse and Coturn |
TURN / Coturn
| Variable | Default | Description |
|---|---|---|
COTURN_REALM |
— | Usually same as SYNAPSE_SERVER_NAME |
COTURN_EXTERNAL_IP |
— | Your server's public IP |
COTURN_PORT |
3478 |
STUN/TURN port (UDP + TCP) |
COTURN_TLS_PORT |
5349 |
TURNS port |
COTURN_MIN_PORT |
49152 |
Start of UDP relay range |
COTURN_MAX_PORT |
49172 |
End of UDP relay range (21 ports = ~10 calls) |
Registration
| Variable | Default | Description |
|---|---|---|
ENABLE_REGISTRATION |
false |
Allow new account creation |
REGISTRATION_REQUIRES_TOKEN |
true |
Require invite token when registration is on |
Performance Tuning
| Variable | Default | Notes |
|---|---|---|
SYNAPSE_MEMORY_LIMIT |
1g |
Reduce to 512m on low-RAM hosts |
POSTGRES_MEMORY_LIMIT |
512m |
|
LOG_LEVEL |
WARNING |
DEBUG / INFO for troubleshooting |
Architecture
Internet
│
▼ :80, :443
Traefik (TLS termination, Let's Encrypt)
│
├──▶ matrix.example.com ──▶ matrix-synapse:8008 ──▶ matrix-postgres:5432
│ (matrix_internal)
└──▶ chat.example.com ──▶ matrix-element:80
Coturn (host network, direct UDP)
:3478 UDP/TCP ──▶ STUN/TURN
:5349 UDP/TCP ──▶ TURNS
:49152-49172 UDP ──▶ media relay
Networks
| Network | Type | Members |
|---|---|---|
matrix_internal |
bridge, internal | synapse, postgres |
traefik |
external | synapse, element, well-known |
| host | host network | coturn |
PostgreSQL is not reachable from outside the matrix_internal network.
Federation & Well-Known Delegation
Matrix federation lets users on your server communicate with users on other servers (e.g., matrix.org).
Simple setup (no delegation)
Set SYNAPSE_SERVER_NAME=matrix.example.com (same as MATRIX_DOMAIN). Federation works out of the box. MXIDs will look like @alice:matrix.example.com.
Delegation setup (nice MXIDs)
For @alice:example.com with Matrix running at matrix.example.com:
-
Set
SYNAPSE_SERVER_NAME=example.comandMATRIX_DOMAIN=matrix.example.comin.env -
Run the installer with the delegation profile:
./install.sh --with-delegation -
This starts an nginx container that serves:
https://example.com/.well-known/matrix/server→{"m.server": "matrix.example.com:443"}https://example.com/.well-known/matrix/client→ client config
Note: If
example.comalready has a web server, add the.well-knownfiles there instead. See${DATA_BASE_PATH}/well-known/.well-known/matrix/for the generated files. -
Verify federation:
https://federationtester.matrix.org/#example.com
First Admin User
After installation, create your first admin account:
docker exec -it matrix-synapse register_new_matrix_user \
-c /data/homeserver.yaml \
-u yourusername \
-p 'YourSecurePassword' \
--admin \
https://matrix.example.com
No admin? If you lose admin access, you can grant it via psql:
docker exec -it matrix-postgres psql -U synapse synapse \ -c "UPDATE users SET admin=1 WHERE name='@yourusername:example.com';"
Registration Tokens
If ENABLE_REGISTRATION=true and REGISTRATION_REQUIRES_TOKEN=true, create tokens via the admin API:
curl -X POST "https://matrix.example.com/_matrix/client/v1/admin/registration_tokens/new" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"uses_allowed": 1}'
TURN Server (VoIP/Video)
Coturn runs with network_mode: host for reliable UDP relay. Required ports must be open in your firewall and (if applicable) forwarded by your router:
| Port | Protocol | Purpose |
|---|---|---|
3478 |
UDP + TCP | STUN/TURN |
5349 |
UDP + TCP | TURNS (TLS) |
49152–49172 |
UDP | Media relay |
Verify TURN is working
- Open: https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
- Add a TURN server:
- URI:
turn:your-coturn-realm:3478 - Username/Password: obtain from Synapse (call
/_matrix/client/v3/voip/turnServer)
- URI:
- Click Gather candidates — you should see
relaytype candidates
Conflict with existing Coturn
If you already run Coturn (e.g., for Nextcloud), you have two options:
Option A: Reuse the existing Coturn instance. Remove the coturn service from docker-compose.yml and add your existing Coturn's secret to .env as COTURN_AUTH_SECRET.
Option B: Run both on different ports. Change COTURN_PORT and COTURN_TLS_PORT to unused ports in .env.
Email Notifications
Email is optional. Leave SMTP_HOST empty in .env to disable.
When configured, Synapse sends:
- Email verification for new accounts
- Password reset emails
- Notification emails (configurable per-user)
Example with a relay / SMTP provider:
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER=postmaster@mg.example.com
SMTP_PASSWORD=your-api-key
SMTP_FROM=matrix@example.com
SMTP_REQUIRE_TRANSPORT_SECURITY=true
Prometheus Metrics
Synapse exposes metrics on port 9092 (internal network only).
Prometheus scrape config
Add to your prometheus.yml:
scrape_configs:
- job_name: 'synapse'
static_configs:
- targets: ['matrix-synapse:9092']
metrics_path: '/_synapse/metrics'
If Prometheus runs in a separate Docker stack, add
matrix-synapseto a shared network or use the container IP.
Grafana Dashboard
Import the official Synapse dashboard from Grafana.com:
- Dashboard ID: 10046 (Synapse monitoring)
User Management
Common admin tasks via the Synapse Admin API:
# Set base URL and your admin token
MATRIX_URL="https://matrix.example.com"
TOKEN="your_access_token" # from Element: Settings → Help & About → Access Token
# List all users
curl -H "Authorization: Bearer $TOKEN" \
"$MATRIX_URL/_synapse/admin/v2/users?from=0&limit=20"
# Deactivate a user
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"deactivated": true}' \
"$MATRIX_URL/_synapse/admin/v2/users/@user:example.com"
# Reset a user's password
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"new_password": "newpassword", "logout_devices": true}' \
"$MATRIX_URL/_synapse/admin/v1/reset_password/@user:example.com"
Maintenance
Update
Pull latest images and recreate containers:
./update.sh
This runs docker compose pull + up -d --remove-orphans + image prune.
Note: Synapse upgrades are usually smooth. If a release requires a DB migration, Synapse runs it automatically on startup. Check logs after update:
docker compose logs -f synapse
Backup & Restore
Create a backup (PostgreSQL dump + all configs):
./backup.sh
Backups are stored in ${DATA_BASE_PATH}/backups/. Last 7 backups are kept.
Restore from backup:
./backup.sh --restore
What's backed up:
| Content | Method |
|---|---|
| PostgreSQL database | pg_dump → gzip |
| Synapse config + signing key | cp |
| Element config | cp |
| Coturn config | cp |
What's NOT backed up by this script:
- Media files (
SYNAPSE_MEDIA_PATH) — can be large (GBs). Back these up separately with rsync or your preferred tool.
Updating configuration
After changing .env:
# Regenerate configs from templates
source .env
envsubst < templates/homeserver.yaml.template > "${DATA_BASE_PATH}/synapse/homeserver.yaml"
envsubst < templates/turnserver.conf.template > "${DATA_BASE_PATH}/coturn/turnserver.conf"
envsubst < templates/element-config.json.template > "${DATA_BASE_PATH}/element/config.json"
# Restart affected services
docker compose restart synapse coturn element
Or just re-run install.sh — it's idempotent (existing signing keys are preserved).
Firewall / Port Reference
| Port | Protocol | Direction | Service | Required |
|---|---|---|---|---|
80 |
TCP | inbound | Traefik (HTTP → HTTPS redirect) | Yes |
443 |
TCP | inbound | Traefik (Matrix + Element) | Yes |
3478 |
UDP + TCP | inbound | Coturn TURN/STUN | For calls |
5349 |
UDP + TCP | inbound | Coturn TURNS | For calls |
49152–49172 |
UDP | inbound | Coturn media relay | For calls |
All other ports are internal to Docker networks and not exposed to the host or internet.
Troubleshooting
Synapse won't start
docker compose logs synapse
Common causes:
- Database not ready: PostgreSQL health check failed. Wait 30s and try again.
- homeserver.yaml error: Syntax error in generated config. Check
LOG_LEVEL=DEBUG. - Signing key missing: Re-run
install.sh(it regenerates the key).
Can't connect to homeserver
# Check Synapse is listening
docker compose exec synapse curl -s http://localhost:8008/_matrix/client/versions | head -c 200
# Check Traefik routing
curl -I https://matrix.example.com/_matrix/client/versions
Federation not working
# Test from your server
curl https://matrix.org/_matrix/federation/v1/version
# Test your server's federation
curl https://federationtester.matrix.org/api/report?server_name=example.com
Common causes:
- Missing
.well-knowndelegation (ifSERVER_NAME != MATRIX_DOMAIN) - Port 443 not reachable from the internet
- Invalid TLS certificate
TURN not working
# Check coturn is running and listening
ss -tulpn | grep -E '3478|5349|49152'
# Check coturn logs
docker compose logs coturn
Common causes:
COTURN_EXTERNAL_IPset to private IP instead of public IP- Relay ports not open in firewall/router
COTURN_AUTH_SECRETmismatch between.envand generated configs
Regenerate configs without reinstalling
# Source env and regenerate all configs
source .env
envsubst < templates/homeserver.yaml.template > "${DATA_BASE_PATH}/synapse/homeserver.yaml"
envsubst < templates/turnserver.conf.template > "${DATA_BASE_PATH}/coturn/turnserver.conf"
envsubst < templates/element-config.json.template > "${DATA_BASE_PATH}/element/config.json"
docker compose restart
Using a Different Reverse Proxy
Remove all traefik.* labels from docker-compose.yml and expose ports manually:
# In docker-compose.yml, replace labels with:
services:
synapse:
ports:
- "127.0.0.1:8008:8008"
element:
ports:
- "127.0.0.1:8080:80"
Then configure nginx/Caddy/Apache to proxy to those ports with TLS termination.
nginx example for Synapse:
server {
listen 443 ssl;
server_name matrix.example.com;
ssl_certificate /etc/letsencrypt/live/matrix.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/matrix.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8008;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
client_max_body_size 100M;
}
}
Security Considerations
- Never enable open registration (
ENABLE_REGISTRATION=true) without also settingREGISTRATION_REQUIRES_TOKEN=true, unless you intend to run a public server. - Rotate secrets if they are ever exposed. After rotating, regenerate configs and restart services.
- Media files can contain sensitive content. Ensure
SYNAPSE_MEDIA_PATHhas appropriate permissions (mode750, owned by UID 991). - PostgreSQL is only accessible within the
matrix_internalDocker network — no port is exposed to the host. - Coturn uses
denied-peer-iprules to prevent TURN server abuse (SSRF via relay). Do not remove these. - Signing key (
*.signing.key) must be kept secret and backed up. Losing it breaks federation permanently for your server name.
License
MIT License — see LICENSE.
Built for self-hosters. Designed to run on a single machine.