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:

  1. Get the Mem0 compose project onto the server.
  2. Write a server-local .env file with the runtime config.
  3. 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: 1024

The 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 .env file with tight permissions:

- name: Write Mem0 environment file
  ansible.builtin.template:
    src: mem0.env.j2
    dest: "{{ mem0_home }}/.env"
    mode: "0600"
  no_log: true

That no_log: true is 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 with community.docker.docker_compose_v2. The two details I want you to actually read are force: false on the git task and remove_orphans: true on the compose task.

force: false protects 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: true keeps 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 .env changes. 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:7687

From a container inside the same Compose project, use service names:

QDRANT_HOST=qdrant
NEO4J_URL=bolt://neo4j:7687

Get 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 neo4j

For Neo4j, actually test Bolt from the network where your client lives. The browser port on 7474 being reachable does not prove the Bolt endpoint on 7687 is 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].

DevOps docker Ansible Mem0 Qdrant Neo4j