Files
turnstone/docs/docker.md
T
Patrick Buckley 1f61350545 feat(compose): let bare-metal turnstone-servers join the cluster (incl. mTLS)
A turnstone-server running outside the compose network ("bare-metal", e.g. a
local-GPU box) couldn't fully join: it can't resolve the in-cluster console
(console:8090) to enroll its mTLS cert, and SearxNG was unreachable for
web_search. Only Postgres was published.

Publish the console's plain-HTTP ACME endpoint (:8090) and SearxNG (:8081)
alongside Postgres, all bound via one knob TURNSTONE_HOST_IP (default 127.0.0.1
-- nothing new on the LAN; set it to the host's LAN IP for a node on another
machine). Postgres keeps honoring the legacy POSTGRES_BIND as a fallback, so
existing .env files don't break.

The node's TLS client now honors TURNSTONE_CONSOLE_URL so a bare-metal node can
point at the published ACME endpoint instead of the unreachable in-cluster name
(empty = in-cluster service discovery, unchanged).

Docs (docker.md, tls.md), the run.sh-generated .env, and the bootstrap wizard
updated to match. The advertised host is the cert's primary SAN and the console
collector dials it back, so mTLS hostname verification holds both ways.
2026-06-15 03:41:24 -07:00

12 KiB

Docker Deployment

Turnstone ships two Docker Compose stacks:

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

git clone https://github.com/turnstonelabs/turnstone
cd turnstone
docker compose up

That builds one image and brings up the whole stack: PostgreSQL, the console, Caddy, the channel gateway, and 10 server nodes (node-1node-10). No .env is required — it ships with insecure dev defaults so it just works.

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):

docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt

Create your first admin user (any node works — they share one database):

docker compose exec node-1 turnstone-admin create-user --username admin --name "Admin"

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):

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, the console's ACME endpoint (:8090), and SearxNG (:8081) are published on 127.0.0.1, so a turnstone-server running directly on the same machine — for example to use a local GPU — can join the same cluster (enrolling its mTLS cert and running web_search) 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):

[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:

chmod 600 ~/.config/turnstone/config.toml
TURNSTONE_NODE_ID=host-1 \
  TURNSTONE_ADVERTISE_URL=http://host.docker.internal:8080 \
  TURNSTONE_CONSOLE_URL=http://localhost:8090 \
  TURNSTONE_SEARXNG_URL=http://localhost:8081 \
  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. TURNSTONE_CONSOLE_URL points the node at the console's published ACME endpoint so it can enroll its mTLS certificate (needed only when the cluster runs mTLS; harmless otherwise), and TURNSTONE_SEARXNG_URL points web_search at the published SearxNG. 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 server on a different machine join, start the stack with TURNSTONE_HOST_IP=<this host's LAN IP> — that binds PostgreSQL, the console ACME endpoint, and SearxNG to that interface. Then on the remote box set the three URLs above to that IP, and set TURNSTONE_ADVERTISE_URL to the remote box's own IP (the address the console dials back). Set a strong POSTGRES_PASSWORD firstTURNSTONE_HOST_IP exposes the database (and every user account + API-token hash in it), the console API, and the unauthenticated SearxNG to your network.

Production stack

For a real deployment use the bundled stack, which pulls released images instead of building:

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):

TURNSTONE_JWT_SECRET=<python -c "import secrets; print(secrets.token_hex(32))">
POSTGRES_PASSWORD=<a strong 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). 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:

docker compose -f turnstone/deploy/compose.yaml -f deploy/docker-compose.tls.yml up

See tls.md for details.

Configuration

Everything is configured with environment variables in .env (copy from .env.example). The dev stack needs none of them — they're overrides.

LLM backend

Variable Default Description
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)
TURNSTONE_SEARXNG_URL http://searxng:8080 SearxNG URL for the web_search tool (local/vLLM models only; Anthropic/OpenAI use native search). Defaults to the bundled searxng service; set to an external instance's URL. To turn web search off, clear tools.searxng_url in the admin Settings tab.
SEARXNG_IMAGE_TAG latest Tag for the bundled searxng/searxng image
MODEL Override the default model alias

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 (transaction pooling) between turnstone and PostgreSQL.

Ports

Both stacks publish Caddy (dashboard) and PostgreSQL; the dev stack additionally publishes the console's ACME endpoint and SearxNG on localhost so a bare-metal node can enroll its cert and run web_search. Everything else is reached through Caddy or proxied by the console:

Variable Default Description
CONSOLE_HTTPS_PORT 8443 Host port for Caddy (dashboard HTTPS)
SEARXNG_HTTPS_PORT 8444 Host port for the SearxNG UI via Caddy (dev: localhost-only; prod: opt-in)
POSTGRES_PORT 5432 Host port for PostgreSQL (for bare-metal joins)
SEARXNG_API_PORT 8081 Host port for the SearxNG API a bare-metal node's web_search dials (dev stack)
TURNSTONE_HOST_IP 127.0.0.1 Interface PostgreSQL, the console ACME endpoint, and SearxNG bind on (dev stack). Set to this host's LAN IP so a bare-metal node on another machine can reach them — set a strong POSTGRES_PASSWORD first (it also exposes the DB and the unauthenticated SearxNG to your network).
POSTGRES_BIND 127.0.0.1 Production stack (turnstone/deploy/compose.yaml) only: interface PostgreSQL binds on; set to the host's LAN IP for remote joins.

Channel gateway

Variable Default Description
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)

The channel runs HTTP-only with no adapters until a token is set, so it's safe to leave running. See Channel Integrations for app setup.

Web search (SearxNG)

The web_search tool for local/vLLM models is backed by a self-hosted SearxNG metasearch service, bundled into both stacks as the searxng service. The Turnstone nodes reach it over the internal docker network at http://searxng:8080 — its API port is not published. Its config — turnstone/deploy/searxng/settings.yml, mounted read-only — enables the JSON API and leaves the rate limiter off (the limiter would need a separate Valkey/Redis instance). A searxng-cache volume persists its favicon + internal cache across restarts. Commercial providers (Anthropic, OpenAI) use their own native search and never touch this service.

Point at an existing SearxNG instead of the bundled one with TURNSTONE_SEARXNG_URL, or narrow the engines via tools.searxng_engines in the admin Settings tab (e.g. duckduckgo,wikipedia).

SearxNG web UI. Caddy can also serve SearxNG's own search/Preferences UI on a dedicated port. The dev stack publishes it at https://localhost:8444 bound to localhost only; the production stack does not publish it by default (uncomment the 8444 port on the caddy service to opt in). Change the port with SEARXNG_HTTPS_PORT. SearxNG has no authentication — never bind this to a public interface, or anyone who can reach it can search through your instance.

AGPL note for operators. SearxNG is licensed AGPL-3.0. Kept on the internal network (or bound to localhost), no external user interacts with it — so the AGPL §13 (remote network interaction) source-offer obligation does not attach. If you publish SearxNG to remote users (bind its port to a public interface, or front it with your own reverse proxy) you become the operator of a network-reachable AGPL service and must offer its corresponding source; that is trivially satisfied by linking to upstream https://github.com/searxng/searxng. Turnstone's own license is unaffected: it talks to SearxNG over HTTP as a separate process (mere aggregation), not by linking.

Other

Variable Default Description
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

Both stacks install all entry points into a single image (turnstone, turnstone-server, turnstone-console, turnstone-channel, turnstone-admin, turnstone-eval, turnstone-bootstrap):

docker compose build            # build the dev image
docker compose build --no-cache # rebuild from scratch

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

docker compose down       # stop and remove containers
docker compose down -v    # also remove volumes (database, certs)