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.
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-1…node-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 first — TURNSTONE_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
servicestable 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)