Skip to main content

Run Compose yourself

The production compose contract — networks, aliases, probes, volumes — so you can write the stack without the CLI.

11 min read

This page is what a stack must reproduce when you write Compose or Kubernetes yourself instead of running tale deploy: which services hold state, the DNS names, the probes, the volumes. The CLI path stays in Quickstart and Upgrades.

When this path is the right one

Use the CLI when you can run it. Use this page when you write Compose, or when you map the same contract onto Kubernetes.

Use this page whenUse the CLI when
You write production Compose, or a cluster mapping — air-gap, existing automation, no CLI on the hostQuickstart plus tale deploy when you want blue-green, tale backup, and tale rollback

There is no official Helm chart.

Stateful and stateless

Ten services, two kinds. Stateful services hold disks and fixed identity — recreate them and you lose data or break DNS. Stateless services are interchangeable replicas of one image; recreate them in place on upgrade. One compose file is the default. Split files or Kubernetes is yours, as long as the DNS names, the isolated sandbox network, and the boot order stay.

100%

sandbox-llm-gateway is the harness path: the api provisions it, and a session container reaches it as llm-gateway. bgutil-provider is the worker's YouTube PO-token sidecar and is best-effort — video-link ingest degrades without it.

The three stateless services share one image (ghcr.io/tale-project/tale/tale-platform:<version>). TALE_ROLE picks api or worker at boot; the web tier is the same image without that role. Pin every tale-* image to the same release tag so the wire contracts cannot skew.

KindServices
Statefulproxy, db, object-store, sandbox, sandbox-egress, sandbox-llm-gateway, bgutil-provider
Statelessplatform, backend-api, backend-worker

The images

Every tale-* image is published on the GitHub Container Registry under the same release tag, so one version number pins the whole stack. Two services run upstream images with versions of their own.

ServiceImage
platform, backend-api, backend-workerghcr.io/tale-project/tale/tale-platform:<version>
proxyghcr.io/tale-project/tale/tale-proxy:<version>
dbghcr.io/tale-project/tale/tale-db:<version>
sandboxghcr.io/tale-project/tale/tale-sandbox:<version>
sandbox-egressghcr.io/tale-project/tale/tale-sandbox-egress:<version>
sandbox-llm-gatewayghcr.io/tale-project/tale/tale-sandbox-llm-gateway:<version>
object-storeminio/minio:RELEASE.2025-04-22T22-12-26Z
bgutil-providerbrainicism/bgutil-ytdlp-pot-provider:1.3.1

One image is not a compose service. The spawner creates every session container from ghcr.io/tale-project/tale/tale-sandbox-runtime:<version>, and its built-in default is the local tag the development stack builds — a host that never built it must name the registry image in SANDBOX_RUNTIME_IMAGE, or Run code, web render, and document generation all fail with an image-not-found. The examples below pin the release this page documents; replace the tag with the release you are installing.

The stateless services

Below are the three stateless roles — aliases, /ping as liveness, TALE_ROLE, NET_ADMIN. Put the stateful services in the same file or elsewhere; the tables on this page are what they must still do. Pin the image tag and fill .env from the Environment reference.

yaml
# Stateless app tier. No container_name: --scale needs free names.
# Add db, proxy, sandbox, … in this file or another — your call. In one file,
# add depends_on: { db: { condition: service_healthy }, … } as well.
services:
  platform:
    image: ghcr.io/tale-project/tale/tale-platform:0.5.11
    env_file: [.env]
    volumes: ['config-data:/app/data:ro']
    restart: unless-stopped
    stop_grace_period: 45s
    healthcheck:
      test:
        [
          'CMD-SHELL',
          'curl -sf http://localhost:3000/api/health && [ -f /tmp/platform-ready ]',
        ]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 180s
    networks:
      internal:
        aliases: [platform]
  backend-api:
    image: ghcr.io/tale-project/tale/tale-platform:0.5.11
    environment:
      TALE_ROLE: api
      PORT: '3005'
      TALE_CONFIG_DIR: /app/data
      DATABASE_URL: postgresql://tale:${DB_PASSWORD}@db:5432/tale_app
      SANDBOX_URL: http://sandbox:8003
      SANDBOX_HTTP_API_BASE_URL: http://backend-api:3005
      OBJECT_STORE_ENDPOINT: http://object-store:9000
    env_file: [.env]
    volumes: ['config-data:/app/data']
    cap_add: [NET_ADMIN]
    restart: unless-stopped
    healthcheck:
      test: ['CMD-SHELL', 'curl -sf http://localhost:3005/ping']
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s
    networks:
      internal:
        aliases: [backend-api]
      sandbox:
        aliases: [backend-api]
  backend-worker:
    image: ghcr.io/tale-project/tale/tale-platform:0.5.11
    environment:
      TALE_ROLE: worker
      TALE_CONFIG_DIR: /app/data
      DATABASE_URL: postgresql://tale:${DB_PASSWORD}@db:5432/tale_app
      SANDBOX_URL: http://sandbox:8003
      SANDBOX_HTTP_API_BASE_URL: http://backend-api:3005
      OBJECT_STORE_ENDPOINT: http://object-store:9000
    env_file: [.env]
    volumes: ['config-data:/app/data']
    cap_add: [NET_ADMIN]
    restart: unless-stopped
    healthcheck: { disable: true }
    networks: [internal]
volumes:
  config-data:
networks:
  internal:
  sandbox:
    name: tale-sandbox-net
    internal: true
    enable_ipv6: false

There is no checked-in production compose to copy. The CLI generates a split file pair and deletes it after up. Your file does not have to look like that.

Secrets you generate before the first boot

tale init mints every secret and writes the .env; without the CLI that job is yours. The Environment reference marks what each variable does — the four below are the ones a hand-rolled stack most often ships without, because the example file leaves them commented for the CLI to fill.

VariableValueWhat breaks without it
SANDBOX_TOKENopenssl rand -hex 32The spawner exits at startup. It holds the host docker socket and answers every session container, so it has no unsigned mode; the backend signs every spawner call with the same value.
OBJECT_STORE_ACCESS_KEYtale, or a name of your ownThe backend logs object store (skipped) at boot and refuses every upload. There is no image default for it — the store's root user must carry the same value.
OBJECT_STORE_SECRET_KEYopenssl rand -hex 32Same skip, same silence. Rotating it later orphans every blob already written under the old credential.
OBJECT_STORE_PUBLIC_ENDPOINTyour SITE_URLUploads fail in the browser with a network error: the presigned URL the backend hands out points at the internal http://object-store:9000, which no browser can reach.

The public endpoint is the one to get right before you start, not after. The backend seeds the deployment-default blob connection into the config volume on its first boot (default/object-storage/connection.json) and never rewrites a default that already exists, so setting the variable on an instance that has already booted changes nothing. To repair such an instance, add "publicEndpoint": "<your SITE_URL>" to that file and restart the backend.

Networks and DNS names

Two Docker networks carry every hop. An ordinary compose network is enough for the internal plane. The sandbox bridge must be named tale-sandbox-net and marked internal so the spawner can docker run --network tale-sandbox-net and a session container cannot reach the internet without going through sandbox-egress. A bridge without internal is an open path out.

Name the process resolvesWho answersNetworks
backend-apiEvery healthy api replica still attachedinternal, sandbox
platformEvery healthy web-tier replica still attachedinternal
knowledge-dbThe db service (production folds the corpus into the same Postgres)internal
object-storeMinIOinternal
sandboxThe sandbox spawnerinternal, sandbox
sandbox-egressThe egress proxyinternal, sandbox
llm-gatewaysandbox-llm-gatewayinternal, sandbox
HOST (your public hostname)proxy, so a container can hairpin to the public URLinternal

Workers have no shared alias. Nothing addresses a worker by name; they only claim jobs from the queue. Colour-suffixed aliases (backend-api-blue, platform-green) are only for a blue-green while two versions are up at once.

The proxy sends app API lanes to backend-api:3005 (BACKEND_UPSTREAM). It sends /api/health and the SPA to platform:3000, and it health-checks platform on /api/health. Fail that probe on a draining web replica and Caddy marks the whole site down.

Volumes

Name these logical volumes in your compose. One file can let compose create them. Mark them external only if something outside this file must mount the same disks.

VolumeWho mounts itWhat it holds
config-dataBackend read-write, platform read-only, sandbox read-only at /app/platform-configOrg config: agents, skills, providers, governance, SSO, branding
db-datadb at /var/lib/postgresql/datatale_app and tale_knowledge
db-backupdb at /var/lib/postgresql/backupIn-container Postgres backup target
object-store-dataobject-store at /dataBlobs
caddy-data, caddy-configproxyCertificates and Caddy state
llm-gateway-datasandbox-llm-gateway at /app/dataPer-session virtual keys

Instances upgraded from before 0.5.11 may still have a convex-data volume beside config-data. The CLI copies the store across once and never deletes the old volume. A hand-rolled first boot on a fresh host does not need convex-data.

Health probes

Liveness and readiness are different questions. Mixing them cuts a draining replica out of DNS before in-flight work finishes, or keeps an unready replica in the pool.

ServiceProbeWhat it means
backend-apiGET /ping on :3005Liveness. Stays 200 while the replica drains. Docker and Caddy use this.
backend-apiGET /ready on :3005Readiness. 503 once this replica is draining. The deploy asks this; Docker and Caddy do not.
platformGET /api/health and file /tmp/platform-readyReady to serve the SPA. Keep this 200 while the replica still holds the platform alias.
backend-workerNoneThe worker exposes no HTTP. Disable the image's baked web healthcheck or the replica reads permanently unhealthy.
proxyhttp://127.0.0.1:2020/healthCaddy admin health.
dbpg_isready and file /tmp/.db_readyPostgres accepts connections and init finished (the knowledge database and extensions). start_period 120s. Stop the container with SIGINT, not SIGTERM.
object-storemc ready localMinIO is accepting writes.
sandboxGET /health on :8003Spawner is up. Do not publish this port on a public host.
sandbox-egressTCP 127.0.0.1:3128tinyproxy is bound. Do not probe an external host.
sandbox-llm-gatewayGET /health on :8080Gateway is up.

Environment the compose must inject

The Environment reference is every variable the process reads from .env. The rows below are what the compose file itself must set — image defaults point the process at the wrong host.

NameValue on a production stack
TALE_ROLEapi on backend-api, worker on backend-worker. Unset on platform.
PORT3005 on the api. The proxy's BACKEND_UPSTREAM default is backend-api:3005.
TALE_CONFIG_DIR/app/data
DATABASE_URLpostgresql://tale:${DB_PASSWORD}@db:5432/tale_app
SANDBOX_URLhttp://sandbox:8003
SANDBOX_HTTP_API_BASE_URLhttp://backend-api:3005
OBJECT_STORE_ENDPOINThttp://object-store:9000
SANDBOX_EGRESS_NETWORKtale-sandbox-net
SANDBOX_EGRESS_PROXYhttp://sandbox-egress:3128
SANDBOX_TOKENThe same value everywhere. sandbox refuses to start without it; the backend signs its spawner calls with it.
SANDBOX_RUNTIME_IMAGEghcr.io/tale-project/tale/tale-sandbox-runtime:<version> on sandbox. The default is a local build tag that a production host does not have.
BACKEND_UPSTREAMbackend-api:3005 on proxy.
OBJECT_STORE_UPSTREAMobject-store:9000 on proxy, so presigned URLs are forwarded at /<bucket>/*.
OBJECT_STORE_BUCKETtale-blobs by default. Rename it and the same name has to reach proxy and both backend roles.
MINIO_ROOT_USER, MINIO_ROOT_PASSWORDOn object-store: the store reads its own names, so map OBJECT_STORE_ACCESS_KEY and OBJECT_STORE_SECRET_KEY onto them.
TALE_DB_ROLEUnset on the folded db. The default role creates tale_knowledge and applies the corpus migrations; platform skips them and leaves the corpus tableless.

Capabilities and mounts that break if omitted

These look optional and fail closed when they are missing.

ServiceMust haveWhat breaks without it
backend-api, backend-workercap_add: [NET_ADMIN]The entrypoint cannot install the SSRF iptables fence (IMDS, link-local, RFC1918).
sandbox-egresscap_drop: [ALL] then NET_ADMIN, DAC_OVERRIDE, CHOWN, SETUID, SETGID, NET_BIND_SERVICENo IMDS/RFC1918 fence; tinyproxy cannot bind or drop privileges.
sandbox/var/run/docker.sock and /var/lib/tale-sandbox bind-mounted 1:1The spawner cannot create session containers; workspace paths the daemon mounts will not match.
dbstop_signal: SIGINT, stop_grace_period: 60s, shm_size: 256mbA SIGTERM wait-for-clients stop ends in SIGKILL and can leave the BM25 index with a zeroed page.
platformstop_grace_period: 45sDocker's default 10s grace SIGKILLs the web tier mid-drain and cuts in-flight HTTP/SSE.
object-storeNo published portsPresigned URLs go through the proxy. Publishing MinIO is an extra public surface.

Publish only 80 and 443 on proxy. Everything else stays on the internal network.

Boot order

Bring the stores up first, then the sandbox plane, then the app tier. An api that starts before db and object-store are healthy crash-loops on ENOTFOUND and on a missing database. In one file, depends_on with service_healthy is enough.

bash
docker compose up -d
# Wait until db, object-store, proxy, sandbox, sandbox-egress, sandbox-llm-gateway
# report healthy. bgutil-provider is best-effort — YouTube ingest degrades without it.

Give every service a restart policy (restart: unless-stopped). Nothing else brings a container back after a host reboot or an OOM kill, and a stack that boots once but never again is the failure operators find weeks later.

Schema migrations run inside the backend at boot, under an advisory lock. There is no separate migrate step. A replica that cannot apply a migration fails to start; leave the previous api running until the new one is healthy.

Kubernetes

There is no Helm chart and no official manifest. Map the Docker contract; do not invent a second architecture.

DockerCluster
Stateless platform, backend-api, backend-workerDeployments. Same image; TALE_ROLE picks the process. Scale these.
Stateful db, object-store, proxy, sandbox planeStatefulSets (or equivalent) plus the volumes on this page. Do not run two writers against one disk.
Compose DNS names (backend-api, platform, knowledge-db, sandbox, llm-gateway, …)Services with those names. The proxy and the sandbox resolve them.
tale-sandbox-net marked internalA NetworkPolicy (or isolated CNI) that blocks a session pod from the internet except through sandbox-egress.
GET /ping on the apiLiveness. Stays 200 while the replica drains.
GET /ready on the apiYour rollout's readiness question. Do not point the Service at /ready if you drain.
Sandbox docker.sock and /var/lib/tale-sandbox bind-mounted 1:1The hard part. The spawner creates session containers; the workspace path the daemon mounts must match the path inside the spawner. A cluster that cannot offer a Docker socket (or an equivalent) cannot run the sandbox plane.
cap_add: [NET_ADMIN] on the backendThe SSRF iptables fence. Without it the entrypoint cannot lock IMDS and RFC1918.

Publish only 80 and 443. Leave Postgres, MinIO, and the sandbox port off the public Service list.

In-place recreate of the stateless Deployments is the default. Zero-downtime is a rolling update you own.

What you give up without the CLI

tale deploy is not a compose up. The commands below have no equivalent in a file you maintain.

CLI behaviourWhat you do instead
Blue-green flip: start the idle colour, wait for every replica, drain the old api, then docker network disconnectIn-place recreate, or implement the flip yourself. Disconnect severs live connections — drain first.
tale backup / tale rollbackYour own volume snapshots. Rollback of a minor or major is a snapshot restore, not a down-migration.
Flip-pending resume after a killed deployYour own record of which colour is live.
Config-volume copy from convex-data on a pre-0.5.11 hostCopy the store yourself, or start fresh.
Sandbox /v1/drain before an in-place spawner rollSIGTERM plus a 30s stop grace is the backstop; in-flight runs still die if you recreate without draining.

An in-place recreate of the stateless services is the default. Zero-downtime is the part you reimplement.

What production must not do

These look local and break a public instance.

Don'tWhy
Publish 5432, 8003, or MinIOExtra public surface. Presigned URLs go through the proxy.
Run a second Postgres for the corpusProduction folds tale_knowledge into db and aliases that service knowledge-db.
Pin names on the app tierReplicas cannot share a container name.
Build from source on a public hostPin ghcr.io/tale-project/tale/<image>:<tag>.
Ship placeholder secretsGenerate them before the first up.
Give session containers a path to the internetThe sandbox network (or its NetworkPolicy) must be isolated.

Where this fits

You now have the contract: which services hold state, two networks, the DNS names the proxy and the sandbox resolve, the probes that must not be swapped, and what Kubernetes must still do. Environment reference is every variable the containers read. Container architecture is what each container owns when one of them dies. Most teams still want the quickstart and tale deploy — this page is the path when that wrapper is the thing you cannot run.

© 2026 Tale by Ruler GmbH — ISO 27001 & SOC 2 certified.

Tale is MIT licensed — free to use, modify, and distribute.