Hermes Agent en Docker: cómo correrlo headless en un VPS
Qué traen realmente la imagen Docker y el Docker Compose de Hermes Agent, cómo persistir HERMES_HOME y correr perfiles aislados, el modelo de auth del dashboard, y las trampas de Swarm que de verdad cuestan tiempo corriéndolo en un VPS.
Ya sabes Docker. Esto no es una guía de instalación. Es lo que realmente contienen la imagen y el Compose de Hermes Agent, lo que se rompe cuando lo corres en un VPS en vez de una laptop, y el puñado de trampas que no están documentadas en ningún lado hasta que te chocas con ellas.
Qué trae realmente la imagen Docker de Hermes Agent
El Dockerfile publicado se construye desde debian:13.4, fijado por su digest sha256 en vez de un tag, porque el resto del build (un uv fijado, un Chromium fijado) ya viene de un lock verificado por sha, y una imagen base flotante se desalinearía debajo de ellos. La primera etapa del build compila SQLite 3.53.4 desde el código fuente y usa ese .so en vez del 3.46.1 que trae Debian 13, que todavía carga el bug de corrupción de WAL-reset upstream. Si alguna vez viste una instalación de Hermes quejarse de un state.db corrupto sobre un paquete de SQLite de la distro, esta es la razón por la que la imagen del contenedor lo evita por completo.
El PID 1 dentro del contenedor es s6-overlay, no tini. Tini se encargaba de recoger los procesos zombie que se acumulan cuando Hermes lanza subprocesos stdio de MCP, git o bun como PID 1; s6-overlay hace la misma recolección sin bloquear y además supervisa el proceso principal de Hermes, el dashboard y los gateways por perfil como servicios con nombre bajo /etc/s6-overlay/s6-rc.d/. Todavía existe un shim de compatibilidad liviano en /usr/bin/tini para templates de orquestación que tienen fijo el entrypoint viejo, pero solo quita los flags de CLI de tini y vuelve a ejecutar /init.
El contenedor corre como un usuario hermes sin privilegios de root, UID 10000 por defecto, que se puede sobrescribir al arrancar con HERMES_UID y HERMES_GID para que los archivos que escribe sigan siendo del usuario del host que realmente seas tú. HERMES_HOME queda fijo en /opt/data, y esa es la única ruta declarada como VOLUME. Todo lo demás bajo /opt/hermes, el venv, el bundle del frontend, la instalación misma, se copia de solo lectura (--chmod=a+rX,go-w) y es propiedad de root. Esa separación importa más adelante: el código es inmutable, el volumen de datos es lo único que necesitas conservar.
Hay un default fácil de asumir mal: meter a Hermes en un contenedor no pone, por sí solo, en sandbox lo que corre. El backend de ejecución de herramientas es local por defecto, lo que significa que un comando de shell que corre el agente se ejecuta dentro de este mismo contenedor, con el mismo filesystem y el mismo namespace de red que el propio Hermes. La imagen instala el paquete docker-cli como dependencia del sistema justo para el caso en que quieras más aislamiento que eso: apunta una tarea al backend docker en cambio, y Hermes llama a esa CLI para lanzar un contenedor hermano en sandbox (cap-drop all, sin privilegios nuevos, con límites de recursos) donde corre el comando. La forma usual de conectar eso desde dentro de un contenedor es montando el socket de Docker del host dentro del contenedor de Hermes. Local es el default. Descartable es una elección que haces por tarea.
El Docker Compose de Hermes Agent, leído directo
El docker-compose.yml que viene incluido corre dos servicios, no uno:
services:
gateway:
build: .
image: hermes-agent
container_name: hermes
restart: unless-stopped
network_mode: host
volumes:
- ~/.hermes:/opt/data
environment:
- HERMES_UID=${HERMES_UID:-10000}
- HERMES_GID=${HERMES_GID:-10000}
command: ["gateway", "run"]
dashboard:
image: hermes-agent
container_name: hermes-dashboard
restart: unless-stopped
network_mode: host
depends_on:
- gateway
volumes:
- ~/.hermes:/opt/data
environment:
- HERMES_UID=${HERMES_UID:-10000}
- HERMES_GID=${HERMES_GID:-10000}
command: ["dashboard", "--host", "127.0.0.1", "--no-open"]
Esa es la forma, recortada de los bloques comentados para las credenciales de Microsoft Teams y Google Chat, que son keys reales en el archivo (TEAMS_CLIENT_ID, GOOGLE_CHAT_SERVICE_ACCOUNT_JSON, y el resto), solo que opt-in. Los dos servicios montan la misma ruta del host en /opt/data, los dos corren con el mismo UID remapeado, y el gateway y el dashboard son contenedores separados a propósito que comparten un volumen de datos, en vez de un solo proceso haciendo los dos trabajos.
El comentario de arriba del propio archivo te dice cómo correrlo, pasando los IDs de tu usuario del host para que lo que es tuyo siga siendo tuyo también dentro del contenedor:
Levantar el stack
Gateway + dashboard
HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d
Sus notas de seguridad valen la pena leerse literalmente, porque son los dos errores que la gente realmente comete: el dashboard se bindea a 127.0.0.1 por diseño, porque guarda API keys, y el comentario es explícito en que el acceso remoto debería pasar por un túnel SSH o un reverse proxy que agregue autenticación, nunca pasando --insecure --host 0.0.0.0. La segunda nota es sobre el servidor de API compatible con OpenAI, que se queda apagado a menos que descomentes API_SERVER_HOST y API_SERVER_KEY juntos, porque la key es obligatoria en el momento en que el servidor queda alcanzable más allá de localhost.
Persistir HERMES_HOME, y perfiles para instancias aisladas
Todo lo que hace que una instalación de Hermes sea tuya vive bajo HERMES_HOME: config.yaml, SOUL.md, MEMORY.md y USER.md, la carpeta skills/ y state.db. Monta ese único directorio y un rebuild del contenedor no pierde nada. Pierde ese mount y te queda una instalación nueva, sin memoria de haber corrido alguna vez.
Si quieres más de un agente en la misma máquina, Hermes le llama a eso un perfil, y es un concepto de primera clase, no un workaround. Dentro del contenedor del gateway corriendo:
Crear un perfil aislado
Créalo
hermes profile create coderÚsalo
coder setup
Eso crea ~/.hermes/profiles/coder/ con su propio config.yaml, .env, SOUL.md y state.db, y además conecta un comando coder, un shortcut para hermes -p coder. Un perfil de asistente personal y un perfil de coding agent en el mismo host nunca comparten memoria, nunca comparten skills, y nunca escriben por accidente en el system prompt del otro, que es lo que realmente sale mal cuando dos agentes comparten un mismo home.
La imagen Docker está pensada justo para esto. El Dockerfile declara servicios s6 estáticos para el proceso principal de Hermes y el dashboard en tiempo de build, pero los servicios de gateway por perfil se registran dinámicamente en runtime bajo /run/service/, que es tmpfs y se borra en cada restart del contenedor. Un script de cont-init.d llamado 02-reconcile-profiles corre antes de que arranque cualquier otra cosa y reconstruye esos slots de servicio leyendo $HERMES_HOME/profiles/<name>/ desde el volumen montado. En la práctica: un contenedor, un volumen, tantas instancias aisladas de Hermes como perfiles tengas, sobreviviendo un restart sin que toques nada.
El dashboard y la web UI de Hermes Agent, detrás de auth
El dashboard se bindea a loopback por defecto, como dice el propio comentario del archivo Compose. Abrirlo a algo más allá de tu propia máquina implica un túnel, un reverse proxy, o las keys dashboard.basic_auth.username y .password de config.yaml, que la CLI te siembra y que la capa de plugin de auth del dashboard revisa que estén realmente habilitadas antes de confiar en ellas.
Esta es la trampa, y no es específica de Hermes. La API nativa WebSocket de un navegador no tiene forma de poner un header Authorization en el handshake. Si un reverse proxy frente al dashboard protege cada ruta, incluidas las de WebSocket, con basic auth, las requests HTTP normales del navegador pasan (el navegador reenvía la credencial), pero el upgrade de WebSocket nunca la lleva, y la UI se queda colgada en “Connecting” para siempre. Me pasó corriendo Hermes detrás de Traefik: el arreglo fue un segundo router de Traefik acotado solo a la ruta de WebSocket, autenticado con un parámetro de query ?token= en vez de basic auth, saltándose por completo el primer router. La misma trampa afectó a la web UI de OpenClaw cuando la evalué la misma semana, que es como sé que es un patrón del edge, no un bug de ninguno de los dos proyectos.
El propio código de Hermes desde entonces formalizó la misma separación una capa más abajo, dentro del proceso del dashboard mismo. En modo gated, un upgrade de WebSocket nunca acepta el token de sesión plano del dashboard; un comentario en el código es explícito en que una constante filtrada no debe otorgar acceso por sí sola. En cambio, el navegador acuña un ticket de un solo uso con un TTL de 30 segundos y lo presenta como un parámetro de query ?ticket= o un subprotocolo hermes-gateway-ticket.<ticket>, un proceso hijo lanzado por el servidor recibe en cambio una credencial ?internal= de vida más larga, y una sesión con sign-in puede caer de vuelta en un ?token= verificado por provider, el mismo camino de verificación que usa el bearer auth de la REST API. Ninguno de los tres es un header, porque un header es exactamente lo que un handshake de WebSocket no puede llevar de forma confiable. La propia nota de historia del código lo dice sin vueltas: antes de que existiera ese camino de ticket, el upgrade de WebSocket en modo gated no tenía ninguna credencial que aceptara, y simplemente fallaba cerrado.
La lección se generaliza más allá de Hermes. Cada vez que pongas auth de estilo sesión frente a una conexión de larga duración, revisa si esa conexión es un WebSocket antes de asumir que la capa de auth la cubre. Si estás proxeando el dashboard tú mismo, dale a la ruta de WebSocket su propia ruta con token en vez de confiar en que el basic auth siga el upgrade.
Mi swarm: bento, Traefik, Portainer
Mi propio Hermes corre en un VPS que convertí en un Docker Swarm con bento, mi propio instalador, que pone Traefik y Portainer frente a lo que sea que despliegues. Hermes corre headless en ese swarm, con el gateway de Telegram activo, sin que se abra nunca una pestaña de navegador en la máquina misma. El archivo Compose de arriba es la forma de un solo host; Swarm cambia algunas cosas una vez que lo despliegas como un stack, y Portainer se vuelve lo que usas para redesplegar en vez de docker compose up directamente.
Las trampas que realmente cuestan tiempo
Ninguna de estas aparece en una demo. Todas aparecieron en un VPS real.
Síntoma, causa real, arreglo
| Lo que ves | Lo que realmente está pasando | Qué lo arregló |
|---|---|---|
| Un contenedor que se llama a sí mismo por su hostname público hace timeout | userland-proxy: false tira el camino de hairpin NAT en un VPS de una sola IP; el paquete sale por la interfaz pública y nunca vuelve a rutear | Quitar la key. El default de Docker en true es el setting correcto acá |
| Un servicio del mismo host tarda unos 130 segundos en responder, no menos de uno | Llamar a un contenedor por su FQDN público rutea por TLS de loopback en vez de por la red interna | Llamarlo por su nombre de servicio interno, no por su hostname público |
| Un contenedor simplemente desaparece, sin error en sus propios logs | El kernel lo mató por OOM. El orquestador solo ve process_lost con un exit code nulo | dmesg | grep oom es el log real. Nada de lo que imprime Docker lo nombra |
| Una llamada a un provider falla con un error de credencial doble | El SDK pone un header de auth desde una env var y un header explícito pone un segundo | Elige una sola fuente para la credencial, nunca las dos a la vez |
| Un servicio se queda en 0/1 réplicas y el proxy devuelve 404 | docker restart sobre una task de Swarm la deja huérfana en vez de reprogramarla | docker service update --force <service>. Nunca hagas docker restart sobre una task directamente |
| agent.log tira Permission denied | Un diagnóstico corrido como root dentro del contenedor dejó archivos propiedad de root en un directorio que el usuario del servicio no puede tocar | chown de los archivos afectados de vuelta al UID del servicio (10000 por defecto) |
Los dos arreglos que vale la pena tener a mano, los mismos dos cada vez:
Los dos comandos que realmente usas
Redesplegar un servicio de Swarm, nunca reiniciar una task
docker service update --force <service>Arreglar archivos de root bajo HERMES_HOME
chown -R 10000:10000 /opt/data
Un patrón más no es un bug, solo vale la pena nombrarlo. Cuando otro contenedor en la misma máquina necesita el binario de Hermes, la respuesta no es una segunda imagen. Injertar los propios volúmenes de la instalación que ya corre en el contenedor del otro servicio le da el binario y los datos sin un rebuild. Así es como mis contenedores de Paperclip llaman a Hermes: los volúmenes los injerta el instalador de bento después de que los dos stacks ya están desplegados, porque Compose solo no tiene una forma limpia de expresar “monta el volumen de este otro stack” cuando el orden de deploy entre los dos stacks no está fijo.
El mejor VPS para Hermes Agent: requisitos, no marcas
No hay ningún provider que vaya a nombrar acá, y una lista de “mejor VPS” es casi siempre copy de afiliados disfrazado de review. Lo que realmente importa, en orden:
Lo que realmente necesita un host para Hermes Agent
- Obligatorio:Una sola IP pública sobre la que controlas el comportamiento de NAT de DockerVPS de una sola IP más userland-proxy: false es la combinación exacta que rompe el hairpin NAT. Sabe cuál de las dos estás alquilando antes de tocar ese setting.
- Obligatorio:Margen de memoria independiente de la carga de trabajo propia del agenteEl dashboard tiene un memory leak abierto ligado a sesiones en vivo, no a tiempo inactivo, y ha llegado a varios gigabytes en una hora en una instalación real. Dimensiona ese proceso por separado del gateway.
- Obligatorio:Soporte para Swarm o Compose, y un usuario de servicio sin privilegios de rootLa imagen ya cae a UID 10000 por ti. Un host que fuerza todo por root deshace esa protección la primera vez que alguien corre un comando puntual como root dentro del contenedor.
- Obligatorio:Acceso por SSH key, no una consola solo de navegadorTodo esto, el archivo Compose, los comandos de Swarm, los arreglos de chown, asume una shell real. Una consola de provider que solo te da una terminal web hace que cada una de estas trampas sea más lenta de arreglar.
- Obligatorio:Storage en bloque persistente que de verdad respaldasHERMES_HOME es el único volumen que importa. Si el storage del host desaparece con la instancia, también desaparece cada perfil que tenías ahí.
Fija una versión, y después lee las notas de la release
El dashboard tiene el suyo propio, todavía abierto. El Issue #80527 reporta que su proceso crece sin límite mientras sirve sesiones en vivo, desde una base estable de unos 250 MB hasta 6,3 GB en una ocurrencia y 7,6 GB, con el swap totalmente agotado, en otra, las dos terminando en un OOM kill que tiró abajo cada cliente conectado a través de él. Quien lo reportó rastreó la causa probable hasta tui_gateway/server.py: la transcripción cruda de la sesión se retiene en memoria para resume y durabilidad incluso después de que la compresión de contexto reemplaza lo que el agente mismo ve, y una sesión larga con mucho output de herramientas hace crecer esa copia retenida sin límite de tamaño. El mismo dashboard sin ninguna sesión activa se mantiene plano en unos 290 MB, así que el leak solo aparece cuando de verdad usas la UI por un rato.
Hasta que eso tenga un arreglo, la regla de operación es aburrida a propósito: fija el tag de imagen que probaste, lee las notas antes de subirlo de versión, y en un VPS con poca memoria, no dejes una sesión larga de dashboard abierta sin supervisión por horas. Reinicia el servicio del dashboard con un horario si tienes que dejarlo corriendo. El contenedor del gateway, que es donde realmente viven tus gateways y tus cron jobs, no es el que tiene este bug.
Hermes Agent en Docker, respuestas rápidas
¿Puedo correr Hermes Agent en Docker?
Sí. Nous Research incluye un Dockerfile y un docker-compose.yml con el código fuente: una imagen supervisada por s6-overlay que corre como un usuario sin privilegios de root, con dos servicios, un gateway y un dashboard, compartiendo un volumen HERMES_HOME.
¿Cómo persisto los datos de Hermes Agent en Docker?
Monta una ruta del host en /opt/data, que es donde vive HERMES_HOME dentro del contenedor (el archivo compose incluido usa ~/.hermes:/opt/data). Ese único volumen guarda config.yaml, SOUL.md, memoria, skills y state.db. Todo lo demás en la imagen es reemplazable.
¿Por qué mi dashboard de Hermes Agent se queda colgado en Connecting?
Casi siempre es un reverse proxy protegiendo el dashboard con basic auth frente a su ruta de WebSocket. Los navegadores no pueden enviar un header Authorization en un upgrade de WebSocket, así que las rutas HTTP normales autentican bien y la ruta de WebSocket se queda colgada para siempre. Dale a la ruta de WebSocket su propia ruta basada en token, en vez de basic auth.
¿Cuál es el mejor VPS para Hermes Agent?
No hay una respuesta de marca. Lo que importa es una sola IP pública sobre la que entiendes los settings de NAT de Docker, margen de memoria dimensionado para el proceso propio del dashboard por separado de la carga del agente, soporte para Swarm o Compose con un usuario de servicio sin privilegios de root, acceso SSH real, y storage persistente que respaldas.
¿Puedo correr varias instancias de Hermes Agent en un solo VPS?
Sí, con perfiles. hermes profile create <name> le da a cada instancia su propio directorio home, config, memoria y base de datos de estado bajo HERMES_HOME/profiles/, y la imagen Docker reconcilia los servicios de gateway por perfil desde ese directorio en cada restart del contenedor.
La newsletter
Don’t Code, Specify. Cada semana, agentes de IA en producción de verdad. Sin hype: lo que funcionó y lo que se rompió.
Suscríbete en Substack (se abre en una pestaña nueva)