CozyLabs documentation Gateway v0.8.2

Run it beside Hermes.

Two ways to run CozyGateway in Docker. Choose one private network, or connect a container to Hermes on another host.

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.

One Docker network

Hermes and CozyGateway in one Compose project, sharing a secret file.

Separate Hermes host

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:

terminal
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:

local/docker-hermes.env dotenv
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:

local/config/cozygateway.config.json json
{
  "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:

terminal
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:

docker-compose.hermes.yml yaml
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:

terminal
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

terminal
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:

terminal
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:

~/.hermes/.env dotenv
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:

terminal
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:

local/config/cozygateway.config.json json
{
  "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"
      }
    }
  }]
}

terminal
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:

terminal
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:

terminal
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.

docker-compose.override.yml yaml
services:
  gateway:
    ports: !override []
    networks:
      - default
      - edge

networks:
  edge:
    external: true
    name: cozy_edge

terminal
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.

terminal
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: 9119 documents 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.