Skip to main content

CubePlex on Docker Compose

docker compose up -d deploys CubePlex (backend + frontend + Postgres + Redis + rustfs S3 store) on a single host. It uses the same container images as the Kubernetes deployment mode — only the orchestration differs.

1. Prerequisites

ItemRequirement
Linux host with Docker engine≥ 24, with docker compose v2
Outbound network to pull imagesghcr.io and Docker Hub (or your own mirror)
LLM provider credentialsat least one — see LLM provider configuration
Open ports on the hostone 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)

Bootstrap services (run-to-completion):
backend-migrate alembic upgrade head (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).

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.2.0 — 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 + two YAML files)

Three files, all gitignored:

FileWhat it does
.envImage tags, host port mappings, infra passwords. Read directly by docker compose for variable substitution in compose.yaml.
config/config.production.local.yamlNon-secret runtime config (mode, public URL, cookie security, sandbox toggle). Mounted into the backend.
config/config.production.secrets.yamlSecrets — JWT/CSRF/vault material, infra passwords (must match .env), LLM provider API keys. Mounted into the backend.

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.2.0 # a release version from the releases page
FRONTEND_TAG=v0.2.0

# 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
FieldDefaultNotes
api.public_urlhttp://localhost:8000The URL clients reach the backend at; if you put a reverse proxy in front, use that URL.
public_base_urlsameUsed by the backend for absolute URL construction.
frontend_base_urlhttp://localhost:3000Where the backend redirects browsers.
deployment.modesingle_tenantsingle_tenant auto-creates an org on first user registration; multi_tenant requires explicit org bootstrap.
auth.cookie_securefalseMust stay false on plain HTTP — otherwise clients silently drop the auth cookie.
sandbox.enabledfalseFlip to true and fill sandbox.{domain,image,api_key} in secrets.yaml to use an external OpenSandbox. See Optional: sandbox execution below.
note

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)
deploy/docker-compose/scripts/up.sh

# tail logs
docker compose -f deploy/docker-compose/compose.yaml logs -f backend

# stop and remove containers (volumes preserved)
docker compose -f deploy/docker-compose/compose.yaml down
warning

docker compose -f deploy/docker-compose/compose.yaml down -v stops and deletes volumes (Postgres data, rustfs data, Redis data) — destructive, use only when you intend to wipe the deployment.

up.sh refuses to start if .env or either YAML config file is missing.

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 → single-tenant auto-setup → create conversation
→ POST message → SSE stream → assert text_delta arrived

Both scripts default to localhost; override with HOST, BACKEND_PORT, FRONTEND_PORT to run against a remote host.

7. Troubleshooting

Backend keeps restarting

docker compose -f deploy/docker-compose/compose.yaml logs backend --tail=50
SymptomFix
CUBEPLEX_AUTH__VAULT_KEY is requiredAdd auth.vault_key in secrets.yaml.
connection refused on postgres:5432Postgres is still starting; should self-heal — check docker compose ps.
Provider 'X' not founddefault_model: "X/..." references a provider not listed under providers.

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.

Optional: sandbox execution (OpenSandbox)

CubePlex executes agent tool calls (bash, file read/write, …) inside a sandbox. Without it, chat still works but tool calls fail. This section covers deploying alibaba's OpenSandbox lifecycle server in Docker runtime mode alongside the compose stack.

If you only need CubePlex chat without agent tool calls, skip this section and leave sandbox.enabled: false in config.production.local.yaml.

What the overlay deploys

The optional compose.opensandbox.yaml overlay adds 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.

danger

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.2.0"
# api_key: "<same as [server].api_key in opensandbox.toml>"

# 3. backend non-secret — enable sandbox + force server proxy
$EDITOR config/config.production.local.yaml
# sandbox:
# enabled: true
# use_server_proxy: true # required: docker bridge endpoints
# # rewrite via the server gateway

# 4. up with the overlay
docker compose \
-f compose.yaml \
-f compose.opensandbox.yaml \
up -d

Operator-managed values (no template):

KeyWhereNotes
opensandbox.toml [server].api_keyconfig/opensandbox.tomlRequired; must match sandbox.api_key in CubePlex secrets.
opensandbox.toml [server].eipsameHost/IP returned to CubePlex in endpoint URLs; usually host.docker.internal.
opensandbox.toml [runtime].execd_imagesameImage carrying the execd binary; pull-reachable by the host Docker daemon.
opensandbox.toml [egress].imagesameEgress sidecar image; required because CubePlex always sends a network policy.
opensandbox.toml [docker].network_modesameMust 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.

FeatureWhat worksWhat doesn't
Network policy (egress firewall)Yes — but only when [docker].network_mode = "bridge"Rejected when network_mode=host or a user-defined bridge network
Signed endpoint URLs (expires=…)Not implemented for Docker mode; CubePlex doesn't use this today
Server-proxy mode (use_server_proxy: true)OpenSandbox v0.1.x drops the port from the proxied endpoint URL. The example config uses use_server_proxy: false instead, and the overlay wires host.docker.internal via extra_hosts so the backend can reach the host-mapped bridge ports of sandbox containers.
pvc.claimName volumesYes — but treated as Docker named volumesNo 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.

The following routes 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 -f compose.opensandbox.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 -f compose.opensandbox.yaml down
# This also removes the CubePlex stack. Use `down opensandbox-server`
# to remove only the overlay's service.

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

Optional: document parsing (docling-serve)

The backend's file_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