The process cwd was nowhere in the model's context: shells start in the inherited process cwd (spawn_group_leader passes no cwd), relative file paths resolve against it, but nothing told the model where it was standing — in stock Docker every shell ran in /data while user files sat in the /workspace mount, and the model's only recourse was to probe with pwd (#857, #833). Lower both facts into the tool schemas, where they gate intrinsically on tool availability (a persona without fs tools carries no note, and coordinator envelopes are untouched): - tools/*.json: cwd_note/workspace_note metadata templates on bash, read_file, write_file, edit_file, search, diff_file; bash also states the fresh-shell-per-call semantics (cd does not persist) and drops a stale reference to the removed man tool. - tools.apply_cwd_context(): renders the notes into descriptions; deep-copies noted tools (the fs dicts are shared across TOOLS/INTERACTIVE_TOOLS/TASK_AGENT_TOOLS and aliased through merge_mcp_tools), passes note-less tools through by reference. - ChatSession._apply_cwd_notes(): wraps every fresh interactive build of _tools AND _task_tools (construction, MCP catalog change, MCP disconnect) — assignment-time, so the wire tools block stays byte-stable for provider prompt caches. os.getcwd() is OSError-guarded (MCP rebuilds run on a background thread; eval tears down its workdir); the workspace hint drops when the dir is missing or equals the cwd. Task-agent sub-agents carry their own notes via _task_tools, independent of parent persona visibility. - config.get_workspace_dir(): [tools] workspace_dir with TURNSTONE_WORKSPACE env fallback (searxng pattern), informational only — no chdir, no path confinement (per-workstream working-dir grants are a separate planned feature). - Dockerfile: ENV TURNSTONE_WORKSPACE=/workspace so stock deployments surface the mount with zero operator config. - docs/docker.md: document the /data working directory, the working_dir: /workspace compose override as the operator-level fix, and the SQLite-fallback-DB-in-cwd caveat. Closes #857
14 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.
To run the bare-metal node as a hardened, persistent service instead of by hand,
use the systemd units in deploy/systemd/.
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 (generate with openssl rand -hex 32):
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 |
TURNSTONE_WORKSPACE |
/workspace (image env) |
Directory named as the user's workspace in the model's tool descriptions; informational only — see Working directory |
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-optimizer, turnstone-doctor):
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) |
Working directory
Node processes run with /data as their working directory (the image's
WORKDIR), and that is where the model's shell commands execute and
relative file paths resolve — not /workspace. The shell and file
tool descriptions state both paths (the working directory, and the
workspace named by TURNSTONE_WORKSPACE), so the model knows to look in
/workspace for your files without being told each session.
To make tools start inside the mount instead, override the working directory on the node services:
services:
turnstone-node:
working_dir: /workspace
Two caveats before overriding:
- SQLite fallback: when a node runs without PostgreSQL, its fallback
database
.turnstone.dbis created in the process working directory. Changingworking_diron an existing SQLite-fallback deployment makes the node create a fresh database inside the mount and your prior state appears lost (it is still in theturnstone-datavolume under/data). The stock compose stacks use PostgreSQL and are unaffected. - Migrations (
entrypoint.sh) run in the same working directory, so the same SQLite caveat applies to them.
Cleanup
docker compose down # stop and remove containers
docker compose down -v # also remove volumes (database, certs)