diff --git a/.env.example b/.env.example index ac4611f9..64351df1 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,61 @@ # ============================================================================= -# Turnstone Environment Variables -# Copy to .env and adjust values for your deployment. +# Turnstone environment overrides — ALL OPTIONAL for the dev stack. # -# Usage: -# Single node: docker compose --profile production up -# 10-node cluster: docker compose --profile cluster up +# `docker compose up` from a clone works with zero config: every value below +# has a built-in (insecure) default. Copy this file to `.env` only to override. +# +# The PRODUCTION stack (turnstone/deploy/compose.yaml) has no baked-in secrets +# and DOES require TURNSTONE_JWT_SECRET and POSTGRES_PASSWORD. +# +# Note: for a turnstone process running on bare metal (not in a container), +# put secrets in ~/.config/turnstone/config.toml (chmod 0600), not the +# environment. See docs/docker.md "Join a bare-metal host". # ============================================================================= -# -- LLM Backend -------------------------------------------------------------- -LLM_BASE_URL=http://host.docker.internal:8000/v1 -OPENAI_API_KEY=dummy -# ANTHROPIC_API_KEY=sk-ant-...# Set instead of OPENAI_API_KEY for Anthropic -# TAVILY_API_KEY=tvly-... # Web search fallback (local models only) -# MODEL=# Override default model alias +# -- LLM backend -------------------------------------------------------------- +# Optional: nodes boot without an LLM. Add real model backends from the console +# UI (Models tab). These only set the bootstrap default a node starts with. +# LLM_BASE_URL=http://host.docker.internal:8000/v1 +# OPENAI_API_KEY=dummy +# ANTHROPIC_API_KEY=sk-ant-... # set instead of OPENAI_API_KEY for Anthropic +# TAVILY_API_KEY=tvly-... # web-search fallback (local models only) +# MODEL= # default model alias -# -- Authentication (required) ------------------------------------------------ -# Generate with: python -c "import secrets; print(secrets.token_hex(32))" -TURNSTONE_JWT_SECRET=changeme-to-32-bytes-of-hex +# -- Secrets ------------------------------------------------------------------ +# The dev stack defaults these to INSECURE values. Always set real ones for +# anything reachable beyond localhost. Generate the JWT secret with: +# python -c "import secrets; print(secrets.token_hex(32))" +# TURNSTONE_JWT_SECRET= +# POSTGRES_PASSWORD= -# -- Database ------------------------------------------------------------------ -# Single-node default is SQLite (zero config). Set these for PostgreSQL: +# -- Database ----------------------------------------------------------------- +# Defaults to the bundled PostgreSQL (shared by every service — required for +# the console to discover nodes). Override to point at an external database: # TURNSTONE_DB_BACKEND=postgresql # POSTGRES_USER=turnstone -# POSTGRES_PASSWORD=changeme -# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:changeme@postgres:5432/turnstone +# TURNSTONE_DB_URL=postgresql+psycopg://turnstone:@postgres:5432/turnstone -# -- Ports --------------------------------------------------------------------- -# SERVER_PORT=8080 -# CONSOLE_PORT=8090 +# -- Ports / networking ------------------------------------------------------- +# The dashboard is reached via Caddy only (HTTP/2 avoids the browser's +# 6-connection cap on the console's SSE streams). Both stacks expose the same +# two host ports; everything else is proxied through the console. +# CONSOLE_HTTPS_PORT=8443 # Caddy (dashboard HTTPS) +# POSTGRES_PORT=5432 # exposed for bare-metal host joins +# POSTGRES_BIND=127.0.0.1 # set 0.0.0.0 to let another machine join -# -- Workspace ----------------------------------------------------------------- -# Bind-mount a host directory into the container at /workspace. -# The model can read/write files here. Default: empty Docker volume. +# -- Workspace ---------------------------------------------------------------- +# Bind-mount a host directory the model can read/write at /workspace: # WORKSPACE_MOUNT=/path/to/your/project -# -- Agent behavior ------------------------------------------------------------ -# SKIP_PERMISSIONS=true # Auto-approve all tool calls (dev only) -# MCP_CONFIG=/workspace/mcp.json# MCP server configuration file +# -- Agent behavior ----------------------------------------------------------- +# SKIP_PERMISSIONS=true # auto-approve all tool calls (dev only) +# MCP_CONFIG=/workspace/mcp.json # MCP server config file -# -- Discord channel gateway --------------------------------------------------- +# -- Channel gateway (Discord / Slack) ---------------------------------------- # TURNSTONE_DISCORD_TOKEN= # TURNSTONE_DISCORD_GUILD=0 +# TURNSTONE_SLACK_TOKEN=xoxb-... +# TURNSTONE_SLACK_APP_TOKEN=xapp-... -# -- Cluster (profile: cluster) ----------------------------------------------- -# These are set per-node in compose.yaml; only override for custom topologies. -# TURNSTONE_NODE_ID=node-1 -# TURNSTONE_ADVERTISE_URL=http://server-1:8080 - +# -- Production image tag ------------------------------------------------------ +# TURNSTONE_IMAGE_TAG=latest # pin the ghcr.io image (production stack) diff --git a/QUICKSTART.md b/QUICKSTART.md index 5e9f5a2e..4d489490 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -42,13 +42,13 @@ That's it — no flags, no arguments. The wizard prompts for everything. ## Deployment Modes -The wizard supports two deployment modes: +- **Single-node production** — `docker compose up` against the bundled + `turnstone/deploy/compose.yaml`: 1 server + console + channel + PostgreSQL, + pulled from ghcr.io. Good for most deployments. +- **Local multi-node cluster** — clone the repo and run `docker compose up` at + the root for a 10-node fleet + console + Caddy + channel, built locally. -- **Single-node production** (`docker compose --profile production up`) — - 1 server + console + PostgreSQL. Good for most use cases. -- **Multi-node cluster** (`docker compose --profile cluster up`) — - 10-node server fleet + console + PostgreSQL. For high-throughput or - HA deployments. +See [docs/docker.md](docs/docker.md) for both. ## Example Session diff --git a/compose.yaml b/compose.yaml index e3c9cdbf..796adbe3 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,16 +1,50 @@ # ============================================================================= -# Turnstone Docker Compose Stack — Development +# Turnstone — local cluster stack (docker compose) # -# This file is for local development from a git clone. It builds images -# locally from the Dockerfile. If you installed via pip/pipx, run -# `turnstone-bootstrap` instead — it writes a production compose.yaml -# that pulls pre-built images from ghcr.io. +# Clone the repo and run: # -# Usage: -# Infra only: docker compose up -# Single node: docker compose --profile production up -# Production (PG): TURNSTONE_DB_BACKEND=postgresql docker compose --profile production up -# 10-node cluster: docker compose --profile cluster up +# docker compose up +# +# That builds one image and brings up a complete, console-visible cluster: +# PostgreSQL + console + Caddy + channel gateway + 10 server nodes (node-1…10). +# +# Dashboard: https://localhost:8443 (Caddy's local CA — trust it once) +# +# Access is via Caddy only — the console's plain-HTTP port is intentionally not +# published (HTTP/2 from Caddy avoids the browser's 6-connection cap on the +# dashboard's SSE streams). Trust Caddy's root once: +# docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt +# +# It works out of the box with INSECURE dev defaults (see the secret/password +# values below) so there's nothing to configure first. A .env file still +# overrides any value. For a real deployment use the bundled production stack +# at turnstone/deploy/compose.yaml — it pulls released images from ghcr.io and +# requires you to set real secrets. +# +# Bring your own LLM: nodes boot without one and show up in the console +# immediately. Add model backends (OpenAI / Anthropic / local vLLM) from the +# console UI's Models tab, or point LLM_BASE_URL / OPENAI_API_KEY (below) at an +# OpenAI-compatible endpoint. +# +# Fewer nodes (lighter machines): +# docker compose up postgres console caddy channel node-1 node-2 node-3 +# +# Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a +# turnstone-server running directly on this machine (e.g. to use a local GPU) +# can join the same cluster. Keep the secret + connection settings in +# ~/.config/turnstone/config.toml (chmod 0600 — the loader warns otherwise): +# [auth] +# jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments" +# [database] +# backend = "postgresql" +# url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone" +# [api] +# base_url = "http://localhost:8000/v1" +# api_key = "dummy" +# then run (node identity isn't a secret, so it stays on the command line): +# TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \ +# turnstone-server --host 0.0.0.0 --port 8080 +# It registers in Postgres and the console reaches it back via host.docker.internal. # ============================================================================= name: turnstone @@ -26,15 +60,26 @@ volumes: caddy-data: caddy-config: +# -- Shared values (scalar anchors) ------------------------------------------- +# Defined once here, referenced (*alias) by every service so the dev defaults +# can't drift. All `${VAR:-default}` values are still overridable via .env. +x-shared: + # INSECURE dev default. Every service MUST share ONE secret — the console + # mints its own service token (signed with this) to reach the nodes. Override + # TURNSTONE_JWT_SECRET in .env for anything that isn't a local sandbox. + jwt-secret: &jwt-secret "${TURNSTONE_JWT_SECRET:-dev-only-insecure-jwt-secret-change-me-for-real-deployments}" + db-backend: &db-backend "${TURNSTONE_DB_BACKEND:-postgresql}" + # All services point at the same Postgres. Node discovery REQUIRES a shared + # DB: each server registers + heartbeats into a `services` table that the + # console polls. (SQLite-per-container can't see other containers.) + db-url: &db-url "${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone}" + services: # ------------------------------------------------------------------- - # PostgreSQL — production database (profile: production) + # PostgreSQL — the shared database that ties the cluster together. # ------------------------------------------------------------------- postgres: image: pgautoupgrade/pgautoupgrade:18-alpine - profiles: - - production - - cluster command: - postgres - -c @@ -44,8 +89,16 @@ services: environment: POSTGRES_DB: turnstone POSTGRES_USER: ${POSTGRES_USER:-turnstone} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile} + # INSECURE dev default — override POSTGRES_PASSWORD in .env for real use. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-turnstone} PGDATA: /var/lib/postgresql/data + # Published on localhost so a bare-metal turnstone-server running on THIS + # host can join the cluster (see "Join a bare-metal host" in the header). + # Bound to 127.0.0.1 by default; set POSTGRES_BIND=0.0.0.0 to let another + # machine connect — but set a real POSTGRES_PASSWORD first, or you'll expose + # a database with the insecure default password to your network. + ports: + - "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432" volumes: - postgres-data:/var/lib/postgresql/data networks: @@ -59,65 +112,21 @@ services: deploy: resources: limits: - memory: 4G - cpus: '4.0' + memory: 2G restart: unless-stopped # ------------------------------------------------------------------- - # turnstone-server — Web UI + chat workstreams + LLM interaction - # ------------------------------------------------------------------- - server: - image: turnstone:local - profiles: - - production - command: - - sh - - -c - - >- - turnstone-server - --host 0.0.0.0 - --port 8080 - --base-url "$${LLM_BASE_URL}" - --api-key "$${OPENAI_API_KEY}" - $${MODEL:+--model $$MODEL} - $${SKIP_PERMISSIONS:+--skip-permissions} - $${MCP_CONFIG:+--mcp-config $$MCP_CONFIG} - ports: - - "${SERVER_PORT:-8080}:8080" - volumes: - - turnstone-data:/data - - ${WORKSPACE_MOUNT:-workspace}:/workspace - environment: - - LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1} - - OPENAI_API_KEY=${OPENAI_API_KEY:-dummy} - - TAVILY_API_KEY=${TAVILY_API_KEY:-} - - SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-} - # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env} - - MODEL=${MODEL:-} - - MCP_CONFIG=${MCP_CONFIG:-} - - TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite} - - TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-} - - TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-} - - TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080} - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - turnstone-net - depends_on: - postgres: - condition: service_healthy - required: false - healthcheck: - test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 60s - restart: unless-stopped - - # ------------------------------------------------------------------- - # turnstone-console — Cluster dashboard + # turnstone-console — cluster dashboard. Reach it ONLY through Caddy at + # https://localhost:8443 (see the caddy service below). + # + # The console port (8090) is deliberately NOT published to the host: a plain + # HTTP/1.1 origin caps the browser at 6 connections, which starves the + # dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2 + # (multiplexed) and proxies to console:8090 internally, so the cap is gone. + # + # The single `build:` here produces the turnstone:local image every other + # service reuses. extra_hosts lets the console reach a bare-metal server + # advertising http://host.docker.internal:8080 (see "Join a host" below). # ------------------------------------------------------------------- console: image: turnstone:local @@ -128,16 +137,18 @@ services: - turnstone-console - --host=0.0.0.0 - --port=8090 - ports: - - "${CONSOLE_PORT:-8090}:8090" environment: - # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env} - - TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite} - - TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-} - - TURNSTONE_CONSOLE_URL=http://console:8090 + TURNSTONE_JWT_SECRET: *jwt-secret + TURNSTONE_DB_BACKEND: *db-backend + TURNSTONE_DB_URL: *db-url + TURNSTONE_CONSOLE_URL: http://console:8090 + extra_hosts: + - "host.docker.internal:host-gateway" networks: - turnstone-net + depends_on: + postgres: + condition: service_healthy healthcheck: test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"] interval: 10s @@ -147,20 +158,18 @@ services: restart: unless-stopped # ------------------------------------------------------------------- - # caddy — browser TLS for the console dashboard (cluster/demo). - # Terminates HTTPS (Caddy's local CA, see deploy/Caddyfile) → console:8090. - # Dashboard: https://localhost:${CONSOLE_HTTPS_PORT:-8443} + # caddy — browser TLS for the console dashboard. + # Terminates HTTPS (Caddy's own local CA, see turnstone/deploy/Caddyfile) → console:8090. + # Dashboard over TLS: https://localhost:${CONSOLE_HTTPS_PORT:-8443} # ------------------------------------------------------------------- caddy: image: caddy:2.11 - profiles: - - cluster depends_on: - console ports: - "${CONSOLE_HTTPS_PORT:-8443}:443" volumes: - - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./turnstone/deploy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data # persist Caddy's local CA across restarts - caddy-config:/config networks: @@ -168,14 +177,12 @@ services: restart: unless-stopped # ------------------------------------------------------------------- - # turnstone-channel — Channel gateway (Discord, Slack, etc.) - # Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter + # turnstone-channel — channel gateway (Discord and/or Slack). + # Runs HTTP-only with no adapters until you set a token, so it's safe + # to leave running. See docs/channels.md. # ------------------------------------------------------------------- channel: image: turnstone:local - profiles: - - production - - cluster command: - sh - -c @@ -184,36 +191,36 @@ services: --http-host=0.0.0.0 $${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD} environment: - - TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-} - - TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0} - # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env} - - TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-postgresql} - - TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone} - - TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091 + TURNSTONE_JWT_SECRET: *jwt-secret + TURNSTONE_DB_BACKEND: *db-backend + TURNSTONE_DB_URL: *db-url + TURNSTONE_DISCORD_TOKEN: ${TURNSTONE_DISCORD_TOKEN:-} + TURNSTONE_DISCORD_GUILD: ${TURNSTONE_DISCORD_GUILD:-0} + TURNSTONE_SLACK_TOKEN: ${TURNSTONE_SLACK_TOKEN:-} + TURNSTONE_SLACK_APP_TOKEN: ${TURNSTONE_SLACK_APP_TOKEN:-} + TURNSTONE_CHANNEL_ADVERTISE_URL: http://channel:8091 networks: - turnstone-net depends_on: postgres: condition: service_healthy - required: false restart: unless-stopped # =================================================================== - # 10-node cluster (profile: cluster) + # Server fleet — node-1 … node-10 # - # All nodes share the same PostgreSQL instance. - # Access via console at :8090. + # Each node registers itself in Postgres on boot (unique + # TURNSTONE_NODE_ID + TURNSTONE_ADVERTISE_URL) and the console + # discovers it automatically — no static node list anywhere. # - # Start: docker compose --profile cluster up + # node-1 carries the shared definition (&node / &node-env); node-2…10 + # inherit it and override only their identity. # =================================================================== - - # -- cluster servers ------------------------------------------------ - - server-1: &cluster-server + node-1: &node image: turnstone:local - build: { context: ., dockerfile: Dockerfile } - profiles: [cluster] + build: + context: . + dockerfile: Dockerfile command: - sh - -c @@ -229,23 +236,26 @@ services: volumes: - turnstone-data:/data - ${WORKSPACE_MOUNT:-workspace}:/workspace - environment: &cluster-server-env + environment: &node-env + TURNSTONE_JWT_SECRET: *jwt-secret + TURNSTONE_DB_BACKEND: *db-backend + TURNSTONE_DB_URL: *db-url + # Bootstrap LLM defaults — real backends are configured in the console UI. LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1} OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy} TAVILY_API_KEY: ${TAVILY_API_KEY:-} - SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-} - # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - TURNSTONE_JWT_SECRET: ${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env} MODEL: ${MODEL:-} MCP_CONFIG: ${MCP_CONFIG:-} - TURNSTONE_DB_BACKEND: ${TURNSTONE_DB_BACKEND:-postgresql} - TURNSTONE_DB_URL: ${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:?}@postgres:5432/turnstone} + SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-} TURNSTONE_NODE_ID: node-1 - TURNSTONE_ADVERTISE_URL: http://server-1:8080 - extra_hosts: ["host.docker.internal:host-gateway"] - networks: [turnstone-net] + TURNSTONE_ADVERTISE_URL: http://node-1:8080 + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - turnstone-net depends_on: - postgres: { condition: service_healthy } + postgres: + condition: service_healthy healthcheck: test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] interval: 10s @@ -254,34 +264,34 @@ services: start_period: 60s deploy: resources: - limits: { memory: 4G, cpus: '4' } + limits: + memory: 4G restart: unless-stopped - server-2: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://server-2:8080" } - server-3: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://server-3:8080" } - server-4: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://server-4:8080" } - server-5: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://server-5:8080" } - server-6: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://server-6:8080" } - server-7: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://server-7:8080" } - server-8: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://server-8:8080" } - server-9: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://server-9:8080" } - server-10: - <<: *cluster-server - environment: { <<: *cluster-server-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://server-10:8080" } - + node-2: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-2, TURNSTONE_ADVERTISE_URL: "http://node-2:8080" } + node-3: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-3, TURNSTONE_ADVERTISE_URL: "http://node-3:8080" } + node-4: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-4, TURNSTONE_ADVERTISE_URL: "http://node-4:8080" } + node-5: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-5, TURNSTONE_ADVERTISE_URL: "http://node-5:8080" } + node-6: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-6, TURNSTONE_ADVERTISE_URL: "http://node-6:8080" } + node-7: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-7, TURNSTONE_ADVERTISE_URL: "http://node-7:8080" } + node-8: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-8, TURNSTONE_ADVERTISE_URL: "http://node-8:8080" } + node-9: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-9, TURNSTONE_ADVERTISE_URL: "http://node-9:8080" } + node-10: + <<: *node + environment: { <<: *node-env, TURNSTONE_NODE_ID: node-10, TURNSTONE_ADVERTISE_URL: "http://node-10:8080" } diff --git a/deploy/docker-compose.bare-metal.yml b/deploy/docker-compose.bare-metal.yml deleted file mode 100644 index e54de81c..00000000 --- a/deploy/docker-compose.bare-metal.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Bare-metal overlay — expose PostgreSQL and let the console reach -# a turnstone-server running outside Docker on the host machine. -# -# Requires TURNSTONE_HOST_IP set to the host's routable IP address. -# -# Usage: -# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')" -# docker compose --profile production \ -# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up -# -# Then on the host: -# export TURNSTONE_JWT_SECRET="" -# export TURNSTONE_DB_BACKEND=postgresql -# export TURNSTONE_DB_URL="postgresql://turnstone:@localhost:5432/turnstone" -# export TURNSTONE_NODE_ID="bare-metal-1" -# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080" -# python -m turnstone.server --host 0.0.0.0 --port 8080 \ -# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY" - -services: - postgres: - ports: - - "${POSTGRES_PORT:-5432}:5432" - - console: - extra_hosts: - - "host.docker.internal:host-gateway" - environment: - # Console needs to reach the bare-metal server on the host - TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}" - - channel: - ports: - - "${CHANNEL_PORT:-8091}:8091" - environment: - # Channel gateway advertises with host-routable IP so the - # bare-metal server can reach it for schedule notifications - TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}" - # Channel needs to reach the bare-metal server on the host - TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}" diff --git a/deploy/docker-compose.tls.yml b/deploy/docker-compose.tls.yml index 7573aa25..07e24fba 100644 --- a/deploy/docker-compose.tls.yml +++ b/deploy/docker-compose.tls.yml @@ -1,7 +1,8 @@ -# TLS overlay — enables mTLS across the turnstone cluster. +# TLS overlay — enables mTLS across the turnstone deployment. # -# Usage (requires base compose.yaml with production profile): -# docker compose -f compose.yaml -f deploy/docker-compose.tls.yml --profile production up +# Layers on the production stack (it patches the `server`, `console`, and +# `channel` services that file defines): +# docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up # # The tls-init service bootstraps a CA and issues certs. # All turnstone services auto-provision their own certs via the @@ -12,7 +13,7 @@ services: # Runs as root to create directories in the volume, then chowns # to turnstone:turnstone with restrictive perms (keys 0600). tls-init: - build: . + image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest} user: root command: - sh diff --git a/docs/channels.md b/docs/channels.md index e968383a..7c947156 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -99,10 +99,10 @@ TURNSTONE_DISCORD_GUILD=123456789 Then start the stack: ```bash -docker compose --profile production up +docker compose up ``` -The `channel` service starts automatically when +The `channel` gateway runs by default; the Discord adapter activates once `TURNSTONE_DISCORD_TOKEN` is set. ### 3. Link User Accounts diff --git a/docs/docker.md b/docs/docker.md index eade60fc..2ef9f150 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,165 +1,230 @@ # Docker Deployment -Docker Compose stack for running the full turnstone platform. +Turnstone ships two Docker Compose stacks: -## Quick Start +| Stack | File | Use it for | +|-------|------|------------| +| **Dev cluster** | `compose.yaml` (repo root) | Clone-and-run. Builds locally, zero config, full 10-node cluster. | +| **Production** | `turnstone/deploy/compose.yaml` | Pip/pipx installs. Pulls released images from ghcr.io, requires real secrets. | + +## Quick start — local cluster ```bash -# Copy and edit environment config -cp .env.example .env - -# Full stack (needs an LLM API on the host) +git clone https://github.com/turnstonelabs/turnstone +cd turnstone docker compose up ``` -Console dashboard: http://localhost:8090 +That builds one image and brings up the whole stack: PostgreSQL, the console, +Caddy, the channel gateway, and **10 server nodes** (`node-1`…`node-10`). No +`.env` is required — it ships with insecure dev defaults so it just works. -> See also: [Deployment diagram](diagrams/png/12-deployment.png) - -## Services - -| Service | Port | Profile | Description | -|---------|------|---------|-------------| -| `server` | 8080 | default | Web UI + chat workstreams + LLM | -| `console` | 8090 | default | Cluster dashboard | -| `channel` | — | production | Channel gateway (Discord and/or Slack adapters) | -| `server-1`…`server-10` | — | cluster | 10-node server fleet (PostgreSQL required) | - -## Profiles - -**Default** (no flag) — starts `server` and `console`. Requires an OpenAI-compatible LLM API running on the host (default: `http://localhost:8000/v1`). +Open the dashboard at **https://localhost:8443**. It's served by Caddy with its +own local CA, so trust the root certificate once (or click through the browser +warning): ```bash -docker compose up +docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt ``` -**Production** — adds PostgreSQL and the channel gateway. Requires `POSTGRES_PASSWORD` and (for Discord) `TURNSTONE_DISCORD_TOKEN`: +Create your first admin user (any node works — they share one database): ```bash -docker compose --profile production up +docker compose exec node-1 turnstone-admin create-user --username admin --name "Admin" ``` -**Cluster** — 10-node server fleet sharing PostgreSQL. Access all nodes via the console at `:8090`. Requires `POSTGRES_PASSWORD`: +### Bring your own LLM + +Nodes boot **without** an LLM and appear in the console immediately. Add real +model backends (OpenAI, Anthropic, or a local/vLLM endpoint) from the console +UI's **Models** tab. To set a node's bootstrap default instead, point +`LLM_BASE_URL` / `OPENAI_API_KEY` at an OpenAI-compatible endpoint in `.env`. + +### Fewer nodes + +Ten nodes is heavy on a laptop. Start a subset by naming the services (always +include `postgres`, `console`, and `caddy`): ```bash -docker compose --profile cluster up +docker compose up postgres console caddy channel node-1 node-2 node-3 ``` +## Why HTTPS-only? + +The console's plain-HTTP port (8090) is **not** published to the host. A plain +HTTP/1.1 origin caps the browser at 6 connections, which starves the +dashboard's per-pane SSE streams. Caddy serves the browser over HTTP/2 +(multiplexed) and proxies to `console:8090` on the internal network, so the cap +is gone. Everything goes through `https://localhost:8443`. + +## Join a bare-metal host + +PostgreSQL is published on `127.0.0.1:5432`, so a `turnstone-server` running +directly on the same machine — for example to use a local GPU — can join the +same cluster and show up in the console alongside the containerized nodes. + +Put the secret and connection settings in `~/.config/turnstone/config.toml` +(secrets belong in this file, not the process environment — keep it `0600`, +the loader warns otherwise): + +```toml +[auth] +jwt_secret = "dev-only-insecure-jwt-secret-change-me-for-real-deployments" + +[database] +backend = "postgresql" +url = "postgresql+psycopg://turnstone:turnstone@localhost:5432/turnstone" + +[api] +base_url = "http://localhost:8000/v1" # your local model endpoint +api_key = "dummy" +``` + +Then start the server. The node identity isn't a secret, so it stays on the +command line: + +```bash +chmod 600 ~/.config/turnstone/config.toml +TURNSTONE_NODE_ID=host-1 TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \ + turnstone-server --host 0.0.0.0 --port 8080 +``` + +The host server registers itself in PostgreSQL; the console reaches it back via +`host.docker.internal`. The `jwt_secret` and DB credentials above are the +dev-stack defaults — match whatever you set in `.env` if you changed them. To +let a **different** machine join, start the stack with `POSTGRES_BIND=0.0.0.0` +and use the host's routable IP in the `url` and `TURNSTONE_ADVERTISE_URL` — +but **set a strong `POSTGRES_PASSWORD` first**, or you'll expose a database with +the insecure default password (and every user account + API-token hash in it) to +your network. + +## Production stack + +For a real deployment use the bundled stack, which pulls released images +instead of building: + +```bash +docker compose -f turnstone/deploy/compose.yaml up +``` + +It's the same shape as the dev stack — Caddy-fronted console, channel, and a +PostgreSQL all share one database so the console discovers the node — but it +pulls released images, runs a single server node, and has **no baked-in +secrets**. Set these in `.env` first (`turnstone-bootstrap` generates them): + +```bash +TURNSTONE_JWT_SECRET= +POSTGRES_PASSWORD= +``` + +The dashboard is at **https://localhost:8443** (Caddy, same as the dev stack); +the console's HTTP port isn't published. For a real domain and a publicly +trusted cert, edit `turnstone/deploy/Caddyfile` to point Caddy at Let's Encrypt +(see [tls.md](tls.md)). Pin the image with `TURNSTONE_IMAGE_TAG` (default: +`latest`). + +### mTLS + +Layer the TLS overlay on the production stack to enable mutual TLS between +services. A bootstrap container creates a CA and every service auto-provisions +certs via the console's ACME endpoint: + +```bash +docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up +``` + +See [tls.md](tls.md) for details. + ## Configuration -All configuration is via environment variables in `.env` (copy from `.env.example`): +Everything is configured with environment variables in `.env` (copy from +[`.env.example`](../.env.example)). The dev stack needs none of them — they're +overrides. -### LLM Backend +### LLM backend | Variable | Default | Description | |----------|---------|-------------| -| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | OpenAI-compatible API URL | +| `LLM_BASE_URL` | `http://host.docker.internal:8000/v1` | Bootstrap OpenAI-compatible API URL (real backends go in the UI) | | `OPENAI_API_KEY` | `dummy` | API key (`dummy` for local servers) | -| `TAVILY_API_KEY` | — | Web search API key (only needed for local/vLLM models; Anthropic and OpenAI search models use native search) | +| `TAVILY_API_KEY` | — | Web-search fallback (only for local/vLLM models; Anthropic/OpenAI use native search) | +| `MODEL` | — | Override the default model alias | -### Server +### Auth & database + +| Variable | Default (dev / prod) | Description | +|----------|----------------------|-------------| +| `TURNSTONE_JWT_SECRET` | insecure default / **required** | JWT signing secret. Every service must share one value. | +| `TURNSTONE_DB_BACKEND` | `postgresql` | `sqlite` or `postgresql`. Multi-node discovery requires `postgresql`. | +| `TURNSTONE_DB_URL` | bundled Postgres | SQLAlchemy URL. Override to use an external database. | +| `POSTGRES_USER` | `turnstone` | PostgreSQL username | +| `POSTGRES_PASSWORD` | `turnstone` / **required** | PostgreSQL password | +| `POSTGRES_MAX_CONNECTIONS` | `300` | `max_connections` for the bundled Postgres | + +> **Discovery needs a shared database.** Each server registers and heartbeats +> into a `services` table that the console polls. All services in these stacks +> point at the same PostgreSQL by default; SQLite-per-container can't see other +> containers. + +> **Large clusters:** each process keeps a small pool (5 max). Beyond ~50 nodes, +> put [PgBouncer](pgbouncer.md) (transaction pooling) between turnstone and +> PostgreSQL. + +### Ports + +Both stacks publish the same two host ports (everything else is reached through +Caddy or proxied by the console): | Variable | Default | Description | |----------|---------|-------------| -| `SERVER_PORT` | `8080` | Host port mapping | -| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tools | +| `CONSOLE_HTTPS_PORT` | `8443` | Host port for Caddy (dashboard HTTPS) | +| `POSTGRES_PORT` | `5432` | Host port for PostgreSQL (for bare-metal joins) | +| `POSTGRES_BIND` | `127.0.0.1` | Interface PostgreSQL binds on; set `0.0.0.0` for LAN access | -### Console +### Channel gateway | Variable | Default | Description | |----------|---------|-------------| -| `CONSOLE_PORT` | `8090` | Host port mapping | +| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (enables the Discord adapter) | +| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to one guild (0 = all) | +| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` | +| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (with the Slack token) | -### Auth +The channel runs HTTP-only with no adapters until a token is set, so it's safe +to leave running. See [Channel Integrations](channels.md) for app setup. -Auth is always enabled. `TURNSTONE_JWT_SECRET` is required. +### Other | Variable | Default | Description | |----------|---------|-------------| -| `TURNSTONE_JWT_SECRET` | — | Secret key for signing JWTs (required) | - -### Database - -| Variable | Default | Description | -|----------|---------|-------------| -| `TURNSTONE_DB_BACKEND` | `sqlite` | Storage backend: `sqlite` or `postgresql` | -| `TURNSTONE_DB_URL` | — | Database URL (e.g. `postgresql+psycopg://user:pass@postgres:5432/turnstone`). For SQLite, defaults to `/data/.turnstone.db` | -| `TURNSTONE_DB_LISTEN_URL` | (falls back to `TURNSTONE_DB_URL`) | Direct-to-PostgreSQL URL for the console's dedicated `LISTEN` connection. Set this when `TURNSTONE_DB_URL` points at PgBouncer in transaction pooling mode — LISTEN is session state and the transaction-pooled connection can't hold it. See [pgbouncer.md](pgbouncer.md). | -| `TURNSTONE_DB_POOL_SIZE` | `2` | PostgreSQL connection pool size per process (default: 2 base + 3 overflow = 5 max) | -| `POSTGRES_USER` | `turnstone` | PostgreSQL container username (used in default `TURNSTONE_DB_URL` for cluster/channel) | -| `POSTGRES_PASSWORD` | — | PostgreSQL container password (required for production and cluster profiles) | - -The database stores workstream history, user accounts, and API tokens. When using JWT auth, a database backend is required for user storage. - -> **Upgrading from <1.3.0a4:** Earlier versions used `DB_BACKEND` and `DATABASE_URL` in `.env`, which `compose.yaml` mapped to the `TURNSTONE_`-prefixed names internally. These short aliases have been removed. Rename `DB_BACKEND` → `TURNSTONE_DB_BACKEND` and `DATABASE_URL` → `TURNSTONE_DB_URL` in your `.env` file. - -> **Large clusters:** Each turnstone process maintains a small connection pool (5 max). At hundreds of nodes this adds up — use [PgBouncer](pgbouncer.md) in transaction pooling mode between turnstone and PostgreSQL. - -> **First-time setup:** After deploying with auth enabled, create an initial admin user by running `turnstone-admin create-user` inside the container: -> -> ```bash -> docker compose exec server turnstone-admin create-user --username admin --name "Admin" -> ``` -> -> You will be prompted to set a password. Use it to log in via the UI or SDK, then create additional users through the admin API. Pass `--token --scopes read,write,approve` to also generate an initial API token. - -### Channel Gateway - -| Variable | Default | Description | -|----------|---------|-------------| -| `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord adapter) | -| `TURNSTONE_DISCORD_GUILD` | `0` | Restrict to a single Discord guild (0 = all guilds) | -| `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token `xoxb-…` (required to enable Slack adapter) | -| `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token `xapp-…` (required with `TURNSTONE_SLACK_TOKEN`) | -| `TURNSTONE_SLACK_CHANNELS` | — | Comma-separated Slack channel IDs to allow (empty = all) | -| `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command registered in the Slack app | - -The channel service runs in the `production` profile. When -`TURNSTONE_DISCORD_TOKEN` or the Slack pair is set the gateway starts the -corresponding adapter; both can run in one process. See -[Channel Integrations](channels.md) for platform app setup and user -account linking. - -## Scaling - -For multi-node testing, use the `cluster` profile which provides 10 server instances with unique node IDs (`node-1` through `node-10`), resource limits, and shared PostgreSQL: - -```bash -POSTGRES_PASSWORD=secret docker compose --profile cluster up -``` - -The default `server` also runs alongside the cluster nodes (11 total). All nodes are accessible via the console dashboard at `:8090`. - -For production clusters beyond ~50 nodes, add PgBouncer between turnstone services and PostgreSQL. See [PgBouncer Connection Pooling](pgbouncer.md) for Docker Compose and Helm configuration. - -## Volumes - -| Volume | Mount | Purpose | -|--------|-------|---------| -| `turnstone-data` | `/data` | SQLite database (`.turnstone.db`) | +| `WORKSPACE_MOUNT` | empty volume | Host directory bind-mounted at `/workspace` for the model to read/write | +| `SKIP_PERMISSIONS` | — | Set to any value to auto-approve all tool calls (dev only) | +| `MCP_CONFIG` | — | Path to an MCP server config file | +| `TURNSTONE_IMAGE_TAG` | `latest` | ghcr.io image tag — production stack | ## Building -The image uses a multi-stage Dockerfile: +Both stacks install all entry points into a single image (`turnstone`, +`turnstone-server`, `turnstone-console`, `turnstone-channel`, `turnstone-admin`, +`turnstone-eval`, `turnstone-bootstrap`): ```bash -# Build all services -docker compose build - -# Rebuild without cache -docker compose build --no-cache +docker compose build # build the dev image +docker compose build --no-cache # rebuild from scratch ``` -All entry points are installed in a single image: `turnstone`, -`turnstone-server`, `turnstone-console`, `turnstone-channel`, -`turnstone-admin`, `turnstone-eval`, and `turnstone-bootstrap`. +## Volumes + +| Volume | Purpose | +|--------|---------| +| `postgres-data` | PostgreSQL data directory | +| `turnstone-data` | `/data` per node (SQLite fallback, local state) | +| `workspace` | `/workspace` (unless `WORKSPACE_MOUNT` is set) | +| `caddy-data` / `caddy-config` | Caddy's local CA and config (dev stack) | ## Cleanup ```bash -# Stop and remove containers -docker compose down - -# Stop, remove containers and volumes -docker compose down -v +docker compose down # stop and remove containers +docker compose down -v # also remove volumes (database, certs) ``` diff --git a/docs/tls.md b/docs/tls.md index d203fd39..dfa81e7b 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -8,7 +8,7 @@ inter-service communication, powered by [lacme](https://pypi.org/project/lacme/) ## Quick Start (Docker Compose) ```bash -docker compose -f compose.yaml -f deploy/docker-compose.tls.yml up +docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up ``` This: @@ -28,19 +28,24 @@ must, because it is the cluster's ACME bootstrap endpoint: new nodes fetch to verify TLS. So the console cannot be HTTPS-only on its port. To put the **browser → console** hop on HTTPS, terminate TLS at a reverse proxy -in front of the console. The `cluster` profile ships a `caddy` service that does -this: +in front of the console. The dev stack (root `compose.yaml`) ships a `caddy` +service that does exactly this — and it's the only published entry point, so the +dashboard is HTTPS by default: ```bash -docker compose --profile cluster up +docker compose up # dashboard: https://localhost:${CONSOLE_HTTPS_PORT:-8443} ``` +The production stack (`turnstone/deploy/compose.yaml`) bundles the same `caddy` +service, so the dashboard is HTTPS there too. For a real domain and a publicly +trusted cert, point Caddy at Let's Encrypt by editing `turnstone/deploy/Caddyfile`. + ``` browser --h2 / HTTPS--> caddy:443 --h1.1 / HTTP--> console:8090 ``` -Caddy uses its **own local CA** (`tls internal`, see `deploy/Caddyfile`), so the +Caddy uses its **own local CA** (`tls internal`, see `turnstone/deploy/Caddyfile`), so the setup is self-contained with no dependency on the console's ACME path. Trust the local root once to silence the browser warning: @@ -217,7 +222,7 @@ const client = new TurnstoneServer({ 3. Fetches CA root cert from `http://console/acme/ca.pem` (plain HTTP, TOFU) 4. Requests a service cert via ACME (plain HTTP, JWS-signed). The cert's primary domain / SAN is the node's **advertised host** (the host of - `TURNSTONE_ADVERTISE_URL`, e.g. `server-1`) — the name peers actually dial, + `TURNSTONE_ADVERTISE_URL`, e.g. `node-1`) — the name peers actually dial, not the container hostname. This makes mTLS hostname verification succeed and keys the cert by a stable name that survives container recreation. 5. Starts auto-renewal (24h interval, re-issues before expiry) **scoped to its diff --git a/pyproject.toml b/pyproject.toml index 91b3694e..b4849c34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,7 @@ include = [ "turnstone/shared_static/hls-1.6.16/**/*", "turnstone/sdk/py.typed", "turnstone/deploy/*.yaml", + "turnstone/deploy/Caddyfile", ] [tool.pytest.ini_options] diff --git a/deploy/Caddyfile b/turnstone/deploy/Caddyfile similarity index 91% rename from deploy/Caddyfile rename to turnstone/deploy/Caddyfile index fea76e66..162e9d81 100644 --- a/deploy/Caddyfile +++ b/turnstone/deploy/Caddyfile @@ -1,4 +1,4 @@ -# Browser TLS for the console dashboard (cluster/demo profile): +# Browser TLS for the console dashboard: # browser --h2/HTTPS--> caddy:443 --h1.1/HTTP--> console:8090 # The console serves HTTP (it's the ACME bootstrap endpoint), so browser TLS is # terminated here. `tls internal` uses Caddy's own local CA; trust its root once: diff --git a/turnstone/deploy/compose.yaml b/turnstone/deploy/compose.yaml index 77cd0a42..ab1710b1 100644 --- a/turnstone/deploy/compose.yaml +++ b/turnstone/deploy/compose.yaml @@ -1,17 +1,31 @@ # ============================================================================= -# Turnstone Docker Compose Stack — Production +# Turnstone — production stack (docker compose) # -# This file is bundled with the turnstone wheel and written by -# turnstone-bootstrap for users who install via pip/pipx. -# It pulls pre-built images from ghcr.io instead of building locally. +# Same shape as the dev stack at the repo root (Caddy-fronted console, shared +# Postgres, channel gateway) but it pulls released images from ghcr.io instead +# of building, runs a single server node, and requires real secrets. Bundled +# with the turnstone wheel and written by turnstone-bootstrap. # -# Usage: -# Infra only: docker compose up -# Single node: docker compose --profile production up -# Production (PG): docker compose --profile production up -# (set TURNSTONE_DB_BACKEND, TURNSTONE_DB_URL, POSTGRES_PASSWORD in .env) +# docker compose -f turnstone/deploy/compose.yaml up # -# Set TURNSTONE_IMAGE_TAG in .env to pin the image version (default: latest). +# Dashboard: https://localhost:8443 (Caddy's local CA — trust it once; for a +# real domain/cert, edit turnstone/deploy/Caddyfile — see docs/tls.md) +# +# Access is via Caddy only — the console's plain-HTTP port is intentionally not +# published (HTTP/2 from Caddy avoids the browser's 6-connection cap on the +# dashboard's SSE streams). +# +# No baked-in secrets: set TURNSTONE_JWT_SECRET and POSTGRES_PASSWORD in .env +# (turnstone-bootstrap generates them). Pin images with TURNSTONE_IMAGE_TAG +# (default: latest). +# +# Join a bare-metal host: Postgres is published on 127.0.0.1:5432, so a +# turnstone-server on this machine can join the cluster. Put its secrets in +# ~/.config/turnstone/config.toml (chmod 0600) — see "Join a bare-metal host" +# in docs/docker.md. +# +# Enable mTLS between services with the overlay: +# docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up # ============================================================================= name: turnstone @@ -24,15 +38,27 @@ volumes: turnstone-data: workspace: postgres-data: + caddy-data: + caddy-config: + +# Defined once, referenced (*alias) by every service so values can't drift. +x-shared: + # Generate with: python -c "import secrets; print(secrets.token_hex(32))" + jwt-secret: &jwt-secret "${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env}" + db-backend: &db-backend "${TURNSTONE_DB_BACKEND:-postgresql}" + # Every service shares one Postgres. Node discovery REQUIRES a shared DB: the + # server registers + heartbeats into a `services` table the console polls. + db-url: &db-url "${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD}@postgres:5432/turnstone}" + image: &image "ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest}" services: # ------------------------------------------------------------------- - # PostgreSQL — production database (profile: production) + # PostgreSQL — the shared database that ties the deployment together. + # Published on localhost so a bare-metal turnstone-server on this host + # can join (set POSTGRES_BIND=0.0.0.0 to let another machine connect). # ------------------------------------------------------------------- postgres: image: pgautoupgrade/pgautoupgrade:18-alpine - profiles: - - production command: - postgres - -c @@ -42,8 +68,10 @@ services: environment: POSTGRES_DB: turnstone POSTGRES_USER: ${POSTGRES_USER:-turnstone} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for production profile} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} PGDATA: /var/lib/postgresql/data + ports: + - "${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432" volumes: - postgres-data:/var/lib/postgresql/data networks: @@ -58,16 +86,66 @@ services: resources: limits: memory: 1G - cpus: '1.0' restart: unless-stopped # ------------------------------------------------------------------- - # turnstone-server — Web UI + chat workstreams + LLM interaction + # turnstone-console — cluster dashboard. Reach it ONLY through Caddy at + # https://localhost:8443. The console port (8090) is not published; Caddy + # serves the browser over HTTP/2 and proxies to console:8090 internally. + # extra_hosts lets it reach a bare-metal server on host.docker.internal. + # ------------------------------------------------------------------- + console: + image: *image + command: + - turnstone-console + - --host=0.0.0.0 + - --port=8090 + environment: + TURNSTONE_JWT_SECRET: *jwt-secret + TURNSTONE_DB_BACKEND: *db-backend + TURNSTONE_DB_URL: *db-url + TURNSTONE_CONSOLE_URL: http://console:8090 + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - turnstone-net + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped + + # ------------------------------------------------------------------- + # caddy — browser TLS for the dashboard. Terminates HTTPS → console:8090. + # Uses the bundled Caddyfile (Caddy's local CA). For a real domain/cert, + # edit turnstone/deploy/Caddyfile (see docs/tls.md). + # ------------------------------------------------------------------- + caddy: + image: caddy:2.11 + depends_on: + - console + ports: + - "${CONSOLE_HTTPS_PORT:-8443}:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data # persist Caddy's local CA across restarts + - caddy-config:/config + networks: + - turnstone-net + restart: unless-stopped + + # ------------------------------------------------------------------- + # turnstone-server — Web UI + chat workstreams + LLM interaction. + # Registers itself in Postgres on boot so the console can see it. Boots + # even with no model configured yet — add model backends in the console UI. # ------------------------------------------------------------------- server: - image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest} - profiles: - - production + image: *image command: - sh - -c @@ -80,24 +158,22 @@ services: $${MODEL:+--model $$MODEL} $${SKIP_PERMISSIONS:+--skip-permissions} $${MCP_CONFIG:+--mcp-config $$MCP_CONFIG} - ports: - - "${SERVER_PORT:-8080}:8080" volumes: - turnstone-data:/data - ${WORKSPACE_MOUNT:-workspace}:/workspace environment: - - LLM_BASE_URL=${LLM_BASE_URL:-http://host.docker.internal:8000/v1} - - OPENAI_API_KEY=${OPENAI_API_KEY:-dummy} - - TAVILY_API_KEY=${TAVILY_API_KEY:-} - - SKIP_PERMISSIONS=${SKIP_PERMISSIONS:-} - # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env} - - MODEL=${MODEL:-} - - MCP_CONFIG=${MCP_CONFIG:-} - - TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite} - - TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-} - - TURNSTONE_NODE_ID=${TURNSTONE_NODE_ID:-} - - TURNSTONE_ADVERTISE_URL=${TURNSTONE_ADVERTISE_URL:-http://server:8080} + TURNSTONE_JWT_SECRET: *jwt-secret + TURNSTONE_DB_BACKEND: *db-backend + TURNSTONE_DB_URL: *db-url + # Bootstrap LLM defaults — real backends are configured in the console UI. + LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:8000/v1} + OPENAI_API_KEY: ${OPENAI_API_KEY:-dummy} + TAVILY_API_KEY: ${TAVILY_API_KEY:-} + MODEL: ${MODEL:-} + MCP_CONFIG: ${MCP_CONFIG:-} + SKIP_PERMISSIONS: ${SKIP_PERMISSIONS:-} + TURNSTONE_NODE_ID: ${TURNSTONE_NODE_ID:-node-1} + TURNSTONE_ADVERTISE_URL: ${TURNSTONE_ADVERTISE_URL:-http://server:8080} extra_hosts: - "host.docker.internal:host-gateway" networks: @@ -105,7 +181,6 @@ services: depends_on: postgres: condition: service_healthy - required: false healthcheck: test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8080/health"] interval: 10s @@ -115,40 +190,11 @@ services: restart: unless-stopped # ------------------------------------------------------------------- - # turnstone-console — Cluster dashboard - # ------------------------------------------------------------------- - console: - image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest} - command: - - turnstone-console - - --host=0.0.0.0 - - --port=8090 - ports: - - "${CONSOLE_PORT:-8090}:8090" - environment: - # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env} - - TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-sqlite} - - TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-} - - TURNSTONE_CONSOLE_URL=http://console:8090 - networks: - - turnstone-net - healthcheck: - test: ["CMD", "python", "/usr/local/bin/healthcheck.py", "http://127.0.0.1:8090/health"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s - restart: unless-stopped - - # ------------------------------------------------------------------- - # turnstone-channel — Channel gateway (Discord, Slack, etc.) - # Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter + # turnstone-channel — channel gateway (Discord and/or Slack). + # Runs HTTP-only with no adapters until you set a token. See docs/channels.md. # ------------------------------------------------------------------- channel: - image: ghcr.io/turnstonelabs/turnstone:${TURNSTONE_IMAGE_TAG:-latest} - profiles: - - production + image: *image command: - sh - -c @@ -157,17 +203,17 @@ services: --http-host=0.0.0.0 $${TURNSTONE_DISCORD_GUILD:+--discord-guild $$TURNSTONE_DISCORD_GUILD} environment: - - TURNSTONE_DISCORD_TOKEN=${TURNSTONE_DISCORD_TOKEN:-} - - TURNSTONE_DISCORD_GUILD=${TURNSTONE_DISCORD_GUILD:-0} - # Generate with: python -c "import secrets; print(secrets.token_hex(32))" - - TURNSTONE_JWT_SECRET=${TURNSTONE_JWT_SECRET:?Set TURNSTONE_JWT_SECRET in .env} - - TURNSTONE_DB_BACKEND=${TURNSTONE_DB_BACKEND:-postgresql} - - TURNSTONE_DB_URL=${TURNSTONE_DB_URL:-postgresql+psycopg://${POSTGRES_USER:-turnstone}:${POSTGRES_PASSWORD:-turnstone}@postgres:5432/turnstone} - - TURNSTONE_CHANNEL_ADVERTISE_URL=http://channel:8091 + TURNSTONE_JWT_SECRET: *jwt-secret + TURNSTONE_DB_BACKEND: *db-backend + TURNSTONE_DB_URL: *db-url + TURNSTONE_DISCORD_TOKEN: ${TURNSTONE_DISCORD_TOKEN:-} + TURNSTONE_DISCORD_GUILD: ${TURNSTONE_DISCORD_GUILD:-0} + TURNSTONE_SLACK_TOKEN: ${TURNSTONE_SLACK_TOKEN:-} + TURNSTONE_SLACK_APP_TOKEN: ${TURNSTONE_SLACK_APP_TOKEN:-} + TURNSTONE_CHANNEL_ADVERTISE_URL: http://channel:8091 networks: - turnstone-net depends_on: postgres: condition: service_healthy - required: false restart: unless-stopped