Back to blog
Self-hosting Toolspoke in Fifteen Minutes
Self-hosting
September 3, 20266 min read

Self-hosting Toolspoke in Fifteen Minutes

One container, a PostgreSQL 17 database you own, and two secrets you generate. What the compose file actually starts, which variables matter, and what you give up by leaving the optional ones unset.

TT
Toolspoke Team
Engineering

Toolspoke runs as one container against a PostgreSQL 17 database you own. It applies its own schema on boot. It needs no vault, no cache, and no object store. If you have Docker and fifteen minutes, the whole install is four commands.

This is the short version of the self-hosting guide, written for someone deciding whether it is worth the afternoon.

The four commands

Start in an empty directory. The compose file is served by the site itself, so there is nothing to clone:

curl -fsSL https://toolspoke.com/self-host/compose.yaml -o compose.self-host.yaml

Generate two secrets. They must be different values:

echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)"
echo "CREDENTIALS_ENCRYPTION_KEY=$(openssl rand -base64 32)"

Write those into a .env beside the compose file, along with a database password you choose and the origin this deployment will answer on:

POSTGRES_PASSWORD=a-password-you-choose
BETTER_AUTH_SECRET=...
CREDENTIALS_ENCRYPTION_KEY=...
APP_URL=https://toolspoke.example.com

Then bring it up:

docker compose -f compose.self-host.yaml up -d

Open your origin and create an account. The first account creates the workspace around it, with a Default project inside.

Why two secrets and not one

BETTER_AUTH_SECRET signs sessions and the OAuth access tokens the MCP gateway verifies. CREDENTIALS_ENCRYPTION_KEY encrypts every stored credential with AES-256-GCM before it is written to your database.

They are deliberately separate. One key doing both jobs means rotating your session secret — a routine, low-stakes operation — takes every stored credential with it. Splitting them makes the routine thing routine.

There is no recovery path for the encryption key. Back it up with the database rather than separately: the ciphertext in your database is unreadable without it, so a backup of one without the other is not a backup.

What the compose file actually starts

Two services, and nothing hidden behind a control plane you cannot see.

postgres holds every workspace, policy, credential and audit row. It is not published to the host. Nothing outside the compose project needs to reach it, and a PostgreSQL bound to 0.0.0.0 on a public server is how installs get owned. If you have a reason to expose it, bind it to 127.0.0.1.

app is the MCP endpoint, the dashboard and the API in one server. It is pulled rather than built, and it applies the schema itself before it starts listening — so there is no migration step, and an upgrade is:

docker compose -f compose.self-host.yaml pull
docker compose -f compose.self-host.yaml up -d

The schema is idempotent and takes an advisory lock, so several replicas booting at once is safe and an existing volume is upgraded in place.

What you give up by setting nothing else

Every other variable is genuinely optional. The feature it configures is unavailable when it is absent, and nothing else degrades.

Leave the Stripe variables unset and every workspace sits on the Free plan, which is the usual shape for an internal deployment. Leave the SMTP variables unset and invitations are still created — you copy their link by hand instead of mailing it. Leave the Google and GitHub pairs unset and those buttons simply are not offered; the login page falls back to email and password.

One omission is worth understanding before you hit it. Toolspoke does not ship a sandbox runtime. Two kinds of tool need one: CLI toolkits, which run a pinned binary with arguments an agent supplies, and stdio MCP servers, which are booted as a process and spoken to over stdin and stdout. Both are executed by Harborbox, which deploys separately.

Without it, REST API toolkits and streamable HTTP MCP servers work normally — which is most of the catalogue. A deployment without Harborbox is a complete gateway for everything reachable over HTTP. It is not a degraded mode, and it says so plainly: runner is false at /api/health, and a call that needs the sandbox is refused rather than quietly doing nothing.

Check it worked

curl -s https://toolspoke.example.com/api/health | jq
{
  "app": true,
  "database": true,
  "runner": false,
  "embedded": { "toolkits": 0, "of": 31, "tools": 0 },
  "commit": "04a52de...",
  "checkedAt": "2026-09-03T20:12:44.108Z"
}

database is a real query, not a ping. runner is whether the sandbox runtime answered — false is expected without Harborbox. commit is which build is answering, baked in at image build time, so you can confirm a deploy without doing archaeology on asset hashes.

The endpoint has no credential in front of it, so it reports counts and never names, and any string long enough to be a secret is scrubbed from the last error it reports.

Before you put it on the internet

Terminate TLS in a reverse proxy — the container serves plain HTTP on port 3000. Do not publish PostgreSQL to a public interface; the compose file does not. Replace every example value, because there are no default credentials in this product and there should be none in your .env either. And review each connector you enable, and the scopes it asks for, before anyone installs it.

The image is published for linux/amd64 and linux/arm64, so it runs on Graviton and Hetzner ARM as well as ordinary x86. Pin a version tag rather than tracking latest on anything you care about — every image is also published under its git SHA if you want to pin exactly.

Tags:Self-hostingDockerMCPPostgreSQL

Ready to give your agents a gateway?

Connect your team's tools, APIs and MCP servers behind per-project access policies and a complete audit log. Start free — no credit card required.

Get started for free

Related articles