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 run command 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-codex provider, 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:

  1. Pin the image by version and digest. A mutable latest tag is not a deployment plan.
  2. Persist /opt/data. Hermes keeps configuration, sessions, skills, memories, and authentication state there.
  3. Publish the API on 127.0.0.1 only. A private Docker network handles future service-to-service access.
  4. Bound the process. 4 GiB of RAM, 2 CPUs, 256 PIDs, bounded logs, and agent loop limits.
  5. 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: 256

Use 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: missing

Notice 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_key

Type the value, press Ctrl-D, paste the resulting !vault block into your group vars, and let a no_log assertion 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, not pip:

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

The follow-up check imports DDGS, verifies the version, and confirms /opt/data/lazy-packages is on sys.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-codex

The refreshable credential lands under the persisted /opt/data directory. Do not copy your laptop’s ~/.codex directory 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/hermes with mode 0755. Arguments pass through, so hermes --help and hermes auth status openai-codex work from the host too.

Then run the play three times: once with --check --diff to preview, once to deploy, and once more to confirm it reports changed=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-agent

That 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

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

DevOps Homelab docker Ansible Ai-agents