Skip to main content

Deploying oc8 with Docker Compose

A single docker compose up brings up the whole stack — Postgres/pgvector, Redis, the backend API plus its worker/ingestion-worker/scheduler, the built frontend, and a Caddy reverse proxy that is the one external origin — for testing on a server. It is not a production deployment; see Deliberate limits at the end.

Kubernetes? See Helm chart for the same stack on a cluster. The chart source lives in the oc8 repository.

Prerequisites

  • A host with Docker and Compose v2 (docker compose version) -- or Podman (v4+, with the podman compose plugin) as a drop-in alternative, see below.
  • Network egress from the host: the agent sandbox pulls its container image at run time, and the images build from public registries.
  • For real agent runs the backend and worker mount the host's container-runtime socket (/var/run/docker.sock by default), which is root-equivalent — use a dedicated or disposable host, not one you share with anything you care about.

Running on Podman instead of Docker

scripts/quickstart.sh/.ps1 accept OC8_CONTAINER_RUNTIME=podman (default is docker) to orchestrate the stack with podman compose instead of docker compose, and to point the backend/worker socket mount at Podman's API socket instead of Docker's -- everything else about the stack, including the sandbox driver code, is unchanged (see the Podman section below).

Enable Podman's API socket first:

  • macOS: podman machine init && podman machine start (the socket comes up automatically with the machine).
  • Linux: systemctl --user enable --now podman.socket.

Then run OC8_CONTAINER_RUNTIME=podman ./scripts/quickstart.sh (or set OC8_CONTAINER_RUNTIME=podman once in .env to make it the default for this checkout). The script fills in OC8_CONTAINER_SOCKET for you.

One security difference worth knowing: rootless Podman's socket is scoped to the invoking user's own containers, not root-equivalent the way the Docker daemon socket is -- so the warning above applies to Docker and to rootful Podman, but not to rootless Podman.

For local frontend-only work, run commands from the frontend workspace:

cd frontend
npm run lint
npm run build

First bring-up

cp .env.example .env

# Fill the three required secrets:
sed -i "s|^OC8_JWT_SECRET=.*|OC8_JWT_SECRET=$(openssl rand -hex 32)|" .env
sed -i "s|^OC8_SECRET_KEK=.*|OC8_SECRET_KEK=$(openssl rand -base64 32)|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 16)|" .env

# (macOS sed: use `sed -i ''` instead of `sed -i`.)

docker compose up -d --build
docker compose logs -f migrate # watch it apply migrations, seed, then exit 0

The migrate service runs alembic upgrade head and then seeds the ACME demo tenant (OC8_SEED_ON_START=true). The API, worker, ingestion-worker and scheduler all wait for it to finish, so migration runs exactly once.

If port 80 is taken, set OC8_HTTP_PORT in .env (e.g. 8090) and re-up.

Verify

docker compose ps # migrate = exited(0); the rest running/healthy
curl -s http://<host>/health # -> {"status":"ok","version":"..."}

Then open http://<host>/ in a browser, log in via dev-login (available because OC8_ENV defaults to dev; setting it to prod disables dev-login), and the Office screen shows the ACME departments — that is the seed data, proving the whole path (frontend → relative /api/v1 → backend → Postgres) works through the single Caddy origin, with no CORS.

Giving agents a model

The app runs without a model, but an actual agent run needs one. Either:

  • A cloud key — set OC8_ANTHROPIC_API_KEY (or OC8_OPENAI_API_KEY / OC8_MISTRAL_API_KEY) in .env and re-up; or
  • Local Ollama
    docker compose --profile ollama up -d
    docker compose exec ollama ollama pull mistral
    CPU-only Ollama is slow but works for testing. For a GPU host, add a deploy.resources.reservations.devices block to the ollama service per the Ollama docs.

Kubernetes (Helm)

For clusters, use the chart in deploy/helm/oc8/ (same components as Compose).

# Build and push/load images first — see deploy/helm/oc8/README.md
export OC8_JWT_SECRET=$(openssl rand -hex 32)
export OC8_SECRET_KEK=$(openssl rand -base64 32)
export POSTGRES_PASSWORD=$(openssl rand -hex 16)

helm upgrade --install oc8 ./deploy/helm/oc8 \
--namespace oc8 --create-namespace \
--set secrets.jwtSecret="$OC8_JWT_SECRET" \
--set secrets.secretKek="$OC8_SECRET_KEK" \
--set secrets.postgresPassword="$POSTGRES_PASSWORD"

Capas are not baked into the image — mount capas/ via backend.capas.hostPath or a PVC. Agent sandboxes (Docker socket) are off by default on Kubernetes.

HTTPS

For a real domain, edit Caddyfile: replace :80 with your hostname (e.g. oc8.example.com), add "443:443" to the caddy service's ports in docker-compose.yml, and re-up. Caddy provisions and renews a Let's Encrypt certificate automatically. Point the domain's DNS at the host first.

Enabling the audit MAC (advanced, off by default)

OC8_AUDIT_MAC_ENABLED keys the audit hash chain. It is off by default and not part of a normal bring-up. Before enabling it, understand that this is a one-way door: once a tenant is keyed, a rollback to a build without the setting stops writes, and turning the flag on over an existing chain requires docker compose run --rm backend oc8 audit-adopt-checkpoints followed by a full verify.

Pruning run evidence (off by default)

Every agent run leaves a session folder on disk — the transcript, the standing instructions it was given, its session state. That is the evidence half of the audit trail: what answers why an agent did something, as opposed to the ledger, which answers what it did. Nothing prunes it, and it is far larger than the ledger. Measured on the dev box: 134 MB for 406 runs, growing with every run.

OC8_EVIDENCE_SWEEP_ENABLED=true turns on a sweep (worker housekeeping timer, or docker compose run --rm backend oc8 evidence-sweep by hand) that, for each finished run past OC8_EVIDENCE_ARCHIVE_AFTER_MINUTES (default 60):

  1. packs the folder into one .tar.xz under <session root>/archive/<agent>/,
  2. writes an evidence.archived entry into the tenant's audit hash chain carrying the archive's SHA-256, and commits it,
  3. only then deletes the loose folder.

Nothing is lost: the archive holds the same bytes and is readable with nothing but a Python standard library. What it does drop is what the container runtime wrote for itself — on the dev box that was 34 % of all bytes, mostly an undeliverable telemetry spool — and the ledger entry records how many bytes were dropped, so it is visible rather than silent.

Measured on that same tree: 134 MB → 11 MB, 6,273 files → 588, and the chain still verifies (oc8 audit-verify --full --once).

OC8_EVIDENCE_RETENTION_DAYS is separate and defaults to 0 = never. A non-zero value is the only lossy part: when an archive is older than the window it is replaced by an evidence.reduced entry naming its hash, so the proof of what happened survives while the ability to re-read the reasoning expires. Set it deliberately, not to save space you have.

Operating notes

  • Logs: docker compose logs -f backend (or worker, scheduler, …).
  • Backup and restore: follow the pilot backup/restore runbook and rehearse it before inviting pilot users.
  • Re-seed / reset data: docker compose down -v drops the Postgres volume; the next up migrates and seeds fresh. Omit -v to keep data across restarts.
  • Update to new code: git pull && docker compose up -d --build. The migrate service applies any new revisions before the app restarts.
  • The four backend processes are one image with different commands: backend (uvicorn), worker, ingestion-worker, scheduler. Scale or restart them independently with docker compose restart <service>.

Deliberate limits

This stack is for testing, and skips what production needs:

  • dev-login is an unauthenticated admin bypass, and it is on by default. With OC8_ENV=dev (the default), POST /api/v1/auth/dev-login mints an org_admin token for the seed tenant for anyone who can reach it — and Caddy exposes it. This is deliberate so the stack is easy to try locally, but it means a publicly reachable host is wide open. Before exposing one: restrict it at the network layer (firewall, VPN, or bind the published port to a private interface — e.g. OC8_HTTP_PORT published as 127.0.0.1:8090:80 behind your own proxy), or set OC8_ENV=prod so dev-login is disabled and configure normal login for your deployment.
  • Weak internal credentials, not exposed. The oc8_app/oc8_migrate database roles use the password oc8 (fixed by backend/docker/init-db.sql). This is acceptable only because Postgres and Redis have no port mapping in this compose — they are reachable only on the internal Docker network, and Caddy is the sole exposed service. The credentials that a network attacker could reach — OC8_JWT_SECRET, OC8_SECRET_KEK, the Postgres superuser — are required (:?) and generated per the setup above, never defaulted. For production you would additionally rotate the role passwords.
  • The Docker-socket mount on backend/worker is root-equivalent on the host.
  • No forced TLS (http by default), no secrets manager (secrets live in .env), no HA, no backups, no OpenTelemetry collector (OTLP export is disabled by default; point OTEL_EXPORTER_OTLP_ENDPOINT at a collector and set OTEL_SDK_DISABLED=false to use it).
  • The agent sandbox pulls its image at run time and starts sibling containers on the host — a standard docker-out-of-docker arrangement, fine for straightforward runs but not isolated the way a production runtime would be.