Ai-agents
-
Deploying Hermes Agent With Ansible Without Creating a Snowflake
I have a home server with Plenty of RAM and no useful GPU, so running a local model was never the interesting part of deploying Hermes Agent. The interesting part was making the agent setup repeatable.
I could have pasted a
docker runcommand over SSH and called it finished. It would have worked. But “it works” and “I can rebuild this server six months from now” are two very different things.So I built an Ansible role around the official Hermes container, authenticated with a ChatGPT subscription through the
openai-codexprovider, and left the one interactive step, OAuth, outside Ansible. That split is the whole idea. Ansible owns the infrastructure. Hermes owns its refresh token. I own the browser login.
Start With the Deployment Contract
Before writing a single task, decide what the role is promising. Mine had five rules:
- Pin the image by version and digest. A mutable
latesttag is not a deployment plan. - Persist
/opt/data. Hermes keeps configuration, sessions, skills, memories, and authentication state there. - Publish the API on
127.0.0.1only. A private Docker network handles future service-to-service access. - Bound the process. 4 GiB of RAM, 2 CPUs, 256 PIDs, bounded logs, and agent loop limits.
- Keep OAuth manual. Ansible creates the service, then the operator completes the device-code login over SSH.
The role layout is conventional:
defaults/main.yml,handlers/main.yml,tasks/main.yml, and templates for the Compose file, the Hermes config, the env file, and a host wrapper script.Put every value you expect to tune in
defaults/main.yml:hermes_agent_home: /home/youruser/Web/hermes-agent hermes_agent_data_dir: "{{ hermes_agent_home }}/data" hermes_agent_image: >- nousresearch/hermes-agent:v2026.8.3@sha256:<tag> hermes_agent_api_port: 8642 hermes_agent_publish_host: "127.0.0.1" hermes_agent_model_provider: openai-codex hermes_agent_model_name: gpt-5.6-terra hermes_agent_memory_limit: 4g hermes_agent_cpu_limit: "2.0" hermes_agent_pids_limit: 256Use a release and digest you have reviewed. The version above is what I deployed, not a promise that it is still the right one when you read this.
The Compose template turns those defaults into an enforceable boundary:
services: hermes: image: {{ hermes_agent_image }} command: ["gateway", "run"] restart: unless-stopped env_file: - ./hermes.env volumes: - {{ hermes_agent_data_dir }}:/opt/data ports: - "127.0.0.1:8642:8642" mem_limit: 4g cpus: "2.0" pids_limit: 256 security_opt: - no-new-privileges:true healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8642/health"] interval: 10s retries: 12 logging: options: max-size: "10m" max-file: "5"The loopback bind matters. The official Hermes Docker docs recommend authenticated access for exposed services, and specifically call out SSH tunnels or private networking as the safer way to reach a loopback-bound dashboard. I don’t need the API listening on every interface just because Docker makes that easy.
Make Ansible Own Setup and Proof
The role should do more than render YAML. It should reject unsafe input before it mutates anything, create persistent directories with deliberate ownership, reconcile Compose, and prove the service came back.
- name: Validate Hermes configuration ansible.builtin.assert: that: - hermes_agent_api_key | length >= 32 - hermes_agent_publish_host in ['127.0.0.1', '::1'] - hermes_agent_model_provider in ['openai-codex', 'openai-api', 'openrouter'] no_log: true - name: Render protected environment ansible.builtin.template: src: hermes.env.j2 dest: "{{ hermes_agent_home }}/hermes.env" mode: "0600" no_log: true - name: Reconcile Hermes Compose project community.docker.docker_compose_v2: project_src: "{{ hermes_agent_home }}" state: present pull: missingNotice the two uses of
no_log. An encrypted variable is protected at rest, but Ansible will happily reveal the decrypted value in task output or--diff. Secret-bearing template and validation tasks should not print their inputs.I encrypted only the API bearer key, not the whole variables file:
ansible-vault encrypt_string \ --vault-password-file vault_password_file \ --stdin-name vault_hermes_agent_api_keyType the value, press Ctrl-D, paste the resulting
!vaultblock into your group vars, and let ano_logassertion check its length. You don’t need to print the plaintext back into your terminal to prove Ansible can decrypt it.I also wanted keyless web search, so the role installs a pinned DDGS package into persistent storage with
uv, notpip:- name: Install pinned DDGS community.docker.docker_container_exec: container: hermes-agent user: hermes argv: - /usr/local/bin/uv - pip - install - --python - /opt/hermes/.venv/bin/python - --target - /opt/data/lazy-packages - --reinstall - ddgs==9.14.4The follow-up check imports
DDGS, verifies the version, and confirms/opt/data/lazy-packagesis onsys.path. Checking for a metadata directory is not enough.One gotcha from the release I deployed: do not render
HERMES_YOLO_MODE=0. Its mere presence still triggered the YOLO banner. If you want manual approvals, omit the variable entirely and set the approval mode in the Hermes config instead.OAuth stays manual, because browser authentication is an operator action, not configuration management:
ssh user@homelab docker exec -it hermes-agent hermes auth add openai-codex --no-browser docker exec hermes-agent hermes auth status openai-codexThe refreshable credential lands under the persisted
/opt/datadirectory. Do not copy your laptop’s~/.codexdirectory into the container just to skip one login.
Make Daily Use Boring Too
The last piece was a host command. I wanted to SSH into the server, type
hermes, and resume the last session without remembering a Docker incantation.#!/bin/sh set -eu if [ "$#" -eq 0 ]; then set -- --continue fi if [ -t 0 ] && [ -t 1 ]; then exec docker exec -it -w /opt/data/workspace hermes-agent hermes "$@" fi exec docker exec -i -w /opt/data/workspace hermes-agent hermes "$@"Install that template as
/usr/local/bin/hermeswith mode0755. Arguments pass through, sohermes --helpandhermes auth status openai-codexwork from the host too.Then run the play three times: once with
--check --diffto preview, once to deploy, and once more to confirm it reportschanged=0. Idempotence you haven’t observed is idempotence you’re guessing at. Finish by checking the container is healthy and the port is where you think it is:docker inspect --format '{{.State.Health.Status}}' hermes-agent docker port hermes-agentThat is the difference between a container I happen to have running and a service I know how to rebuild. The manual OAuth step isn’t a failure of automation, it’s a clean boundary around a human credential flow.
Exactly what I want from Ansible.
Sources & References
- Hermes Agent Docker guide — official container deployment docs
- Hermes Agent provider documentation — including
openai-codex - Ansible Vault: encrypting individual variables
- community.docker.docker_compose_v2
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- Pin the image by version and digest. A mutable