Skip to content
MonkeyLLMDocs

Operate

Deploy

The Station is the self-hostable host: one container that serves the REST API, the MCP surface and the Studio console. Everything worth keeping lives in named volumes, so the container itself is disposable.

On this page(21)

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.

Station surfaces by path
PathSurface
/The Studio console, served as static files.
/v1The REST API: primitives, jobs, administration. See REST API.
/mcpThe MCP surface over streamable HTTP. See MCP server.
/v1/healthUnauthenticated liveness, and the two facts a console needs before anyone has signed in: whether setup is still open and whether a password door exists.
httpsoptional
Studio userbrowser
AgentMCP client
Reverse proxyTLS, your domain
stationone container, :8800
forests/forests volume
registry/registry volume
embedllama.cpp sidecar
models/models volume
The station container mounts the models volume too, so weights the sidecar downloads survive every rebuild. An agent can also skip the host entirely and speak MCP straight to the engine, with no accounts at all.

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.

bash
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 in

Then open http://localhost:8800.

The compose file, explained

yamldocker-compose.yml (the station service, abridged)
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:8800 is 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.
  • --writable is 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/health every 30 seconds after a 15 second grace period.
  • restart: unless-stopped is safe precisely because state is in the volumes and not in the container.
yamldocker-compose.yml (the network)
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. 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. 2

    Or, with no browser, take a key instead

    Start once with --bootstrap-key (or MONKEYLLM_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. 3

    Create the first forest

    Setup offers to do it. Otherwise the owner creates one from the console's empty state, or over POST /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:

bash
docker compose exec station station key \
  --principal admin --forest handbook \
  --caps read,query,write,tend,ingest,admin

The volumes

A crash, a rebuild or an update never loses data, because everything that matters lives in named volumes rather than in the container.

The Docker volumes
VolumeMounted atHolds
forests/forestsEvery forest: the markdown nodes, each forest's own embedded git history, the dataset .db payloads, the rebuildable _derived/ caches.
registry/registryThe host registry (station.db): principals, API-key digests, grants, provider configuration, audit. One SQLite file, never inside a forest.
models/modelsGGUF 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/dataThe 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

NameTypeRequiredDefaultDescription
MONKEYLLM_STATION_ROOTpathoptional/forestsThe forest registry directory. Already set by the image; override only for a non-Docker install.
MONKEYLLM_STATION_REGISTRYpathoptional/registry/station.dbThe host registry SQLite file: principals, API-key digests, grants, audit. Never inside a forest.
MONKEYLLM_STATION_ADMINstringoptionalnoneBreak-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_PASSWORDstringoptionalnoneThe password for the account above. Rotated by editing the variable and restarting. Never echoed to the log.
MONKEYLLM_STATION_BOOTSTRAP_KEYbooleanoptionalnoneAccepts 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_HOSTSstringoptionallocalhost,localhost:8800,127.0.0.1,127.0.0.1:8800Comma 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_WARMbooleanoptionalonOpen 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_ROOTSstringoptionalemptyDirectories 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_MBnumberoptionalno capSize ceiling for an uploaded snapshot bundle on the admin import route. Enforced while the body streams, not after it lands.
MONKEYLLM_STATION_CLIPPER_DIRpathoptionalthe sibling app directoryWhere 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

NameTypeRequiredDefaultDescription
MONKEYLLM_LLM_ENDPOINTurloptionalemptyBase 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_KEYstringoptionalno-keySent as a bearer token. Never copied into the registry: rotate by editing the variable and restarting.
MONKEYLLM_LLM_PROVIDERstringoptionalthe host nameWhat to call this provider in the console. Purely a label.
MONKEYLLM_LLM_MODELstringoptionallocalThe 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_TOKENSnumberoptional300Completion budget for the curation client. Raise it for verbose or reasoning models.
MONKEYLLM_LLM_REASONINGstringoptionaloffon 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_ENDPOINTurloptionalemptyEmbedding 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_MODELstringoptionalbge-m3Embedding model id. Switching it invalidates the index: rebuild the canopy afterwards.
MONKEYLLM_EMBED_API_KEYstringoptionalno-keyBearer token for the embedding endpoint.
MONKEYLLM_EMBED_PROVIDERstringoptionalthe host nameLabel 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_ENDPOINTurloptionalemptyS3-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

NameTypeRequiredDefaultDescription
STATION_BINDstringoptional127.0.0.1The 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_PORTnumberoptional8800The published host port. The container always listens on 8800 internally.
STATION_NETWORKstringoptionalmonkeyllmThe docker network everything joins. On a managed host, name the proxy network it already created.
STATION_NETWORK_EXTERNALbooleanoptionalfalseWhether 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_PROFILESstringoptionalemptyCompose 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_HFstringoptionalbartowski/Qwen2.5-7B-Instruct-GGUF:Q4_K_MHugging Face repo and quantization the chat sidecar downloads on first start.
LLAMA_EMBED_HFstringoptionalgpustack/bge-m3-GGUF:Q8_0Same, for the embedding sidecar.
LLAMA_CTXnumberoptional8192Context size passed to the chat sidecar.
LLM_PORTnumberoptional8090Host port for the chat sidecar.
EMBED_PORTnumberoptional8091Host 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
}

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:

dotenv
MONKEYLLM_STATION_ALLOWED_HOSTS=station.example.com

The 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. 1

    Create the service

    Create Service, Compose, pick the repository and branch, and leave Compose Path at ./docker-compose.yml.
  2. 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. 3

    Add the domain

    In the Domains tab, pointing at service station, container port 8800, 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. 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:

Model roles
RoleUsed forWithout it
ingestCuration: summaries, tags, edge proposals, branch rollups.Summaries are derived from the opening text. Everything still ingests.
answerThe navigating, grounded answer over the forest.The read primitives still work; the console's answer view does not.
visionDescribing 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.
embedThe 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=off

Three things reliably bite people with a hosted catalog:

  • Name the model. Leaving MONKEYLLM_LLM_MODEL at local makes the client take the first entry of the provider's /models list, 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/embeddings there, 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:

yamldocker-compose.yml (the embed service)
  # 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-stopped

The 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:

yamldocker-compose.yml (the llm service, commented out)
#  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-stopped
bash
docker compose --profile local-embed up -d
# and, once you have uncommented the chat service:
docker compose --profile local-llm --profile local-embed up -d

The 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:

dotenv.env
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-m3

The 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.

bash
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/dist
bash
station serve \
  --root /forests \
  --registry /registry/station.db \
  --host 0.0.0.0 \
  --port 8800 \
  --writable
Station command-line flags
FlagDefaultMeaning
--root/forestsThe forest registry directory.
--registry/registry/station.dbThe host registry SQLite file.
--host127.0.0.1Bind address.
--port8800Listening port.
--writableoffAccept writes, ingest and forest creation. Reads always work.
--no-warmoffDo not open every forest at boot.
--bootstrap-keyoffMint 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.

bash
# 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.zip

Operationally 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 .db files are binary payloads beside the markdown, so pass --with-payloads to get the sidecar zip and --payloads to 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.

bash
docker compose exec station vine snapshot create --forest /forests/<name>
docker compose pull || true
docker compose up --build -d

On 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.

Where to go next

Documents MonkeyLLM v0.1.0. Last reviewed against the engine source on .