# Tortoise Self-Host — reference topology (#338 D2/T4.3) # # daemon + falkordb sidecar (AOF on, named volume, healthcheck). This is the # RECOMMENDED multi-writer path. Its durability is the sidecar's own # persistence — AOF `everysec` survives a process/container crash (≤1s), and # does NOT survive losing the named volume or the host. The off-box copy is a # STEP OF THIS SETUP (see the `tortoise` service env and # docs/durability-posture.md): declare the COMPLETE `R2_*` + `TORTOISE_BACKUP_KEY` # target, or decline one with TORTOISE_OFFBOX_BACKUP_DECLINED=1. Leaving both # unset boots and serves, but the daemon reports the single local copy LOUDLY at # startup (#8543) — never silently. A complete target also arms the off-box # backup driver (#8544), which is what makes a declared target produce an # archive. The embedded-FalkorDBLite path (docs/quickstart-selfhosted.md # Option C) is single-writer eval only: durable for ONE process since #915, but # concurrent writers LOSE data (2026-08-05 incident #101 is the historical # context: AOF-off + no backups). # # FalkorDB is intentionally NOT bundled inside the Tortoise image — its # official image is SSPLv1 (license interaction) and the sidecar lets AOF + # backups live with the database (incident-informed, see entrypoint.sh notes). # # Security (code-review P1, #338): the daemon requires TORTOISE_API_KEY — # auth_mode=none on a non-loopback bind is refused at startup. The sidecar's # requirepass + AOF are passed via REDIS_ARGS (the falkordb image entrypoint # ignores `command:` — it expands $REDIS_ARGS; see build/docker/run.sh). # # Usage: docker compose up -d # tortoise daemon → http://localhost:8000 (MCP at /mcp) # set TORTOISE_API_KEY below before exposing beyond localhost. # Host-side clients reach the sidecar at # TORTOISE_DB_URI=docker://:falkordb@localhost:6379/tortoise # (the falkordb service publishes 127.0.0.1:6379, loopback only). services: tortoise: build: context: . dockerfile: Dockerfile.selfhost image: ghcr.io/daniel-ospina/tortoise-selfhost:local ports: - "127.0.0.1:8000:8000" # loopback by default — expose deliberately environment: TORTOISE_DB_URI: "docker://:falkordb@falkordb:6379/tortoise" # ⚠️ Set a strong key before exposing beyond localhost (auth_mode=static); # unset + loopback bind → auth_mode=none (eval only). TORTOISE_API_KEY: "" # ── #8543: the off-box backup target — DECLARE or DECLINE, never silent ── # Durability = the store's persistence PLUS an off-box copy. AOF on the # sidecar covers a crash; it does NOT cover a lost volume or host. Pick # exactly ONE of the two blocks below: # # (a) DECLARE an off-box target (the repo's backup env contract — # tortoise/backup_config.py). ALL FIVE are required: a partial target # cannot produce a restorable archive, so the daemon reports a # partial one as UNDECIDED (naming what is missing) rather than # "declared". Fill all five in: R2_ACCOUNT_ID: "" R2_ACCESS_KEY_ID: "" R2_SECRET_ACCESS_KEY: "" R2_BUCKET: "" # the archive encryption key (base64 32 bytes; keep it safe — the # archive is unreadable without it): # python -c "import base64,secrets; print(base64.b64encode(secrets.token_bytes(32)).decode())" TORTOISE_BACKUP_KEY: "" # (b) ...or DECLINE explicitly, accepting AOF-only durability (`1`): TORTOISE_OFFBOX_BACKUP_DECLINED: "" # Both unset = UNDECIDED: the daemon boots and serves, and says so at # startup (#8543). NOTE (#8544): a DECLARED target arms the off-box backup # DRIVER — the daemon sweeps this deployment's graph to that target every # TORTOISE_OFFBOX_BACKUP_INTERVAL_SECONDS (default 3600, floor 60) with # the read-back-verified logical dump, and prunes under the shared # retention triple. The first archive appears after the first interval; # a failed run is logged on every attempt and never skipped silently. # See docs/durability-posture.md. TORTOISE_OFFBOX_BACKUP_INTERVAL_SECONDS: "" # #8497: the per-graph JSONL REBUILD journal. Without it the self-hosted # deployment was journal-less by construction: the derived graph was the # ONLY copy, `rebuild` had nothing to replay, and §17.1's single-writer # guard was inert (`config.ensure_selfhost_event_log` also defaults this # when /data is a mount; stating it here makes the deployment's config # inspectable). The journal is the REBUILD source — NOT the durability # mechanism: durability is the sidecar's AOF + volume # (docs/durability-posture.md). TORTOISE_EVENT_LOG_BASE_DIR: "/data/events" volumes: # #8497: the journal (and the embedded-eval DB, when that path is used) # must outlive the container — /data is the persistent root the journal # default is gated on. - tortoise-data:/data depends_on: falkordb: condition: service_healthy restart: unless-stopped falkordb: # server-only image (no browser UI surface); honors REDIS_ARGS env # Pinned (issue #1146): :latest silently drifts engine versions between # runs — benchmark/performance parity needs a reproducible engine. # v4.20.4 = what :latest resolved to as of 2026-08-26; the V3 benchmark # ingest (issue #1549) ran on 4.20.4, so the pin now matches the engine # the dataset was built/verified on (issue #1733). image: falkordb/falkordb-server:v4.20.4 ports: - "127.0.0.1:6379:6379" # loopback only — host-side MCP configs reach the sidecar environment: # Entrypoint expands $REDIS_ARGS — `command:` is ignored by run.sh. # #1786 (R4): automatic RDB BGSAVE is DISABLED (`--save ""` — the # escaped spelling because run.sh expands $REDIS_ARGS UNQUOTED: a bare # `--save` would reach redis-server as a flag with no value) — the # eval's own ingest load triggered ~every-5-min forks of the 4.8 GB # heap (server-side stalls > the 10 s socket bound → the verified # write-path loss mechanism). AOF everysec is strictly more durable # (appendonly yes already on) and the loader prefers AOF. The # `--auto-aof-rewrite-min-size 1gb` bump prevents BGREWRITEAOF (also # a fork) from firing on small growth mid-run (live aof_current_size # ~167 MB). ⚠️ Task 3's preflight persistence gate (preflight.py — a # separate owner's surface) does NOT exist yet: until it lands and is # verified against the LIVE server, a drifted server (aof off + save # empty) has NO tripwire in the repo — do not apply this config to a # container carrying eval data without first landing that gate. REDIS_ARGS: "--requirepass falkordb --appendonly yes --save \"\" --auto-aof-rewrite-min-size 1gb" volumes: - falkordb-data:/var/lib/falkordb/data healthcheck: test: ["CMD", "redis-cli", "-a", "falkordb", "ping"] interval: 5s timeout: 3s retries: 10 restart: unless-stopped # Resource parity for reproducible benchmark runs (issue #1146): bounds # the sidecar so prod-like runs don't silently get more/fewer resources. # # memory: 8g — NOT 2g. The V3 benchmark ingest dataset needs 4.2-4.8 GB # heap (RDB header recorded 4824.10 Mb at save time); under 5-worker # ingest (≥10k changes/60s) the 2g cap tripped the cgroup OOM killer on # BGSAVE forks (issue #1733, crash 2026-08-25 18:05 UTC — 30 pilot # questions lost). 8g gives ≥2× headroom over peak heap for BGSAVE # copy-on-write. Keep in sync with any live container — including the swap # half (#7357): `docker update --memory 8g --memory-swap 8g falkordb`. # Without `--memory-swap` the live container keeps whatever swap allowance # it already had, so the compose-declared container and the "kept in sync" # one would differ in exactly the property the paragraph below adds. deploy: resources: limits: cpus: "2" memory: 8g # #7357: swap EQUALS the limit. `deploy.resources.limits.memory` bounds RAM # only; docker's default swap allowance is 2x, and a swapping FalkorDB is the # 96-146 s RDB reload #7357 records instead of a fast, isolated death. memswap_limit: 8g volumes: falkordb-data: tortoise-data: