CubePlex on Docker Compose
docker compose -f compose.yaml -f compose.tempo.yaml up -d deploys CubePlex
(backend + frontend + Postgres + Redis + rustfs S3 store + OpenSandbox +
Tempo) on a single host. It uses the same container images as the Kubernetes
deployment mode — only the orchestration differs. scripts/up.sh passes both
files unless .env has TEMPO_ENABLED=false.
1. Prerequisites
| Item | Requirement |
|---|---|
| Linux host with Docker engine | ≥ 24, with docker compose v2 |
| Outbound network to pull images | ghcr.io and Docker Hub (or your own mirror) |
| LLM provider credentials | at least one — see LLM provider configuration |
| Open ports on the host | one for the frontend (default 3000), optionally one for the backend (default 8000) |
No Kubernetes, no Helm.
2. Architecture
Host
├─ port :3000 → frontend (Next.js) ── proxies /api/* server-side ──┐
│ │
└─ port :8000 → backend (FastAPI / uvicorn) ◄─────────────────────┘
├─ depends on → postgres (named volume)
├─ depends on → redis (named volume)
├─ depends on → rustfs (S3 store, named volume)
└─ tempo on internal `tracing` network (no host port)
Bootstrap services (run-to-completion):
backend-migrate lock + schema/cutover verification (gates backend boot)
bucket-init mc mb (idempotent rustfs bucket create)
All inter-service communication uses Docker DNS (for example, the backend
reaches Postgres at postgres:5432). The host only sees the frontend port
(and optionally the backend port, for direct API access). Tempo is not
published; only backend can reach :3200 / :4318 on the internal
tracing network.
3. Choose images
The compose stack defaults to the public prebuilt release images on GHCR — no build step required:
ghcr.io/cubeplexai/cubeplex-backend:<version>
ghcr.io/cubeplexai/cubeplex-frontend:<version>
Pick a version tag from the
releases page — backend and
frontend share one app version, e.g. v0.9.3 — and set it as BACKEND_TAG /
FRONTEND_TAG in the next step. IMAGE_REGISTRY / IMAGE_REPO already default
to ghcr.io / cubeplexai, so you don't need to change them for a standard
install. GHCR release images are public — no docker login needed.
Build your own images instead (private registry / air-gapped / patched)
Use the Kubernetes mode's build script — the backend and frontend images are identical either way:
deploy/kubernetes/scripts/build-and-push.sh
# pushes to ${REGISTRY}/${REPO}/cubeplex-{backend,frontend}:<YYMMDD>-<branch>-<short-sha>
Then point IMAGE_REGISTRY, IMAGE_REPO, BACKEND_TAG, and FRONTEND_TAG in
.env at that build.
4. Configure (.env + OpenSandbox + two YAML files)
Four files, all gitignored:
| File | What it does |
|---|---|
.env | Image tags, host port mappings, infra passwords. Read directly by docker compose for variable substitution in compose.yaml. |
config/config.production.local.yaml | Non-secret runtime config (mode, public URL, cookie security, sandbox toggle). Mounted into the backend. |
config/config.production.secrets.yaml | Secrets — JWT/CSRF/vault material, infra passwords (must match .env), LLM provider API keys. Mounted into the backend. |
config/opensandbox.toml | OpenSandbox server configuration. Its API key must match sandbox.api_key in the backend secrets file. |
This section covers the keys needed to boot. For every backend config key, the layering rules, and the env-var mapping, see the backend configuration reference.
4.1 .env
cp .env.example .env
$EDITOR .env
Required:
IMAGE_REGISTRY=ghcr.io
IMAGE_REPO=cubeplexai
BACKEND_TAG=v0.9.3 # a release version from the releases page
FRONTEND_TAG=v0.9.3
# openssl rand -hex 16
POSTGRES_PASSWORD=<...>
REDIS_PASSWORD=<...>
RUSTFS_SECRET_KEY=<...>
Optional (defaults shown):
BACKEND_PORT=8000
FRONTEND_PORT=3000
POSTGRES_USER=cubeplex
POSTGRES_DB=cubeplex
RUSTFS_ACCESS_KEY=cubeplex
OBJECTSTORE_BUCKET=cubeplex
4.2 config.production.local.yaml
cp config/config.production.local.yaml.example config/config.production.local.yaml
$EDITOR config/config.production.local.yaml
| Field | Default | Notes |
|---|---|---|
api.public_url | http://localhost:8000 | The URL clients reach the backend at; if you put a reverse proxy in front, use that URL. |
public_base_url | same | Used by the backend for absolute URL construction. |
frontend_base_url | http://localhost:3000 | Where the backend redirects browsers. |
deployment.mode | single_tenant | single_tenant auto-creates an org on first user registration; multi_tenant requires explicit org bootstrap. |
auth.cookie_secure | false | Must stay false on plain HTTP — otherwise clients silently drop the auth cookie. |
sandbox.enabled | true | Required. The backend refuses to start unless it remains true and sandbox.{domain,image,api_key} are all configured. |
database.host, redis.url, and objectstore.endpoint use Docker DNS names
(postgres, redis, rustfs) — don't change them unless you renamed the
services.
4.3 config.production.secrets.yaml
cp config/config.production.secrets.yaml.example config/config.production.secrets.yaml
$EDITOR config/config.production.secrets.yaml
Required:
production:
auth:
jwt_secret: "<openssl rand -hex 32>"
csrf_secret: "<openssl rand -hex 32>"
vault_key: "<Fernet.generate_key()>"
database:
password: "<same as POSTGRES_PASSWORD>"
redis:
url: "redis://:<REDIS_PASSWORD>@redis:6379/0"
objectstore:
access_key: "cubeplex" # same as RUSTFS_ACCESS_KEY
access_secret: "<RUSTFS_SECRET_KEY>"
See Required secrets for what
jwt_secret, csrf_secret, and vault_key are for and how to generate
them.
4.4 LLM providers
Configured under production.llm in config.production.secrets.yaml — see
LLM provider configuration for
the full field reference and examples.
5. Up / down / logs
# bring up (also pulls the latest tags; includes Tempo unless TEMPO_ENABLED=false)
deploy/docker-compose/scripts/up.sh
# equivalent raw compose (always pass both files for the default install)
docker compose -f deploy/docker-compose/compose.yaml \
-f deploy/docker-compose/compose.tempo.yaml up -d
# tail logs
docker compose -f deploy/docker-compose/compose.yaml \
-f deploy/docker-compose/compose.tempo.yaml logs -f backend
# stop and remove containers (volumes preserved)
docker compose -f deploy/docker-compose/compose.yaml \
-f deploy/docker-compose/compose.tempo.yaml down
docker compose … down -v stops and deletes volumes (Postgres data,
rustfs data, Redis data, Tempo data) — destructive, use only when you intend
to wipe the deployment.
up.sh refuses to start if .env, either YAML config file, or
config/opensandbox.toml is missing.
Upgrading a database from before the task-lifecycle cutover
The normal backend-migrate container deliberately refuses to change an
existing pre-cutover database. The backfill must run while no old backend can
create commands or notifications:
# Use the new BACKEND_TAG, then stop every old API/worker in this stack.
docker compose -f deploy/docker-compose/compose.yaml \
-f deploy/docker-compose/compose.tempo.yaml stop backend frontend
# One process holds the database migration lock, backfills and verifies, then
# applies the final constraint. The command is safe to rerun after a failure.
docker compose -f deploy/docker-compose/compose.yaml \
-f deploy/docker-compose/compose.tempo.yaml run --rm backend-migrate \
python -m cubeplex.scripts.lifecycle_upgrade --maintenance
# Normal startup now performs a read-only cutover check and starts the backend.
deploy/docker-compose/scripts/up.sh
Do not use --maintenance while an old backend is running. A reported active
run-lifetime command or legacy monitor must finish or be explicitly stopped.
The same applies to an active command whose original sandbox instance or
provider process handle was not recorded: resolve or stop it before retrying.
The migrator does not guess that it ended and does not attach it to a
replacement sandbox.
6. Verification
# fast health-only check
deploy/docker-compose/scripts/smoke-test.sh
# end-to-end including a real LLM call
PROMPT="Say the word hello and nothing else." \
deploy/docker-compose/scripts/e2e.sh
e2e.sh drives:
register → login → resolve workspace → create conversation
→ POST message → SSE stream → assert text_delta arrived
On a fresh deployment the first registered user has no workspace yet, so the script runs onboarding to create one; later users already have one from registration.
Both scripts default to localhost; override with HOST, BACKEND_PORT,
FRONTEND_PORT to run against a remote host.
Prometheus metrics
The backend serves /metrics on its mapped BACKEND_PORT (default 8000).
The Compose service has prometheus.io/* labels for Docker service discovery;
labels alone do not configure a Prometheus scrape job. A Prometheus container
on the same Compose network can use:
scrape_configs:
- job_name: cubeplex-backend
static_configs:
- targets: ["backend:8000"]
From the host, verify with curl http://localhost:${BACKEND_PORT:-8000}/metrics.
Because the backend port is mapped to the host, restrict access to that port
when the metrics endpoint should stay internal. See the
metric list for names and counting rules.
7. Troubleshooting
Backend keeps restarting
docker compose -f deploy/docker-compose/compose.yaml logs backend --tail=50
| Symptom | Fix |
|---|---|
CUBEPLEX_AUTH__VAULT_KEY is required | Add auth.vault_key in secrets.yaml. |
connection refused on postgres:5432 | Postgres is still starting; should self-heal — check docker compose ps. |
Provider 'X' not found | default_model: "X/..." references a provider not listed under providers. |
Login cookie missing on HTTP
config.production.local.yaml's auth.cookie_secure must be false —
otherwise the browser (or curl) silently drops the auth cookie because the
connection is plain HTTP.
Frontend → backend fails (CORS / 502)
compose.yaml sets CUBEPLEX_API_URL=http://backend:8000 on the frontend
container, so Next.js proxies /api/* server-side over the Docker network.
If you changed service names, update that env var too.
Image pull fails
The default GHCR release images are public — no login needed. Check that
BACKEND_TAG / FRONTEND_TAG name a real release tag (see the
releases page); a
manifest unknown / not found error means the tag doesn't exist.
If you pointed IMAGE_REGISTRY at a private mirror instead:
docker login ${IMAGE_REGISTRY}
The compose stack inherits the Docker daemon's credentials.
Stuck bucket-init
docker compose -f deploy/docker-compose/compose.yaml logs bucket-init
If rustfs isn't reachable, check the rustfs container's healthcheck — rustfs
publishes a console on :9001 you can hit locally to confirm it's up.
8. Required OpenSandbox runtime
CubePlex executes agent tool calls (bash, file read/write, …) inside a sandbox. The base compose file deploys alibaba's OpenSandbox lifecycle server in Docker runtime mode.
Service
The base compose stack includes one service:
opensandbox-server image: opensandbox/server:latest
mounts: /var/run/docker.sock
reads: /etc/opensandbox/config.toml
port: 8090
The OpenSandbox server is a normal Python/FastAPI container. When it
receives POST /sandboxes, it talks to the host Docker daemon via the
mounted socket to spawn sibling sandbox containers (not nested) — they
run on the same Docker engine as CubePlex, on a separate bridge network.
Anything inside the opensandbox-server container can effectively root the
host via the Docker socket. Keep it on your private network — don't expose
port 8090 publicly.
Quickstart
cd deploy/docker-compose
# 1. OpenSandbox config (gitignored)
cp config/opensandbox.toml.example config/opensandbox.toml
$EDITOR config/opensandbox.toml # set api_key, eip/host_ip, execd_image, egress.image
# 2. backend secrets — sandbox section
$EDITOR config/config.production.secrets.yaml
# sandbox:
# domain: "opensandbox-server:8090" # Docker DNS name from this overlay
# image: "ghcr.io/cubeplexai/cubeplex-sandbox:v0.9.3"
# api_key: "<same as [server].api_key in opensandbox.toml>"
# 3. backend non-secret settings (the example already enables sandbox)
$EDITOR config/config.production.local.yaml
# sandbox:
# enabled: true
# secure_access: false # docker runtime rejects secureAccess=True
# use_server_proxy: false # OpenSandbox v0.1.x drops the port from
# # proxied URLs; use direct host-mapped ports
# 4. start the complete stack
docker compose up -d
Operator-managed values (no template):
| Key | Where | Notes |
|---|---|---|
opensandbox.toml [server].api_key | config/opensandbox.toml | Required; must match sandbox.api_key in CubePlex secrets. |
opensandbox.toml [server].eip | same | Host/IP returned to CubePlex in endpoint URLs; usually host.docker.internal. |
opensandbox.toml [runtime].execd_image | same | Image carrying the execd binary; pull-reachable by the host Docker daemon. |
opensandbox.toml [egress].image | same | Egress sidecar image; required because CubePlex always sends a network policy. |
opensandbox.toml [docker].network_mode | same | Must be bridge for CubePlex (see the compatibility matrix below). |
Compatibility — CubePlex features under Docker-mode OpenSandbox
Docker runtime mode has real limitations compared to Kubernetes-mode
OpenSandbox. This matrix reflects opensandbox-server v0.1.14.
Secure-access toggle: the Docker runtime rejects secureAccess=True
with HTTP 400 — secured endpoints are a Kubernetes ingress-gateway feature.
CubePlex exposes a sandbox.secure_access config knob that defaults to
true (matching Kubernetes-mode behavior); the compose mode's example
config sets it to false, so CubePlex sends secureAccess: false and the
Docker runtime accepts the request. With that flag set, chat → sandbox tool
call → tool_result works end to end.
| Feature | What works | What doesn't |
|---|---|---|
| Chat → agent tool calls (bash / file read-write in the sandbox) | Yes — chat → sandbox execute → tool_result works end to end | — |
| Network policy (egress firewall) | Yes — the egress sidecar is created and enforces the policy in DNS mode, but only when [docker].network_mode = "bridge" | Rejected when network_mode=host or a user-defined bridge network |
| File-tree panel (chat UI) | Yes | — |
| Interactive terminal panel (chat UI) | Yes — proxied through the backend's panel route | Requires api.public_url set to a URL the user's browser can reach that also forwards WebSocket |
| Live browser panel (Neko, chat UI) | Yes — same panel route as the terminal | Same api.public_url requirement |
Secret injection / env substitution (cbxref_… placeholders) | – | ❌ Not available. It needs the backend mTLS credential-exchange listener + egress CA/client certs that the Kubernetes chart deploys (gen-egress-certs.sh + the egress webhook). The compose overlay ships none of that, and sandbox.egress_exchange_host stays empty, so injection is disabled. |
Server-proxy mode (use_server_proxy: true) | – | OpenSandbox v0.1.x drops the port from the proxied endpoint URL. Keep use_server_proxy: false (the example default), and the overlay wires host.docker.internal via extra_hosts on both the backend and the opensandbox-server so they can reach the host-mapped bridge ports of sandbox containers. |
pvc.claimName volumes | Yes — but treated as Docker named volumes | No CSI features, no ReadWriteMany |
Pause / resume (POST /sandboxes/{id}/pause, etc.) | Calls Docker pause/unpause (cgroup freezer) | No checkpoint to disk — paused state is lost on host Docker restart. CubePlex defaults pause_on_idle: false for this reason. |
In short: under Docker-mode OpenSandbox, the agent can run commands,
read/write files, browse the file tree, and open the interactive terminal
and live browser panels — all through the backend's panel reverse proxy, so
set api.public_url to a browser-reachable, WebSocket-forwarding backend URL.
Only secret injection still requires the Kubernetes runtime; for that, deploy
via the Kubernetes guide.
The following routes also return 501 Not Implemented on Docker runtime, even
though they exist in the OpenAPI spec (CubePlex doesn't call any of them
today): POST /pools and related pre-warmed pod pools, and the snapshot
APIs (POST /sandboxes/{id}/snapshots, etc.) — both are Kubernetes-only.
Verifying
docker compose -f compose.yaml ps
# expect: opensandbox-server Up (healthy)
Direct API probe (from inside the backend container, using Docker DNS):
docker exec cubeplex-backend-1 python -c "
import urllib.request, json
req = urllib.request.Request(
'http://opensandbox-server:8090/sandboxes',
headers={'OPEN-SANDBOX-API-KEY': '<your api_key>'},
)
print(urllib.request.urlopen(req, timeout=5).read().decode())
"
# expect: {"items":[], ...}
End-to-end (CubePlex chat → sandbox tool call) works once
config.production.local.yaml has sandbox.enabled: true and
sandbox.secure_access: false and sandbox.use_server_proxy: false. A
prompt like ls -la /workspace should produce a real tool_result
containing the sandbox filesystem contents.
Tearing down
docker compose -f compose.yaml down
The MITM CA and any sandbox containers spawned by the server stay on the
host Docker engine — they aren't part of this project's compose network.
Inspect with docker ps --filter "name=sandbox-".
Tempo tracing (default on)
scripts/up.sh starts Grafana Tempo 3.0.3 unless .env has
TEMPO_ENABLED=false. A raw docker compose command must pass both files:
docker compose -f compose.yaml -f compose.tempo.yaml up -d
Tempo is only on the internal tracing network (backend joins it; frontend,
postgres, and OpenSandbox do not). There is no host port in the default
files. Tempo's query API has no authentication — that network is the control,
not tenant isolation. Retention is 7 days. record_content is false. JSONL
on-disk export is off (CUBEPLEX_TRACING__JSONL__ENABLED=false).
Admin traces at /admin/traces return 503 when Tempo is disabled or
query_endpoint is still null.
Disable: omit compose.tempo.yaml, or set TEMPO_ENABLED=false for up.sh.
That drops both the Tempo container and the CUBEPLEX_TRACING__* env, so a
tracing.enabled: false in local yaml is not overridden.
BYO: disable the overlay, then set tracing.otlp.endpoint and
tracing.tempo.query_endpoint in config.production.local.yaml.
Debug-only localhost publish — never the default install:
docker compose \
-f compose.yaml \
-f compose.tempo.yaml \
-f compose.tempo.publish.yaml \
up -d
That binds 127.0.0.1:3200. Do not interpolate an optional host port onto a
ports: list in the default compose files.
Optional: document parsing (docling-serve)
The backend's read tool converts uploaded PDF / office documents to
markdown by calling a docling-serve
instance. Without it, other file types still work but document parsing
doesn't. The optional compose.docling.yaml overlay supports two
deployment shapes.
Combined: same host, same Docker network
cd deploy/docker-compose
docker compose \
-f compose.yaml \
-f compose.docling.yaml \
--profile cpu \
up -d
Backend reaches it as docling-serve-cpu:5001 over Docker DNS — no manual
network bridging needed. Use --profile gpu instead for the CUDA image
(docling-serve-cu130, requires the NVIDIA container runtime on the host).
Standalone: a separate host
Copy just compose.docling.yaml to its own host — for example a
dedicated GPU box shared by multiple projects — and run it there on its
own:
docker compose -f compose.docling.yaml --profile gpu up -d
Then point CubePlex's backend at that host from wherever it runs.
Configure the backend
Either way, always pass --profile cpu or --profile gpu — neither
docling-serve-* service starts without one (the model-download job runs
for either). Set the resulting URL in config.production.local.yaml:
parsers:
docling_serve:
base_url: "http://docling-serve-cpu:5001" # combined, --profile cpu
# base_url: "http://docling-serve-cu130:5001" # combined, --profile gpu
# base_url: "http://<standalone host>:<port>" # standalone deployment
Model download and registries
The docling-models service downloads the model set (layout, table former,
OCR, and VLM models — several GB) into a named volume on first start, and
reuses it on restart. Both docling-serve-cpu and docling-serve-cu130
wait for it to finish before serving.
If the default GHCR registry or HuggingFace are slow or blocked from your build host, override before starting:
# Alternate image registry (quay.io mirror, or a China mainland sync — verify before production use)
export DOCLING_REGISTRY=quay.io/docling-project
# or: export DOCLING_REGISTRY=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/ghcr.io/docling-project
# HuggingFace mirror for model downloads
export HF_ENDPOINT=https://hf-mirror.com
# HF_TOKEN=hf_xxx # only needed for gated/private repos