Self-host mem0 for agent memory on a VPS
Run the mem0 memory server on your own VPS: the real RAM floor, a Compose file that binds to localhost, TLS in front of the API, and a fully local Ollama path.
What self-hosting mem0 on a VPS actually costs in RAM
Self-hosting mem0 means running three containers: the FastAPI memory server, Postgres with the pgvector extension, and a Next.js dashboard. mem0 is a memory layer for agents. You post a conversation to it, a language model pulls the durable facts out of that conversation, and those facts are stored as vectors so a later query can pull the relevant ones back.
Budget roughly 1 GB of resident memory for the three containers, and 3 to 4 GB of disk once the images are built. A 2 GB VPS runs this comfortably when the language model lives somewhere else. When the model runs on the same box through Ollama, the model dominates everything else: an 8B model quantised to 4 bits wants around 6 GB on its own, so the fully local build starts at 8 GB.
Do not take those figures from a blog post, including this one. Measure the stack you actually built.
docker compose ps
docker stats --no-stream
docker system df -vdocker stats prints resident memory per container. docker system df -v prints the disk each image and each volume holds.
Steady state is not the peak. docker compose up -d --build compiles the Next.js dashboard, and that Node build is the hungriest moment of the whole install. On a 1 GB VPS the kernel out-of-memory killer stops it and the build ends with exit code 137. Confirm the cause before you go looking for a Docker bug:
dmesg -T | grep -i "killed process"If a server feels like too much machinery for what you need, the smaller options are real. a local agent memory store with no server at all and memory that lives inside Claude Code itself both skip the database. Come back here when several agents, or several machines, need to read the same memories.
Do I need Neo4j for mem0 graph memory?
No. If a guide tells you to add a Neo4j container, that guide is older than the code.
Graph memory in mem0 used to mean an external graph database, configured under a graph_store key with enable_graph set to true. The new memory algorithm, shipped in April 2026, removed both keys from the open source SDK. Entity extraction now runs inside the ordinary add path, and the entities are written to a second pgvector collection named after your main one with _entities appended. There is no migration to run. Built in entity linking starts working on the next add call.
Dropping the graph store saves a JVM container, its heap, and several hundred megabytes of image. On a 2 GB VPS that is the difference between running and swapping.
Here is what you give up, stated plainly. Search results used to carry a relations field listing edges between entities. That field is gone. Entity matches now raise a memory's position in the combined score, and there is no structure you can traverse. If your application walked those relationships, mem0 no longer holds them, and you keep a graph database of your own outside mem0, fed by your own code.
The compose file in the repo is a development compose
server/docker-compose.yaml declares name: mem0-dev, and it means it. Read it before you run it, because five things in it are wrong for a server.
- It builds from
server/dev.Dockerfileand mounts your checkout over the image with.:/app, so the container runs whatever sits in that directory rather than what you built. - Its command is
rm -rf /app/packages && pip install -q --force-reinstall --no-deps mem0ai && alembic upgrade head && uvicorn main:app --reload. That reinstallsmem0aifrom PyPI on every start, so the version your server runs can change during a restart you did not think was an upgrade. - The same pip step means a restart with no outbound network fails before uvicorn ever runs. Your memory server is then down because PyPI was unreachable.
--reloadstarts uvicorn's file watcher. It exists to restart the process when you edit code, and it costs memory and a second process to do nothing useful in production. The productionDockerfilecarries--reloadin itsCMDtoo, so you override the command either way.- The published ports are
"8888:8000","8432:5432"and"3000:3000". A published port with no address in front of it binds0.0.0.0, so Postgres answers the public internet on 8432 the moment the stack starts.
That last point deserves its own warning. Docker publishes a port by writing its own rules ahead of the chain ufw manages, so ufw deny 8432 does not close a published container port. Docker publishing ports straight past ufw walks through the rules involved.
A compose file for a real server
Work inside server/, keep init-db.sh where it is, and replace docker-compose.yaml with this.
name: mem0
services:
mem0:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:8888:8000"
networks: [mem0_network]
volumes:
- mem0_history:/app/history
depends_on:
postgres:
condition: service_healthy
command: >
sh -c "alembic upgrade head &&
uvicorn main:app --host 0.0.0.0 --port 8000"
environment:
- PYTHONUNBUFFERED=1
- DASHBOARD_URL=https://mem0.example.com
- APP_DB_NAME=mem0_app
- AUTH_DISABLED=false
- MEM0_TELEMETRY=false
postgres:
image: pgvector/pgvector:pg17
restart: unless-stopped
shm_size: "128mb"
networks: [mem0_network]
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
healthcheck:
test: ["CMD-SHELL", "pg_isready -q -U ${POSTGRES_USER:-postgres}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- postgres_db:/var/lib/postgresql/data
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
mem0-dashboard:
build: ./dashboard
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
networks: [mem0_network]
environment:
- NEXT_PUBLIC_API_URL=https://mem0.example.com
- API_INTERNAL_URL=http://mem0:8000
depends_on:
mem0:
condition: service_started
volumes:
postgres_db:
mem0_history:
networks:
mem0_network:
driver: bridgeFive changes matter here, and each one has a reason.
Every ports entry starts with 127.0.0.1, so the kernel accepts those connections only from the box itself. Everything from outside arrives through the reverse proxy, which is the only thing holding a certificate.
Postgres has no ports block at all. The mem0 container reaches it over mem0_network by service name, so publishing 8432 gains you nothing and costs you an open port. Use docker compose exec postgres psql -U postgres when you need a shell.
History moves from the ./history bind mount to a named volume. A bind mount ties the data to one path and one uid on this host, while a named volume is an object Docker can snapshot and move. Named volumes against bind mounts covers when each one is right.
The command drops --reload and keeps alembic upgrade head. Keep that migration step. Without it the app boots against a database with no tables, and every request fails on the first query.
NEXT_PUBLIC_API_URL is the URL your browser calls, so it must be the public HTTPS address and not http://mem0:8000. Next.js inlines every NEXT_PUBLIC_ value at build time, so changing it needs docker compose up -d --build mem0-dashboard. A plain restart keeps the old value baked into the JavaScript and the dashboard calls the wrong host.
Secrets live in .env, and .env stays off the internet
cd server
cp .env.example .env
openssl rand -hex 32 # paste into JWT_SECRET
openssl rand -hex 32 # paste into ADMIN_API_KEY
chmod 600 .envSet POSTGRES_PASSWORD, JWT_SECRET and ADMIN_API_KEY. Leave AUTH_DISABLED=false. The name is honest about what that flag does: with it on, the server hands every memory it holds to anyone who can reach the port. Set MEM0_TELEMETRY=false if you do not want the onboarding event sent upstream.
ADMIN_API_KEY is compared against the X-API-Key header with secrets.compare_digest, and a match skips every database lookup. It is a root credential for the whole API. Treat it as one: no shell history, no git, no pasting it into a prompt. Compose env files and where secrets leak out of them and keeping API keys out of an agent's context both apply directly, because the callers of this server are agents.
Values loaded from env_file sit in the container environment, and docker inspect prints them in full. Anyone in the docker group can read them, and anyone in the docker group is effectively root on the host.
Put TLS in front of the API instead of opening 8888
The API answers on 127.0.0.1:8888 and the dashboard on 127.0.0.1:3000. nginx terminates TLS (transport layer security) on 443 and forwards to both.
server {
listen 443 ssl;
server_name mem0.example.com;
ssl_certificate /etc/letsencrypt/live/mem0.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mem0.example.com/privkey.pem;
location ~ ^/(memories|search|configure|auth|api-keys|docs|openapi.json) {
proxy_pass http://127.0.0.1:8888;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 180s;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}proxy_read_timeout matters more than it looks. An add call blocks while the language model reads the conversation and extracts facts. A local 8B model on CPU regularly takes longer than nginx's 60 second default, and then the caller sees 504 Gateway Time-out while the model is still working and the memory still gets written. You end up with a memory you were told failed.
Close the rest with a default deny ufw policy, leaving 22 and 443 open. Issue the certificate with certbot on Ubuntu 24.04 behind nginx. If the box already fronts other apps with Traefik routing several Compose apps, add mem0 to that router instead of installing a second proxy.
Smoke test: add one memory and read it back
export MEM0_KEY='<the ADMIN_API_KEY from .env>'
curl -sS -X POST http://127.0.0.1:8888/memories \
-H "Content-Type: application/json" \
-H "X-API-Key: $MEM0_KEY" \
-d '{"messages":[{"role":"user","content":"I deploy with Docker Compose and I run Postgres 17."}],"user_id":"smoke"}'A healthy response is a JSON object with a results list, and each entry holds an id, the extracted memory text, and "event": "ADD". The current algorithm returns ADD events only. UPDATE and DELETE events were removed, so their absence is not a bug.
curl -sS -X POST http://127.0.0.1:8888/search \
-H "Content-Type: application/json" \
-H "X-API-Key: $MEM0_KEY" \
-d '{"query":"which database do I run?","filters":{"user_id":"smoke"},"top_k":5}'The fact about Postgres 17 should come back with a score. Pass the identifier inside filters, as shown. A top level user_id still works, and the server logs Top-level user_id in /search is deprecated. Use filters={...} instead. every time you use it.
Clean up after yourself so the test data does not pollute real searches:
curl -sS -X DELETE "http://127.0.0.1:8888/memories?user_id=smoke" \
-H "X-API-Key: $MEM0_KEY"If the search returns fewer rows than you expected, check the defaults before you blame retrieval. In the current release top_k defaults to 20, down from 100, and threshold defaults to 0.1 rather than none, so weak matches are now filtered out for you. Once this works over curl, the same endpoints are what you wire into an agent, whether directly or through an MCP server running on the same VPS.
Run mem0 with no OpenAI key at all
Start with the blocker, because you will hit it in the first five minutes. The server image ships a fixed set of provider libraries, and /configure rejects anything outside them:
LLM provider 'ollama' is not bundled in this image. Bundled providers: openai, anthropic, gemini. To use another provider, install its Python package, rebuild the container, and extend BUNDLED_LLM_PROVIDERS in server/main.py.You do not have to rebuild anything. Ollama serves an OpenAI compatible API at /v1, covering /v1/chat/completions and /v1/embeddings, and mem0's openai provider accepts an openai_base_url. Point that key at Ollama and the bundled check passes, because the provider genuinely is openai. Only the address changes.
Add Ollama to the same Compose project:
ollama:
image: ollama/ollama
restart: unless-stopped
networks: [mem0_network]
ports:
- "127.0.0.1:11434:11434"
volumes:
- ollama_models:/root/.ollamaAdd ollama_models: under the top level volumes: key, then pull one chat model and one embedding model:
docker compose up -d ollama
docker compose exec ollama ollama pull llama3.1:8b
docker compose exec ollama ollama pull nomic-embed-textIf Ollama already runs on the host as a systemd unit, as in running Ollama directly on a VPS, do not point the container at 127.0.0.1:11434. Inside the mem0 container, 127.0.0.1 is the mem0 container. Give the mem0 service extra_hosts: ["host.docker.internal:host-gateway"], set Environment="OLLAMA_HOST=0.0.0.0:11434" in a systemd drop-in so Ollama listens on an address the bridge can reach, and keep 11434 shut at the firewall.
Ask the model its embedding dimension before you configure anything
This one step decides whether retrieval works at all.
mem0's pgvector store creates its table with a fixed vector width, vector vector(1536), because embedding_model_dims defaults to 1536, the width of OpenAI's text-embedding-3-small. nomic-embed-text returns 768 values. Nothing inside mem0 compares those two numbers, so the mismatch surfaces from Postgres on the first insert:
expected 1536 dimensions, not 768Do not trust the number in this paragraph either. Ask the model:
curl -sS http://127.0.0.1:11434/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model":"nomic-embed-text","input":"dimension check"}' \
| python3 -c "import json,sys; print(len(json.load(sys.stdin)['data'][0]['embedding']))"That prints the width your collection has to use. Write the configuration into a file, because pasting a Postgres password through shell quoting is how typos get into production.
{
"vector_store": {
"provider": "pgvector",
"config": {
"host": "postgres",
"port": 5432,
"dbname": "postgres",
"user": "postgres",
"password": "<POSTGRES_PASSWORD from .env>",
"collection_name": "memories_local_768",
"embedding_model_dims": 768
}
},
"llm": {
"provider": "openai",
"config": {
"model": "llama3.1:8b",
"api_key": "ollama",
"openai_base_url": "http://ollama:11434/v1",
"temperature": 0.2
}
},
"embedder": {
"provider": "openai",
"config": {
"model": "nomic-embed-text",
"api_key": "ollama",
"openai_base_url": "http://ollama:11434/v1"
}
}
}curl -sS -X POST http://127.0.0.1:8888/configure \
-H "Content-Type: application/json" \
-H "X-API-Key: $MEM0_KEY" \
-d @config.json
curl -sS http://127.0.0.1:8888/configure -H "X-API-Key: $MEM0_KEY"The second call reads the configuration back, which is the check that the write landed. Then repeat the smoke test above.
Four details in that JSON are not obvious, and each one breaks something if you get it wrong.
api_key is the string ollama, and Ollama ignores its value. It cannot be empty, because the OpenAI client library raises before any request leaves the process when no key is set. Any non empty string works.
embedding_model_dims goes on the vector store, and there is deliberately no embedding_dims on the embedder. mem0 sends the OpenAI dimensions parameter only when you set embedding_dims, and backends that do not implement Matryoshka truncation reject that parameter outright. Set the width where the table is created, and leave the embedder alone.
collection_name is new. mem0 creates its table with CREATE TABLE IF NOT EXISTS, so pointing a different width at an existing collection does nothing at all: the old vector(1536) column stays, and every insert fails. A width change needs a fresh collection name, or you drop the old table by hand.
The host in openai_base_url is the Compose service name ollama, not localhost. Containers resolve each other by service name on their shared network.
What the fully local path costs you
Be honest with yourself about quality. mem0's published benchmark scores were measured with frontier models doing the extraction, so treat them as a ceiling rather than a forecast for an 8B model on your VPS. A small model writes vaguer facts, and sometimes returns prose where JSON was requested, which shows up as an add call returning an empty results list with no error.
Speed is the other cost. CPU only extraction takes seconds per add call, and every message you store pays it. If that latency matters, a VPS with a GPU attached is the honest fix. Throwing more CPU cores at an 8B model helps far less than people expect.
One rule holds whatever you choose: never mix embedding models inside one collection. Two different models that happen to share a width produce vectors that are not comparable. The insert succeeds, the search returns rows, and the rows are wrong, with nothing anywhere reporting an error.
Backups: there are two databases, not one
The most common mem0 backup mistake is dumping a single database. init-db.sh creates mem0_app alongside the default postgres database, and they hold different things. The postgres database holds the pgvector collections, which are the memories. mem0_app holds users, sessions, API keys and request logs.
Restore only postgres and the memories come back while every account and API key is gone, so nothing can authenticate to read them. Dump both, plus roles, in one command:
docker compose exec -T postgres pg_dumpall -U postgres --clean \
| gzip > "mem0-$(date +%F).sql.gz"The history volume is separate from Postgres and needs its own copy:
docker run --rm -v mem0_mem0_history:/data -v "$PWD:/backup" \
alpine tar czf /backup/mem0-history.tgz -C /data .Docker prefixes volume names with the project name, so confirm yours with docker volume ls before assuming mem0_mem0_history.
Restore into a scratch container and check the row counts before you believe any of it:
gunzip -c mem0-2026-08-03.sql.gz \
| docker compose exec -T postgres psql -U postgres -d postgresA backup you have never restored is a guess. Once the dumps are correct, push them off the box with restic snapshots to off-site storage, because a backup living on the server it protects protects nothing.
Failure modes and the exact strings you will see
{"detail":"Authentication required. Provide a Bearer token or X-API-Key header."} means the header is missing or misspelled. The name is X-API-Key, and curl sends header names literally.
{"detail":"At least one identifier (user_id, agent_id, run_id) is required."} on an add means the request had none of them. A memory has to be scoped to something, because search filters on exactly those fields.
LLM provider 'ollama' is not bundled in this image with HTTP 400 means you sent "provider": "ollama". Use "provider": "openai" with openai_base_url pointed at Ollama.
expected 1536 dimensions, not 768 from Postgres means the collection was created at one width and the embedder returns another. Set embedding_model_dims on the vector store and use a new collection_name.
Search returns rows that make no sense after a model change, with no error anywhere. The width still matches, so the database is happy, but two models place the same sentence in different positions. Start a new collection and re-add.
Connection refused in the mem0 logs while reaching Ollama usually means 127.0.0.1 in openai_base_url. Inside the container that address is the container. Use the service name, or the host gateway when Ollama runs on the host.
504 Gateway Time-out from nginx on an add means the model took longer than proxy_read_timeout. Raise it, and check whether the memory was written anyway before you retry the request.
exit code 137 during docker compose up --build is the out-of-memory killer stopping the dashboard build. Add swap, or build the image on a larger machine and push it to a registry.
error: port 3000 is already in use comes from the repo's make up target, which refuses to start when 3000 or 8888 are taken. Find the owner with lsof -iTCP:3000 -sTCP:LISTEN.
FAQ
Do I still need Neo4j to run mem0 with graph memory?
No. The new memory algorithm, shipped in April 2026, removed the graph_store and enable_graph configuration keys from the open source SDK. Entity extraction now runs during a normal add and writes to a second pgvector collection named <collection_name>_entities, so there is no external graph database, no extra container and no migration step. The trade is that the relations field on search results no longer exists. Entities now raise a memory's ranking rather than giving you edges to traverse, so an application that walked those relationships needs its own graph store outside mem0.
What is the smallest VPS that runs a self-hosted mem0 server?
With the language model hosted elsewhere, 2 GB of RAM and about 4 GB of free disk is enough for the API container, Postgres and the dashboard. The tight moment is the first build, because compiling the Next.js dashboard uses more memory than running it, and a 1 GB box gets its build killed with exit code 137. If Ollama runs on the same server, size for the model instead: an 8B model at 4-bit quantisation needs roughly 6 GB by itself, so plan on 8 GB.
Can I run mem0 without an OpenAI API key?
Yes, through Ollama's OpenAI compatible endpoint. Setting "provider": "ollama" fails, because the server image bundles only the openai, anthropic and gemini libraries and returns HTTP 400. Instead keep "provider": "openai" and set "openai_base_url": "http://ollama:11434/v1" with any non empty api_key, for both the llm and the embedder. Ollama ignores the key, and the bundled provider check passes because the provider really is openai.
Why does mem0 return no results after I switch to a local embedding model?
Because the pgvector table was created at a fixed width. embedding_model_dims defaults to 1536, nomic-embed-text returns 768, and Postgres rejects the insert with expected 1536 dimensions, not 768. mem0 creates the table with CREATE TABLE IF NOT EXISTS, so changing the number alone does nothing to an existing collection. Set embedding_model_dims to your model's real width, confirm that width by calling /v1/embeddings and counting the values it returns, and give the vector store a new collection_name at the same time.