---
title: "Self-Hosting Hermes Agent with Matrix E2EE on Proxmox — Liran Tal"
description: "Build a private, mobile-first Hermes Agent using Matrix Synapse, Element, end-to-end encryption, Tailscale Serve, and separate Proxmox LXC containers."
canonical: "https://lirantal.com/blog/self-host-hermes-agent-matrix-e2ee-proxmox"
---

# Self-Hosting Hermes Agent with Matrix E2EE on Proxmox — Liran Tal

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](https://tailscale.com/docs/features/magicdns), and [Tailscale Serve terminates HTTPS](https://tailscale.com/docs/features/tailscale-serve) 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](https://community-scripts.org/scripts/elementsynapse). 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](https://tailscale.com/docs/features/containers/lxc/lxc-unprivileged).

### 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](https://forum.proxmox.com/threads/lxc-debian-13-with-nesting-disabled-no-console.173418/).

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`](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html). 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](https://tailscale.com/docs/how-to/set-up-https-certificates) 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](https://tailscale.com/docs/reference/tailscale-cli/serve) 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](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix) 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](https://github.com/nousresearch/hermes-agent/blob/main/website/docs/reference/environment-variables.md) 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](https://x.com/liran_tal) for new guides, and explore more security and developer tooling work on [GitHub](https://github.com/lirantal).

## More writing

[All writing →](/blog)

*   Aug 2, 2026

    ### [The Practical Second Brain: Building an LLM-Operated Markdown Knowledge Graph](/blog/practical-second-brain-llm-markdown-knowledge-graph)

*   Aug 1, 2026

    ### [Building a Second Brain with LLMs and AI Agents](/blog/building-second-brain-llms-ai-agents)

*   Jul 17, 2026

    ### [Invisible Unicode Can Hijack Gemini CLI Agent Skills](/blog/gemini-cli-invisible-unicode-skill-injection)


* * *

Liran Tal is the author of the book [Node.js Secure Coding: Defending Against Command Injection Vulnerabilities](https://www.nodejs-security.com/)

[Start Building Secure Node.js Applications ↗](https://www.nodejs-security.com/)

 [![](/_astro/nodejs-secure-coding-book-3d-style.DiTWpzFs_Z3c7s1.png)](https://www.nodejs-security.com/)
