Secure communications remain one of the most challenging problems in enterprise security. Commercial platforms leak metadata, centralized services create single points of compromise, and most “encrypted” tools still expose message graphs to the platform operator. Matrix, the open federation protocol, offers a self-hosted alternative with genuine end-to-end encryption — but deploying it in a hardened, air-gapped environment requires careful architecture decisions that most guides gloss over.
This article walks through a production-grade Matrix deployment with E2EE bridges, covering the security boundaries that actually matter.
Architecture Overview
The target architecture separates concerns across three zones:
- DMZ: Reverse proxy (TLS termination, rate limiting)
- Application Zone: Synapse homeserver, bridge processes, Pantalaimon E2EE proxy
- Data Zone: PostgreSQL with TDE, encrypted media store
┌─────────────────────────────────────────────┐
│ DMZ │
│ ┌─────────────────┐ │
│ │ reverse-proxy01 │ TLS termination │
│ │ (Apache/Traefik) │ /.well-known routing │
│ └────────┬────────┘ │
├───────────┼─────────────────────────────────┤
│ App Zone │ │
│ ┌────────▼────────┐ ┌──────────────────┐ │
│ │ synapse01 │ │ pantalaimon01 │ │
│ │ (homeserver) │ │ (E2EE proxy) │ │
│ └────────┬────────┘ └──────────────────┘ │
│ │ │
│ ┌────────▼────────┐ ┌──────────────────┐ │
│ │ mautrix-signal │ │ mautrix-whatsapp │ │
│ │ mautrix-slack │ │ mautrix-discord │ │
│ └────────┬────────┘ └──────────────────┘ │
├───────────┼─────────────────────────────────┤
│ Data Zone│ │
│ ┌────────▼────────┐ ┌──────────────────┐ │
│ │ postgres01 │ │ media-store01 │ │
│ │ (TDE enabled) │ │ (LUKS volume) │ │
│ └─────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────┘
Deploying Synapse in a Hardened Environment
Start with a minimal Synapse deployment. Avoid the Docker image for air-gapped environments — use pip install into a dedicated virtualenv with vendored wheels.
# homeserver.yaml — security-relevant excerpts
server_name: "comms.example-corp.com"
public_baseurl: "https://comms.example-corp.com"
# Disable federation for air-gapped deployments
federation_domain_whitelist: []
allow_public_rooms_over_federation: false
allow_public_rooms_without_auth: false
# Force E2EE on all rooms
encryption_enabled_by_default_for_room_type: all
# Database — use PostgreSQL, never SQLite in production
database:
name: psycopg2
args:
host: postgres01.internal
port: 5432
database: synapse
user: synapse_svc
password_file: /etc/synapse/secrets/db_password
sslmode: verify-full
sslrootcert: /etc/synapse/certs/ca.pem
# Rate limiting
rc_message:
per_second: 0.5
burst_count: 10
rc_login:
address:
per_second: 0.1
burst_count: 3
account:
per_second: 0.1
burst_count: 3
# Media storage on encrypted volume
media_store_path: "/mnt/encrypted-media/synapse"
max_upload_size: "50M"
url_preview_enabled: false # Disable in air-gapped — prevents SSRF
# Retention policy
retention:
enabled: true
default_policy:
min_lifetime: 1d
max_lifetime: 365d
# Logging — no message content in logs
log_config: "/etc/synapse/log.yaml"
Database Encryption at Rest
PostgreSQL Transparent Data Encryption protects against offline disk attacks. On PostgreSQL 16+:
# Initialize encrypted cluster
initdb --data-encryption aes-256
--data-checksums
-D /var/lib/postgresql/16/encrypted
# Or use LUKS at the volume level (more common)
cryptsetup luksFormat /dev/vdb
cryptsetup luksOpen /dev/vdb pg_encrypted
mkfs.ext4 /dev/mapper/pg_encrypted
mount /dev/mapper/pg_encrypted /var/lib/postgresql/16/main
Store the LUKS key in a TPM or HSM. For air-gapped deployments where key servers are unavailable, use systemd-cryptenroll with a TPM2 PCR policy so the volume auto-unlocks only when the expected OS is booted.
Bridge Security Boundaries
Bridges are the most security-sensitive components. Each bridge is a separate process with its own appservice registration, database, and credentials. Never run bridges as root. Never co-locate bridge databases with Synapse.
Signal Bridge (mautrix-signal)
# mautrix-signal config.yaml
homeserver:
address: http://synapse01.internal:8008
domain: comms.example-corp.com
appservice:
address: http://localhost:29328
database:
type: postgres
uri: postgres://signal_bridge:[email protected]/mautrix_signal?sslmode=verify-full
bridge:
encryption:
allow: true
default: true
require: true # Refuse to bridge unencrypted rooms
delete_keys:
delete_outbound_on_ack: true
dont_store_outbound: false
ratchet_on_decrypt: true
delete_fully_used_on_decrypt: true
delete_prev_on_new_session: true
verification: true
rotation:
enable_custom: true
milliseconds: 604800000 # 7 days
messages: 100
# Limit who can use the bridge
permissions:
"comms.example-corp.com":
"@admin:comms.example-corp.com": admin
"@*:comms.example-corp.com": user
Metadata Exposure Risks
Even with E2EE, bridges expose metadata. Understand what leaks:
| Data Point | Signal Bridge | WhatsApp Bridge | Slack Bridge |
|—|—|—|—|
| Message timestamps | Visible to homeserver | Visible to homeserver | Visible to homeserver + Slack |
| Sender/recipient | Visible to homeserver | Visible to homeserver | Visible to homeserver + Slack |
| Message content | E2EE (not visible) | E2EE (not visible) | Visible to Slack |
| Read receipts | Synced | Synced | Synced to Slack |
| Typing indicators | Synced | Synced | Synced to Slack |
| Group membership | Visible to homeserver | Visible to homeserver | Visible to both |
The Slack and Discord bridges are fundamentally different from Signal/WhatsApp bridges. Slack and Discord are not E2EE platforms — the bridge encrypts traffic on the Matrix side, but messages are plaintext on the Slack/Discord side. Use these bridges for convenience, not security.
Pantalaimon for Bot and Service E2EE
Bots and automated services cannot perform interactive key verification. Pantalaimon acts as an E2EE-aware proxy that handles Olm/Megolm key management on behalf of non-E2EE-capable clients.
# pantalaimon.conf
[Default]
LogLevel = Warning
Notifications = Off
[comms-proxy]
Homeserver = http://synapse01.internal:8008
ListenAddress = 127.0.0.1
ListenPort = 8009
IgnoreVerification = false
UseKeyring = false
SSL = false
# Store keys in an encrypted database
[Keys]
KeyStoreType = database
KeyStorePath = /var/lib/pantalaimon/keys.db
Services connect to Pantalaimon (port 8009) instead of Synapse directly. Pantalaimon transparently encrypts outgoing messages and decrypts incoming ones.
# Bot connecting through Pantalaimon
import aiohttp
async def send_secure_message(room_id: str, message: str):
"""Send E2EE message through Pantalaimon proxy."""
async with aiohttp.ClientSession() as session:
await session.put(
f"http://127.0.0.1:8009/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn_id}",
headers={"Authorization": f"Bearer {BOT_ACCESS_TOKEN}"},
json={"msgtype": "m.text", "body": message}
)
# Pantalaimon handles Megolm session creation and encryption
Key Verification Workflows
For high-security environments, establish a key verification protocol:
- Initial device verification: Use SAS (Short Authentication String) emoji verification for first-time device pairing. This must happen in-person or over a verified voice channel.
- Cross-signing: Enable cross-signing so users verify their own devices, and other users verify the user identity once.
- Verification bot: Deploy a verification enforcement bot that monitors rooms and flags unverified devices.
# verification_enforcer.py — conceptual excerpt
async def on_room_member(event):
user_id = event.sender
devices = await client.query_keys({user_id: []})
unverified = [
d for d in devices[user_id].values()
if not d.verified
]
if unverified:
await client.send_notice(
event.room_id,
f"⚠ {user_id} has {len(unverified)} unverified device(s). "
f"Please verify before sharing sensitive information."
)
Media Store Hardening
Media uploads are stored outside the database and represent an exfiltration risk. Harden the media store:
# Mount encrypted tmpfs for in-transit media processing
mount -t tmpfs -o size=512M,mode=0700,uid=synapse,gid=synapse tmpfs /tmp/synapse-media
# Set filesystem ACLs on the permanent media store
setfacl -R -m u:synapse:rwX /mnt/encrypted-media/synapse
setfacl -R -d -m u:synapse:rwX /mnt/encrypted-media/synapse
setfacl -R -m g::--- /mnt/encrypted-media/synapse
# Enable audit logging on media directory
auditctl -w /mnt/encrypted-media/synapse -p rwa -k synapse_media
Configure Synapse to strip EXIF metadata from uploaded images and scan media with ClamAV before serving:
# homeserver.yaml
media_storage_providers:
- module: file_system
store_local: true
store_remote: false
config:
directory: /mnt/encrypted-media/synapse
Operational Considerations
Backup strategy: Back up the Synapse database and media store to an encrypted target. E2EE keys stored in the database are essential — losing them means losing message history. Use pg_dump with --format=custom and encrypt the output with age or GPG before writing to backup media.
Monitoring: Monitor Synapse metrics via Prometheus. Key metrics: synapse_federation_server_pdu_count (should be 0 in air-gapped), synapse_storage_events (growth rate), and synapse_handler_presence_notify_count.
Incident response: If a bridge credential is compromised, immediately revoke the appservice token in Synapse, rotate the bridge’s database credentials, and issue a POST /_synapse/admin/v1/reset_password for the bridge bot user. Review room membership changes during the compromise window.
This architecture provides genuine E2EE communications with bridge convenience, while maintaining the security boundaries that matter in sensitive environments. The key insight is that bridges are trust boundaries — treat each one as a separate service with its own blast radius.
