Skip to content

The local stack

The local stack brings up a complete apomesh deployment on your machine: Postgres, Redis, one orchestrator, and one worker, with a sample project mounted as the agent’s workspace. It is a local-evaluation reference, not a production template — but it exercises the real substrate end to end.

This page is the operator reference for that stack. The recipes below use just (the human-facing command alias); each one wraps a plain docker compose invocation, shown alongside where it helps.

  • Docker with the docker compose plugin.
  • just for the operator recipes (brew install just on macOS).
  • OpenSSL, only for the optional mTLS cert generator under deploy/local/certs/.
  • uv, only for the sample dispatch and the opt-in auth profile.

The default docker compose up starts four services. All ports bind to 127.0.0.1 — nothing is exposed off your host.

ServiceImagePortRole
postgrespgvector/pgvector5432Durable state store and semantic-memory vector column. The same image the substrate’s Hybrid backend references.
redisredis6379Write-through hot-replay buffer for the event bus.
orchestratorapomesh-orchestrator:dev50051 (gRPC), 50052 (REST + OpenAPI)The control plane — sessions, routing, cost rollups, the credential store.
workerapomesh-worker:devThe execution plane — runs every tool inside its sandbox boundary and dispatches LLM calls.

A fifth service, zitadel, starts only under the opt-in auth profile (see Profiles below); the zero-config stack leaves it out.

The worker runs privileged: true in this recipe because Docker Desktop’s Linux VM blocks the unprivileged user namespaces Bubblewrap needs. The worker container itself is the isolation boundary here — treat the whole recipe as one trusted dev sandbox. A production Linux host does not need privileged.

Local config lives in exactly two places, split by one question — is this value the same for every developer, or specific to my machine?

  • deploy/local/ (tracked) holds fixed, same-for-every-dev config: the Compose file, config.<profile>.toml, and providers.yaml. The repo commits only neutral *.example.* skeletons; just bootstrap (run automatically by just up) copies each one to its real, git-ignored counterpart without clobbering your edits. No secrets live here.
  • ~/.apomesh/env (out of repo) holds per-operator values: every secret (provider keys, sealing key, OAuth tokens) and machine-specific knobs. Compose loads this file straight into both daemons via env_file, so you never source it by hand — a credential that must reach the containers belongs in this file, not exported in a shell.

Each value is set in exactly one home. A value hardcoded in Compose is never also in ~/.apomesh/env, and a secret is never inlined into a tracked file. See .claude/rules/local-dev-config.md for the full contract.

The active daemon config is config.${APOMESH_PROFILE}.toml, chosen by the single selector APOMESH_PROFILE in deploy/local/.env:

  • dev (the default, when APOMESH_PROFILE is absent) → config.dev.toml, with [auth] backend = "none". Every caller is the dev tenant with every scope. This is the zero-config path.
  • oauthconfig.oauth.toml, wired to a local Zitadel identity provider so the console, the SDK, and curl all authenticate the way a real deployment does.
  • mtlsconfig.mtls.toml, giving the orchestrator↔worker hop mutual TLS. This is the profile where the secure secret path actually runs: secret forwarding is fail-closed, so a plaintext hop forwards nothing, and the dev profile’s relaxation means the secure path is never exercised there.

Switch on the auth profile — it provisions Zitadel, renders the oauth config, and records the selector:

Terminal window
just up-auth # start the stack + Zitadel, provision it, apply the config
just smoke-auth # prove the caller-identity + authorization plane end to end

Return to the zero-config stack — this drops the selector and leaves the provisioned IdP intact, so just up-auth can re-enable it in one step:

Terminal window
just deauth
just up

For the seed identities, the browser sign-in flow, and the JWKS-over-HTTP dev caveat, see the full walkthrough in deploy/local/README.md.

mtls is the one profile that proves the credential plane’s fail-closed rule rather than relaxing it. Mint the org CA once, put the printed token in ~/.apomesh/env, then bring the stack up:

Terminal window
just mint-enroll # mints the org CA + the orchestrator leaf; prints the enrollment token
just up-mtls # renders config.mtls.toml with the CA, starts under the override file

The worker enrolls for its certificate rather than mounting one. Confirm the hop actually came up mutually authenticated:

Terminal window
docker compose logs orchestrator | grep -i 'worker registered'
docker compose logs worker | grep -i 'certificate'

Two mechanics differ from the other profiles and will bite otherwise:

  • mtls is an override file, not a Compose profiles: gate. docker-compose.mtls.yml is what bind-mounts the identity material and redeclares the listeners, so a bare docker compose up -d — which resolves only the base file — starts a daemon that reads config.mtls.toml, finds no CA, and exits. just up detects the selector and adds the override for you.

  • Tear it down with the base file alone. The override declares APOMESH_DEV_ENROLL_TOKEN with :?, so an unsourced shell fails loudly instead of silently blanking the token — but that guard fires on down as well as up:

    Terminal window
    docker compose -f docker-compose.yml down

Leave the profile with just demtls, which drops the selector and returns to the plaintext dev hop. The worker’s enrolled identity persists on its volume, so re-entering needs no fresh token; docker compose down -v is what discards it.

Without a credential the orchestrator falls back to a fake provider and every session fails at the first planner dispatch. Put a real credential in ~/.apomesh/env — Anthropic via a Claude Max OAuth token is the simplest path:

Terminal window
echo 'CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat...' >> ~/.apomesh/env
just up

An Anthropic Console static key works too (ANTHROPIC_API_KEY), as do Gemini and the embedding-provider keys for semantic memory. Confirm the orchestrator picked it up:

Terminal window
just logs orchestrator | grep "LLM provider"
# "LLM provider configured from environment" → real provider active
# "no LLM provider configured" → still falling back to fake

The full env-var surface — provider selection, per-class model pins, embedding keys, and the durable providers.yaml pre-config — is documented in deploy/local/README.md.

Terminal window
just up # bootstrap config, then start the stack (detached)
just logs orchestrator worker # tail one or more services
just down # stop; keeps volumes (Postgres data, Redis snapshot)
just nuke # stop and WIPE volumes

The raw equivalents run docker compose from deploy/local/:

Terminal window
cd deploy/local
docker compose up -d
docker compose logs -f orchestrator
docker compose down # keep volumes
docker compose down -v # wipe volumes

A source edit reaches a container only through an image rebuild — bringing the stack up does not rebuild a stale image. just up builds any missing image on first run, but never re-builds one that already exists.

After changing orchestrator or worker code, rebuild first, then recreate:

Terminal window
just build-images # rebuild both images from infra/Dockerfile.*
just up # start against the fresh images

just restart chains this for you — it stops the stack, rebuilds both images, and starts fresh (preserving volumes). The raw image builds run from the workspace root:

Terminal window
docker build -t apomesh-orchestrator:dev -f infra/Dockerfile.orchestrator .
docker build -t apomesh-worker:dev -f infra/Dockerfile.worker .

With the stack healthy, drive the shipped sample task — it fixes a deliberately-planted bug in deploy/local/sample-project/ and verifies the fix with pytest:

Terminal window
just dispatch-sample

dispatch-sample runs the Python SDK against the orchestrator at 127.0.0.1:50051 and tails the session’s events to your terminal. To exercise the substrate over REST + SSE instead — no SDK, no console — run a Deep Research goal through the smoke driver:

Terminal window
just smoke "What is today's top news story?"