A fully automated Ansible deployment of Matrix (Synapse) + Element Web for secure messaging with E2E encryption.
Supports multi-region deployment with two distinct connectivity profiles:
- Iran — limited/no direct internet; uses SSH-tunneled proxy for apt, Iranian Docker registry mirrors for images, and locally-obtained TLS certificates pushed to the server.
- Europe — direct internet access; pulls images from Docker Hub, runs certbot on-server for TLS via Cloudflare DNS-01.
Both regions use the same playbook, with per-region variable files controlling behavior.
Key properties:
- End-to-end encrypted messages — if the server is seized, message content is unreadable
- Federation between regions via xray VLESS-over-WebSocket tunnels with iptables DNAT forwarding
- Runs entirely on internal network after initial setup
- Automated setup via Ansible
┌──────────────── Iran Server ────────────────┐ xray tunnel ┌──────────────── EU Server ──────────────────┐
│ │◄────────────────►│ │
│ ┌───────────┐ ┌──────────┐ │ :8448 ◄► :8449 │ ┌───────────┐ ┌──────────┐ │
│ │ Nginx │────►│ Synapse │──┐ │ │ │ Nginx │────►│ Synapse │──┐ │
│ │ :443 │ │ (Matrix) │ │ ┌──────┐ │ │ │ :443 │ │ (Matrix) │ │ ┌──────┐ │
│ │ :8448 │ └──────────┘ ├►│Pg 16 │ │ │ │ :8449 │ └──────────┘ ├►│Pg 16 │ │
│ └───────────┘ ┌──────────┐ │ └──────┘ │ │ └───────────┘ ┌──────────┐ │ └──────┘ │
│ │ │ Element │──┘ │ │ │ │ Element │──┘ │
│ └───────────►│ (Web) │ │ │ └───────────►│ (Web) │ │
│ └──────────┘ │ │ └──────────┘ │
└─────────────────────────────────────────────┘ └─────────────────────────────────────────────┘
Docker mirrors: arvancloud, runflare Direct Docker Hub + certbot auto-renewal
Apt via SSH proxy tunnel Direct apt
All services run in Docker containers managed by Docker Compose.
On your local machine:
- Python 3
- Ansible (
pip install ansible) - SSH access to the target server(s)
- Unrestricted internet access — required to provide the SSH proxy for Iran's apt/Docker setup and to obtain TLS certificates via Cloudflare DNS-01 (using
get-cert.sh)
On the target server:
- Ubuntu 24.04 or Debian 12+
- Root access (or a sudo-capable user — set
ansible_userin your inventory accordingly) - At least 2GB RAM
- Iran server: Apt sources must be configured to use Iranian mirrors (e.g.
mirror.arvancloud.ir). See ArvanCloud Linux Repository setup for instructions. The deployment proxy is only used fordownload.docker.com— all other apt traffic goes directly through the local mirrors. - Iran server: DNS servers must be able to resolve Iranian local domains (e.g.
docker.arvancloud.ir) for Docker registry mirrors. If DNS is broken, configure Iranian DNS servers before running the playbook (see DNS Resolvers for Iran below), or setdocker_pull_proxyto pull images through the SSH-tunneled proxy instead.
For federation tunnel (Iran server only):
- A SOCKS5 proxy running on
127.0.0.1:2080— this can be any proxy tool (mihomo, v2ray, xray, sing-box) or a DNSTT-based tunnel. Setting up this proxy is out of scope for this project. The xray tunnel connects through it using VLESS over WebSocket.
A domain:
- A real domain with DNS managed by Cloudflare
Each region needs its own DNS records. Cloudflare proxy (orange cloud) must be OFF — use DNS only (grey cloud).
| Type | Name | Value |
|---|---|---|
| A | ir.example.com |
Iran server IP |
| A | chat.example.com |
Iran server IP |
| Type | Name | Value |
|---|---|---|
| A | eu.example.com |
EU server IP |
| A | chat-eu.example.com |
EU server IP |
These help other Matrix servers discover your federation endpoints:
| Type | Name | Priority | Weight | Port | Target |
|---|---|---|---|---|---|
| SRV | _matrix-fed._tcp.ir.example.com |
10 | 0 | 8448 | ir.example.com |
| SRV | _matrix-fed._tcp.eu.example.com |
10 | 0 | 8449 | eu.example.com |
Note: Iran uses port 8448 and Europe uses port 8449 for federation. The .well-known/matrix/server endpoint on each server advertises the correct port automatically.
If the Iran server's default DNS resolvers are blocked or unreachable, configure Iranian DNS servers before deploying. On Ubuntu 24.04 with systemd-resolved:
# Set global DNS
ssh iran-ssh-host "sudo mkdir -p /etc/systemd/resolved.conf.d && sudo tee /etc/systemd/resolved.conf.d/dns.conf > /dev/null << 'EOF'
[Resolve]
DNS=217.218.127.127 217.218.155.155
EOF
sudo systemctl restart systemd-resolved"
# Override per-interface DNS (if interfaces have unreachable DNS servers)
ssh iran-ssh-host "sudo resolvectl dns eth0 217.218.127.127 217.218.155.155"
# Verify
ssh iran-ssh-host "resolvectl status | head -10"git clone git@github.com:bugfloyd/matrix-bridge-deploy.git
cd matrix-bridge-deploy
cp inventory/hosts.example.yml inventory/hosts.yml
cp group_vars/iran.example.yml group_vars/iran.yml
cp group_vars/europe.example.yml group_vars/europe.ymlEdit inventory/hosts.yml with your SSH hosts:
all:
children:
iran:
hosts:
matrix-iran:
ansible_host: iran-ssh-host # SSH config alias or IP
ansible_user: root # or "ubuntu" if root login is disabled
europe:
hosts:
matrix-eu:
ansible_host: eu-ssh-host # SSH config alias or IP
ansible_user: rootNote: If the server disables root login (e.g., "Please login as ubuntu"), set
ansible_userto the sudo-capable user. The playbook usesbecome: trueso it will escalate to root via sudo.
Edit group_vars/iran.yml and group_vars/europe.yml with your domains and federation settings.
# Deploy Iran instance (starts SSH proxy, uses Docker mirrors)
./scripts/setup.sh iran
# Deploy Europe instance (direct internet, certbot on server)
./scripts/setup.sh europeThe setup.sh script accepts a target argument — a group name (iran, europe) or a specific host name. It automatically detects whether the target needs a proxy by reading use_proxy from the matching group_vars/<target>.yml.
What the Iran deployment does:
- Starts a local HTTP forward proxy (
proxy.py) on your machine - Creates an SSH reverse tunnel (
-R) so the server can reach the proxy - Configures a selective apt proxy — only
download.docker.comis proxied; local Iranian mirrors are used directly for all other packages - Installs Docker, pulls images via Iranian registry mirrors (
docker.arvancloud.ir,mirror-docker.runflare.com) - Deploys Synapse, Element, Nginx, Postgres via Docker Compose
- Adds
/etc/hostsentries and iptables DNAT rules for federation - Cleans up the apt proxy config when finished
What the Europe deployment does:
- Installs Docker, pulls images directly from Docker Hub
- Deploys all services with hardening applied
- Installs certbot + Cloudflare DNS plugin on the server
- Obtains TLS certificates for
eu.example.com,chat-eu.example.com, and any federation peer hostnames - Sets up weekly auto-renewal cron job (Mondays 03:30, restarts nginx on renewal)
- Adds
/etc/hostsentries and iptables DNAT rules for federation
Secrets are auto-generated on first run and saved to credentials/ (gitignored).
After both servers are deployed, set up the xray tunnel for federation:
./scripts/setup-tunnel.shThis downloads xray, generates a VLESS UUID, and deploys xray on both servers — Iran as a bridge (outbound through SOCKS proxy) and EU as a portal (accepts connections and enables reverse forwarding). See Setting up the xray tunnel for details.
Prerequisite: A SOCKS5 proxy must be running on the Iran server at 127.0.0.1:2080 before running this. This can be any proxy tool (mihomo, v2ray, xray, sing-box). Setting up this proxy is out of scope for this project.
Iran (no internet on server):
Obtain a wildcard certificate locally using Cloudflare DNS-01 challenge, then push it to the server:
# Get wildcard cert for *.example.com (runs certbot locally)
./scripts/get-cert.sh example.com
# Push certs to Iran server
./scripts/sync-certs.sh example.com localhost iran-ssh-hostThe get-cert.sh script will prompt for a Cloudflare API token if one isn't saved in credentials/cloudflare_token. It installs certbot locally if needed.
The sync-certs.sh script takes three arguments: the domain, a source (localhost for local certs, or a remote SSH host), and the target SSH destination (e.g. ir-ssh-alias or user@ip). It copies fullchain.pem + privkey.pem to the target server, then reloads nginx.
Europe (certbot on server):
If you set cloudflare_api_token in group_vars/europe.yml or saved it in credentials/cloudflare_token, certbot runs automatically during deployment. Nothing extra needed.
Creating a Cloudflare API token:
- Go to https://dash.cloudflare.com/profile/api-tokens
- Click "Create Token"
- Use the "Edit zone DNS" template with:
- Permission: Zone > DNS > Edit
- Zone resource: Include > Specific zone > your domain
All user management scripts take an SSH destination as the first argument — either a ~/.ssh/config alias (e.g. ir-ssh-alias) or user@ip (e.g. root@203.0.113.1).
Create users:
# Create admin user on Iran
./scripts/create-user.sh ir-ssh-alias admin --admin
# Create regular user on Iran
./scripts/create-user.sh ir-ssh-alias alice
# Create users on Europe
./scripts/create-user.sh root@203.0.113.1 admin --admin
./scripts/create-user.sh root@203.0.113.1 bobThe second argument is the Matrix username. The optional --admin flag grants admin privileges. You will be prompted for a password.
Reset a user's password:
./scripts/reset-password.sh ir-ssh-alias aliceYou will be prompted for the new password and admin credentials.
Deactivate a user:
./scripts/deactivate-user.sh ir-ssh-alias aliceDeactivation erases the user's profile data and prevents login, but the user ID remains permanently reserved in the Matrix spec — it cannot be re-registered with create-user.sh. Use reactivate-user.sh to restore it. Requires admin credentials.
Reactivate a deactivated user:
./scripts/reactivate-user.sh ir-ssh-alias aliceRestores a previously deactivated user and sets a new password. Requires admin credentials.
Summary of user management scripts:
| Script | Purpose | Prompts for |
|---|---|---|
create-user.sh |
Create a new user | User password |
reset-password.sh |
Change password for an active user | New password, admin credentials |
deactivate-user.sh |
Deactivate user (profile erased, ID reserved) | Admin credentials |
reactivate-user.sh |
Restore a deactivated user with new password | New password, admin credentials |
- Iran: Open
https://chat.example.com - Europe: Open
https://chat-eu.example.com
For mobile, download Element and set the homeserver to https://ir.example.com or https://eu.example.com.
Federation lets users on different servers communicate. In this setup, federation traffic flows through xray tunnels (VLESS over WebSocket) between servers because they may not have direct network connectivity.
Each server uses a different federation port:
- Iran: listens on port 8448
- Europe: listens on port 8449
Traffic between them flows through an xray tunnel (VLESS over WebSocket):
Iran Synapse ──► 127.0.0.1:8449 ──► [xray forward] ──► EU Nginx :8449
EU Synapse ──► 127.0.0.1:8448 ──► [xray reverse] ──► Iran Nginx :8448
The mechanism uses four pieces, all configured automatically by the playbook:
-
/etc/hostsentries — Each server resolves the other's hostname to127.0.0.1, so federation traffic goes to localhost instead of the real IP. -
Xray tunnel — Iran runs xray as a bridge connecting to EU's xray portal through the SOCKS proxy (mihomo). A dokodemo-door inbound on Iran's port 8449 forwards traffic to EU's port 8449 (forward direction). EU's dokodemo-door on port 8448 uses xray's reverse proxy to route traffic back through the bridge to Iran's port 8448 (reverse direction). WebSocket transport keeps the connection alive through the SOCKS proxy.
-
iptables DNAT — Synapse runs inside Docker and resolves federated hostnames to
172.17.0.1(the Docker bridge IP viahost-gateway). An iptables PREROUTING rule DNATs172.17.0.1:<port>to127.0.0.1:<port>, routing traffic to the xray tunnel. Theroute_localnetsysctl is enabled to allow DNAT to localhost. -
.well-knownproxy in Nginx — Before federating, Synapse checkshttps://<server>:443/.well-known/matrix/serverto discover the federation port. Since the hostname resolves to localhost, this request hits the local Nginx. The playbook adds an Nginx server block for each federated peer's hostname that returns the correct.well-knownresponse (e.g.,{"m.server": "ir.example.com:8448"}). Without this, Synapse would see the local server's.well-knownand fail.
In group_vars/iran.yml:
matrix_federation_port: 8448
federation_peers:
- { hostname: "eu.example.com", port: 8449 }
matrix_federation_whitelist:
- "eu.example.com"In group_vars/europe.yml:
matrix_federation_port: 8449
federation_peers:
- { hostname: "ir.example.com", port: 8448 }
matrix_federation_whitelist:
- "ir.example.com"Each federation_peers entry configures:
/etc/hosts: resolveshostnameto127.0.0.1extra_hostsin docker-compose: resolveshostnametohost-gateway(172.17.0.1)- iptables DNAT: forwards
172.17.0.1:<port>to127.0.0.1:<port> - Nginx server block: serves correct
.well-knownforhostnamereturningport
SRV records help external Matrix servers discover your federation endpoints. They're optional for the xray tunnel setup between your own servers but recommended:
| Type | Name | Priority | Weight | Port | Target |
|---|---|---|---|---|---|
| SRV | _matrix-fed._tcp.ir |
10 | 0 | 8448 | ir.example.com |
| SRV | _matrix-fed._tcp.eu |
10 | 0 | 8449 | eu.example.com |
Note the different ports: Iran uses 8448, Europe uses 8449.
Re-run both playbooks after changing federation settings:
./scripts/setup.sh iran
./scripts/setup.sh europeA separate playbook (tunnel.yml) automates the federation tunnel setup using xray with VLESS over WebSocket. This is more reliable than SSH tunnels through SOCKS proxies, which tend to kill long-lived idle connections.
Prerequisites:
The Iran server must have a SOCKS proxy running on 127.0.0.1:2080. This can be any SOCKS5-compatible proxy — for example, mihomo, v2ray, xray, or sing-box. Setting up this proxy is out of scope for this project; it is assumed to already be running before the tunnel playbook is executed.
What the tunnel playbook does:
- Downloads the xray binary on the control machine and copies it to both servers
- Generates a VLESS UUID (saved in
credentials/tunnel_vless_uuid) - Deploys xray on Iran as a bridge:
xray-federation.servicewith a dokodemo-door inbound on port 8449 (forwards to EU's federation port through the VLESS tunnel)- Reverse bridge that allows EU to reach Iran's federation port back through the same connection
- All traffic routes through mihomo's SOCKS proxy via xray's
dialerProxy
- Deploys xray on EU as a portal:
xray.servicewith a VLESS-over-WebSocket server on port 8443 (accepts tunnel from Iran)- dokodemo-door inbound on port 8448 (routes traffic to Iran through the reverse bridge)
- Disables the old SSH tunnel service if present
Run it:
./scripts/setup-tunnel.shOr directly:
ansible-playbook tunnel.ymlConfiguration — edit group_vars/iran.yml:
tunnel_eu_ip: "203.0.113.1" # EU server's public IP
tunnel_eu_federation_port: 8449 # EU's federation port (matches europe.yml)
tunnel_socks_port: 2080 # SOCKS proxy port on Iran serverShared settings in group_vars/all.yml:
tunnel_vless_port: 8443 # Port EU listens on for VLESS connections
tunnel_vless_uuid: "..." # Auto-generated, stored in credentials/Check tunnel status:
ssh ir-ssh-host "systemctl status xray-federation"
ssh eu-ssh-host "systemctl status xray"
ssh ir-ssh-host "journalctl -u xray-federation -f"
ssh eu-ssh-host "journalctl -u xray -f"Check that federation ports are listening:
# On Iran server — should show :8448 listening (Nginx) and :8449 (xray dokodemo-door)
ss -tlnp | grep -E '8448|8449'
# On EU server — should show :8449 listening (Nginx), :8448 (xray reverse), :8443 (xray VLESS)
ss -tlnp | grep -E '8443|8448|8449'Check /etc/hosts entries:
ssh iran-ssh-host "grep example.com /etc/hosts"
# Expected: 127.0.0.1 eu.example.com
ssh eu-ssh-host "grep example.com /etc/hosts"
# Expected: 127.0.0.1 ir.example.comCheck iptables DNAT rules:
ssh iran-ssh-host "iptables -t nat -L PREROUTING -n | grep 8449"
# Expected: DNAT tcp -- 0.0.0.0/0 172.17.0.1 tcp dpt:8449 to:127.0.0.1:8449
ssh eu-ssh-host "iptables -t nat -L PREROUTING -n | grep 8448"
# Expected: DNAT tcp -- 0.0.0.0/0 172.17.0.1 tcp dpt:8448 to:127.0.0.1:8448Check route_localnet is enabled:
ssh iran-ssh-host "sysctl net.ipv4.conf.docker0.route_localnet"
# Expected: net.ipv4.conf.docker0.route_localnet = 1Test federation endpoint through the tunnel (from inside the Synapse container):
# On Iran, test reaching EU's federation endpoint
ssh iran-ssh-host "cd /opt/matrix && docker compose exec synapse \
curl -sf https://eu.example.com:8449/_matrix/federation/v1/version -k"
# On EU, test reaching Iran's federation endpoint
ssh eu-ssh-host "cd /opt/matrix && docker compose exec synapse \
curl -sf https://ir.example.com:8448/_matrix/federation/v1/version -k"Check .well-known delegation (from inside the Synapse container — this is critical):
# On EU, check what Synapse sees when looking up Iran's .well-known
ssh eu-ssh-host "docker exec synapse curl -sk https://ir.example.com/.well-known/matrix/server"
# Expected: {"m.server": "ir.example.com:8448"}
# On Iran, check what Synapse sees when looking up EU's .well-known
ssh iran-ssh-host "docker exec synapse curl -sk https://eu.example.com/.well-known/matrix/server"
# Expected: {"m.server": "eu.example.com:8449"}If the .well-known returns the local server's info instead of the peer's, the Nginx proxy block for the federated hostname is missing or misconfigured.
Check Synapse federation logs:
ssh iran-ssh-host "cd /opt/matrix && docker compose logs synapse 2>&1 | grep -i federation | tail -20"Important: Each server in a federated room sees room membership and message timestamps (metadata). Message content remains E2E encrypted.
Applied automatically on all instances:
| Measure | Detail |
|---|---|
| E2E encryption | Enabled by default on all rooms |
| Registration | Disabled — admin creates accounts manually |
| Email/phone | Not required or stored |
| Federation | Whitelist-only (only listed servers allowed) |
| Message retention | Auto-purge after 30 days (configurable) |
| Logging | WARNING level only — minimal metadata in logs |
| Telemetry | Disabled (report_stats: false) |
| Key servers | No external key servers configured |
| Guest access | Disabled |
| Rate limiting | Relaxed for internal use (10/s, burst 50) |
- Message content — encrypted with Megolm/Olm, keys only on client devices
- Media in E2E rooms — encrypted at rest
- Metadata — who talked to whom, room membership, timestamps
- User accounts — usernames (but no email/phone if not configured)
- Unencrypted rooms — if any user manually disables encryption
Iran — Let's Encrypt certificates expire after 90 days. Re-run locally:
./scripts/get-cert.sh example.com
./scripts/sync-certs.sh example.com localhost iran-ssh-hostEurope — auto-renewal is set up via a weekly cron job during deployment (certbot renew every Monday at 03:30, restarts nginx on success). No manual action needed.
Re-run playbook (idempotent):
./scripts/setup.sh iran
./scripts/setup.sh europeView logs:
ssh iran-ssh-host "cd /opt/matrix && docker compose logs -f synapse"
ssh eu-ssh-host "cd /opt/matrix && docker compose logs -f synapse"Restart services:
ssh iran-ssh-host "cd /opt/matrix && docker compose restart"Update container images:
ssh eu-ssh-host "cd /opt/matrix && docker compose pull && docker compose up -d"The project uses Ansible host groups with per-region variable files. The setup.sh script limits the playbook run to the specified group or host using --limit.
group_vars/
├── all.yml # Shared settings (secrets, paths, hardening, retention)
├── iran.yml # Iran: proxy, Docker mirrors, federation port 8448
└── europe.yml # Europe: direct internet, certbot, federation port 8449
| Feature | Iran | Europe |
|---|---|---|
| Internet access | SSH-tunneled proxy for apt | Direct |
| Docker images | SSH-tunneled proxy or Iranian mirrors | Docker Hub |
| TLS certificates | Local certbot + push via sync-certs.sh |
Certbot on server (Cloudflare DNS-01) |
| Federation port | 8448 | 8449 |
| Timezone | Asia/Tehran | Europe/Berlin |
| Element country | IR | DE |
| Element URL | chat.example.com |
chat-eu.example.com |
To add a new region, create group_vars/<region>.yml, add hosts to the inventory, and run ./scripts/setup.sh <region>.
| Variable | Default | Description |
|---|---|---|
matrix_registration_shared_secret |
auto-generated | Secret for user registration API |
matrix_macaroon_secret_key |
auto-generated | Macaroon signing key |
matrix_form_secret |
auto-generated | Form signing secret |
postgres_password |
auto-generated | PostgreSQL password |
matrix_base_dir |
/opt/matrix |
Base directory for all Matrix files on server |
matrix_enable_registration |
false |
Public registration (always off) |
matrix_retention_max_lifetime |
30d |
Message retention period before auto-purge |
matrix_minimal_logging |
true |
Use WARNING-level logging only |
apt_proxy_port |
8185 |
Port for SSH-tunneled apt proxy |
tunnel_vless_uuid |
auto-generated | VLESS UUID for xray federation tunnel |
tunnel_vless_port |
8443 |
Port EU listens on for xray VLESS connections |
| Variable | Iran | Europe | Description |
|---|---|---|---|
use_proxy |
true |
false |
Whether to tunnel apt through SSH reverse proxy |
apt_proxy_domains |
["download.docker.com"] |
— | If set, only proxy apt traffic for these domains (others go direct via local mirrors) |
docker_registry_mirrors |
Iranian mirrors (arvancloud, runflare) | [] |
Docker daemon registry mirrors |
docker_pull_proxy |
— (or http://127.0.0.1:8185 if no mirrors) |
— | HTTP proxy for Docker image pulls (fallback when mirrors are unreachable) |
certbot_on_server |
false |
true |
Whether to run certbot directly on the server |
cloudflare_api_token |
— | from credentials/cloudflare_token |
Cloudflare API token for DNS-01 challenge |
matrix_server_name |
ir.example.com |
eu.example.com |
Matrix server identity (cannot change after first run) |
matrix_hostname |
ir.example.com |
eu.example.com |
Hostname Nginx listens on for client API |
element_hostname |
chat.example.com |
chat-eu.example.com |
Hostname for Element web client |
matrix_tls_cert_dir |
/etc/letsencrypt/live/example.com |
/etc/letsencrypt/live/eu.example.com |
Directory containing fullchain.pem and privkey.pem |
matrix_timezone |
Asia/Tehran |
Europe/Berlin |
Server timezone |
element_country_code |
IR |
DE |
Default country code in Element phone number picker |
matrix_federation_port |
8448 |
8449 |
Port Nginx listens on for federation traffic |
federation_peers |
[{hostname: "eu.example.com", port: 8449}] |
[{hostname: "ir.example.com", port: 8448}] |
Federated servers: configures /etc/hosts, iptables DNAT, docker extra_hosts, and Nginx .well-known proxy |
matrix_federation_whitelist |
["eu.example.com"] |
["ir.example.com"] |
Servers allowed to federate (empty list disables federation) |
tunnel_eu_ip |
203.0.113.1 |
— | EU server's public IP (xray portal address) |
tunnel_eu_federation_port |
8449 |
— | EU's federation port (forwarded via xray dokodemo-door) |
tunnel_socks_port |
2080 |
— | SOCKS5 proxy port on Iran server (mihomo, v2ray, etc.) |
matrix-bridge-deploy/
├── ansible.cfg # Ansible settings (pipelining, SSH args)
├── inventory/hosts.yml # SSH hosts (gitignored)
├── group_vars/
│ ├── all.yml # Shared: secrets, paths, retention, proxy port
│ ├── iran.yml # Iran: proxy, mirrors, federation port 8448
│ └── europe.yml # Europe: certbot, direct access, port 8449
├── playbook.yml # Main deployment: pre_tasks (proxy), roles, post_tasks (cleanup)
├── tunnel.yml # Federation tunnel: xray VLESS-over-WebSocket setup on both servers
├── credentials/ # Auto-generated secrets + cloudflare token (gitignored)
├── roles/
│ ├── common/tasks/main.yml # Base packages, timezone, /etc/hosts, sysctl
│ ├── docker/tasks/main.yml # Docker CE install, registry mirrors, proxy config
│ ├── federation/tasks/main.yml # route_localnet, iptables DNAT (runs after Docker)
│ ├── certbot/tasks/main.yml # On-server certbot + Cloudflare DNS (EU only)
│ ├── xray-tunnel/
│ │ ├── tasks/main.yml # Xray install, bridge/portal config, systemd services
│ │ ├── handlers/main.yml # Service restart triggers
│ │ └── templates/
│ │ ├── xray-bridge.json.j2 # Iran: VLESS client + dokodemo-door + reverse bridge
│ │ ├── xray-portal.json.j2 # EU: VLESS server + dokodemo-door + reverse portal
│ │ ├── xray-federation.service.j2 # Iran systemd service
│ │ └── xray.service.j2 # EU systemd service
│ └── matrix/
│ ├── tasks/ # Deployment logic
│ ├── handlers/ # Service restart triggers
│ └── templates/
│ ├── docker-compose.yml.j2 # Synapse, Postgres, Element, Nginx containers
│ ├── homeserver.yaml.j2 # Synapse config (federation whitelist, retention, E2E)
│ ├── element-config.json.j2 # Element web client config
│ ├── nginx.conf.j2 # Nginx: :443 client, :8448/:8449 federation, Element vhost
│ └── log.config.j2 # Python logging config for Synapse
└── scripts/
├── setup.sh # Deploy: ./setup.sh <region|host> (auto-detects proxy need)
├── setup-tunnel.sh # Set up xray federation tunnel between Iran and EU
├── proxy.py # HTTP forward proxy for SSH tunnel (Iran deployments)
├── get-cert.sh # Get wildcard cert locally via Cloudflare DNS-01
├── sync-certs.sh # Push certs to server: ./sync-certs.sh <domain> <source> <target-ssh-host>
├── create-user.sh # Create user: ./create-user.sh <ssh-host> <username> [--admin]
├── deactivate-user.sh # Deactivate user: ./deactivate-user.sh <ssh-host> <username>
├── reactivate-user.sh # Reactivate user: ./reactivate-user.sh <ssh-host> <username>
└── reset-password.sh # Reset password: ./reset-password.sh <ssh-host> <username>
The playbook (playbook.yml) runs in this order:
- pre_tasks — If
use_proxyis true, writes apt proxy config pointing to127.0.0.1:<apt_proxy_port>and tests connectivity - Role: common — Installs base packages, sets timezone, adds
/etc/hostsentries for federation, creates/opt/matrix - Role: docker — Installs Docker CE, configures
daemon.jsonwith registry mirrors, sets up Docker proxy ifdocker_pull_proxyis defined, restarts Docker if config changed - Role: federation — (after Docker) Enables
route_localnetfordocker0, adds iptables DNAT rules for federation ports, persists iptables rules - Role: matrix — Renders templates (docker-compose, homeserver.yaml, nginx.conf, element-config), pulls images, starts containers
- Role: certbot — (EU only, skipped when
certbot_on_serveris false) Installs certbot + Cloudflare plugin, obtains certificates, sets up weekly renewal cron - post_tasks — Removes apt proxy and Docker proxy configurations if they were set