fix(server): harden self-hosted server — pgvector upgrade, admin auth, endpoint security (#5360)

This commit is contained in:
Kartik
2026-06-05 16:35:12 +05:30
committed by GitHub
parent 90f2d24e83
commit ae7f406265
10 changed files with 375 additions and 31 deletions
+2 -1
View File
@@ -302,7 +302,8 @@
"group": "Migration",
"icon": "arrow-right",
"pages": [
"migration/oss-v2-to-v3"
"migration/oss-v2-to-v3",
"migration/server-pgvector-upgrade"
]
},
{
+1
View File
@@ -228,6 +228,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
- [OSS v2 to v3 Migration](https://docs.mem0.ai/migration/oss-v2-to-v3) [OSS]: Use when upgrading a self-hosted deployment across major versions.
- [Platform v2 to v3 Migration](https://docs.mem0.ai/migration/platform-v2-to-v3) [Platform]: Use when upgrading a Platform integration across major versions.
- [API Changes](https://docs.mem0.ai/migration/api-changes) [Both]: Use when the upgrade involves API surface changes.
- [Server pgvector Image Upgrade](https://docs.mem0.ai/migration/server-pgvector-upgrade) [OSS]: Use when upgrading the self-hosted server Docker image from ankane/pgvector to pgvector/pgvector.
- [Changelog](https://docs.mem0.ai/changelog/highlights) [Both]: Use when the user asks what shipped recently.
## Open Source
+158
View File
@@ -0,0 +1,158 @@
---
title: "Server: Upgrading the pgvector Docker Image"
description: "Migrate your self-hosted Mem0 server from the archived ankane/pgvector image to the official pgvector/pgvector image."
icon: "arrow-right"
iconType: "solid"
---
## Overview
The self-hosted Mem0 server has upgraded its PostgreSQL Docker image:
| | Before | After |
| --- | --- | --- |
| Docker image | `ankane/pgvector:v0.5.1` | `pgvector/pgvector:pg17` |
| PostgreSQL | 15 | 17 |
| pgvector | 0.5.1 | 0.8.0 |
| Credentials | Hardcoded `postgres` / `postgres` | Set via `POSTGRES_USER` / `POSTGRES_PASSWORD` env vars |
<Warning>
The `ankane/pgvector` image is **archived and no longer maintained**. The new `pgvector/pgvector` image is the official distribution maintained by the pgvector project.
</Warning>
<Info>
**Should you migrate?**
- You are running the Mem0 server via `docker-compose.yaml` in the `server/` directory.
- You want to stay on a maintained, actively-patched PostgreSQL + pgvector image.
- You want pgvector 0.8.0 features (improved HNSW performance, parallel index builds).
</Info>
## Fresh Installs
No migration is needed. Copy the example env file, set your password, and start the stack:
```bash
cd server
cp .env.example .env
# Edit .env — set POSTGRES_PASSWORD (required) and OPENAI_API_KEY at minimum
make up
```
## Migrating an Existing Install
PostgreSQL 17 cannot read data files created by PostgreSQL 15 directly. You need to export your data from the old container and import it into the new one.
### 1. Back Up Your Data
With the **old** stack still running:
```bash
cd server
docker compose exec -T postgres pg_dumpall -U postgres > mem0_backup.sql
```
Verify the dump is non-empty:
```bash
ls -lh mem0_backup.sql
```
<Warning>
Do not skip this step. The next step permanently deletes your Postgres data volume.
</Warning>
### 2. Stop the Old Stack and Remove the Volume
```bash
docker compose down
docker compose down -v
```
### 3. Update Your `.env`
Postgres credentials are no longer hardcoded in `docker-compose.yaml`. Add them to your `.env`:
```bash
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<your-password> # required — compose will refuse to start without it
POSTGRES_COLLECTION_NAME=memories
```
<Info>
`POSTGRES_PASSWORD` is **required** — `docker compose up` will refuse to start without it. If you previously relied on the hardcoded default, set `POSTGRES_PASSWORD=postgres`.
</Info>
### 4. Start Only Postgres
Start **only** the Postgres container first — do **not** start the mem0 API yet.
The API runs `alembic upgrade head` on startup, which creates empty tables that
would conflict with the restore.
```bash
docker compose up -d postgres
```
Wait for Postgres to become healthy:
```bash
docker compose exec -T postgres pg_isready -q && echo "ready" || echo "not ready"
```
### 5. Restore Your Data
```bash
docker compose exec -T postgres psql -U postgres < mem0_backup.sql
```
You may see notices like `role "postgres" already exists` — these are safe to ignore.
<Warning>
You must restore **before** starting the mem0 API container. The API runs
database migrations on startup which create empty tables — restoring after
that would fail with duplicate-key errors and lose your API keys and settings.
</Warning>
### 6. Start the API
Now start the mem0 API container. Alembic will detect the existing tables and
only apply any new migrations:
```bash
docker compose up -d mem0
```
### 7. Verify
```bash
# Check service health
cd server && make health
# Confirm memories are accessible
curl -s http://localhost:8888/memories?user_id=<your-user-id> \
-H "X-API-Key: <your-api-key>"
```
## Rollback
If something goes wrong, revert the image tag in `docker-compose.yaml`:
```yaml
postgres:
image: ankane/pgvector:v0.5.1
```
Then destroy the new volume, start the old image, and restore from your backup:
```bash
docker compose down -v
docker compose up -d --build
docker compose exec -T postgres psql -U postgres < mem0_backup.sql
```
## Need Help?
- Join our [Discord community](https://mem0.ai/discord) for real-time support
- Open an issue on [GitHub](https://github.com/mem0ai/mem0/issues)
+6 -5
View File
@@ -4,12 +4,13 @@ OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=
POSTGRES_HOST=
POSTGRES_PORT=
POSTGRES_DB=
POSTGRES_USER=
# Postgres — POSTGRES_PASSWORD is required; docker-compose will not start without it.
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=
POSTGRES_COLLECTION_NAME=
POSTGRES_COLLECTION_NAME=memories
ADMIN_API_KEY=
JWT_SECRET=
+150
View File
@@ -2,8 +2,24 @@
Mem0 ships a self-hosted FastAPI server plus a local dashboard. It is secure by default, supports dashboard login and API keys, and exposes OpenAPI docs at `/docs`.
> **Upgrading?** The Postgres image changed from the archived `ankane/pgvector:v0.5.1`
> to the official `pgvector/pgvector:pg17`, and `POSTGRES_PASSWORD` is now a required
> env var. If you have an existing install, see
> [Migrating from ankane/pgvector to pgvector/pgvector](#migrating-from-ankanepgvector-to-pgvectorpgvector)
> before running `docker compose up`.
## Quick Start
### Prerequisites
Copy the example env file and set a Postgres password (required):
```bash
cd server
cp .env.example .env
# Edit .env — at minimum set POSTGRES_PASSWORD and OPENAI_API_KEY
```
### Agent-first
Run one command; the terminal prints the admin email, password, and first API key.
@@ -121,6 +137,140 @@ The dashboard sets the following response headers on every path (see `server/das
Together these prevent iframe embedding, sniffing of mislabelled MIME types, and cross-origin referrer leaks. Harden further behind your own reverse proxy if needed.
## Migrating from `ankane/pgvector` to `pgvector/pgvector`
The `ankane/pgvector` Docker image is archived and no longer maintained. This release
replaces it with the official `pgvector/pgvector:pg17` image (PostgreSQL 17, pgvector 0.8.0).
**What changed:**
| | Before | After |
|---|---|---|
| Docker image | `ankane/pgvector:v0.5.1` | `pgvector/pgvector:pg17` |
| PostgreSQL version | 15 | 17 |
| pgvector version | 0.5.1 | 0.8.0 |
| Credentials | Hardcoded `postgres`/`postgres` | Driven by `POSTGRES_USER` / `POSTGRES_PASSWORD` env vars |
### Fresh installs (no existing data)
No migration needed. Copy `.env.example` to `.env`, set `POSTGRES_PASSWORD`, and run:
```bash
cd server
make up
```
### Existing installs (preserving data)
PostgreSQL 17 cannot read data files written by PostgreSQL 15 directly.
You must export your data first, then import it into the new container.
**1. Export your data from the old container**
With the old stack still running:
```bash
cd server
# Dump all databases (mem0 memories + mem0_app auth/config data)
docker compose exec -T postgres pg_dumpall -U postgres > mem0_backup.sql
```
Verify the dump file is non-empty:
```bash
ls -lh mem0_backup.sql
```
**2. Stop the old stack and remove the old volume**
```bash
# Stop containers
docker compose down
# Remove the old Postgres data volume
docker compose down -v
```
> **Warning:** `docker compose down -v` deletes the `postgres_db` volume permanently.
> Only run this after you have verified your backup.
**3. Update your `.env`**
The Postgres credentials are no longer hardcoded in `docker-compose.yaml`.
Add them to your `.env` file (or verify they match your old setup):
```bash
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<your-password> # required — compose will refuse to start without it
POSTGRES_COLLECTION_NAME=memories
```
If you previously relied on the hardcoded defaults (`postgres`/`postgres`), set
`POSTGRES_PASSWORD=postgres` to keep the same credentials.
**4. Start only Postgres**
Start **only** the Postgres container first — do not start the mem0 API yet.
The API runs `alembic upgrade head` on startup, which creates empty tables that
would conflict with the restore.
```bash
docker compose up -d postgres
```
Wait for the Postgres healthcheck to pass:
```bash
docker compose exec -T postgres pg_isready -q && echo "ready" || echo "not ready"
```
**5. Restore your data**
```bash
docker compose exec -T postgres psql -U postgres < mem0_backup.sql
```
You may see notices like `role "postgres" already exists` — these are harmless.
> **Important:** You must restore before starting the mem0 API container. The API
> runs database migrations on startup which create empty tables — restoring after
> that would fail with duplicate-key errors and lose your API keys and settings.
**6. Start the API**
Now start the mem0 API container. Alembic will detect the existing tables and
only apply any new migrations:
```bash
docker compose up -d mem0
```
**7. Verify**
```bash
# Check the API is healthy
make health
# Confirm your memories are present
curl -s http://localhost:8888/memories?user_id=<your-user-id> -H "X-API-Key: <your-api-key>"
```
### Rollback
If you need to revert, restore the old image tag in `docker-compose.yaml`:
```yaml
postgres:
image: ankane/pgvector:v0.5.1
```
Then `docker compose down -v`, `docker compose up -d --build`, and restore from
`mem0_backup.sql` into the old container the same way.
## Reference
Additional product and API documentation lives at [docs.mem0.ai](https://docs.mem0.ai/open-source/overview).
+32 -3
View File
@@ -3,16 +3,15 @@ import secrets
import uuid
from datetime import datetime, timedelta, timezone
from db import get_db
from fastapi import Depends, HTTPException, Request
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from models import APIKey, RefreshTokenJti, User
from passlib.context import CryptContext
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from db import get_db
from models import APIKey, RefreshTokenJti, User
JWT_SECRET = os.environ.get("JWT_SECRET", "")
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
@@ -184,3 +183,33 @@ async def require_auth(
return default_user
raise HTTPException(status_code=401, detail="Authentication required.")
return user
_BOOTSTRAP_ADMIN = User(
id=uuid.UUID(int=0), name="admin_api_key", email="", password_hash="", role="admin", created_at=datetime.min.replace(tzinfo=timezone.utc),
)
async def require_admin(
request: Request,
user: User | None = Depends(verify_auth),
db: Session = Depends(get_db),
) -> User:
"""Like require_auth but also enforces admin role.
ADMIN_API_KEY and AUTH_DISABLED callers are treated as admin even when
the users table is empty (fresh-deploy bootstrap).
"""
auth_type = getattr(request.state, "auth_type", "none")
if user is None:
if auth_type in {"admin_api_key", "disabled"}:
default_user = _get_default_user(db)
if default_user is not None:
if default_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required.")
return default_user
return _BOOTSTRAP_ADMIN
raise HTTPException(status_code=401, detail="Authentication required.")
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required.")
return user
+4 -4
View File
@@ -30,16 +30,16 @@ services:
- MEM0_TELEMETRY=${MEM0_TELEMETRY:-true}
postgres:
image: ankane/pgvector:v0.5.1
image: pgvector/pgvector:pg17
restart: on-failure
shm_size: "128mb"
networks:
- mem0_network
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
healthcheck:
test: ["CMD", "pg_isready", "-q", "-d", "postgres", "-U", "postgres"]
test: ["CMD-SHELL", "pg_isready -q -d postgres -U ${POSTGRES_USER:-postgres}"]
interval: 5s
timeout: 5s
retries: 5
+14 -8
View File
@@ -5,7 +5,7 @@ import time
from typing import Any, Dict, List, Optional
import telemetry
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, verify_auth
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, require_admin, verify_auth
from db import SessionLocal
from dotenv import load_dotenv
from errors import (
@@ -316,8 +316,8 @@ def list_bundled_providers(_auth=Depends(verify_auth)):
@app.post("/configure", summary="Configure Mem0")
def set_config(config: Dict[str, Any], _auth=Depends(verify_auth)):
"""Set memory configuration."""
def set_config(config: Dict[str, Any], _auth=Depends(require_admin)):
"""Set memory configuration. Requires admin role."""
_validate_bundled_providers(config)
update_config(config)
return {"message": "Configuration set successfully"}
@@ -391,19 +391,25 @@ def _list_all_memories(limit: int = ALL_MEMORIES_LIMIT) -> Dict[str, Any]:
@app.get("/memories", summary="Get memories")
def get_all_memories(
request: Request,
user_id: Optional[str] = None,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
_auth=Depends(verify_auth),
):
"""Retrieve stored memories. Lists all memories when no identifier is provided."""
"""Retrieve stored memories. Lists all memories when no identifier is provided (admin only)."""
try:
if not any([user_id, run_id, agent_id]):
auth_type = getattr(request.state, "auth_type", "none")
if _auth is not None and _auth.role != "admin" and auth_type not in {"admin_api_key", "disabled"}:
raise HTTPException(status_code=403, detail="Admin role required to list all memories.")
return _list_all_memories()
filters = {
k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None
}
return get_memory_instance().get_all(filters=filters)
except HTTPException:
raise
except Exception:
raise upstream_error()
@@ -479,9 +485,9 @@ def delete_all_memories(
user_id: Optional[str] = None,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
_auth=Depends(verify_auth),
_auth=Depends(require_admin),
):
"""Delete all memories for a given identifier."""
"""Delete all memories for a given identifier. Requires admin role."""
if not any([user_id, run_id, agent_id]):
raise HTTPException(status_code=400, detail="At least one identifier is required.")
try:
@@ -495,8 +501,8 @@ def delete_all_memories(
@app.post("/reset", summary="Reset all memories")
def reset_memory(_auth=Depends(verify_auth)):
"""Completely reset stored memories."""
def reset_memory(_auth=Depends(require_admin)):
"""Completely reset stored memories. Requires admin role."""
try:
get_memory_instance().reset()
return {"message": "All memories reset"}
+3 -4
View File
@@ -2,11 +2,10 @@ from collections import defaultdict
from datetime import datetime
from typing import Any, Literal, Optional
from auth import require_admin, verify_auth
from errors import upstream_error
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from auth import verify_auth
from errors import upstream_error
from schemas import MessageResponse
from server_state import get_memory_instance
@@ -69,7 +68,7 @@ def list_entities(_auth=Depends(verify_auth)):
@router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse)
def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(verify_auth)):
def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(require_admin)):
try:
get_memory_instance().delete_all(**{TYPE_TO_FIELD[entity_type]: entity_id})
except Exception:
+5 -6
View File
@@ -1,15 +1,14 @@
from datetime import datetime
import uuid
from datetime import datetime
from auth import require_admin
from db import get_db
from fastapi import APIRouter, Depends, Query
from models import RequestLog
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from auth import require_auth
from db import get_db
from models import RequestLog, User
router = APIRouter(prefix="/requests", tags=["requests"])
@@ -30,7 +29,7 @@ API_KEY_AUTH_TYPES = ("api_key", "admin_api_key")
@router.get("", response_model=list[RequestLogItem])
def list_requests(
user: User = Depends(require_auth),
_auth=Depends(require_admin),
db: Session = Depends(get_db),
limit: int = Query(default=50, ge=1, le=200),
):