~ 18 min read

Self-Hosting Hermes Agent with Matrix E2EE on Proxmox

share this story on
Build a private, mobile-first Hermes Agent using Matrix Synapse, Element, end-to-end encryption, Tailscale Serve, and separate Proxmox LXC containers.

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.

ComponentReference setup
HypervisorProxmox VE 8.4.x
Matrix OSDebian 13 LXC
Matrix implementationSynapse
Matrix LXC hostnamematrix
Matrix LAN IP192.168.50.20
Tailnet DNS suffixtail-example.ts.net
Matrix FQDNmatrix.tail-example.ts.net
Human Matrix account@owner:matrix.tail-example.ts.net
Bot Matrix account@hermes:matrix.tail-example.ts.net
Hermes hostSeparate Proxmox LXC
Mobile clientElement on Android
Remote transportTailscale
Matrix client transportHTTPS via Tailscale Serve
Message encryptionMatrix 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 optionAdvantagesTrade-offs
Private LAN HTTPNo extra Tailscale client or MagicDNS dependency in HermesTrusts the homelab LAN for transport metadata
Tailnet HTTPSConsistent HTTPS endpoint from every clientAdds Tailscale state and DNS dependency to the Hermes LXC
Public reverse proxyWorks without a tailnet clientExpands 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: false and enable_registration_without_verification: false.
  • Hermes is restricted with MATRIX_ALLOWED_USERS=@owner:matrix.tail-example.ts.net.
  • E2EE is required through either MATRIX_ENCRYPTION=true or MATRIX_E2EE_MODE=required.
  • MATRIX_RECOVERY_KEY is stored in ~/.hermes/.env, and that file has mode 0600.
  • MATRIX_DEVICE_ID is stable across restarts.
  • tailscale serve status says (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.