~ 18 min read
Self-Hosting Hermes Agent with Matrix E2EE on Proxmox
A self-hosted assistant is only useful if you can reach it safely when you are away from your desk. This setup gives Hermes Agent a private, encrypted mobile interface without exposing Synapse or Hermes to the public Internet.
The architecture separates three jobs that are easy to conflate: Tailscale provides private reachability, HTTPS gives Element a trusted homeserver endpoint, and Matrix end-to-end encryption protects messages between devices. Synapse runs in one Proxmox LXC, Hermes runs in another, and only the Matrix container joins the tailnet.
Android phone
βββ Element
βββ Tailscale
β
β HTTPS + Matrix E2EE
βΌ
matrix.<tailnet>.ts.net
β
βΌ
Proxmox: Matrix LXC
βββ Tailscale
β βββ Tailscale Serve :443
β βββ http://127.0.0.1:8008
βββ Synapse :8008
β
β private LAN
βΌ
Proxmox: Hermes LXC
βββ Hermes Matrix gateway
The result is a private Matrix homeserver, Element on Android as the mobile UI, E2EE between Element and Hermes, and a dedicated bot identity restricted by a sender allowlist. The same encrypted direct-message room can later receive reminders, cron output, and other proactive notifications.
This guide uses a Proxmox LXC container for Synapse, not a full VM. Most Matrix, Tailscale, and Hermes steps also work in a VM, but the TUN and nesting settings are specific to LXC.
Reference environment
The working deployment behind this guide used the following components. All network and account identifiers below are documentation examples, not live endpoints.
| Component | Reference setup |
|---|---|
| Hypervisor | Proxmox VE 8.4.x |
| Matrix OS | Debian 13 LXC |
| Matrix implementation | Synapse |
| Matrix LXC hostname | matrix |
| Matrix LAN IP | 192.168.50.20 |
| Tailnet DNS suffix | tail-example.ts.net |
| Matrix FQDN | matrix.tail-example.ts.net |
| Human Matrix account | @owner:matrix.tail-example.ts.net |
| Bot Matrix account | @hermes:matrix.tail-example.ts.net |
| Hermes host | Separate Proxmox LXC |
| Mobile client | Element on Android |
| Remote transport | Tailscale |
| Matrix client transport | HTTPS via Tailscale Serve |
| Message encryption | Matrix E2EE |
Replace the example IP address, usernames, and tailnet name with your own values. In the commands that follow, assume:
MATRIX_LAN_IP=192.168.50.20
TAILNET_DNS=tail-example.ts.net
MATRIX_FQDN=matrix.tail-example.ts.net
Why this architecture works
Matrix identity, network reachability, and message encryption are related, but none substitutes for the others. Keeping those boundaries explicit makes the deployment easier to debug and avoids exposing more of the homelab than necessary.
Matrix identity
Synapseβs server_name becomes the domain portion of every Matrix identity:
@owner:matrix.tail-example.ts.net
@hermes:matrix.tail-example.ts.net
Choose it carefully. Synapse treats server_name as a foundational identifier, so you should not plan to rename it later.
Private reachability and HTTPS
The Android phone needs to reach Synapse remotely. Instead of forwarding a router port, install Tailscale directly in the Matrix LXC. MagicDNS assigns the node a tailnet DNS name, and Tailscale Serve terminates HTTPS before proxying to Synapseβs local HTTP listener:
https://matrix.tail-example.ts.net
β
βΌ
Tailscale Serve
β
βΌ
http://127.0.0.1:8008
The endpoint remains available only inside the tailnet. Do not enable Tailscale Funnel for this design.
Matrix E2EE
HTTPS protects transport between a client and the homeserver. Matrix E2EE is a separate layer: Element encrypts a message for Matrix devices before Synapse stores or forwards it, and Hermes decrypts it through its Matrix crypto store.
Matrix message
β
βββ encrypted by Matrix E2EE
βΌ
HTTPS over Tailscale
β
βΌ
Synapse
This distinction matters later because Hermes can contact Synapse over the LAN without giving up room encryption.
Choose the permanent Matrix name first
Before installing Synapse, open DNS in the Tailscale admin console and find the DNS suffix assigned to your tailnet. If the suffix were tail-example.ts.net and the LXC hostname were matrix, the resulting MagicDNS name would be:
matrix.tail-example.ts.net
Use that full name as the Synapse server_name. This couples every Matrix user ID to the tailnet DNS namespace, so treat the tailnet suffix and the matrix machine name as long-lived identifiers.
Create the Synapse LXC
The Proxmox VE Helper-Scripts project provides an Element Synapse installer. Run it from the Proxmox host shell:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/elementsynapse.sh)"
This executes a remotely downloaded script. Review the script or pin a trusted revision first if that is required by your threat model.
Choose Advanced Install and use these settings:
OS: Debian 13
Container type: Unprivileged
Hostname: matrix
Disk: 8 GB
CPU: 1 core
RAM: 2048 MiB
Bridge: vmbr0
IPv4: static IP or DHCP reservation
TUN/TAP: YES
Nesting: YES
Keyctl: Enabled
FUSE: No
GPU passthrough: No
One core and 2 GB RAM are a reasonable starting point for a small personal server.
Enable TUN/TAP
Tailscale on Linux normally needs /dev/net/tun. An unprivileged LXC does not receive access automatically, so enable TUN/TAP support. Tailscale documents this /dev/net/tun passthrough in its guide to running Tailscale inside an unprivileged LXC.
Enable nesting on Debian 13
Enable Nesting even though this container does not run Docker. Debian 13 and systemd inside a Proxmox LXC can otherwise produce a confusing state: the container runs and networking works, but the Proxmox console shows only a blank cursor. Proxmox staff have connected this behavior to cgroup nesting requirements in newer systemd configurations.
If you created the container without nesting, enter it from the host:
pct enter <CTID>
Then enable CT β Options β Features β Nesting and perform a full stop and start:
pct stop <CTID>
pct start <CTID>
Set and verify the Synapse server name
The installer warns that the server name cannot be changed later. Enter the full Tailscale FQDN without https://:
matrix.tail-example.ts.net
These are incorrect:
https://matrix.tail-example.ts.net
@owner:matrix.tail-example.ts.net
After installation, enter the Matrix LXC and check Synapse:
systemctl status matrix-synapse --no-pager
curl http://127.0.0.1:8008/_matrix/client/versions
You should receive HTTP 200 and Matrix JSON similar to:
{
"versions": ["r0.6.1", "v1.1", "v1.2"],
"unstable_features": {}
}
The exact version list will differ. At this point you have verified Synapse itself, without involving DNS, TLS, or Tailscale.
Close registration and create accounts
Inspect the registration settings:
grep -E '^(enable_registration|enable_registration_without_verification):' \
/etc/matrix-synapse/homeserver.yaml
The helper installation may leave enable_registration_without_verification: true. For a private homeserver, make registration explicitly closed:
enable_registration_without_verification: false
enable_registration: false
You can apply that configuration from the shell:
sed -i \
's/^enable_registration_without_verification:.*/enable_registration_without_verification: false/' \
/etc/matrix-synapse/homeserver.yaml
grep -q '^enable_registration:' /etc/matrix-synapse/homeserver.yaml || \
echo 'enable_registration: false' >> /etc/matrix-synapse/homeserver.yaml
systemctl restart matrix-synapse
systemctl status matrix-synapse --no-pager
Keep the registration_shared_secret generated by the helper. The Synapse configuration reference documents it as the mechanism used by register_new_matrix_user. Check for its presence without printing the secret:
grep -q '^registration_shared_secret:' /etc/matrix-synapse/homeserver.yaml \
&& echo "registration_shared_secret exists" \
|| echo "registration_shared_secret MISSING"
Create separate non-admin human and bot accounts:
register_new_matrix_user \
--no-admin \
--user owner \
--config /etc/matrix-synapse/homeserver.yaml \
http://127.0.0.1:8008
register_new_matrix_user \
--no-admin \
--user hermes \
--config /etc/matrix-synapse/homeserver.yaml \
http://127.0.0.1:8008
The resulting IDs are:
@owner:matrix.tail-example.ts.net
@hermes:matrix.tail-example.ts.net
Do not use the generated Synapse administrator account for normal chat. The helper exposes its administrator credentials with cat ~/matrix.creds; that command prints secrets to the terminal, so use it only in a private session and never paste its output into support chats or logs.
Put the Matrix LXC on Tailscale
Install Tailscale directly inside the Matrix LXC even if another container already acts as a subnet router. A subnet router provides IP connectivity to the LAN address, but it does not turn Matrix into a Tailscale node with its own MagicDNS identity and HTTPS Serve endpoint.
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --hostname=matrix
As with any curl | sh installation, inspect or pin the downloaded script when your environment requires tighter supply-chain controls. Authenticate through the URL that Tailscale prints, then verify the node:
tailscale status
tailscale ip -4
tailscale status --json | grep -A2 '"DNSName"'
You should see a Tailscale IP such as 100.x.y.z, a peer named matrix, and output similar to:
"DNSName": "matrix.tail-example.ts.net."
The trailing dot is normal DNS notation. If another tailnet node advertises subnet routes, Matrix may warn that --accept-routes is false. That is harmless here because Matrix already has a native LAN interface and does not need to consume those routes.
Enable MagicDNS, HTTPS, and Serve
Tailscale Serve requires MagicDNS and HTTPS certificates. In the Tailscale admin console, enable both:
DNS
βββ MagicDNS: Enabled
βββ HTTPS Certificates: Enabled
Tailscale obtains publicly trusted certificates for the deviceβs *.ts.net name. Its HTTPS certificate documentation notes that certificate names enter public Certificate Transparency logs. This reveals the machine and tailnet hostname, but it does not grant access to the service.
Synapse listens locally on http://127.0.0.1:8008. Configure a persistent tailnet-only proxy:
tailscale serve --bg 8008
If Serve is not yet enabled, follow the authorization URL printed by Tailscale. Do not enable Funnel. Check the configuration with:
tailscale serve status
Expected output:
https://matrix.tail-example.ts.net (tailnet only)
|-- / proxy http://127.0.0.1:8008
The --bg flag preserves the Serve configuration through ordinary Tailscale restarts and host reboots. The Tailscale Serve CLI reference documents the current command behavior.
Validate the remote Matrix endpoint
Test the public-certificate HTTPS path on the Matrix LXC:
curl -v \
https://matrix.tail-example.ts.net/_matrix/client/versions
You should see SSL certificate verify ok, an HTTP 200 response, and the Matrix JSON body. Repeat the command from a different Tailscale node. That second test verifies all of these layers together:
MagicDNS OK
Tailscale connectivity OK
TLS certificate OK
Tailscale Serve OK
Synapse OK
On Android, join the same tailnet and enable Use Tailscale DNS settings. You do not need an exit node; exit nodes route general Internet traffic and are unrelated to direct tailnet communication.
With Wi-Fi disabled and mobile data enabled, open this URL in Chrome:
https://matrix.tail-example.ts.net/_matrix/client/versions
Seeing Matrix JSON is the definitive mobile-network test. If Tailscale connects but the hostname does not resolve, check the Android DNS setting and confirm that MagicDNS is enabled for the tailnet.
Configure Element on Android
Install Element, choose a custom homeserver, and enter:
https://matrix.tail-example.ts.net
Sign in with the human account:
@owner:matrix.tail-example.ts.net
Element should discover the homeserver and complete login. If it offers account recovery or security backup, store the recovery material securely. This completes the mobile-to-Matrix side before Hermes enters the path.
Prepare the Hermes LXC for Matrix E2EE
Perform the remaining installation on the Hermes LXC as the user that owns Hermes:
su - hermes
whoami
echo "$HOME"
Expected output:
hermes
/home/hermes
The managed Hermes source installation normally lives at /home/hermes/.hermes/hermes-agent. Do not install Python packages into this tree as root.
On Debian or Ubuntu, install the native libolm dependency as root or through sudo:
sudo apt update
sudo apt install -y libolm-dev
Return to the hermes user. If uv --version reports command not found, install uv for that user:
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.bashrc
uv --version
Do not rely on a copy under /root/.local/bin. The Hermes user needs its own normal user-level installation.
Hermes already has a managed virtual environment, although an interactive shell may not activate it. Running uv pip install -e ".[matrix]" directly can therefore fail with No virtual environment found. Target the existing environment explicitly:
cd ~/.hermes/hermes-agent
ls -l venv/bin/python
uv pip install \
--python ./venv/bin/python \
-e ".[matrix]"
Do not create a second environment with uv venv and do not install into the system Python. Verify the dependencies:
./venv/bin/python -c "import mautrix; print('mautrix OK')"
./venv/bin/python -c "import olm; print('olm OK')"
Expected output:
mautrix OK
olm OK
The Hermes Matrix setup guide documents mautrix encryption extras and libolm as E2EE requirements.
Let Hermes use the private LAN
Hermes does not need its own Tailscale client. The Android client uses the trusted Tailscale HTTPS endpoint, while Hermes and Matrix already share the homelab LAN. Hermes can reach the same Synapse server at:
http://192.168.50.20:8008
Test that path from the Hermes LXC:
curl -sS \
http://192.168.50.20:8008/_matrix/client/versions \
| head -c 200
echo
The three values now serve different purposes:
Synapse server_name / Matrix identity:
matrix.tail-example.ts.net
Element homeserver URL:
https://matrix.tail-example.ts.net
Hermes homeserver URL:
http://192.168.50.20:8008
Hermes still logs in as @hermes:matrix.tail-example.ts.net; it merely uses a different endpoint to contact the same Synapse server. Matrix E2EE continues to protect message content. The HTTP LAN hop changes transport between Hermes and Synapse, not room encryption.
| Hermes-to-Synapse option | Advantages | Trade-offs |
|---|---|---|
| Private LAN HTTP | No extra Tailscale client or MagicDNS dependency in Hermes | Trusts the homelab LAN for transport metadata |
| Tailnet HTTPS | Consistent HTTPS endpoint from every client | Adds Tailscale state and DNS dependency to the Hermes LXC |
| Public reverse proxy | Works without a tailnet client | Expands the attack surface and is unnecessary for this design |
Configure the Hermes Matrix gateway
Run the setup wizard as hermes:
hermes gateway setup
Select Matrix and provide:
Homeserver URL:
http://192.168.50.20:8008
Access token (leave empty for password login):
<press Enter>
User ID:
@hermes:matrix.tail-example.ts.net
Password:
<the password created for the Hermes Matrix account>
Enable end-to-end encryption (E2EE)?
y
Allowed user:
@owner:matrix.tail-example.ts.net
Home room ID:
<leave blank>
Hermes recommends access tokens, but password login is supported and is the path used here. Do not omit the user allowlist on a personal assistant that can access a terminal, files, or external tools. Leave the home room blank because the direct-message room does not exist yet.
Bootstrap cross-signing and recovery
A new Hermes Matrix account may connect while logging cross-signing keys are missing. Configure a one-time output file so Hermes can bootstrap a recovery key:
echo \
'MATRIX_RECOVERY_KEY_OUTPUT_FILE=/home/hermes/.hermes/matrix-recovery-key.txt' \
>> ~/.hermes/.env
chmod 600 ~/.hermes/.env
hermes gateway restart
Inspect the Matrix-related logs:
journalctl --user \
-u hermes-gateway \
-n 100 \
--no-pager \
| grep -i matrix
A successful bootstrap reports that Hermes created cross-signing for @hermes:... and wrote a recovery key with mode 0600. Do not print or share the key. Verify only the file permissions:
ls -l ~/.hermes/matrix-recovery-key.txt
Expected permissions:
-rw-------
Move the generated value into Hermesβs secret environment file without printing it:
sed -i '/^MATRIX_RECOVERY_KEY=/d' ~/.hermes/.env
printf 'MATRIX_RECOVERY_KEY=' >> ~/.hermes/.env
cat ~/.hermes/matrix-recovery-key.txt >> ~/.hermes/.env
printf '\n' >> ~/.hermes/.env
sed -i \
'/^MATRIX_RECOVERY_KEY_OUTPUT_FILE=/d' \
~/.hermes/.env
chmod 600 ~/.hermes/.env
Copy the recovery value into a secure password manager or secret store before removing the standalone file:
rm ~/.hermes/matrix-recovery-key.txt
The Hermes environment-variable reference documents MATRIX_RECOVERY_KEY for restoring cross-signing state and self-signing the Matrix device during startup.
Pin a stable Matrix device ID
E2EE device identity should remain stable across gateway restarts. Check the Matrix logs for the current device ID. It may resemble:
@hermes:matrix.tail-example.ts.net/HERMESBOT1
Pin the value reported by your instance:
sed -i '/^MATRIX_DEVICE_ID=/d' ~/.hermes/.env
echo 'MATRIX_DEVICE_ID=HERMESBOT1' >> ~/.hermes/.env
chmod 600 ~/.hermes/.env
hermes gateway restart
hermes gateway status
Replace HERMESBOT1 with the actual current device ID. A healthy user service reports Active: active (running) and, when installation enabled it, Systemd linger is enabled.
Validate configuration without leaking it
Never paste the complete ~/.hermes/.env into a troubleshooting conversation. This script checks whether the important values exist without printing them:
python3 - <<'PY'
from pathlib import Path
lines = (Path.home() / ".hermes/.env").read_text().splitlines()
for key in [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ALLOWED_USERS",
"MATRIX_E2EE_MODE",
"MATRIX_ENCRYPTION",
"MATRIX_DEVICE_ID",
"MATRIX_RECOVERY_KEY",
]:
matches = [x for x in lines if x.startswith(key + "=")]
if matches:
value = matches[-1].split("=", 1)[1]
print(f"{key}: {'OK' if value else 'EMPTY'}")
else:
print(f"{key}: not set")
PY
A wizard-created setup may report MATRIX_ENCRYPTION: OK and MATRIX_E2EE_MODE: not set. That remains valid because Hermes treats MATRIX_ENCRYPTION=true as backward-compatible required E2EE. The newer spelling is MATRIX_E2EE_MODE=required, but you do not need to alter a working wizard-generated configuration solely to adopt the new name.
Send the first encrypted DM
Follow the gateway logs in one terminal:
journalctl --user \
-u hermes-gateway \
-f \
-o cat
On Android, connect Tailscale, open Element, start a chat with @hermes:matrix.tail-example.ts.net, and send Hello Hermes. Hermes should accept the invitation and reply. Its Matrix adapter supports encrypted incoming messages, encrypted replies, automatic invite acceptance, and direct messages without an @mention.
That response validates the complete path:
Element Android
β
β Matrix E2EE
βΌ
Tailscale HTTPS
β
βΌ
Synapse
β
βΌ
Hermes Matrix gateway
β
βΌ
Hermes Agent
Inside the working DM, send /sethome. If Element intercepts slash commands locally, send !sethome. Hermes should confirm that this room is now its Matrix home room, making the same private DM the destination for reminders, cron jobs, notifications, and other gateway-initiated messages.
Security hardening checklist
Before treating the setup as a permanent assistant endpoint, verify every boundary explicitly.
- Registration is closed with
enable_registration: falseandenable_registration_without_verification: false. - Hermes is restricted with
MATRIX_ALLOWED_USERS=@owner:matrix.tail-example.ts.net. - E2EE is required through either
MATRIX_ENCRYPTION=trueorMATRIX_E2EE_MODE=required. MATRIX_RECOVERY_KEYis stored in~/.hermes/.env, and that file has mode0600.MATRIX_DEVICE_IDis stable across restarts.tailscale serve statussays(tailnet only).- Tailscale Funnel and public router port forwarding remain disabled.
Hermes warns that without a sender allowlist, anyone who can reach the bot in an eligible room may trigger agent turns. That risk is especially serious when the agent has terminal, filesystem, and external-tool access.
Review Hermesβs API-server exposure separately. If logs warn that the API server binds to 0.0.0.0 while using a local unsandboxed terminal backend, treat that as another hardening task. Matrix does not require a publicly exposed Hermes API listener.
Optional room allowlist
MATRIX_ALLOWED_USERS protects the personal DM because Hermes exempts direct-message rooms from the room filter. If you later add Hermes to ordinary Matrix rooms, configure MATRIX_ALLOWED_ROOMS too.
Find the internal room ID in Room β Settings β Advanced. It looks like:
!exampleRoom123:matrix.tail-example.ts.net
Then configure:
MATRIX_ALLOWED_ROOMS=!exampleRoom123:matrix.tail-example.ts.net
Use an internal ID beginning with !, not a room alias beginning with #.
Troubleshooting by layer
The fastest way to diagnose this stack is to test one boundary at a time. A single failed Element login does not tell you whether the problem sits in Synapse, MagicDNS, TLS, Tailscale, or Hermes.
The Debian 13 LXC console is blank
If the container runs and networking works but the console displays only a cursor, enter it with pct enter <CTID>, enable Options β Features β Nesting, and perform a full stop and start.
Tailscale fails in the unprivileged LXC
Verify the tunnel device:
ls -l /dev/net/tun
If it is missing, expose TUN/TAP through Proxmox. The required configuration is equivalent to enabling /dev/net/tun passthrough with the necessary LXC features.
The Matrix tailnet hostname does not resolve on Android
Enable Tailscale app β Use Tailscale DNS settings, then verify that MagicDNS is enabled for the tailnet. Android does not need an exit node.
HTTPS fails
Check Serve before changing Synapse:
tailscale serve status
curl -v \
https://matrix.tail-example.ts.net/_matrix/client/versions
If this succeeds inside the Matrix LXC but fails on Android, inspect tailnet connectivity and client DNS. If it fails locally, fix Serve or HTTPS first.
Synapse works locally but Element cannot connect
Compare the local and tailnet endpoints:
curl http://127.0.0.1:8008/_matrix/client/versions
curl https://matrix.tail-example.ts.net/_matrix/client/versions
The first command isolates Synapse. The second adds DNS, Tailscale, TLS, and Serve.
Hermes cannot resolve the tailnet hostname
That is expected when Hermes is not a Tailscale node. Use the Matrix LXCβs LAN endpoint:
MATRIX_HOMESERVER=http://192.168.50.20:8008
Do not install Tailscale in Hermes only to make MagicDNS resolve unless the container actually needs to join the tailnet.
uv pip install cannot find a virtual environment
Use Hermesβs existing environment rather than creating another one:
cd ~/.hermes/hermes-agent
uv pip install \
--python ./venv/bin/python \
-e ".[matrix]"
If uv exists only for root, install it again as the hermes user.
Hermes reports missing cross-signing keys
Set the one-time output path, restart once, persist the resulting value as MATRIX_RECOVERY_KEY, and remove MATRIX_RECOVERY_KEY_OUTPUT_FILE afterward. Keep both the recovery key and .env out of logs.
Hermes reports no one-time keys or device keys
No one-time keys nor device keys got when trying to share keys can appear during E2EE startup. It does not prove that Matrix is broken. Judge the deployment by hermes gateway status and the functional test: send an encrypted Element DM and receive a Hermes reply.
Old TEMPFAIL lines remain in the journal
journalctl --user -u hermes-gateway -n 80 includes historical entries. Check the current process instead:
hermes gateway status
systemctl --user show hermes-gateway \
-p ActiveState \
-p SubState \
-p MainPID \
-p ExecMainStartTimestamp
Final topology and design trade-offs
The completed system stays deliberately small:
ββββββββββββββββββββββββ
β Android β
β Element + Tailscale β
ββββββββββββ¬ββββββββββββ
β
tailnet-only HTTPS
β
βΌ
https://matrix.<tailnet>.ts.net
β
Matrix E2EE payloads
β
βΌ
βββββββββββββββββββββββββββββ Proxmox βββββββββββββββββββββββββββββ
β βββββββββββββββββββββββββββ β
β β Matrix LXC β β
β β Debian 13 β β
β β Synapse :8008 β β
β β Tailscale Serve :443 β β
β ββββββββββββββ¬βββββββββββββ β
β β private LAN HTTP β
β βΌ β
β βββββββββββββββββββββββββββ β
β β Hermes LXC β β
β β Hermes Agent β β
β β Matrix gateway β β
β β mautrix + libolm β β
β β E2EE crypto store β β
β βββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Only the Matrix LXC needs a Tailscale client for the mobile HTTPS endpoint. Hermes remains an ordinary LAN host. The trade-off is that the Hermes-to-Synapse hop relies on the private LAN, while E2EE still protects the message payload from the homeserver.
A VM would remove the LXC-specific TUN and nesting work, at the cost of more memory and storage. A public reverse proxy would remove the Android tailnet dependency, but it would also expose a Matrix surface that this personal deployment does not need. The chosen design keeps the smallest useful attack surface without forcing every component onto the tailnet.
Frequently asked questions
Does LAN HTTP disable Matrix E2EE?
No. The LAN URL controls how Hermes reaches Synapse. E2EE happens at the Matrix device layer, so Element encrypts messages for Hermes before Synapse stores or forwards them. The LAN can still expose transport metadata, which is why this design assumes a trusted homelab network.
Why not install Tailscale in the Hermes LXC too?
You can, but this deployment gains little from it. Hermes already reaches the Matrix LXC over the LAN, and avoiding another Tailscale client removes device state and MagicDNS from the Hermes failure path. Install it when Hermes needs other tailnet-only resources.
Why use a *.ts.net name as the permanent Matrix server name?
It gives Element a stable name with a publicly trusted certificate while keeping access inside the tailnet. The cost is permanence: Matrix user IDs become tied to that tailnet namespace, and the certificate hostname appears in Certificate Transparency logs.
Is a sender allowlist enough?
It is enough for the single personal DM described here. Add MATRIX_ALLOWED_ROOMS before inviting Hermes into ordinary rooms, and continue to restrict the sender list. The functional check is to send messages from both an allowed and a non-allowed account and inspect the gateway logs.
What proves the deployment is healthy?
Run the checks in increasing scope: query Synapse on loopback, query its tailnet HTTPS URL from another node, query its LAN URL from Hermes, confirm hermes gateway status, then exchange an encrypted DM. That sequence identifies the failing layer instead of treating the stack as one black box.
Turn the private chat into a second brain
Once transport works, it should fade into the background. Point Hermes at a durable knowledge directory such as:
/home/hermes/second-brain
A practical structure might be:
second-brain/
βββ inbox/
βββ daily/
βββ people/
βββ projects/
βββ areas/
βββ resources/
βββ archive/
The boundaries remain clean: Matrix and Element provide the conversation interface, Hermes supplies reasoning and automation, and ordinary files hold durable knowledge. If you replace Hermes later, the second brain survives outside application-private state.
Start by deploying Synapse, validate each network layer independently, and enable the Hermes allowlist before the first real conversation. Then back up the recovery key, confirm device persistence across a restart, and test an encrypted DM over mobile data. If you extend the setup, compare the LAN and tailnet alternatives against your own threat model rather than adding infrastructure by default.
Follow @liran_tal on X/Twitter for new guides, and explore more security and developer tooling work on GitHub.