Ansible
-
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
-
Run Your Whole Agent Stack on a $5 Box
I SSH’d into my home server this afternoon and ran
docker statson the memory layer that every one of my coding agent sessions talks to. Here’s what came back:mem0-qdrant 28.09MiB / 60.75GiB 2.13% mem0-neo4j 612.7MiB / 60.75GiB 0.77%640 megabytes. Vector store and graph store, both up for three weeks straight, serving every
rememberandrecallcall my agents make. The entire persistent memory for my AI tooling uses less RAM than one Chrome tab with Figma open.So let’s talk about why you’re paying a monthly subscription for this.
Local-First, Not Local-Only
I want to be precise here, because “self-hosted AI” has become a phrase people use to mean nine different things.
My setup is local-first, not local-only. The state lives on my hardware. The memories, the embeddings, the graph relationships, everything my agents have learned about my projects, all of it sits on a box I own, in a Docker volume I can
tarand carry away. Nobody can deprecate it, price-hike it, or sunset it.The inference does not. My embedder points at Mistral’s managed API. I’ll get to why, and how to swap it, but I’m not going to pretend otherwise in a post about self-hosting.
That embedder is the only thing that leaves my network, and only when something actually gets embedded, so when writing a memory and searching for one. Listing, deleting, and every graph operation are local with zero API calls.
State is what you can’t get back. Compute is a commodity you rent by the token. Losing access to an API means switching providers. Losing two years of accumulated project context means starting over.
The Four Pieces
Qdrant is semantic search. When I ask what it remembers about my package manager preferences, Qdrant turns that into a similarity query and hands back the relevant memories. It’s Rust, it’s fast, and at 28MB resident it’s essentially free to run. One gotcha: vector dimensions are fixed when the collection is created. Swap embedding models and you need a new collection, not a migration. I learned that the annoying way.
Neo4j is the graph store. Vectors are great at “find me things that sound like this” and bad at “what depends on what.” The graph holds explicit subject-predicate-object facts, so
project Xbuilt_withPython 3.13is a traversable edge instead of a fuzzy match. It’s the heavy one at 613MB, but it’s a JVM, so that’s mostly heap floor rather than working set. If you’re squeezing onto the smallest possible VPS, interrogate this one first.mem0 is the orchestration on top: what gets extracted from a conversation, what gets deduped against existing memories, what gets written where. That’s the difference between a database and a memory system.
The MCP server is what makes any of it useful. A small Go binary that speaks Model Context Protocol over stdio to Claude Code, exposing eight tools:
remember,recall,list_memories,forget,memory_stats,add_relation,recall_related,forget_relation.The topology is deliberately boring:
Mac Home server ┌──────────────┐ ┌──────────┐ ┌─────────────────┐ │ Claude Code │◄─►│ mem0-mcp │ LAN │ Qdrant + Neo4j │ │ │ │ (Go) │───────►│ │ └──────────────┘ └──────────┘ └─────────────────┘ stdio HTTP + boltClient binary on my laptop, containers on a box. No cloud in the middle, no account, no dashboard, no seat license.
The Ansible Role Is the Whole Argument
Anyone can
docker compose upa stack once. That’s a weekend, not infrastructure. What makes this real is that it’s a role in a repo, and rebuilding it on a fresh box is one command:ansible-playbook -i common_hosts home.yml --tags mem0That role does the unglamorous work:
- Installs a read-only deploy key scoped to exactly one repo, with an SSH
Hostalias so it can’t collide with my personal GitHub key - Clones and updates the source at a pinned branch
- Templates a
.envwith secrets pulled from Ansible Vault,no_log: trueso nothing leaks into terminal output on a--diffrun - Brings up the compose stack with
remove_orphans: true, so when I dropped a service upstream, the stale container went with it instead of lingering forever
Be careful with your secrets and how you are creating your .env files!
The Honest Part About the API Key
I self-host the state and rent the inference. Two reasons.
The first reason is speed. I ran embeddings locally before this, on CPU, and it was miserable: roughly 87 seconds to embed 32 memories, against about 2 seconds through a hosted API. That is a 45x difference on an operation sitting directly in the path of every
rememberandrecall. A good model on a CPU is still a slow model, and this was never a quality problem.The second is that embeddings have gotten cheap enough that not worth the time to setup your own embedding service. Mistral charges $0.10 per million tokens for
mistral-embed. Google’sgemini-embedding-001is $0.15 per million, halved on their batch API. Both are good models. Both bill you.Cloudflare is the worth knowing about if you’d rather not pay at all. Workers AI includes 10,000 neurons per day free, on the free plan as well as the paid one. Neurons are their normalized compute unit, and
bge-m3costs 1,075 of them per million input tokens — so that daily allowance is roughly nine million tokens a day, at no cost. Past it you’re at $0.012 per million, which is an order of magnitude under the paid competition. For a personal memory layer, nine million tokens a day is not a trial. It’s just free.One detail if you’re swapping:
bge-m3emits 1024-dimension vectors, the same asmistral-embed. Go back to that Qdrant gotcha — matching dimensions means your existing collection still works. Mismatched ones mean starting over.And the escape hatch is already built. The env vars in my role are
TEI_BASE_URL,TEI_MODEL,TEI_DIMENSIONS. Generic OpenAI-compatible embedder knobs, named after Text Embeddings Inference for historical reasons and pointed at Mistral today. Aim them at a self-hosted TEI container, at Ollama, at anything speaking that shape, and the rest of the stack doesn’t notice.That’s what local-first buys you. Not purity. Optionality.
So, the $5 Box
My server has 60GB of RAM, which is absurd overkill and exists because it does a dozen other things. The stack itself measured 640MB with three weeks of uptime, essentially zero CPU at idle.
That fits comfortably on a small cloud VPS in the few-dollars-a-month range. Check current pricing yourself rather than trusting a number in a blog post, but the shape is: a 2 vCPU / 4GB instance from Hetzner or similar costs less per month than one seat of most AI memory SaaS products, and you get to run everything else on it too.
Your real constraint is RAM, specifically Neo4j’s JVM floor. On a 1GB instance you’d be fighting it. At 2GB you’re fine. At 4GB you’ll forget it’s running.
Why I Care
The indie web ethos is about noticing that renting your identity from a platform means the platform decides what happens to it.
We’re about to make the same mistake with agent memory, except worse, because the thing being accumulated this time is a working model of how you think and what you’re building. Every “our AI remembers you across sessions” product is a proposal that you deposit that into someone else’s database and hope the pricing page stays reasonable.
Qdrant is Apache 2.0. Neo4j Community is GPL. Docker Compose is a YAML file. Ansible is idempotent YAML. Nothing in this stack is exotic. The barrier to owning your agent memory is an afternoon and 640 megabytes.
Not everyone needs this, and I’m not going to pretend a solo dev with three side projects is being exploited by a $20 subscription. But if you’re accumulating context you’d be genuinely sad to lose, the math changes. Own the state, rent the compute, and keep the role in version control so the whole thing is reproducible on a box you haven’t bought yet.
Moving the embedder onto Cloudflare’s free tier is next on my list, what’s on yours?
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].
- Installs a read-only deploy key scoped to exactly one repo, with an SSH
-
Running Mem0's Memory Backend as Real Infrastructure with Ansible
Mem0 works best when you treat its memory backend as infrastructure, not as some throwaway process you start by hand and forget about. There are two pieces worth automating early: the vector store and the graph store. Qdrant holds the embeddings for semantic lookup. Neo4j holds the relationship-oriented graph memory.
Quick note before we go further, because this can be confusing. When folks talk about Mem0’s “graph” support, that is not GraphQL. GraphQL is an API query layer you’d put in front of your app. Mem0’s graph memory is an actual graph database, usually Neo4j, that the client talks to over Bolt. Different thing entirely.
This post walks through a practical Ansible shape for running those backing services with Docker Compose. I’m not going to hand you a complete role. The point is to show the decisions that make the setup repeatable, because those are the parts people usually get wrong.
What the deployment actually does
Three responsibilities, that’s it:
- Get the Mem0 compose project onto the server.
- Write a server-local
.envfile with the runtime config. - Start the Compose stack with Qdrant and Neo4j.
In a small setup, the Python client runs on your laptop while the services run on a LAN host or a small VPS. The server exposes these ports, but only to trusted networks:
- Qdrant HTTP:
6333 - Qdrant gRPC:
6334 - Neo4j browser:
7474 - Neo4j Bolt:
7687
If you’re on a public internet host, bind these to localhost or hide them behind a VPN, firewall, or private network. Do not casually publish database ports to the internet. I shouldn’t have to say remind you…
Role inputs
A generalized role only needs a handful of variables:
mem0_home: /opt/mem0 mem0_repo: [email protected]:your-org/your-mem0-project.git mem0_branch: main mem0_qdrant_collection: default mem0_neo4j_enabled: true mem0_embedder_base_url: https://api.example.com/v1 mem0_embedder_model: your-embedding-model mem0_embedder_dimensions: 1024The collection name matters more than it looks. Qdrant collections have a fixed vector size. If you switch embedding models and the dimensions change, create a new collection. Don’t try to reuse the old one. This is a common way people break a working Mem0 setup, so treat the collection name as part of the embedding config, not an afterthought.
Secrets
Keep API keys and database passwords out of your regular vars files. Ansible Vault, a secrets manager, whatever your deployment system gives you. Then template a
.envfile with tight permissions:- name: Write Mem0 environment file ansible.builtin.template: src: mem0.env.j2 dest: "{{ mem0_home }}/.env" mode: "0600" no_log: trueThat
no_log: trueis not optional. Template diffs will happily leak plaintext API keys and graph passwords into CI logs, terminal scrollback, and ticket attachments. Once a secret lands in a CI log, you’re rotating it, not deleting it.The template itself stays small:
EMBEDDER_API_KEY={{ vault_embedder_api_key }} EMBEDDER_MODEL={{ mem0_embedder_model }} EMBEDDER_DIMENSIONS={{ mem0_embedder_dimensions }} QDRANT_HOST=qdrant QDRANT_PORT=6333 QDRANT_COLLECTION={{ mem0_qdrant_collection }} NEO4J_URL=bolt://neo4j:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD={{ vault_neo4j_password }}Notice
QDRANT_HOST=qdrant, not an IP. When the Mem0 container talks to sibling Compose services, use the service names. Save hostnames and LAN DNS for clients that live outside the Compose network. More on that in a second, because it’s a real gotcha.Compose shape
Just what you need to run Mem0’s memory backend in Docker.
services: qdrant: image: qdrant/qdrant:v1.12.6 ports: - "6333:6333" - "6334:6334" volumes: - qdrant_storage:/qdrant/storage neo4j: image: neo4j:5.26 environment: NEO4J_AUTH: "neo4j/${NEO4J_PASSWORD}" ports: - "7474:7474" - "7687:7687" volumes: - neo4j_data:/data - neo4j_logs:/logs volumes: qdrant_storage: neo4j_data: neo4j_logs:Pin your image versions. Floating tags make it impossible to tell whether a later failure came from your playbook, your app, or an upstream image that changed under you at 2am. Pinning turns a mystery into a diff.
The two Ansible flags that matter
Most of the role can stay boring. Ensure the directory exists, clone the repo, template the
.env, start the stack withcommunity.docker.docker_compose_v2. The two details I want you to actually read areforce: falseon the git task andremove_orphans: trueon the compose task.force: falseprotects local edits in the checkout. If the role needs to patch a generated file, make that explicit and safe instead of letting Git clobber the tree.remove_orphans: truekeeps Compose honest. Say you rip out an old local embedding service and switch to a managed embedding API. Without this, the old container just keeps running forever, quietly, and you’ll swear the new config isn’t taking effect.And for restarts, use handlers. Notify a restart handler when the checkout changes or when
.envchanges. Don’t bounce the stack on every single playbook run. The steady-state run should be quiet.Service names vs. hostnames
From a client running outside the Compose network, point Mem0 at the server’s reachable hostname:
QDRANT_HOST=mem0.example.test NEO4J_URL=bolt://mem0.example.test:7687From a container inside the same Compose project, use service names:
QDRANT_HOST=qdrant NEO4J_URL=bolt://neo4j:7687Get this backwards and you burn an afternoon on it…
Check your work
After the playbook runs, verify the pieces independently:
docker compose ps curl http://localhost:6333/healthz docker compose logs --tail=100 neo4jFor Neo4j, actually test Bolt from the network where your client lives. The browser port on
7474being reachable does not prove the Bolt endpoint on7687is usable. Different port, different assumption, don’t confuse a green browser page for a working client.The whole pattern
Strip away the YAML and it’s simple. Docker Compose owns Qdrant and Neo4j. Ansible owns the checkout, the
.env, and the Compose lifecycle. Vault owns the credentials. The client picks service names or external hostnames depending on where it runs.That gets you a repeatable Mem0 backend without turning your Ansible role into a second copy of the entire app. Pin your images, guard your secrets with
no_log, and never reuse a collection after the vector size changes. Do those three and the rest is boring, which is exactly what you want from infrastructure.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].