Aller au contenu
Hermès Skills
← Retour au catalogue

hermes-webui-deployment

Install, configure, secure, and manage the Hermes WebUI (nesquena/hermes-webui) — a self-hosted web frontend with 1:1 CLI parity.

Quand l'utiliser (Trigger)

Déclenchement standard selon le contexte de l'écosystème Hermès.

Mode d'emploi (Usage)

Mode d'emploi standard via l'agent Hermès.

Hermes WebUI Deployment

Install and configure nesquena/hermes-webui, the community web frontend for Hermes Agent with full CLI parity — sessions, workspace browser, model switching, settings, and cron management in a three-panel dark-themed UI.

When to use

  • User asks to install a web interface for Hermes Agent
  • User wants browser-based access to Hermes (instead of CLI or Telegram)
  • User wants remote access from their phone or other devices
  • User asks for a dashboard or admin panel for Hermes

Prerequisites

  • Hermes Agent already installed and configured (~/.hermes/config.yaml exists)
  • Git, Python 3.11+, curl
  • Docker (optional — for containerized deployment) or just Python for native install
  • For SSH tunnel access: an SSH client on the remote machine

Architecture Options

ApproachOverheadIsolationComplexityBest for
Native (start.sh)MinimalLowLowSame machine, Hermes already native
Docker single-containerLowHighLowClean isolation, easy management
Docker two-containerMediumHighMediumGateway + WebUI separation
Docker three-containerMediumHighMediumGateway + WebUI + Dashboard

Recommendation for existing Hermes installs: Native install — uses the same Python venv, zero Docker overhead, auto-discovers Hermes config. Docker adds image pulls and volume mounts for no benefit on a machine where Hermes already runs natively.

Access Methods

Before installing, decide how the WebUI will be accessed:

Access methodWebUI bindingSecurity modelSetup complexity
SSH tunnel only127.0.0.1🔒 Best — requires SSH keyLow
Reverse proxy (Traefik/Caddy)0.0.0.0🔐 Password + firewall + HTTPSMedium
Direct port exposure0.0.0.0⚠️ Password only — not recommendedLow

1. Clone and configure

git clone https://github.com/nesquena/hermes-webui.git ~/hermes-webui
cd ~/hermes-webui

2. Create .env with optimal settings

cat > .env << 'EOF'
# Performance
HERMES_WEBUI_PORT=8787
HERMES_WEBUI_HOST=127.0.0.1
HERMES_WEBUI_STATE_DIR=$HOME/.hermes/webui
HERMES_WEBUI_DEFAULT_WORKSPACE=$HOME/workspace
HERMES_WEBUI_PREFILL_DISABLED=1

# Auto-detection points to existing Hermes
HERMES_WEBUI_AGENT_DIR=$HOME/.hermes/hermes-agent

# Security (REQUIRED when exposing outside localhost)
# HERMES_WEBUI_PASSWORD=your-s...word

# Permission handling for bind-mounted .hermes
HERMES_SKIP_CHMOD=1
EOF

3. Start the WebUI

# Foreground test
python3 bootstrap.py --no-browser

# Background daemon (recommended)
./ctl.sh start

4. Verify

./ctl.sh status
curl -s http://127.0.0.1:8787/health

Expected health response: {"status": "ok", "sessions": 0, "active_streams": 0, ...}

Installation (Docker)

git clone https://github.com/nesquena/hermes-webui.git
cd hermes-webui
cp .env.docker.example .env
# Edit UID/GID if not 1000 (macOS: starts at 501)
echo "UID=$(id -u)" >> .env
echo "GID=$(id -g)" >> .env
# Set password for any exposure outside localhost
echo "HERMES_WEBUI_PASSWORD=your-s...word" >> .env

# Single container (simplest)
docker compose up -d

# Or double/triple container for separation
docker compose -f docker-compose.two-container.yml up -d
docker compose -f docker-compose.three-container.yml up -d

Security Configuration

SSH Tunnel (most secure for remote access)

Bind to localhost only, then tunnel from your client machine:

# Server side (already localhost binding in .env)
# HERMES_WEBUI_HOST=127.0.0.1

# Client side (run on your laptop):
ssh -N -L 8787:127.0.0.1:8787 -p 22 user@server
# Open http://localhost:8787

Password Protection (when exposing to network)

echo "HERMES_WEBUI_PASSWORD=strong-random-password" >> .env
./ctl.sh restart

Reverse Proxy (Traefik/Caddy/Nginx)

For production deployments with a domain:

Traefik (Coolify-compatible):

(Choose the right approach based on how the WebUI is deployed)

A) WebUI in Docker (direct)

The WebUI runs as a Docker container on the Coolify/Traefik network. Simplest path.

services:
  hermes-webui:
    ports:
      - "127.0.0.1:8787:8787"
    networks:
      - coolify    # join Coolify's Traefik network
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.hermes-webui.rule=Host(`hermes.domain.com`)"
      - "traefik.http.routers.hermes-webui.entrypoints=https"
      - "traefik.http.routers.hermes-webui.tls=true"
      - "traefik.http.routers.hermes-webui.tls.certresolver=letsencrypt"
      - "traefik.http.services.hermes-webui.loadbalancer.server.port=8787"
      - "traefik.docker.network=coolify"

B) WebUI native + socat bridge (for Docker isolation)

Problem: When the WebUI runs natively (not in Docker), Coolify’s Traefik lives on the coolify Docker bridge network. That bridge network cannot reach the host’s services by default — socat containers forwarding to 10.0.1.1:8787 get Connection refused because Docker bridge isolation blocks host-bound traffic.

Solution: Run socat natively on the host listening on the Coolify bridge IP, plus a label-only container for Traefik route discovery.

Step 1 — Install socat:

sudo apt-get install -y socat

Step 2 — The host’s IP on the Coolify bridge is 10.0.1.1 (the gateway). Run socat:

socat TCP-LISTEN:8787,bind=10.0.1.1,fork,reuseaddr TCP:127.0.0.1:8787

This makes the WebUI reachable from any container on the coolify network via 10.0.1.1:8787.

Step 3 — Deploy a minimal Docker container for Traefik label discovery (no traffic goes through it — socat handles forwarding):

# docker-compose.traefik.yml
services:
  hermes-webui-bridge:
    image: alpine/socat:latest
    container_name: hermes-webui-bridge
    command: "TCP-LISTEN:8787,fork,reuseaddr TCP:10.0.1.1:8787"
    networks:
      - coolify
    restart: unless-stopped
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.hermes-webui.rule=Host(`hermes.iatuto.com`)"
      - "traefik.http.routers.hermes-webui.entrypoints=https"
      - "traefik.http.routers.hermes-webui.tls=true"
      - "traefik.http.routers.hermes-webui.tls.certresolver=letsencrypt"
      - "traefik.http.services.hermes-webui.loadbalancer.server.port=8787"
      - "traefik.docker.network=coolify"

networks:
  coolify:
    external: true
    name: coolify

Step 4 — Deploy and wait for Let’s Encrypt:

docker compose -f docker-compose.traefik.yml up -d
# Wait 30-60s for TLS certificate
curl -s https://hermes.iatuto.com/health

Systemd socat bridge (optional, for persistent native bridge):

Create /etc/systemd/system/hermes-webui-bridge.service:

[Unit]
Description=Hermes WebUI Traefik bridge (socat)
After=docker.service
BindsTo=hermes-webui.service

[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:8787,bind=10.0.1.1,fork,reuseaddr TCP:127.0.0.1:8787
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

C) WebUI native + socat + Traefik file provider (no Docker container needed)

Alternative to the Docker label approach above. Uses Traefik’s file provider directly — no Docker container for label discovery required.

Prerequisite: Coolify’s Traefik must have file provider enabled (--providers.file.directory=/traefik/dynamic/). This is enabled by default in Coolify 4.x.

Step 1 — Install socat and create the native bridge:

sudo apt-get install -y socat

Create a systemd service for persistence:

sudo tee /etc/systemd/system/hermes-webui-bridge.service > /dev/null << 'SERVICEEOF'
[Unit]
Description=Socat bridge Hermes WebUI to Docker network
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:8787,bind=10.0.1.1,fork,reuseaddr TCP:127.0.0.1:8787
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
SERVICEEOF

sudo systemctl daemon-reload
sudo systemctl enable --now hermes-webui-bridge.service

Step 2 — Create the Traefik dynamic config file:

sudo mkdir -p /data/coolify/proxy/dynamic
sudo tee /data/coolify/proxy/dynamic/hermes-webui.yml > /dev/null << 'TRAEFIKEOF'
http:
  routers:
    hermes-webui:
      rule: "Host(`hermes.yourdomain.com`)"
      entrypoints:
        - https
      tls:
        certResolver: letsencrypt
      service: hermes-webui
  services:
    hermes-webui:
      loadBalancer:
        servers:
          - url: "http://10.0.1.1:8787"
TRAEFIKEOF

Step 3 — Verify the config was picked up:

docker logs $(docker ps --filter name=traefik -q) 2>&1 | grep -i "hermes-webui"

Step 4 — Test:

curl -sI https://hermes.yourdomain.com/health

Wait 30-60 seconds for Let’s Encrypt certificate on first request.

Architecture: This avoids needing a Docker container for label discovery. The flow is:

User → hermes.yourdomain.com (HTTPS/Cloudflare)
  → Coolify Traefik (reads /traefik/dynamic/hermes-webui.yml)
  → Host 10.0.1.1:8787 (via coolify Docker bridge)
  → socat (native, binds 10.0.1.1:8787)
  → Hermes WebUI (127.0.0.1:8787)

Pitfall — Docker bridge host reachability: The host’s IP on the coolify bridge is 10.0.1.1. Docker bridge networks DO NOT forward inbound traffic to host services by default. A socat container running TCP:10.0.1.1:8787 inside Docker gets Connection refused. The fix is to run socat natively on the host so it can bind directly to 10.0.1.1:8787.

Nginx:

server {
    listen 443 ssl;
    server_name hermes.domain.com;
    
    location / {
        proxy_pass http://127.0.0.1:8787;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        
        # Performance: increase buffers for streaming chat
        proxy_buffering off;
        proxy_read_timeout 300s;
    }
}

Auto-Start (systemd)

⚠️ Important: DO NOT use Type=forking with ctl.sh

Type=forking + ExecStart=./ctl.sh start fails with (Result: protocol) — systemd cannot track the ctl.sh daemon’s PID correctly. Use Type=simple with direct bootstrap.py invocation instead (shown below).

Working systemd unit

[Unit]
Description=Hermes WebUI
After=network.target

[Service]
Type=simple
User=USERNAME
Group=USERNAME
WorkingDirectory=/home/USERNAME/hermes-webui
ExecStart=/home/USERNAME/.hermes/hermes-agent/venv/bin/python /home/USERNAME/hermes-webui/bootstrap.py --foreground --no-browser --host 127.0.0.1 8787
ExecStop=/bin/kill -TERM $MAINPID
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
Environment=HERMES_WEBUI_HOST=127.0.0.1
Environment=HERMES_WEBUI_PORT=8787
Environment=HERMES_WEBUI_STATE_DIR=/home/USERNAME/.hermes/webui
Environment=HERMES_WEBUI_DEFAULT_WORKSPACE=/home/USERNAME/workspace
Environment=HERMES_WEBUI_AGENT_DIR=/home/USERNAME/.hermes/hermes-agent
Environment=HERMES_WEBUI_PREFILL_DISABLED=1

[Install]
WantedBy=default.target

Set environment variables directly in the service unit rather than relying on .env loading — systemd’s sandboxing may not source shell .env files correctly.

sudo systemctl daemon-reload
sudo systemctl enable hermes-webui
sudo systemctl start hermes-webui

Daemon Management (ctl.sh)

./ctl.sh start              # Background daemon
./ctl.sh status             # PID, uptime, bound host/port, health
./ctl.sh logs --lines 100   # View logs
./ctl.sh logs --follow      # Follow logs (tail -f)
./ctl.sh restart            # Restart
./ctl.sh stop               # Stop

Override env vars inline:

HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start

Performance Optimizations

  1. File descriptor limit: The bootstrap auto-raises to 4096 (from 1024). For systemd, set LimitNOFILE=65536.
  2. Localhost binding: Avoids proxy overhead — HERMES_WEBUI_HOST=127.0.0.1.
  3. No framework overhead: The WebUI is vanilla JS + Python, no build step, no bundler — already lightweight.
  4. Native over Docker: On a machine where Hermes runs natively, native install avoids Docker volume mount I/O and image overhead.
  5. Prefill disabled: HERMES_WEBUI_PREFILL_DISABLED=1 skips loading session context prefill on every page load.

Troubleshooting / Pitfalls

“File descriptor soft limit raised” message

Normal — the bootstrap auto-raises the limit. If using ctl.sh/systemd, you may see the message every restart. Not an error.

SSH tunnel: “Connection refused”

Cause: WebUI not running, or bound to wrong interface. Check ./ctl.sh status — the bound address must be 127.0.0.1 for the SSH tunnel to work. If it says 0.0.0.0, the tunnel still works but the WebUI is exposed on the network.

Permission denied on .hermes files

Fix: Set HERMES_SKIP_CHMOD=1 in .env to bypass the credential-permission fixer.

Port conflict when migrating from ctl.sh to systemd

When stopping a ctl.sh daemon and starting a systemd service, the old process may still hold the port:

ss -tlnp | grep 8787 → LISTEN ... pid=OLD_PID
sudo systemctl restart hermes-webui → port still held by OLD_PID

Fix: Kill the old process manually before starting systemd:

sudo kill <OLD_PID>
sleep 2
sudo systemctl restart hermes-webui

“No password set” warning persists on startup

The startup prints this as a tip even with a password set. Check that HERMES_WEBUI_PASSWORD is set in .env and the file is readable by the running user. If it’s set and the warning persists, verify the env var is being loaded (check logs for any “loading .env” messages).

Auth 401 on /api/sessions despite password set

Symptom: HERMES_WEBUI_PASSWORD is defined in .env, health check returns 200, but /api/sessions returns HTTP 401.

Root cause: The auth mechanism may not be reading the env var correctly, or the session cookie/token mechanism has a bug. Debug steps:

  1. Verify .env is loadable: cat .env | grep PASSWORD
  2. Check startup logs in webui.log for auth-related messages
  3. Check if the password is being read: grep -i password server.py api/*.py
  4. Try accessing / (root) — it should redirect (302) to a login page. If it doesn’t, auth flow may be broken
  5. Test with curl: curl -v http://localhost:8787/api/sessions
  6. Check if there’s a cookie-based session mechanism vs. basic auth — 401 suggests the former

Coolify health check kills slow-start containers

Symptom: When deploying hermes-webui via Coolify (dockerimage build pack), the Docker build succeeds but the container is killed within 60s. Docker logs show bootstrap.py still installing dependencies (uv + venv + pip install) while Coolify’s health check already declares it unhealthy.

Root cause: bootstrap.py installs uv, creates a virtual environment, and installs all Python dependencies (cryptography, fastapi, uvicorn, etc.) on first startup — this takes 90–120 seconds. Coolify’s default health check parameters (start_period=5s, retries=10, interval=5s) kill the container after only ~50s of failed health checks.

Fix: Increase Coolify health check settings:

  • Start period: 180 seconds
  • Retries: 30
  • Interval: 10 seconds

Or pre-build dependencies in a custom Dockerfile (see coolify-hermes-deployment skill for details).

Workspace appears empty

Cause: Workspace directory doesn’t exist or UID mismatch. Create the directory: mkdir -p ~/workspace. For Docker, verify HERMES_WORKSPACE points to an existing host directory and UID/GID match.

Systemd service: “Failed to start” or file appears truncated

Symptom: A systemd service file created via heredoc shows incomplete content or fails with (Result: protocol).

Cause: When copy-pasting a heredoc block from an agent response, the bash heredoc delimiter can be truncated if the block contains curl, echo, or other commands after the delimiter line. The trailing EOF line gets absorbed into the next command.

Fix: Always verify the file immediately after creation:

cat /etc/systemd/system/hermes-service.service  # Check it has all 3 sections: [Unit], [Service], [Install]

Use echo password | sudo -S tee /path/to/file > /dev/null << 'EOF' patterns with unique, short delimiters.

“sudo: a terminal is required to read the password”

The Hermes session shell blocks echo password | sudo -S as a brute-force prevention measure. Even PTY mode doesn’t always give sudo a proper terminal. Workarounds:

  • For the agent: Write a copy-paste script for the user to run manually in their own terminal
  • For the user: Run the sudo commands directly in their SSH session
  • Sudo-free alternative (auto-start without sudo):
    (crontab -l 2>/dev/null; echo "@reboot cd ~/hermes-webui && ./ctl.sh start") | crontab -

YouTube bot detection on VPS

When using Hermes from a VPS, YouTube and yt-dlp often get bot-blocked (“Sign in to confirm you’re not a bot”). This is environment-specific — not a WebUI issue. Fall back to oEmbed API + web_search for YouTube metadata extraction.

Bypassing localhost onboarding check (for reverse proxy / VPS)

When deploying behind a reverse proxy or on a remote server, the WebUI may block onboarding with a “local network” check. Set:

HERMES_WEBUI_ONBOARDING_OPEN=1

This explicitly acknowledges the exposure and bypasses the check (only for deployments where you control the firewall).

Verification Checklist

After installation, confirm:

  • ./ctl.sh status shows “running” with a PID
  • curl http://127.0.0.1:8787/health returns {"status":"ok"}
  • Browser at http://localhost:8787 shows the WebUI login/setup page
  • WebUI detects Hermes Agent (check startup logs for agent dir: [ok])
  • Workspace browser shows files (if ~/workspace exists)