Prepare once. Choose one path.
You need Docker with Compose v2, Git, and a configured Hermes model provider. These shell examples use macOS or Linux. For the standard background-service installation, follow Connect CozyChat.
Port 8787 serves CozyChat. Port 9119 is the Hermes
dashboard and control plane, which carries credentials: do not publish it
to the internet or forward it on your router.
Build the gateway from checkout v0.8.2. Keep the gateway and
its attach plugin on the same release.
git clone --branch v0.8.2 https://github.com/shiftedx/cozygateway.git
cd cozygateway
cp .env.example .env
mkdir -p local/config local/secrets
Keep local out of version control. Each Hermes profile needs an
enabled attach plugin, a matching attach token, a persistent spool, and a running
Hermes gateway process. Replace every placeholder before running the examples.
Hermes and CozyGateway in one Compose project, sharing a secret file.
Keep your existing Hermes host; run only the gateway in Docker.
One private Docker network.
Use this when both services will live on the same computer. Follow this path or the separate-host path below.
Set up both services in Compose
Hermes and CozyGateway run as two services in one Compose project.
Hermes binds 0.0.0.0:9119 inside its container because the
gateway container has to reach it, but Compose never publishes that port
to the host. Replace <HERMES_TAG> in both the setup command
and Compose file with the same tested Hermes image tag. Create the persistent
directory and run setup once:
mkdir -p local/hermes
docker run -it --rm \
-e HERMES_UID="$(id -u)" \
-e HERMES_GID="$(id -g)" \
-v "$PWD/local/hermes:/opt/data" \
nousresearch/hermes-agent:<HERMES_TAG> setup
Back up local/hermes: it holds provider credentials,
sessions, skills, memory, profile state and the attach spool. Set HERMES_UID and HERMES_GID in .env to the values from id -u and id -g.
the shared secret file
Save this as local/docker-hermes.env, then
chmod 600 it. Both services read the same file, so the dashboard
password and the attach token cannot drift apart:
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=cozybridge
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=<DASHBOARD_PASSWORD>
HERMES_DASHBOARD_BASIC_AUTH_SECRET=<RESTART_STABLE_RANDOM_SECRET>
COZYGATEWAY_TOKEN=<ONE_LONG_RANDOM_ATTACH_TOKEN>
COZYGATEWAY_URL=http://gateway:8787
COZYGATEWAY_SPOOL_PATH=/opt/data/plugin-data/cozygateway/attach-v1.sqlite
COZYGATEWAY_HOME_CHANNEL=thread
If a value contains $, single-quote it so Compose keeps
it literal.
the gateway config
Save this as local/config/cozygateway.config.json. The
config names environment variables and never holds the credentials
themselves. hermesEndpoints is a list: one entry per Hermes
you connect, each with its own id, and profile names stay
bare while there is only one entry:
{
"name": "cozygateway",
"host": "0.0.0.0",
"port": 8787,
"dbPath": "/data/cozygateway.db",
"hermesEndpoints": [{
"id": "default",
"url": "ws://hermes:9119/api/ws",
"authMode": "password",
"username": "cozybridge",
"passwordEnv": "HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
"baseUrl": "http://hermes:9119",
"profiles": {
"default": { "name": "Default", "tokenEnv": "COZYGATEWAY_TOKEN" }
}
}]
} hermes and gateway are Compose service names.
Docker's private DNS resolves them, so you need no host IPs and no
host.docker.internal. Then hand the directory to the
container's user:
sudo chown -R 1000:1000 local/config
chmod 750 local/config
chmod 640 local/config/cozygateway.config.json
The image runs as UID 1000 and mounts local/config as a directory:
renaming the gateway from the app writes a temp file and swaps it in, which
a file bind mount cannot do atomically.
local/secrets is never mounted.
the compose file
Save this as docker-compose.hermes.yml at the repo root. The
gateway port binds to host loopback. Enable Tailscale Serve on that host, then pass its HTTPS origin to the Docker pairing command
below. The access guide's installer commands apply to native installations.
The read-only mount puts the plugin from your pinned checkout at the path
Hermes already looks in:
services:
hermes:
image: nousresearch/hermes-agent:<HERMES_TAG>
restart: unless-stopped
command: ["gateway", "run"]
environment:
HERMES_DASHBOARD: "1"
HERMES_DASHBOARD_HOST: "0.0.0.0"
HERMES_UID: "${HERMES_UID:-10000}"
HERMES_GID: "${HERMES_GID:-10000}"
env_file:
- ./local/docker-hermes.env
volumes:
- ./local/hermes:/opt/data
- ./integrations/attach-plugin:/opt/data/plugins/cozygateway:ro
expose:
- "9119"
gateway:
build:
context: .
dockerfile: packages/gateway/Dockerfile
restart: unless-stopped
depends_on:
- hermes
env_file:
- ./local/docker-hermes.env
ports:
- "127.0.0.1:8787:8787"
volumes:
- gateway-data:/data
- ./local/config:/config:rw
volumes:
gateway-data:
Enable the plugin once, which writes the setting into the persistent
local/hermes/config.yaml, then start both services:
HERMES_UID="$(id -u)" HERMES_GID="$(id -g)" \
docker compose -f docker-compose.hermes.yml run --rm hermes \
plugins enable cozygateway --no-allow-tool-override HERMES_UID="$(id -u)" HERMES_GID="$(id -g)" \
docker compose -f docker-compose.hermes.yml up --build -d depends_on orders startup only; it proves neither dashboard
auth nor an attached profile, so run the checks in Validate, then pair. A containerized Hermes is managed: CozyChat's update button cannot
apply, so pull a newer image instead.
Hermes on a separate host.
Use this when Hermes already runs on another computer reachable over a trusted private network.
Connect a gateway container to your Hermes host
CozyGateway runs in Docker and reaches Hermes at a private address on
port 9119. The Hermes attach plugin connects back to the
gateway at /attach/v1. Both directions must be reachable.
on the Hermes host
Use the same pinned CozyGateway checkout so the plugin matches the gateway, and enable it for the profile:
mkdir -p "$HOME/.hermes/plugins/cozygateway"
cp -R integrations/attach-plugin/. "$HOME/.hermes/plugins/cozygateway/"
hermes -p default plugins enable cozygateway --no-allow-tool-override
Put these in that profile's ~/.hermes/.env, using an
absolute spool path. Hermes documents the command that produces the
password hash in its
web dashboard guide; keep the plaintext password, because CozyGateway signs in with it:
COZYGATEWAY_URL=<GATEWAY_REACHABLE_ORIGIN>
COZYGATEWAY_TOKEN=<ONE_LONG_RANDOM_ATTACH_TOKEN>
COZYGATEWAY_SPOOL_PATH=/home/<USER>/.hermes/plugin-data/cozygateway/attach-v1.sqlite
COZYGATEWAY_HOME_CHANNEL=thread
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=cozybridge
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH=<SCRYPT_HASH_OF_DASHBOARD_PASSWORD>
HERMES_DASHBOARD_BASIC_AUTH_SECRET=<RESTART_STABLE_RANDOM_SECRET> Then lock the file down, install the profile gateway, and run the dashboard on the host's private address:
chmod 600 "$HOME/.hermes/.env"
hermes -p default gateway install
hermes dashboard --host <HERMES_PRIVATE_IP> --port 9119 --no-open
The dashboard binds 127.0.0.1 by default. The explicit private
address is what makes it reachable from the gateway host. Any non-loopback
bind makes Hermes require authentication and fail closed when no provider
is configured. Allow inbound TCP 9119 from the gateway host
only.
on the gateway host
The .env you copied already points Compose at the
local/config directory for the config and
local/secrets/cozygateway.env for the secrets. Save this as
the config, pointed at the Hermes host's private address:
{
"name": "cozygateway",
"host": "0.0.0.0",
"port": 8787,
"dbPath": "/data/cozygateway.db",
"hermesEndpoints": [{
"id": "default",
"url": "ws://<HERMES_PRIVATE_IP>:9119/api/ws",
"authMode": "password",
"username": "cozybridge",
"passwordEnv": "COZYGATEWAY_HERMES_PASSWORD",
"baseUrl": "http://<HERMES_PRIVATE_IP>:9119",
"profiles": {
"default": {
"name": "Default",
"tokenEnv": "COZYGATEWAY_ATTACH_TOKEN_DEFAULT"
}
}
}]
} sudo chown -R 1000:1000 local/config
chmod 750 local/config
chmod 640 local/config/cozygateway.config.json
Then write the secrets file. The attach token here must equal the
COZYGATEWAY_TOKEN you gave the Hermes profile, and the password
is the plaintext one the dashboard hash was made from:
umask 077
cat > local/secrets/cozygateway.env <<'EOF'
COZYGATEWAY_HERMES_PASSWORD='<DASHBOARD_PASSWORD>'
COZYGATEWAY_ATTACH_TOKEN_DEFAULT='<ONE_LONG_RANDOM_ATTACH_TOKEN>'
EOF
chmod 600 local/secrets/cozygateway.env Build and start the gateway:
docker compose up --build -d
The base Compose file binds 8787 to host loopback, keeps SQLite
in
gateway-data, and health-checks /ready. A
failed healthcheck does not restart an unless-stopped container.
Inspect the gateway and Hermes logs if it becomes unhealthy.
Optional: use a tunnel, proxy, or Caddy
behind a tunnel or proxy
For this split-host path, you can remove the host port and share a
Docker network with a tunnel or proxy that terminates HTTPS. Save
this as docker-compose.override.yml; put your proxy
on the same network, forwarding to gateway:8787.
services:
gateway:
ports: !override []
networks:
- default
- edge
networks:
edge:
external: true
name: cozy_edge docker network create cozy_edge
docker compose up --build -d
Hermes dials the public HTTPS COZYGATEWAY_URL your phone
uses; the plugin switches to wss. The proxy passes
/ws, /attach/v1 and Authorization.
no tunnel? Caddy overlay
As an alternative to the tunnel, set COZY_TLS_HOSTNAME in .env and persist caddy-data. Keep
the same certificate state across restarts. Include both Compose
files in subsequent commands for this path.
docker compose -f docker-compose.yml -f docker-compose.tls-caddy.yml up --build -d Validate, then pair.
The checks run inside the gateway container, so they also work with no
published host port. You need jq on the computer running these
commands. Use the block matching your Compose file.
For a Hermes-configured gateway, a successful /ready check means
its Hermes control bridge reports online. It does not verify that every Hermes
Attach profile is configured and online; use the attach fields
in /health for that Hermes-specific check. The assertion below
requires at least one configured profile, every profile online, and zero dead
letters. Finish by sending a real message.
These container configs listen on 0.0.0.0 inside Docker. Leave
publicUrl unset: this release reserves it for loopback listeners.
The pair --url flag puts the gateway's reachable address in that
pairing slip.
Check and pair: one Docker network
docker compose -f docker-compose.hermes.yml exec -T gateway node -e \
"fetch('http://127.0.0.1:8787/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
docker compose -f docker-compose.hermes.yml exec -T gateway node -e \
"fetch('http://127.0.0.1:8787/health').then(r=>r.json()).then(d=>console.log(JSON.stringify(d))).catch(()=>process.exit(1))" | \
jq -e '.attach.configured > 0 and .attach.online == .attach.configured and .attach.deadLetters == 0'
Replace the example origin with your reachable HTTPS address, such as
the name assigned by Tailscale Serve. Include --url each time
you pair a device.
docker compose -f docker-compose.hermes.yml exec gateway \
node dist/cli.js pair --config /config/cozygateway.config.json \
--url https://gateway.example.com Check and pair: separate Hermes host
From the gateway host, first check Hermes. It should report auth_required: true and the basic provider:
curl -s http://<HERMES_PRIVATE_IP>:9119/api/status | jq '.auth_required, .auth_providers' docker compose exec -T gateway node -e \
"fetch('http://127.0.0.1:8787/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
docker compose exec -T gateway node -e \
"fetch('http://127.0.0.1:8787/health').then(r=>r.json()).then(d=>console.log(JSON.stringify(d))).catch(()=>process.exit(1))" | \
jq -e '.attach.configured > 0 and .attach.online == .attach.configured and .attach.deadLetters == 0'
Replace the origin below with your tunnel or proxy's HTTPS address.
Include --url each time you pair a device. If you chose Caddy,
include its -f flags in these Compose commands too.
docker compose exec gateway \
node dist/cli.js pair --config /config/cozygateway.config.json \
--url https://gateway.example.com The pairing code expires after ten minutes. Open Connect gateway → Scan QR code in CozyChat, then send a message to confirm the whole path. If a check fails, inspect the gateway and Hermes logs before pairing.
Update together.
Move the gateway and the plugin together. Back up state and wait for
active work to finish, then replace <NEXT_TAG> with the release
you intend to install.
Update: one Docker network
The plugin is mounted from the checkout. Restart Hermes after rebuilding the gateway so it loads the matching plugin.
git fetch --tags
git checkout <NEXT_TAG>
HERMES_UID="$(id -u)" HERMES_GID="$(id -g)" \
docker compose -f docker-compose.hermes.yml up --build -d gateway
HERMES_UID="$(id -u)" HERMES_GID="$(id -g)" \
docker compose -f docker-compose.hermes.yml restart hermes Update: separate Hermes host
git fetch --tags
git checkout <NEXT_TAG>
docker compose up --build -d
On the Hermes host, check out the same tag, copy integrations/attach-plugin into the profile's plugin directory again, and restart that profile's
gateway process. Keep any Caddy Compose flags you used during setup.
Repeat the validation checks after updating. To update a containerized Hermes, change both image references to the same tested tag or digest. Never run two gateways against one data directory.
Keep state and secrets safe.
-
Keep the Hermes dashboard private.
expose: 9119documents container access and does not open a host port. - Give each additional Hermes profile its own random attach token and spool path.
- Use mode-600 secret files. The JSON config names environment variables rather than embedding credentials.
-
Back up the Hermes directory and
gateway-data. Deleting them discards sessions, replay state, or device pairings. - Keep dashboard Basic Auth on a trusted LAN or VPN. Pin releases or digests before updating.
References: Hermes Docker guide, Hermes dashboard guide, and CozyGateway Docker guide · v0.8.2.
Need a hand? Visit support.
Gateway source · v0.8.2 ↗