This guide covers deploying NodeTool on your own infrastructure.
Overview
Self-hosted deployment runs NodeTool in a Docker container — on localhost
or on a remote host reached over SSH. Docker is the only supported deployment
type (SUPPORTED_TYPES = ["docker"]).
Two paths:
- Docker Compose — a single
docker-compose.ymlyou run yourself. The fastest way to stand up one server. See below. nodetool deployCLI — a managed flow driven bydeployment.yamlthat also handles remote hosts over SSH, image transfer, and workflow sync. See Deployment Configuration.
Docker Compose (reference)
The repository ships a reference
docker-compose.yml
for running one server on a host you control.
cp .env.example .env # fill in the provider keys you use
docker compose up -d
# open http://localhost:17777
The server binds to 0.0.0.0:7777 inside the container and is published on the
host as ${NODETOOL_PORT:-17777}. All persistent state — SQLite database,
assets, vector store, model cache, and the generated secret key — lives under
/workspace, backed by the named nodetool-data volume, so it survives
restarts and image upgrades.
Common overrides (set in .env or the shell):
| Variable | Default | Purpose |
|---|---|---|
NODETOOL_IMAGE |
ghcr.io/nodetool-ai/nodetool:${NODETOOL_VERSION} |
Full image reference. Set it to run a locally built image (for example nodetool:dev) |
NODETOOL_VERSION |
latest |
Image tag to pull (pin a release in production) |
NODETOOL_PORT |
17777 |
Host port mapped to the container’s 7777 |
NODETOOL_TRUST_LOCAL_NETWORKS |
172.16.0.0/12,192.168.65.0/24 |
⚠️ Source CIDRs trusted as user 1 without a login (Local mode). Docker’s Linux bridge plus Docker Desktop’s VM gateway subnet by default — never 0.0.0.0/0 on a public IP. Ignored in Supabase mode |
SECRETS_MASTER_KEY |
generated on first start | 32-byte base64 key encrypting stored secrets. If unset, the image entrypoint generates one and stores it in /workspace/.secrets_master_key (override the path with SECRETS_MASTER_KEY_FILE). The line is commented out in the compose file, so uncomment it to pass your own (openssl rand -base64 32) |
OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, FAL_API_KEY, HF_TOKEN |
unset | Model provider keys |
The compose file also sets NODETOOL_ENV=production and
NODETOOL_NODE_PROFILE=full. Production defaults to the curated cloud node
catalog with no local model runtimes, and full restores the whole catalog,
including the llama.cpp, vLLM, and Ollama providers. The database is SQLite at
/workspace/nodetool.sqlite3 (DB_PATH). The entrypoint refuses to start when
neither DB_PATH nor DATABASE_URL is set.
The compose file has commented-out blocks you can enable:
- Ollama: set
OLLAMA_API_URL(http://host.docker.internal:11434for Ollama on the host, with theextra_hostsentry uncommented). - llama.cpp and vLLM services: uncomment the service and set
LLAMA_CPP_URLorVLLM_BASE_URL. vLLM needs an NVIDIA GPU on Linux. - PostgreSQL: uncomment the
postgresservice, removeDB_PATH, and setDATABASE_URL. WithDATABASE_URLset, the entrypoint runsdb-migrate.mjson every start unlessNODETOOL_MIGRATE_ON_BOOT=0.
Upgrade in place:
docker compose pull
docker compose up -d
To store data in a host directory instead of the named volume, replace the
nodetool-data:/workspace mount with a bind mount (e.g. ./nodetool-data:/workspace)
and make sure the host directory is writable by the container’s node user.
Authentication / login screen
Auth is configured entirely on the backend. The web UI fetches its auth mode
and public Supabase credentials at runtime from GET /api/config (a public,
non-secret endpoint), so the same frontend build works with or without login —
no rebuild, and it works even when the frontend is served from a different
origin.
- Login off (default). With
SUPABASE_URL/SUPABASE_KEYunset the server runs in Local mode: it trusts requests by source IP (loopback, plus anyNODETOOL_TRUST_LOCAL_NETWORKSyou set) and runs them as a single user; other requests are rejected, and the UI shows no login screen. In Docker the bundled Compose file trusts the Docker bridge (172.16.0.0/12) and Docker Desktop’s VM gateway subnet (192.168.65.0/24) so a local install works out of the box — see the warning below. -
Login on. Set these three on the server to switch to Supabase mode — the server requires a valid Supabase JWT on every request and the UI shows the login screen:
SUPABASE_URL=https://your-project.supabase.co SUPABASE_KEY=your-service-role-key # server-only, never sent to the browser SUPABASE_ANON_KEY=your-anon-key # public key the login screen usesOptionally set
AUTH_REDIRECT_URLwhen serving behind a domain/proxy (it must be in the Supabase project’s redirect allow list).
GET /api/config returns authMode, supabaseUrl, supabaseAnonKey,
authRedirectUrl, and version — never the service-role key. See
Authentication and Supabase Deployment.
🔒 Do not expose Local mode to the internet
Local mode has no login.
NODETOOL_TRUST_LOCAL_NETWORKStrusts a set of source IPs as admin user"1"with no password — full access to your data, secrets, and API keys. Anyone who can reach the published port from a trusted range gets in.
- Safe on a laptop or a private LAN/VPN behind a firewall.
- The default
172.16.0.0/12,192.168.65.0/24trusts only Docker’s bridge and Docker Desktop’s VM gateway, not the wider internet. Never change it to0.0.0.0/0on a public IP.- Putting NodeTool on a public address or sharing it with untrusted users? Enable Supabase mode (above) so every request needs a real login, and terminate TLS in front of the server.
Serving the web UI from a different origin (e.g. a CDN)? Point the frontend at the backend with the build-time
VITE_API_URL, and add that origin to the server’sNODETOOL_ALLOWED_ORIGINSso the cross-originGET /api/configand API calls are permitted. Everything else still comes from/api/config.
Deployment Configuration
Deployments are configured via deployment.yaml.
Docker Deployment
deployments:
my-server:
type: docker
enabled: true
host: 192.168.1.10
ssh:
user: ubuntu
key_path: ~/.ssh/id_rsa
container:
name: nodetool-server
port: 8000
gpu: "0"
paths:
workspace: /data/nodetool
hf_cache: /data/hf-cache
image:
name: ghcr.io/nodetool-ai/nodetool
tag: latest
For a local host, set host: localhost and omit the ssh block.
Apply Flow
- Directory Creation: Creates
workspace(withdata,assets,temp,proxy, andacmesubdirectories) andhf_cacheon the host. - Image Check: Verifies the configured image exists on the host.
deploy applydoes not pull. A local host without the image fails with an error, so pull or build it first. - Image Transfer: For a remote host that lacks the image, pipes
docker savefrom your local daemon intodocker loadover SSH. An image the remote already holds is never replaced. - Container Management: Stops and removes the existing container, and any other
nodetool-*container publishing the same host port, then runs a new one namednodetool-<container.name>. Docker is used when installed, otherwise Podman. SetNODETOOL_CONTAINER_RUNTIME=dockerorpodmanto choose. - Health Check: Polls
http://127.0.0.1:<host port>/healthon the host, 10 attempts 2 seconds apart, and fails the apply if the server never answers.
container.port is the host port. The container serves on 7777, and a
container.port of 7777 is published as 8000. The container is started with
--restart unless-stopped and a Docker health check on /health.
End-to-End: Local Docker Deployment
This walkthrough matches a common local setup flow:
- Pull the Docker image.
- Add a docker deployment interactively.
- Review the generated deployment.
- Apply deployment and validate health.
- Sync workflows.
- Run a synced workflow on the deployed instance.
0. Pull the Image First
docker pull ghcr.io/nodetool-ai/nodetool:latest
1. Add Local Docker Deployment
nodetool deploy add local --type docker
--type docker is required. The command then prompts for the rest:
- Docker host (IP or hostname):
localhost - SSH user and SSH key path: asked only for a remote host (defaults
rootand~/.ssh/id_rsa) - Docker image name:
ghcr.io/nodetool-ai/nodetool - Image tag:
latest - Container name:
nodetool-<deployment name> - Container port:
8000
The command does not prompt for GPU or paths. It sets paths.workspace to
~/.nodetool-workspace and paths.hf_cache to the Hugging Face cache it finds
(HF_HUB_CACHE, then $HF_HOME/hub, then ~/.cache/huggingface/hub). Edit
container.gpu and paths with nodetool deploy edit.
Path meanings:
- Workspace: mounted at
/workspace. Holds the database, assets, and temporary runtime data. - HF cache: mounted at
/hf-cache, read-only. Holds downloaded Hugging Face models.
2. Review Deployment Config
nodetool deploy show local
This dumps the deployment entry as YAML. It also lists enabled, state, server_auth_token, and container.environment (with a generated SECRETS_MASTER_KEY). You should see something like:
local:
type: docker
host: localhost
image:
name: ghcr.io/nodetool-ai/nodetool
tag: latest
container:
name: nodetool-local
port: 8000
paths:
workspace: <your workspace path>
hf_cache: <your HF cache path>
The server endpoint is at http://localhost:8000. The container EXPOSEs 7777;
when container.port is 7777 the deployer maps it to host port 8000 (any
other container.port is used as-is).
You can also inspect the raw config:
cat ~/.config/nodetool/deployment.yaml
To change a field deploy add did not prompt for, open the file in $EDITOR:
nodetool deploy edit
deploy edit takes no deployment name — it opens the whole deployment.yaml.
Without one on disk it stops and tells you to run nodetool deploy init first.
3. Apply Deployment
nodetool deploy apply local
Apply prints each step, then the result as JSON. A successful run shows:
- directories created
Image already present.- app container started
Health endpoint OK: http://127.0.0.1:8000/health"status": "success"in the final JSON
If apply fails (for example because the image is missing or the health check times out), fix the cause and run it again:
nodetool deploy apply local
Then confirm runtime state:
nodetool deploy status local
docker ps --filter name=nodetool --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
curl http://127.0.0.1:8000/health
4. Sync Workflows to the Deployment
List local workflows first:
nodetool workflows list
Sync one workflow by ID to the deployed instance:
nodetool deploy workflows sync local <workflow_id>
Sync pushes the workflow definition (name, description, access, graph, and
settings) to PUT /api/workflows/<id> on the deployment. It does not upload
assets or download models. Add assets and models on the target yourself.
All deploy workflows subcommands (sync, list, delete, run) need an
admin bearer token, resolved like the API users commands below.
delete prompts first unless you pass --force.
Verify remote workflows:
nodetool deploy workflows list local
5. Test Workflow Execution on Deployed Instance
Run a synced workflow remotely:
nodetool deploy workflows run local <workflow_id>
Pass parameters with a repeatable -p key=value:
nodetool deploy workflows run local <workflow_id> -p prompt="hello"
If a run fails, inspect logs:
nodetool deploy logs local --tail 200
API Users
A deployment that serves more than one person needs API users. Four
nodetool deploy subcommands manage them over the running server’s admin API,
so the deployment must already be applied and reachable. The caller must be an
admin: user 1, or an id listed in ADMIN_USER_IDS. Records live in the file
named by USERS_FILE.
Every one of them needs an admin bearer token. It comes from --token, else
from NODETOOL_ADMIN_TOKEN, else from an interactive prompt — with no TTY and
neither set, the command exits with
Admin token required. Provide --token or set NODETOOL_ADMIN_TOKEN.
users-add <deployment> <username>— create a user and print its token.--role <admin|user>defaults touser; any other value is refused. The token is printed once and never again.users-list <deployment>— username, user id, role, a 16-character preview of the stored token hash, and creation time.--jsonprints the raw records.users-remove <deployment> <username>— delete a user. Prompts first;--forceskips the prompt.users-reset-token <deployment> <username>— issue a new token and invalidate the old one. Likeusers-add, the new token is shown once.
Examples:
export NODETOOL_ADMIN_TOKEN=<admin token>
nodetool deploy users-add local alice --role admin
nodetool deploy users-list local
nodetool deploy users-list local --json
nodetool deploy users-reset-token local alice
nodetool deploy users-remove local alice --force
MCP over HTTP and Python nodes
Two surfaces are off when NODETOOL_ENV=production — which the published image,
fly.toml, and docker-compose.yml all set. Each has an opt-in flag.
/mcp — NODETOOL_ENABLE_MCP=1
The streamable-HTTP MCP mount carries the full agent toolbelt, so it registers in production only with the flag:
environment:
NODETOOL_ENABLE_MCP: "1"
Without it the route is not registered, /mcp answers 404, and the boot log
names the flag.
The mount is not a second door. It sits behind the same onRequest auth hook as
/api, and binds the user that hook resolved: a request it cannot authenticate
is refused at initialize with 401, never given an anonymous session. The session
id in the mcp-session-id header is scoped to its owner — a second user
presenting the same id gets 404 Session not found, the same answer as an id
that never existed.
How an agent authenticates:
- A token minted in the app — the short path, and the one to reach for
first. Settings → MCP → Connect an agent remotely hands back a
ready-to-paste
claude mcp addcommand carrying the URL and a bearer token. The token is revocable one at a time, stored only as a hash, and works in every auth mode. - Supabase mode (
SUPABASE_URL+SUPABASE_KEY): the agent sends a Supabase access token asAuthorization: Bearer <jwt>, like any web client. Fine for a script that already signs in; the JWT expires within the hour, so it is a poor fit for a config file. - Local mode behind a VPN: set
NODETOOL_TRUST_LOCAL_NETWORKSto the VPN’s CIDR. Requests from that range are trusted as user1with no token. Scope it to the VPN — anything reaching the server from a trusted range gets the whole toolbelt, and there is nothing to revoke afterwards. - A bot or bridge: mint a delegated token through the integration routes
(
NODETOOL_INTEGRATION_TOKEN) and send it as the bearer token. The session binds the linked account rather than a shared identity.
Full walkthrough, including how to check a setup and what each error answer means: MCP on a production server.
Not covered by the flag: the tRPC mcpConfig router stays disabled in
production. It edits MCP client config files on the server’s filesystem, which
has no meaning on a shared host.
Python nodes — NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION=1
The Python bridge refuses to connect in production. The flag lifts that:
environment:
NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION: "1"
NODETOOL_PYTHON: "/opt/conda/envs/nodetool/bin/python"
The published image ships no Python worker. ghcr.io/nodetool-ai/nodetool
carries the TypeScript server only, so the flag by itself changes nothing except
the error you get. To run Python nodes, derive an image that installs
nodetool-core and set NODETOOL_PYTHON to that interpreter:
FROM ghcr.io/nodetool-ai/nodetool:latest
RUN pip install --no-cache-dir nodetool-core
ENV NODETOOL_PYTHON=/usr/local/bin/python3 \
NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION=1
With the flag set and no interpreter found, the boot log says
Python not found — Python nodes will not be available. With the flag unset it
names the flag instead, so the log tells you which of the two you are missing.
Manual Troubleshooting
Container Logs
nodetool deploy logs local --tail 200
For a remote host you can also read the container logs directly:
ssh user@host "docker logs nodetool-<container.name>"