Hermes Agent no Docker: como rodar numa VPS, headless
O que a imagem Docker e o Docker Compose do Hermes Agent realmente trazem, como persistir o HERMES_HOME e rodar profiles isolados, o modelo de auth do dashboard, e as pegadinhas do Swarm que custaram tempo de verdade rodando numa VPS.
Você já conhece Docker. Isso não é um guia de instalação. É o que a imagem e o Compose do Hermes Agent realmente contêm, o que quebra quando você roda numa VPS em vez de num notebook, e o punhado de pegadinhas que não estão documentadas em lugar nenhum até você bater de cara com elas.
O que a imagem Docker do Hermes Agent realmente traz
O Dockerfile publicado parte de debian:13.4, fixado pelo digest sha256 em vez de uma tag, porque o resto do build (um uv fixado, um Chromium fixado) já vem de um lock verificado por sha, e uma imagem base flutuante ia derivar embaixo deles. O primeiro estágio do build compila o SQLite 3.53.4 a partir do código-fonte e empacota esse .so em vez do 3.46.1 que vem junto com o Debian 13, que ainda carrega o bug de corrupção no reset do WAL do upstream. Se você já viu uma instalação do Hermes reclamar de um state.db corrompido num pacote de SQLite da distro, é por isso que a imagem do container evita isso completamente.
O PID 1 dentro do container é o s6-overlay, não o tini. O tini costumava recolher os processos zombie que se acumulam quando o Hermes gera subprocessos stdio de MCP, git ou bun como PID 1; o s6-overlay faz essa mesma coleta sem bloquear e, além disso, supervisiona o processo principal do Hermes, o dashboard e os gateways por profile como serviços nomeados dentro de /etc/s6-overlay/s6-rc.d/. Um shim fino de compatibilidade em /usr/bin/tini ainda existe para templates de orquestração que fixam o entrypoint antigo no código, mas ele só remove as flags de CLI do tini e reexecuta /init.
O container roda como um usuário hermes não-root, UID 10000 por padrão, sobrescrevível na inicialização com HERMES_UID e HERMES_GID, para que os arquivos que ele escreve fiquem com o dono que for o usuário real do host. O HERMES_HOME fica fixado em /opt/data, e esse é o único caminho declarado como VOLUME. Tudo o resto dentro de /opt/hermes, o venv, o bundle do frontend, a instalação em si, é copiado como somente leitura (--chmod=a+rX,go-w) e pertence ao root. Essa separação importa mais adiante: o código é imutável, o volume de dados é a única coisa que você precisa preservar.
Um default é fácil de assumir errado: colocar o Hermes num container não coloca, por si só, o que ele roda num sandbox. O backend de execução de ferramentas usa local por padrão, o que significa que um comando de shell que o agente roda executa dentro desse mesmo container, com o mesmo filesystem e o mesmo network namespace do próprio Hermes. A imagem instala o pacote docker-cli como dependência de sistema exatamente para o caso em que você quer mais isolamento que isso: aponte uma tarefa para o backend docker em vez disso, e o Hermes chama essa CLI para lançar um container irmão em sandbox (cap-drop all, sem novos privilégios, limites de recurso) e roda o comando ali dentro. O jeito usual de ligar isso de dentro de um container é montando o socket do Docker do host dentro do container do Hermes. Local é o padrão. Descartável é uma escolha que você faz por tarefa.
O Docker Compose do Hermes Agent, lido direto
O docker-compose.yml que vem com o projeto roda dois serviços, não um:
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"]
Essa é a forma, cortada dos blocos comentados para credenciais do Microsoft Teams e do Google Chat, que são chaves reais dentro do arquivo (TEAMS_CLIENT_ID, GOOGLE_CHAT_SERVICE_ACCOUNT_JSON, e o resto), só que opcionais. Os dois serviços montam o mesmo caminho do host em /opt/data, os dois rodam com o mesmo UID remapeado, e o gateway e o dashboard são, de propósito, containers separados compartilhando um único volume de dados em vez de um processo só fazendo as duas coisas.
O próprio comentário no topo do arquivo diz como rodar, passando os IDs do seu usuário do host para que o que é seu continue seu dentro do container também:
Subir a stack
Gateway + dashboard
HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d
As notas de segurança dele valem a leitura ao pé da letra, porque são os dois erros que o pessoal realmente comete: o dashboard escuta em 127.0.0.1 por design, já que guarda API keys, e o comentário é explícito que acesso remoto deveria passar por um túnel SSH ou um reverse proxy que adiciona autenticação, nunca passando --insecure --host 0.0.0.0. A segunda nota é sobre o servidor de API compatível com OpenAI, que fica desligado a menos que você descomente API_SERVER_HOST e API_SERVER_KEY juntos, porque a key é obrigatória no momento em que o servidor fica alcançável além do localhost.
Persistindo o HERMES_HOME, e profiles para instâncias isoladas
Tudo que faz uma instalação do Hermes ser sua vive dentro de HERMES_HOME: config.yaml, SOUL.md, MEMORY.md e USER.md, a pasta skills/, e state.db. Monte esse diretório único e um rebuild de container não perde nada. Perca esse mount e você tem uma instalação nova, sem memória nenhuma de ter rodado algum dia.
Se você quer mais de um agente na mesma máquina, o Hermes chama isso de profile, e é um conceito de primeira classe, não um workaround. Dentro do container do gateway rodando:
Criar um profile isolado
Criar
hermes profile create coderUsar
coder setup
Isso cria ~/.hermes/profiles/coder/ com seu próprio config.yaml, .env, SOUL.md e state.db, e também monta um comando coder, um atalho para hermes -p coder. Um profile de assistente pessoal e um profile de coding agent na mesma máquina nunca compartilham memória, nunca compartilham skills, e nunca escrevem no system prompt um do outro por acidente, que é a coisa que realmente dá errado quando dois agentes compartilham uma home.
A imagem Docker já é planejada exatamente para isso. O Dockerfile declara serviços s6 estáticos para o processo principal do Hermes e o dashboard em tempo de build, mas os serviços de gateway por profile são registrados dinamicamente em runtime dentro de /run/service/, que é tmpfs e é limpo em todo restart de container. Um script de cont-init.d chamado 02-reconcile-profiles roda antes de qualquer outra coisa começar e reconstrói esses slots de serviço lendo $HERMES_HOME/profiles/<name>/ direto do volume montado. Na prática: um container, um volume, tantas instâncias isoladas do Hermes quantos profiles você tiver, sobrevivendo a um restart sem você tocar em nada.
A web UI e o dashboard do Hermes Agent, atrás de auth
O dashboard escuta em loopback por padrão, como o próprio comentário do arquivo Compose diz. Abrir ele para qualquer coisa além da sua própria máquina significa um túnel, um reverse proxy, ou as chaves dashboard.basic_auth.username e .password do config.yaml, que a CLI preenche para você e que a camada de plugin de auth do dashboard confere se estão de fato habilitadas antes de confiar nelas.
Aqui está a pegadinha, e ela não é específica do Hermes. A API nativa de WebSocket do navegador não tem jeito de definir um header Authorization no handshake. Se um reverse proxy na frente do dashboard trava todo caminho, incluindo os de WebSocket, com basic auth, as requisições HTTP normais do navegador passam (o navegador reenvia a credencial), mas o upgrade de WebSocket nunca carrega ela, e a UI trava em “Connecting” para sempre. Eu bati nisso rodando o Hermes atrás do Traefik: a correção foi um segundo router do Traefik restrito só ao caminho do WebSocket, autenticado com um parâmetro de query ?token= em vez de basic auth, contornando o primeiro router completamente. A mesma pegadinha atingiu a web UI do OpenClaw quando eu avaliei ele na mesma semana, e é assim que eu sei que é um padrão na borda, não um bug de nenhum dos dois projetos.
O próprio código do Hermes desde então formalizou essa mesma separação uma camada mais abaixo, dentro do próprio processo do dashboard. No modo com portão, um upgrade de WebSocket nunca aceita o token de sessão simples do dashboard; um comentário no código é explícito que uma constante vazada não deve, por si só, dar acesso. Em vez disso, o navegador gera um ticket de uso único com TTL de 30 segundos e apresenta ele como um parâmetro de query ?ticket= ou um subprotocolo hermes-gateway-ticket.<ticket>, um processo filho gerado pelo servidor recebe, em vez disso, uma credencial ?internal= de vida mais longa, e uma sessão autenticada pode recorrer a um ?token= verificado pelo provider, o mesmo caminho de verificação que o bearer auth da REST API usa. Nenhum dos três é um header, porque um header é exatamente o que um handshake de WebSocket não consegue carregar de forma confiável. A própria nota de histórico do código diz isso sem rodeios: antes desse caminho de ticket existir, o upgrade de WebSocket do modo com portão não aceitava credencial nenhuma, e simplesmente falhava fechado.
A lição vai além do Hermes. Toda vez que você colocar auth no estilo sessão na frente de uma conexão de vida longa, confira se essa conexão é um WebSocket antes de assumir que a camada de auth cobre ela. Se você está fazendo o proxy do dashboard por conta própria, dê ao caminho do WebSocket a própria rota de token em vez de confiar que o basic auth vai acompanhar o upgrade.
Meu swarm: bento, Traefik, Portainer
O meu Hermes roda numa VPS que eu transformei num Docker Swarm com o bento, meu próprio instalador, que configura Traefik e Portainer na frente de qualquer coisa que você faz deploy. O Hermes roda headless nesse swarm, gateway do Telegram ligado, nenhuma aba de navegador jamais aberta na própria máquina. O arquivo Compose acima é a forma de host único; o Swarm muda algumas coisas quando você faz deploy como stack, e o Portainer passa a ser por onde você redeploya em vez de docker compose up direto.
As pegadinhas que custaram tempo de verdade
Nenhuma dessas aparece numa demo. Todas apareceram numa VPS real.
Sintoma, causa real, correção
| O que você vê | O que está acontecendo de verdade | O que resolveu |
|---|---|---|
| Um container chamando o próprio hostname público dá timeout | userland-proxy: false derruba o caminho de hairpin NAT numa VPS de IP único; o pacote sai pela interface pública e nunca roteia de volta | Remover a chave. O default true do Docker é a configuração correta aqui |
| Um serviço no mesmo host leva cerca de 130 segundos para responder, não menos de um | Chamar um container pelo FQDN público roteia através de TLS em loopback em vez da rede interna | Chamar pelo nome de serviço interno, não pelo hostname público |
| Um container simplesmente desaparece, sem erro no próprio log | O kernel matou ele por OOM. O orquestrador só vê process_lost com exit code nulo | dmesg | grep oom é o log de verdade. Nada que o Docker imprime nomeia isso |
| Uma chamada ao provider falha com erro de credencial duplicada | O SDK define um header de auth a partir de uma env var e um header explícito define um segundo | Escolher uma única fonte para a credencial, nunca as duas ao mesmo tempo |
| Um serviço fica em 0/1 replicas e o proxy retorna 404 | docker restart numa task do Swarm orfaniza ela em vez de reagendar | docker service update --force <service>. Nunca docker restart numa task direto |
| agent.log lança Permission denied | Um diagnóstico rodado como root dentro do container deixou arquivos de root num diretório que o usuário do serviço não consegue tocar | chown dos arquivos afetados de volta para o UID do serviço (10000 por padrão) |
As duas correções que vale a pena ter à mão, as mesmas duas sempre:
Os dois comandos que você realmente roda
Redeployar um serviço do Swarm, nunca reiniciar uma task
docker service update --force <service>Corrigir arquivos de root dentro de HERMES_HOME
chown -R 10000:10000 /opt/data
Mais um padrão que não é bug, só vale nomear. Quando outro container na mesma máquina precisa do binário do Hermes, a resposta não é uma segunda imagem. Enxertar os próprios volumes da instalação já rodando no container do outro serviço dá a ele o binário e os dados sem rebuild. É assim que meus containers do Paperclip chamam o Hermes: os volumes são enxertados pelo instalador do bento depois que as duas stacks já estão deployadas, já que o Compose por si só não tem um jeito limpo de expressar “monta o volume dessa outra stack” quando a ordem de deploy entre as duas stacks não é fixa.
Melhor VPS para o Hermes Agent: requisitos, não marcas
Não tem provedor que eu nomearia aqui, e uma lista de “melhor VPS” é, na maior parte, copy de afiliado vestida de review. O que realmente importa, em ordem:
O que um host para o Hermes Agent realmente precisa
- Obrigatório:Um único IP público sobre o qual você controla o comportamento de NAT do DockerVPS de IP único mais userland-proxy: false é exatamente a combinação que quebra o hairpin NAT. Saiba qual das duas você está alugando antes de tocar nessa configuração.
- Obrigatório:Margem de memória independente da própria carga de trabalho do agenteO dashboard tem um memory leak aberto ligado a sessões ao vivo, não a tempo ocioso, e já chegou a múltiplos gigabytes em uma hora numa instalação real. Dimensione esse processo separadamente do gateway.
- Obrigatório:Suporte a Swarm ou Compose, e um usuário de serviço não-rootA imagem já desce para o UID 10000 para você. Um host que força tudo por root desfaz essa proteção na primeira vez que alguém roda um comando pontual como root dentro do container.
- Obrigatório:Acesso por chave SSH, não um console só de navegadorTudo aqui, o arquivo Compose, os comandos do Swarm, as correções de chown, assume um shell de verdade. Um console de provedor que só dá um terminal web torna cada uma dessas pegadinhas mais lenta de corrigir.
- Obrigatório:Armazenamento em bloco persistente que você realmente faz backupHERMES_HOME é o único volume que importa. Se o armazenamento do host desaparece junto com a instância, todo profile nela desaparece também.
Fixe uma versão, depois leia as release notes
O dashboard carrega o próprio problema aberto. A issue #80527 reporta o processo dele crescendo sem limite enquanto serve sessões ao vivo, de uma base estável de cerca de 250 MB até 6,3 GB numa ocorrência e 7,6 GB, com o swap totalmente esgotado, em outra, as duas terminando num OOM kill que derrubou todo cliente conectado através dele. Quem reportou rastreou a causa provável até tui_gateway/server.py: a transcrição bruta da sessão é mantida em memória para resume e durabilidade mesmo depois que a compressão de contexto substitui o que o próprio agente vê, e uma sessão longa com saída pesada de ferramenta faz essa cópia retida crescer sem limite de tamanho. O mesmo dashboard sem sessão ativa fica estável em torno de 290 MB, então o leak só aparece depois que você realmente usa a UI por um tempo.
Até isso ganhar uma correção, a regra de operação é chata de propósito: fixe a tag de imagem que você testou, leia as notas antes de atualizar, e numa VPS com memória restrita, não deixe uma sessão longa de dashboard aberta sem supervisão por horas. Reinicie o serviço do dashboard numa agenda se precisar deixar rodando. O container do gateway, que é onde os seus gateways e cron jobs realmente vivem, não é o que tem esse bug.
Hermes Agent no Docker, respostas rápidas
Dá para rodar o Hermes Agent no Docker?
Dá. A Nous Research distribui um Dockerfile e um docker-compose.yml junto com o código-fonte: uma imagem supervisionada por s6-overlay, rodando como usuário não-root, com dois serviços, um gateway e um dashboard, compartilhando um único volume HERMES_HOME.
Como eu persisto os dados do Hermes Agent no Docker?
Monte um caminho do host em /opt/data, que é onde o HERMES_HOME vive dentro do container (o compose que vem no projeto usa ~/.hermes:/opt/data). Esse volume único guarda config.yaml, SOUL.md, memória, skills e state.db. Tudo o resto na imagem é substituível.
Por que o dashboard do Hermes Agent trava em Connecting?
Na maioria das vezes é um reverse proxy travando o dashboard com basic auth na frente do caminho de WebSocket. Navegadores não conseguem enviar um header Authorization num upgrade de WebSocket, então as rotas HTTP normais autenticam bem e a rota de WebSocket trava para sempre. Dê ao caminho de WebSocket a própria rota baseada em token em vez de basic auth.
Qual é a melhor VPS para o Hermes Agent?
Não tem resposta de marca. O que importa é um único IP público sobre o qual você entende as configurações de NAT do Docker, margem de memória dimensionada para o processo do dashboard separadamente da carga do agente, suporte a Swarm ou Compose com um usuário de serviço não-root, acesso SSH real, e armazenamento persistente do qual você faz backup.
Dá para rodar várias instâncias do Hermes Agent numa VPS só?
Dá, através de profiles. hermes profile create <name> dá a cada instância o próprio diretório home, config, memória e banco de estado dentro de HERMES_HOME/profiles/, e a imagem Docker reconcilia os serviços de gateway por profile a partir desse diretório em todo restart de container.
A newsletter
Don’t Code, Specify. Toda semana, agentes de IA em produção de verdade. Sem hype: o que funcionou e o que quebrou.
Assinar no Substack (abre em nova aba)