SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

How to self-host mem0 on VPS with low RAM

Run mem0 for agents on your VPS: 1 GB RAM fit handle 3 containers, but local Ollama with an 8B model needs about 8 GB. Get Compose and TLS steps.

Wetin self-hosting mem0 for VPS really cost for RAM

Self-hosting mem0 mean say you dey run three containers: FastAPI memory server, Postgres with pgvector extension, and Next.js dashboard. mem0 na memory layer for agents. You post conversation give am, language model go pull out the durable facts from that conversation, then dem facts go store as vectors so later query fit bring back the ones wey matter.

Plan roughly 1 GB resident memory for the three containers, and 3 to 4 GB disk after the images don build. 2 GB VPS fit run this well when language model dey somewhere else. When model dey run for the same box through Ollama, model go use pass everything else: 8B model wey dem quantise to 4 bits need around 6 GB by itself, so fully local setup start from 8 GB.

No just carry these figures from blog post, including this one. Measure the stack wey you actually build.

docker compose ps
docker stats --no-stream
docker system df -v

docker stats dey print resident memory for each container. docker system df -v dey print the disk space wey each image and each volume dey hold.

Steady state no be peak. docker compose up -d --build dey compile the Next.js dashboard, and that Node build na the time wey the whole installation dey use the most memory. For 1 GB VPS, kernel out-of-memory killer go stop am and the build go end with exit code 137. Confirm the cause before you begin look for Docker bug:

dmesg -T | grep -i "killed process"

If server dey look like too much machinery for wetin you need, the smaller options dey real. local agent memory store wey no need server at all and memory wey dey inside Claude Code itself both skip the database. Come back here when several agents, or several machines, need read the same memories.

I need Neo4j for mem0 graph memory?

No. If guide tell you make you add Neo4j container, that guide old pass the current code.

Before, graph memory for mem0 mean external graph database, wey dem configure under graph_store key with enable_graph set to true. The new memory algorithm wey dem release for April 2026 remove both keys from the open source SDK. Entity extraction now dey run inside the normal add path, and the entities dey write to another pgvector collection wey dem name after your main one, with _entities added at the end. You no need run any migration. Built in entity linking go start work for the next add call.

If you remove the graph store, you save one JVM container, the heap memory, and several hundred megabytes of image. For 2 GB VPS, na the difference between the service running and the system swapping.

Make we state wetin you lose clearly. Search results before get relations field wey list the edges between entities. That field don go. Entity matches now dey increase a memory position for the combined score, and no structure dey wey you fit traverse. If your application dey walk through those relationships, mem0 no longer store dem. You go need keep your own graph database outside mem0 and feed am with your own code.

The compose file wey dey for repo na development compose

server/docker-compose.yaml declare name: mem0-dev, and na exactly so e be. Read am before you run am, because five things dey inside wey no correct for server.

  • E build from server/dev.Dockerfile and mount your checkout over the image with .:/app, so container go run anything wey dey inside that directory instead of wetin you build.
  • E command na rm -rf /app/packages && pip install -q --force-reinstall --no-deps mem0ai && alembic upgrade head && uvicorn main:app --reload. E reinstall mem0ai from PyPI every time e start, so the version wey your server dey run fit change during restart wey you no plan as upgrade.
  • The same pip step mean say restart without outbound network go fail before uvicorn ever run. Your memory server go dey down because PyPI no reachable.
  • --reload start uvicorn's file watcher. E dey restart the process when you edit code, and e dey use memory plus one extra process to do nothing useful for production. The production Dockerfile still get --reload for its CMD, so you go override the command either way.
  • The published ports na "8888:8000", "8432:5432" and "3000:3000". When published port no get address before am, e bind to 0.0.0.0, so Postgres go answer public internet for 8432 as soon as stack start.

That last point need separate warning. Docker publish port by writing its own rules before the chain wey ufw dey manage, so ufw deny 8432 no close published container port. Docker publishing ports straight past ufw explain the rules wey dey involved.

Compose file for real server

Work inside server/, keep init-db.sh where e dey, 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: bridge

Five changes dey important here, and each one get reason.

Every ports entry start with 127.0.0.1, so kernel go accept those connections only from the box itself. Everything from outside go pass through reverse proxy, na the only thing wey dey hold certificate.

Postgres no get ports block at all. The mem0 container reach am through mem0_network by service name, so publishing 8432 no give you anything and e just cost you one open port. Use docker compose exec postgres psql -U postgres when you need shell.

History don move from ./history bind mount go named volume. Bind mount tie the data to one path and one uid for this host, while named volume na object wey Docker fit snapshot and move. Named volumes versus bind mounts explain when each one make sense.

The command remove --reload and keep alembic upgrade head. Keep this migration step. Without am, app go boot against database wey no get tables, and every request go fail for the first query.

NEXT_PUBLIC_API_URL na the URL wey your browser dey call, so e must be the public HTTPS address and no be http://mem0:8000. Next.js dey inline every NEXT_PUBLIC_ value during build time, so if you change am, you need docker compose up -d --build mem0-dashboard. Plain restart go leave the old value baked into the JavaScript, and dashboard go call the wrong host.

Secrets dey inside .env, and .env no dey 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 .env

Set POSTGRES_PASSWORD, JWT_SECRET and ADMIN_API_KEY. Leave AUTH_DISABLED=false. The name talk plainly wetin that flag dey do: when e dey on, the server go hand over every memory e get to anybody wey fit reach the port. Set MEM0_TELEMETRY=false if you no want make e send the onboarding event upstream.

ADMIN_API_KEY dey compare with the X-API-Key header by using secrets.compare_digest, and when dem match, e go skip every database lookup. Na root credential for the whole API. Treat am like that: no shell history, no git, and no paste am inside prompt. Compose env files and where secrets fit leak from and how to keep API keys out of an agent context apply directly, because agents na the callers of this server.

Values wey load from env_file dey inside the container environment, and docker inspect go print dem complete. Anybody wey dey the docker group fit read dem, and anybody wey dey the docker group na effectively root for the host.

API-rere TLS put instead of opening 8888

The API dey answer for 127.0.0.1:8888 and the dashboard dey answer for 127.0.0.1:3000. nginx dey terminate TLS (transport layer security) for 443 and forward traffic go both services.

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 matter pass as e first look. Add call dey block while language model dey read the conversation and extract facts. Local 8B model wey dey run for CPU regularly dey take pass nginx default of 60 seconds. Then caller go see 504 Gateway Time-out while model still dey work and memory still dey write. You go end up with memory wey dem tell you say e fail.

Close the remaining access with default deny ufw policy, and leave 22 and 443 open. Issue the certificate with certbot for Ubuntu 24.04 behind nginx. If the box already dey front other apps with Traefik wey dey route several Compose apps, add mem0 to that router instead of installing another proxy.

Fwaka test: add one memory and read am 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"}'

Correct response na JSON object wey get one results list. Each entry get one id, the extracted memory text, and "event": "ADD". The current algorithm dey return ADD events only. UPDATE and DELETE events don remove, so say dem no dey there no be 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 suppose come back with score. Pass the identifier inside filters, as e show. Top-level user_id still dey work, and server dey log Top-level user_id in /search is deprecated. Use filters={...} instead. every time you use am.

Clean up after yourself so test data no pollute real searches:

curl -sS -X DELETE "http://127.0.0.1:8888/memories?user_id=smoke" \
  -H "X-API-Key: $MEM0_KEY"

If search return rows wey less than wetin you expect, check the defaults before you blame retrieval. For current release, top_k default na 20, down from 100, and threshold default na 0.1 instead of none, so weak matches dey filter out automatically. Once this one work through curl, na these same endpoints you go connect to an agent, either directly or through MCP server wey dey run for the same VPS.

Run mem0 without any OpenAI key

Start with the blocker, because you go hit am within the first five minutes. The server image get a fixed set of provider libraries, and /configure dey reject anything wey no dey inside dem:

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 no need rebuild anything. Ollama dey serve an OpenAI compatible API for /v1, and e cover /v1/chat/completions and /v1/embeddings. mem0's openai provider accepts an openai_base_url. Point that key to Ollama and the bundled check go pass, because the provider truly dey openai. Na only the address change.

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/.ollama

Add 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-text

If Ollama already dey run for the host as a systemd unit, like for running Ollama directly on a VPS, no point the container to 127.0.0.1:11434. Inside the mem0 container, 127.0.0.1 na the mem0 container. Give the mem0 service extra_hosts: ["host.docker.internal:host-gateway"], set Environment="OLLAMA_HOST=0.0.0.0:11434" inside a systemd drop-in so Ollama go listen on an address wey the bridge fit reach, and keep 11434 shut for the firewall.

Ask the model for its embedding dimension before you configure anything

This one step decide whether retrieval go work at all.

mem0's pgvector store creates its table with a fixed vector width, vector vector(1536), because embedding_model_dims dey default to 1536, wey be the width of OpenAI's text-embedding-3-small. nomic-embed-text dey return 768 values. Nothing inside mem0 compares those two numbers, so Postgres go show the mismatch on the first insert:

expected 1536 dimensions, not 768

No trust the number for this paragraph too. 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 one go print the width wey your collection must use. Write the configuration into a file, because when you paste Postgres password through shell quoting, na so typo dey enter 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. That one na the check say the write enter correctly. Then repeat the smoke test above.

Four details for that JSON no obvious, and each one go break something if you set am wrong.

api_key na the string ollama, and Ollama ignores its value. E no fit empty, because the OpenAI client library go raise error before any request comot from the process when no key dey set. Any non-empty string go work.

embedding_model_dims dey go on the vector store, and deliberately no embedding_dims dey on the embedder. mem0 dey send the OpenAI dimensions parameter only when you set embedding_dims, and backends wey no implement Matryoshka truncation go reject that parameter outright. Set the width where the table dey created, and leave the embedder as e be.

collection_name na new. mem0 creates its table with CREATE TABLE IF NOT EXISTS, so if you point a different width to an existing collection, nothing go happen: the old vector(1536) column go remain, and every insert go fail. Width change need a fresh collection name, or you drop the old table by hand.

The host for openai_base_url na the Compose service name ollama, no be localhost. Containers dey resolve each other through service name on their shared network.

The cost of the fully local path

Be honest with yourself about quality. mem0's published benchmark scores use frontier models to do the extraction, so treat dem as a ceiling, no be forecast for an 8B model on your VPS. Small model dey write facts wey no too clear, and sometimes e dey return prose when JSON dem request, which go show as an add call returning an empty results list without error.

Speed na the other cost. CPU-only extraction dey take seconds for each add call, and every message wey you store go pay that cost. If that latency matter, a VPS with a GPU attached na the honest fix. Adding more CPU cores to an 8B model help far less than people dey expect.

One rule still hold no matter wetin you choose: never mix embedding models inside one collection. Two different models wey happen to share one width go produce vectors wey no comparable. The insert go succeed, the search go return rows, and the rows go wrong, with nothing anywhere reporting an error.

Backups: database dey two, no be one

The commonest mem0 backup mistake na to dump only one database. init-db.sh dey create mem0_app together with the default postgres database, and dem dey hold different things. postgres database dey hold pgvector collections, wey be the memories. mem0_app dey hold users, sessions, API keys and request logs.

If you restore only postgres, the memories go come back but every account and API key go disappear, so nothing fit authenticate to read dem. Dump both databases, plus roles, with one command:

docker compose exec -T postgres pg_dumpall -U postgres --clean \
  | gzip > "mem0-$(date +%F).sql.gz"

The history volume dey separate from Postgres, so e need im own copy:

docker run --rm -v mem0_mem0_history:/data -v "$PWD:/backup" \
  alpine tar czf /backup/mem0-history.tgz -C /data .

Docker dey prefix volume names with the project name, so confirm your own with docker volume ls before you assume say na mem0_mem0_history.

Restore am inside scratch container and check the row counts before you trust any result:

gunzip -c mem0-2026-08-03.sql.gz \
  | docker compose exec -T postgres psql -U postgres -d postgres

Backup wey you never restore na only guesswork. Once the dumps correct, push dem comot from the box with restic snapshots go off-site storage, because backup wey dey live for the server wey e suppose protect no dey protect anything.

Failure modes and the exact strings you will see

{"detail":"Authentication required. Provide a Bearer token or X-API-Key header."} mean say header dey miss or dem spell am wrong. The name na X-API-Key, and curl dey send header names exactly as dem be.

{"detail":"At least one identifier (user_id, agent_id, run_id) is required."} for add mean say request no get any of dem. You must scope a memory to something, because search dey filter exactly on those fields.

LLM provider 'ollama' is not bundled in this image with HTTP 400 mean say you send "provider": "ollama". Use "provider": "openai" with openai_base_url wey point to Ollama.

expected 1536 dimensions, not 768 from Postgres mean say dem create collection with one width, but embedder dey return another one. Set embedding_model_dims for vector store and use new collection_name.

Search returns rows that make no sense after model change, and no error dey anywhere. The width still match, so database dey okay, but the two models dey put the same sentence for different positions. Start new collection and add the items again.

Connection refused for mem0 logs while e dey reach Ollama usually mean say 127.0.0.1 for openai_base_url. Inside the container, that address na the container itself. Use the service name, or the host gateway when Ollama dey run for the host.

504 Gateway Time-out from nginx during add mean say the model take pass proxy_read_timeout. Increase am, then check whether memory write succeed before you retry the request.

exit code 137 during docker compose up --build na out-of-memory killer dey stop the dashboard build. Add swap, or build the image for bigger machine and push am to a registry.

error: port 3000 is already in use come from the repo make up target, wey no gree start when ports 3000 or 8888 don already dey occupied. Find the owner with lsof -iTCP:3000 -sTCP:LISTEN.

FAQ

I still need Neo4j to run mem0 with graph memory?

No. The new memory algorithm wey release for April 2026 remove the graph_store and enable_graph configuration keys from the open source SDK. Entity extraction now dey run during normal add and e dey write to second pgvector collection wey dem name <collection_name>_entities. So no external graph database, no extra container, and no migration step dey needed. The trade-off be say relations field no dey for search results again. Entities now dey improve memory ranking instead of giving you edges to traverse. So application wey dey walk those relationships need its own graph store outside mem0.

Which one be the smallest VPS wey fit run self-hosted mem0 server?

If language model dey hosted for another place, 2 GB RAM and about 4 GB free disk space enough for the API container, Postgres, and dashboard. The tight part na the first build, because compiling the Next.js dashboard dey use more memory than running am. For 1 GB machine, the build dey get killed with exit code 137. If Ollama dey run for the same server, size the server based on the model instead. 8B model with 4-bit quantisation need roughly 6 GB by itself, so plan for 8 GB.

I fit run mem0 without OpenAI API key?

Yes, through Ollama's OpenAI compatible endpoint. Setting "provider": "ollama" go fail because the server image bundle only the openai, anthropic, and gemini libraries, then e return HTTP 400. Instead, leave "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 no dey use the key, and the bundled provider check pass because the provider really be openai.

Why mem0 dey return no results after I switch to local embedding model?

Because dem create the pgvector table with fixed width. embedding_model_dims default to 1536, while nomic-embed-text return 768, and Postgres reject the insert with expected 1536 dimensions, not 768. mem0 create the table with CREATE TABLE IF NOT EXISTS, so changing the number alone no go affect existing collection. Set embedding_model_dims to the real width of your model. Confirm the width by calling /v1/embeddings and counting the values wey e return. At the same time, give the vector store a new collection_name.