What you are deploying
One image, one process, three surfaces. The Studio is a static React bundle compiled into the image at build time, so there is no separate frontend service to run and nothing to point at a second origin.
| Path | Surface |
|---|---|
/ | The Studio console, served as static files. |
/v1 | The REST API: primitives, jobs, administration. See REST API. |
/mcp | The MCP surface over streamable HTTP. See MCP server. |
/v1/health | Unauthenticated liveness, and the two facts a console needs before anyone has signed in: whether setup is still open and whether a password door exists. |
Docker Compose
There is one compose file, and it is the same one you run on a laptop. Everything that differs between a laptop and a server lives in variables, so a deployment cannot drift away from the file that gets tested.
git clone https://github.com/JimmyWesley/MonkeyLLM.git
cd MonkeyLLM
cp .env.example .env # optional, fill in what you use
docker compose up --build -d
docker compose logs station # the first boot says how to get inThen open http://localhost:8800.
The compose file, explained
services:
station:
build:
context: .
dockerfile: apps/station/Dockerfile
image: monkeyllm-station
ports:
- "${STATION_BIND:-127.0.0.1}:${STATION_PORT:-8800}:8800"
command: ["station", "serve", "--host", "0.0.0.0", "--port", "8800", "--writable"]
volumes:
- forests:/forests
- registry:/registry
- models:/models
- documents:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8800/v1/health', timeout=4)"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3- The published port defaults to loopback.
127.0.0.1:8800:8800is what you want on a laptop, and on a server it is the difference between a console reachable over TLS and an API answering in the clear on the public IP. A reverse proxy reaches the container over the docker network, not over the host port. --writableis in the command, not in an environment variable. It accepts writes, ingest and forest creation. Remove it and the Station is read-only.- The healthcheck uses urllib, not curl. The base image is
python:3.12-slim, which ships no curl. It polls/v1/healthevery 30 seconds after a 15 second grace period. restart: unless-stoppedis safe precisely because state is in the volumes and not in the container.
networks:
default:
name: ${STATION_NETWORK:-monkeyllm}
external: ${STATION_NETWORK_EXTERNAL:-false}Both fields are interpolated for one reason: a managed host routes to containers over a proxy network it created itself, and joining that network is all that separates a deployment from a laptop. There is no second compose file to keep in sync.
First boot
Starting a Station mints nothing. The registry is exactly as authoritative after boot as before it, and the first-boot log prints the console URL and nothing else.
- 1
Open the console and claim the owner seat
A Station with nobody in it shows the setup screen: pick a username and a password and you are the owner, administrator of every forest present and future, including before the first one exists. The screen also offers to start you with a demo forest, an empty one, or nothing at all. Setup exists only while the registry holds no credential, and it closes permanently the moment it is used. - 2
Or, with no browser, take a key instead
Start once with--bootstrap-key(orMONKEYLLM_STATION_BOOTSTRAP_KEY=1, for a platform UI with no argv field) and the boot mints one full-authority API key with the owner bit and prints it in the log exactly once. Only its digest is stored, so save it before the logs rotate. - 3
Create the first forest
Setup offers to do it. Otherwise the owner creates one from the console's empty state, or overPOST /v1/admin/forests, which accepts the owner on an empty registry precisely so no deployment needs a shell to become usable. For scripted provisioning the container CLI is still there.
Setup is a race you should win first
A Station published on a public URL with an unclaimed owner seat is a race against strangers. Point your domain at it and complete setup yourself before announcing the address. The first-boot log says as much, in those words.
The setup screen and the bootstrap key are two doors onto the same one-shot window: whichever you use spends it. A restart with the flag on a Station that already has a way in mints nothing.
You do not need the break-glass account
MONKEYLLM_STATION_ADMIN and MONKEYLLM_STATION_PASSWORD are a credential held in the environment, compared at login and never stored. Setting them replaces the setup screen rather than complementing it, because two doors competing for the first identity is a race nobody wants on a public URL. Reach for them when you want a credential that rotates by restarting, or to get back in after losing the owner's password.
To hand out a key from the shell, for a forest that already exists:
docker compose exec station station key \
--principal admin --forest handbook \
--caps read,query,write,tend,ingest,adminThe volumes
A crash, a rebuild or an update never loses data, because everything that matters lives in named volumes rather than in the container.
| Volume | Mounted at | Holds |
|---|---|---|
forests | /forests | Every forest: the markdown nodes, each forest's own embedded git history, the dataset .db payloads, the rebuildable _derived/ caches. |
registry | /registry | The host registry (station.db): principals, API-key digests, grants, provider configuration, audit. One SQLite file, never inside a forest. |
models | /models | GGUF weights for the optional local inference sidecars. Multi-gigabyte, deliberately out of the image and alive across updates. The Station itself never loads a weight. |
documents | /data | The container's working directory, and a place to put files the Station may mirror. Swap it for a bind mount of your own folder (./documents:/data) and set MONKEYLLM_INGEST_ROOTS=/data to turn that mode on. |
Why the working directory is an empty volume
The install lives at /app; only the working directory moves to /data. A path bug that resolves to "here" then lands in an empty volume instead of in the product's own source tree, which is exactly how an early version ingested itself.
Environment reference
The compose file reads .env natively; anything left unset falls back to the code's own default. Outside Docker nothing loads .env for you, so load it into the shell yourself with set -a; source .env; set +a.
Station
Host variables
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
MONKEYLLM_STATION_ROOT | path | optional | /forests | The forest registry directory. Already set by the image; override only for a non-Docker install. |
MONKEYLLM_STATION_REGISTRY | path | optional | /registry/station.db | The host registry SQLite file: principals, API-key digests, grants, audit. Never inside a forest. |
MONKEYLLM_STATION_ADMIN | string | optional | none | Break-glass console login, compared against the environment and never stored. Both this and the password must be set, or the password door does not exist. Setting them replaces the first-run setup screen rather than complementing it. |
MONKEYLLM_STATION_PASSWORD | string | optional | none | The password for the account above. Rotated by editing the variable and restarting. Never echoed to the log. |
MONKEYLLM_STATION_BOOTSTRAP_KEY | boolean | optional | none | Accepts 1, true, yes or on. On a Station nobody owns yet, the boot mints one full-authority API key and prints it once instead of opening the setup screen. Equivalent to the --bootstrap-key flag. |
MONKEYLLM_STATION_ALLOWED_HOSTS | string | optional | localhost,localhost:8800,127.0.0.1,127.0.0.1:8800 | Comma separated hosts the MCP surface answers to (DNS-rebinding protection). The default shown is what the compose file passes. Add your public host when serving through a proxy, or * to skip the check entirely. Every request still needs an API key. REST and the Studio are unaffected. |
MONKEYLLM_STATION_WARM | boolean | optional | on | Open every forest at boot so the first call is not measuring cold SQLite. Set 0, false, no or off (or pass --no-warm) for a registry with more forests than you want held resident. |
MONKEYLLM_INGEST_ROOTS | string | optional | empty | Directories the Station may read on a caller's behalf, separated by the OS path separator. Empty means none: uploads keep working, "mirror a host folder" is simply off. Naming the forest registry is refused and logged at boot, and a listed directory that does not exist fails the boot rather than the ingest. |
MONKEYLLM_STATION_IMPORT_MAX_MB | number | optional | no cap | Size ceiling for an uploaded snapshot bundle on the admin import route. Enforced while the body streams, not after it lands. |
MONKEYLLM_STATION_CLIPPER_DIR | path | optional | the sibling app directory | Where the Station finds the Clipper build it hands out. Only for a deployment that stages that artifact elsewhere. |
Models
A provider configured here arrives pre-configured and read-only in the console's Models view, marked as coming from the environment. The key is never copied into the registry: you rotate it by editing the variable and restarting, and a console operator is never asked to paste it.
Model variables
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
MONKEYLLM_LLM_ENDPOINT | url | optional | empty | Base URL of an OpenAI compatible chat API, including the /v1. Empty means no provider from the environment; models can still be configured in the console. |
MONKEYLLM_LLM_API_KEY | string | optional | no-key | Sent as a bearer token. Never copied into the registry: rotate by editing the variable and restarting. |
MONKEYLLM_LLM_PROVIDER | string | optional | the host name | What to call this provider in the console. Purely a label. |
MONKEYLLM_LLM_MODEL | string | optional | local | The model id. local asks the endpoint's /models and takes the first entry, which is right for a single-model server and wrong for a hosted catalog. Always name it explicitly with a provider like OpenRouter. |
MONKEYLLM_LLM_MAX_TOKENS | number | optional | 300 | Completion budget for the curation client. Raise it for verbose or reasoning models. |
MONKEYLLM_LLM_REASONING | string | optional | off | on requests reasoning tokens and adds 1000 to the token budget to leave room for the thinking. Off, the client asks OpenRouter to disable reasoning, because hybrid thinkers otherwise spend the whole budget and return empty content. |
MONKEYLLM_EMBED_ENDPOINT | url | optional | empty | Embedding server for the optional vector layer. Unset keeps entry search on its BM25-only contract, which is a supported configuration, not a degraded one. |
MONKEYLLM_EMBED_MODEL | string | optional | bge-m3 | Embedding model id. Switching it invalidates the index: rebuild the canopy afterwards. |
MONKEYLLM_EMBED_API_KEY | string | optional | no-key | Bearer token for the embedding endpoint. |
MONKEYLLM_EMBED_PROVIDER | string | optional | the host name | Label for the embedding provider. Same endpoint and key as the chat one means one provider in the console; different ones mean two, deliberately. |
MONKEYLLM_S3_ENDPOINT | url | optional | empty | S3-compatible endpoint for remote payloads (MinIO, R2). |
Compose knobs
These are read by Compose itself when it interpolates the file, not by the application.
Compose variables
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
STATION_BIND | string | optional | 127.0.0.1 | The host address the published port binds to. Loopback by default: on a server a reverse proxy reaches the container over the docker network, never the host port. Set 0.0.0.0 only when you actually want it on the LAN. |
STATION_PORT | number | optional | 8800 | The published host port. The container always listens on 8800 internally. |
STATION_NETWORK | string | optional | monkeyllm | The docker network everything joins. On a managed host, name the proxy network it already created. |
STATION_NETWORK_EXTERNAL | boolean | optional | false | Whether that network already exists. true plus the name above is the entire difference between this file on a laptop and the same file in production. |
COMPOSE_PROFILES | string | optional | empty | Compose reads this on its own, which is how you turn on the sidecars on a platform with no --profile flag. For example local-embed or local-llm,local-embed. |
LLAMA_CHAT_HF | string | optional | bartowski/Qwen2.5-7B-Instruct-GGUF:Q4_K_M | Hugging Face repo and quantization the chat sidecar downloads on first start. |
LLAMA_EMBED_HF | string | optional | gpustack/bge-m3-GGUF:Q8_0 | Same, for the embedding sidecar. |
LLAMA_CTX | number | optional | 8192 | Context size passed to the chat sidecar. |
LLM_PORT | number | optional | 8090 | Host port for the chat sidecar. |
EMBED_PORT | number | optional | 8091 | Host port for the embedding sidecar. |
Behind a reverse proxy
The container listens on 0.0.0.0:8800 inside its network and publishes to loopback on the host. Terminate TLS in front of it and forward everything: the console, the API and the MCP surface all live on the same origin, and splitting them buys nothing.
station.example.com {
reverse_proxy 127.0.0.1:8800
}server {
server_name station.example.com;
location / {
proxy_pass http://127.0.0.1:8800;
proxy_http_version 1.1;
# The MCP surface checks the Host header against
# MONKEYLLM_STATION_ALLOWED_HOSTS, so pass the real one.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# /mcp is streamable HTTP: do not sit on the response.
proxy_buffering off;
proxy_read_timeout 300s;
}
}One setting is not optional. The MCP surface protects itself against DNS rebinding by checking the Host header against an allow-list, so the public host has to be on it:
MONKEYLLM_STATION_ALLOWED_HOSTS=station.example.comThe 502 that does not explain itself
If the proxy runs in a container, it must share a network with the Station or it has nothing to route to: the deploy succeeds, the domain answers 502, and nothing in either log says why. Set STATION_NETWORK to the proxy's network and STATION_NETWORK_EXTERNAL=true. A proxy running directly on the host instead reaches the published port at 127.0.0.1:8800.
MCP clients then point at https://your-domain/mcp/ (streamable HTTP) with an Authorization: Bearer <key> header. Setting the allow-list to * skips the host check; every request still needs a key. REST and the Studio are unaffected either way.
Dokploy
Dokploy runs the same compose file, so the walkthrough is four steps and no second copy of anything.
- 1
Create the service
Create Service, Compose, pick the repository and branch, and leave Compose Path at./docker-compose.yml. - 2
Set the environment
In the Environment tab. The first two lines are the ones that make it a deployment rather than a laptop.dotenv# The two that make it a deployment: join the network the proxy lives on. STATION_NETWORK=dokploy-network STATION_NETWORK_EXTERNAL=true MONKEYLLM_STATION_ALLOWED_HOSTS=monkeyllm.example.com MONKEYLLM_LLM_ENDPOINT=https://openrouter.ai/api/v1 MONKEYLLM_LLM_API_KEY=sk-or-... MONKEYLLM_LLM_MODEL=google/gemma-3-12b-it - 3
Add the domain
In the Domains tab, pointing at servicestation, container port8800, HTTPS on. The platform proxy reaches the container over its own network, so the host port stays bound to loopback and the Station is never answering in the clear on the public IP. - 4
Deploy, then claim the owner seat
Open your domain. The setup screen is waiting: create the owner, choose whether to start with a demo forest, and you are in. No container terminal, no key from the logs, no CLI. Complete it before announcing the URL.
No --profile flag in a platform UI
A managed host runs a plain docker compose up, which starts every service except the profiled ones. A deploy that shows only station is the profiles working as designed, not a failed sidecar. Turn them on with COMPOSE_PROFILES=local-embed in the environment tab, which Compose reads on its own.
Binding a model
The Station never loads a weight. It calls OpenAI compatible endpoints, which is what makes the model question a deployment decision rather than a hardware one. Models are bound per forest and per role:
| Role | Used for | Without it |
|---|---|---|
ingest | Curation: summaries, tags, edge proposals, branch rollups. | Summaries are derived from the opening text. Everything still ingests. |
answer | The navigating, grounded answer over the forest. | The read primitives still work; the console's answer view does not. |
vision | Describing and transcribing images at ingest, once per image. | Images plant with a stub body: format, size, and the admission that nothing has described them yet. |
embed | The optional vector layer behind hybrid entry search. | Entry search stays BM25-only, which is the contract it was designed against. |
Any OpenAI compatible endpoint
Anything that speaks the OpenAI chat completions API works: a hosted gateway, a llama.cpp server, vLLM, Ollama. Two shapes cover almost everything.
MONKEYLLM_LLM_ENDPOINT=https://openrouter.ai/api/v1
MONKEYLLM_LLM_API_KEY=sk-or-your-key-here
MONKEYLLM_LLM_MODEL=google/gemma-3-12b-it
# optional
#MONKEYLLM_LLM_PROVIDER=openrouter
#MONKEYLLM_LLM_MAX_TOKENS=300
#MONKEYLLM_LLM_REASONING=offMONKEYLLM_LLM_ENDPOINT=http://localhost:8090/v1
MONKEYLLM_LLM_API_KEY=no-key
MONKEYLLM_LLM_MODEL=localThree things reliably bite people with a hosted catalog:
- Name the model. Leaving
MONKEYLLM_LLM_MODELatlocalmakes the client take the first entry of the provider's/modelslist, which on OpenRouter is an arbitrary model out of hundreds. A wrong id is an HTTP 400, and the client surfaces the provider's own message. - Reasoning is off by default. Hybrid thinking models otherwise spend the completion budget on thinking and return empty content, so the client asks OpenRouter to disable it. A handful of models refuse that and answer 400: those need
MONKEYLLM_LLM_REASONING=on, which also adds 1000 tokens of headroom. - OpenRouter serves no embeddings. There is no
/v1/embeddingsthere, so hybrid entry search needs a local embedder or a different provider. Without one, entry search stays BM25-only and everything else is unaffected.
A local llama.cpp sidecar
The compose file carries llama.cpp beside the Station, behind profiles so it never starts by accident. The embedder ships active behind the local-embed profile:
# Optional local embedder (Canopy vector layer), profile `local-embed`
embed:
image: ghcr.io/ggml-org/llama.cpp:server
profiles: ["local-embed"]
environment:
LLAMA_CACHE: /models
command: >
-hf ${LLAMA_EMBED_HF:-gpustack/bge-m3-GGUF:Q8_0}
--embeddings --host 0.0.0.0 --port 8091
volumes:
- models:/models
ports:
- "${STATION_BIND:-127.0.0.1}:${EMBED_PORT:-8091}:8091"
restart: unless-stoppedThe chat sidecar ships as a commented template on the local-llm profile, because chat weights are heavy and many deployments point at an external endpoint instead. Uncomment it to use it:
# llm:
# image: ghcr.io/ggml-org/llama.cpp:server
# profiles: ["local-llm"]
# environment:
# LLAMA_CACHE: /models
# command: >
# -hf ${LLAMA_CHAT_HF:-bartowski/Qwen2.5-7B-Instruct-GGUF:Q4_K_M}
# --host 0.0.0.0 --port 8090 -c ${LLAMA_CTX:-8192}
# volumes:
# - models:/models
# ports:
# - "${STATION_BIND:-127.0.0.1}:${LLM_PORT:-8090}:8090"
# restart: unless-stoppeddocker compose --profile local-embed up -d
# and, once you have uncommented the chat service:
docker compose --profile local-llm --profile local-embed up -dThe first start downloads the GGUF from Hugging Face into the models volume and later starts reuse it. It is slow and quiet while it pulls; docker compose logs embed shows the progress. Then point the Station at the sidecars by their compose service names:
MONKEYLLM_LLM_ENDPOINT=http://llm:8090/v1
MONKEYLLM_LLM_API_KEY=no-key
MONKEYLLM_LLM_MODEL=local
MONKEYLLM_EMBED_ENDPOINT=http://embed:8091/v1
MONKEYLLM_EMBED_MODEL=bge-m3The sidecar is only half the job
The Station reads these variables at startup, so starting the sidecar is not enough: set the variable and restart the Station before entry search goes hybrid. Switching embedding models also invalidates the vector index, which records the model it was built with, so rebuild the canopy afterwards.
Hardware
The honest answer is: less than you would expect, and possibly none.
- Entry search needs no GPU at all. It is BM25 over SQLite full-text search: no embeddings, no vector database, no accelerator. That is the Phase 0 contract, not a fallback, and leaving the embedder off is a supported configuration.
- A quantized 12B fits a mid-range 12 GB card such as an RTX 3060. Cards with 8 GB work with tighter quantization or partial CPU offload through llama.cpp. For reference, the sidecar defaults are around 5 GB for the 7B Q4 chat model and under a gigabyte for the Q8 embedder.
- You can skip local hardware entirely. Point the chat binding at any OpenAI compatible endpoint and the Station needs no GPU whatsoever, because it never loads a weight in the first place.
- The benchmark rig is that same mid-range card. The published numbers were measured on a single RTX 3060, 12 GB and nothing more exotic. That is what was in the machine, not what the system requires.
The sidecars run on CPU by default, which is workable for the small navigator models. Give the chat service a GPU through deploy.resources in the compose file if you have one.
From source
Install the engine and the host, build the console once, and serve. The Station serves the bundle from apps/studio/dist; if you skip the build it answers with that exact hint instead of a console.
pip install -e . # the engine and the vine CLI
pip install -e apps/station # the host: REST, MCP, serves the Studio
cd apps/studio
npm ci && npm run build # the console, built once into apps/studio/diststation serve \
--root /forests \
--registry /registry/station.db \
--host 0.0.0.0 \
--port 8800 \
--writable| Flag | Default | Meaning |
|---|---|---|
--root | /forests | The forest registry directory. |
--registry | /registry/station.db | The host registry SQLite file. |
--host | 127.0.0.1 | Bind address. |
--port | 8800 | Listening port. |
--writable | off | Accept writes, ingest and forest creation. Reads always work. |
--no-warm | off | Do not open every forest at boot. |
--bootstrap-key | off | Mint and print the first key on a Station nobody owns yet. |
git is a runtime dependency
Writes are git commits inside the forest and the engine shells out to the git binary, so a writable Station cannot run without it. The image installs it; a bare-metal install needs it on the PATH.
Operations
Backups: the forest is git
Each forest is a git repository, embedded, with every plant, graft and curated summary as a commit. That is not a metaphor for the backup story, it is the backup story: a snapshot packages one forest as a git bundle carrying its full history, and restoring it is a clone.
# one forest, full git history, as a bundle
docker compose exec station vine snapshot create --forest /forests/handbook
# with the dataset payloads alongside it
docker compose exec station vine snapshot create --forest /forests/handbook \
--with-payloads
# restore it somewhere else
vine snapshot restore handbook.bundle --forest ./handbook \
--payloads handbook-payloads.zipOperationally that means three things worth writing down:
- A bundle is a single file with all of history. You can restore to any commit, diff two points in time, and read the entire audit of who wrote what with
git log. A vector index has no equivalent of this. - Dataset payloads are not in the bundle. The
.dbfiles are binary payloads beside the markdown, so pass--with-payloadsto get the sidecar zip and--payloadsto restore it. A restore without it gives you every passport and no data behind them. - Back up the registry with the snapshots. Grants and key digests belong with the forests they govern, and the registry is one SQLite file in its own volume. A forest restored without its grants is a forest nobody can reach.
vine snapshot create --to accepts a file:// or s3:// destination, so the bundle can be pushed straight to object storage from the same command.
Upgrades
An upgrade is a rebuild. The image is replaced, the volumes are untouched, and work continues where it was.
docker compose exec station vine snapshot create --forest /forests/<name>
docker compose pull || true
docker compose up --build -dOn a managed host it is the redeploy button, and the same rule holds: take the snapshot first. The container is disposable by design, so the only thing an upgrade can hurt is something that was not in a volume.
Read-only Station
Remove --writable from the command: line and redeploy. Reads keep working; writes, ingest and forest creation are refused up front with E_READONLY rather than failing somewhere deeper.
There is one deliberate exception: POST /v1/admin/reindex is still served, because a catalog rebuild only writes the disposable _derived/ layer. An index the Station could never repair would degrade forever.