Matrix / Element Self-Hosted Server
A Docker Compose setup for running a self-hosted Matrix homeserver (Synapse) with the Element Web client and a Synapse admin panel.
What's included
| Service | Image | Default Port | Purpose |
|---|---|---|---|
| Synapse | matrixdotorg/synapse:latest |
8008 |
Matrix homeserver |
| Element Web | vectorim/element-web:latest |
8080 |
Web chat client |
| Synapse Admin | awesometechnologies/synapse-admin:latest |
8081 |
Admin UI |
| PostgreSQL | postgres:14 |
5432 |
Database for Synapse |
Prerequisites
- Docker and Docker Compose
- A domain name pointed at your server (e.g.
chat.example.com) - A reverse proxy (nginx, Caddy, Traefik, etc.) to handle TLS termination — the containers themselves do not manage HTTPS
Setup
1. Clone the repository
git clone <your-repo-url>
cd matrix
2. Configure environment variables
Copy the example env file and edit it:
cp .env.example .env
Open .env and set each value:
# PostgreSQL
POSTGRES_DB=synapse
POSTGRES_USER=synapse
POSTGRES_PASSWORD=<strong-random-password>
# Synapse — must match your public domain
SYNAPSE_SERVER_NAME=chat.example.com
# Synapse Admin UI
REACT_APP_SERVER=https://chat.example.com
REACT_APP_REGISTRATION_ENABLED=false # set to true only if you want open registration
Security note: Use a strong, unique password for
POSTGRES_PASSWORD. Never commit.envto version control — it is already in.gitignore.
3. Generate the Synapse configuration
Synapse needs a homeserver.yaml generated before it can start. Run this once:
docker run --rm \
-e SYNAPSE_SERVER_NAME=chat.example.com \
-e SYNAPSE_REPORT_STATS=yes \
-v "$(pwd)/synapse-data:/data" \
matrixdotorg/synapse:latest generate
Replace chat.example.com with your actual domain. This creates synapse-data/homeserver.yaml.
4. Point Synapse at PostgreSQL
Open synapse-data/homeserver.yaml and replace the default SQLite database block with:
database:
name: psycopg2
args:
user: synapse
password: <your-POSTGRES_PASSWORD>
database: synapse
host: postgres
cp_min: 5
cp_max: 10
Use the same credentials you set in .env.
5. Configure the Element Web client
Copy the example config and edit it:
cp element-config.json.example element-config.json
Update every occurrence of chat.example.com to your domain:
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://chat.example.com",
"server_name": "chat.example.com"
}
},
"disable_custom_urls": true,
"brand": "My Matrix Server",
"showLabsSettings": true,
"voip": {
"stun_servers": [
{ "urls": ["stun:turn.example.com:3478"] }
],
"turn_servers": [
{
"urls": [
"turn:turn.example.com:3478?transport=udp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:5349?transport=tcp"
],
"secret": "<your-turn-server-secret>",
"expiry": 86400000
}
]
}
}
If you do not have a TURN server, remove the voip and webrtc blocks entirely. Voice/video calls on the same local network will still work without them.
6. Start the stack
docker compose up -d
Verify all containers are running:
docker compose ps
Check Synapse logs for errors:
docker compose logs -f synapse
7. Create your first admin user
Once Synapse is running, register an admin account:
docker compose exec synapse register_new_matrix_user \
-c /data/homeserver.yaml \
-u <username> \
-p <password> \
-a \
http://localhost:8008
The -a flag grants admin privileges. You can omit it for regular users.
Reverse proxy / TLS
The containers expose plain HTTP. You must front them with a reverse proxy that terminates TLS. A minimal nginx example:
server {
listen 443 ssl;
server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
# Element Web
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Synapse Matrix API and federation
location /_matrix {
proxy_pass http://127.0.0.1:8008;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 50M;
}
location /_synapse {
proxy_pass http://127.0.0.1:8008;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
For automatic TLS with Let's Encrypt, use Certbot or Caddy.
Matrix federation (optional)
If you want other Matrix homeservers to be able to communicate with yours, port 8448 must be reachable publicly. You can either:
- Add a second
server { listen 8448 ssl; ... }block that proxies tohttp://127.0.0.1:8008, or - Add a
.well-known/matrix/serverfile served from your domain pointing federation to port 443
Accessing the services
| URL | Service |
|---|---|
https://chat.example.com |
Element Web client |
https://chat.example.com:8081 |
Synapse Admin panel |
https://chat.example.com/_matrix |
Matrix homeserver API |
Log into the admin panel at port 8081 using the admin credentials you created in step 7.
Data persistence
All persistent data is stored in local directories that are bind-mounted into the containers:
| Directory | Contents |
|---|---|
postgres-data/ |
PostgreSQL database files |
synapse-data/ |
Synapse config, media uploads, signing keys |
Both directories are excluded from git via .gitignore. Back them up regularly.
Upgrading
Pull the latest images and recreate the containers:
docker compose pull
docker compose up -d
Check the Synapse changelog before upgrading major versions — some releases require manual migration steps.
Troubleshooting
Synapse fails to start with a database error
Make sure the credentials in synapse-data/homeserver.yaml match those in .env, and that the postgres service is fully healthy before Synapse starts. You can force the order with:
docker compose up -d postgres
# wait a few seconds, then:
docker compose up -d synapse element-web element-admin
Element Web shows "Homeserver is not reachable"
Verify that base_url in element-config.json uses https:// and points to the domain your reverse proxy serves (not localhost).
"M_FORBIDDEN" when registering users
Open registration is disabled by default. Either use the register_new_matrix_user command (step 7) or set enable_registration: true in homeserver.yaml and restart Synapse.
Port conflicts
If any default port is already in use on your host, change the left side of the port mapping in docker-compose.yml (e.g. "8082:80" for Element Web) and update your reverse proxy accordingly.