The repo ships a Dockerfile that packages the one areev
binary with the two non-default features a container deployment wants already
compiled in: postgres (the server tier — --db postgres://…?schema=<name>) and tls (native rustls for deployments
with nowhere to run a terminating proxy). Nothing else changes: the image
adds no daemon, no scheduler, and no new verbs — with one deliberate
exception, the image-provided heartbeat command below.
docker build -t areev .
docker run --rm areev --helpBuild it as areev:latest deliberately: that is the image name
areev trigger render --target k8s-cronjob has always emitted — this image
is what that CronJob template runs.
No Rust toolchain, no cargo install — a named volume is the memory:
docker run --rm -v areev-data:/data areev add john prefers "window seat"
docker run --rm -v areev-data:/data areev recall john --render sml$AREEV_DB defaults to /data/areev.db inside the image, so one-shot verbs
need no --db flag; override it with a path or a postgres:// DSN per
container. The console, with a token:
AREEV_UI_TOKEN=$(openssl rand -hex 16) docker compose --profile console up
# → http://127.0.0.1:7437 (any username, password = the token)| Role | Command | What it is |
|---|---|---|
| Console | ui --addr 0.0.0.0:7437 --allow-remote --token-env AREEV_UI_TOKEN |
the web console — browse, query, the run approval queue |
| Heartbeat | heartbeat [trigger-run args…] |
an image-provided loop of one-shot areev trigger run evaluations (docker/heartbeat.sh) |
Two container-specific notes, both about honesty rather than mechanics:
- Inside a container the server must bind
0.0.0.0, and Areev refuses a non-loopback bind without--allow-remote. Passing it moves the exposure control to the Docker port publish: keep-p 127.0.0.1:7437:7437and put a TLS-terminating proxy in front before publishing wider — the same deployment profile as bare metal, with the container boundary standing in for loopback.--token-envis optional onui(a token-less console is read-only beyond loopback concerns), but a published port with no token is an open memory — set one. heartbeatis provided by the image, not the binary. The trigger design is "no daemon: cadence is data, evaluation is a command" (triggers.md) — a container's natural dumb heartbeat is a shell loop, so the image carries one.AREEV_HEARTBEAT_SECS(default 60, the render floor) sets the tick; everything after the wordheartbeatis passed toareev trigger runverbatim, and host config rides the environment exactly as it would on a cron line:
docker run -d -v areev-data:/data -v "$PWD/tools:/work:ro" \
-e AREEV_RUN_TOOL_CMD=/work/tools.sh \
-e AREEV_RUN_MODEL=claude-sonnet-5 -e ANTHROPIC_API_KEY \
areev heartbeat --ns accounting --max-usd 0.25 --ask-ttl 3600Tool and connector scripts run via /bin/sh inside the container — mount
them (read-only) and point $AREEV_RUN_TOOL_CMD / $AREEV_RUN_CONNECTOR_CMD
at the mounted path, not at a path on the host that rendered the config.
The embedded backend takes an exclusive OS file lock (inside the pinned
Turso engine). A second process opening the same .db — even for a pure
read — fails at open with STO-E001; a second handle inside one process is
STO-E002. The one lock-free door is areev blob get, which reads CAS
attachments without opening the memory.
Containers make this visible immediately, because every role is its own process. On an embedded file:
- one container owns the memory at a time — the root
docker-compose.ymlencodes this as profiles (--profile consoleor--profile heartbeatagainst one volume); - while the heartbeat executes a fired run, the evaluator holds the file, so the run's tools cannot open it — the same rule as bare metal (triggers.md §"what a trigger-started run can see");
docker execinto a serving console and runningareev addon the same file getsSTO-E001, by design — write through the console or stop it first.
This is not a Docker limitation to engineer around; it is the isolation
model. When you genuinely need the console, a heartbeat, and app instances
holding one memory concurrently, that is the definition of the server
tier: move the memory to the Postgres backend, where any number of processes
share a schema, writers serialize at reserve_write, and the trigger claim
protocol exists precisely so concurrent evaluators produce exactly one
firing.
One memory per agent is the rule that keeps agents from contradicting each other (how-to-create-an-areev-agent.md): the memory is the unit of isolation, erasure, sync, and portability, so two agents on the same box can never race each other's heads, poison each other's recall, or block each other's erasure. Namespaces partition within an agent; memories separate between agents. Concretely:
- Embedded fleet (one box): one volume + one heartbeat container per agent, a console container started against whichever memory you are inspecting. Agents share the image, the host, and nothing else.
- Postgres fleet (shared cluster): one schema per agent
(
?schema=agent_billing,?schema=agent_support), every role concurrent.docker/compose.fleet.ymlis a running example — two agents, one Postgres, console + heartbeats all live at once. Adding an agent is adding a schema and a heartbeat service. - Cross-agent reads go through read-only
ASSEMBLEfacade mounts (--mount org=/data/org.db) — never a shared writable memory. Mount paths are file-backend today: on a Postgres fleet, share knowledge by exporting a bundle from the source memory and following it as a local read-only replica. - Separation of duties survives co-location: one process = one principal
(
--as), grants live in each memory asmg:permitsFacts, and an approver structurally cannot be the initiator — so a worker agent and its reviewer can run on one host without the host becoming the trust boundary. - Budget the connections on a shared Postgres: one handle is 1–2 connections (telemetry sidecar), there is no built-in pool, and first open of a new schema runs the DDL bootstrap — provision schemas when the agent is created, not on its first turn (deployment-profile.md).
The image is the deployment unit everywhere; the real decision is the backend. A stateless platform has no durable disk, so it is the server tier (ARCHITECTURE.md §11): managed Postgres with the pgvector extension. A platform with a persistent volume can stay embedded — cheaper and microsecond-fast, at the price of the one-writer rule above.
| Platform | Long-running role (console) | The heartbeat | Memory |
|---|---|---|---|
| AWS | ECS/Fargate service | EventBridge Scheduler → ECS scheduled task, or a heartbeat service | RDS PostgreSQL (pgvector); embedded on EC2/EBS for a single-writer box |
| GCP | Cloud Run service | Cloud Scheduler → Cloud Run job | Cloud SQL for PostgreSQL (pgvector) — Cloud Run has no durable disk, so the server tier is the fit by design |
| Azure | Container Apps | Container Apps job (cron trigger) | Azure Database for PostgreSQL Flexible Server (allowlist vector) |
| Kubernetes | Deployment (one per served memory) | areev trigger render --target k8s-cronjob — emits this image's name |
Postgres, or a RWO PersistentVolumeClaim per embedded memory |
| Self-hosted | the compose files in this repo | the heartbeat service |
named volumes, behind a caddy/nginx TLS proxy |
Three honest caveats before you wire production:
-
The Postgres connection encrypts when the DSN says so. This image is built with
postgres-tls, so the DSN carries the decision: appendsslmode=verify-full(plussslrootcert=/path/to/ca.pemwhen the provider signs with its own root, as AWS RDS does) and the connection is validated end to end.sslmodefollows libpq exactly —requireencrypts without checking the certificate, only theverify-*pair validates it — sorequireis not the safe rung it sounds like. Omittingsslmodeleaves libpq'spreferdefault, which is best-effort and unvalidated; name the mode. A build without the feature refuses an encrypting DSN by name (STO-E003) rather than connecting in the clear. The local-proxy pattern (Cloud SQL Auth Proxy, PgBouncer with a TLS upstream) still works and is still right where the proxy is doing something else too — point the DSN at it withsslmode=disable. -
There is no official published image yet.
docker buildfrom a release tag and push to the registry your platform pulls from (ECR / Artifact Registry / ACR). Multi-arch:docker buildx build --platform linux/amd64,linux/arm64— the engine's C pieces compile natively under emulation. -
Secrets ride the environment, never the command line —
--token-env/--key-env/--credential NAME=ENV_VARall name variables, so they map directly onto your platform's secret manager. Variables named by--token-env/--passphrase-env/--credentialare scrubbed from tool subprocess environments.Better still on a platform that issues short-lived tokens: don't put the secret in the environment at all.
--credential 'x=cmd:…'mints one per call from the container's own ambient identity (ECS task role, Workload Identity, Managed Identity), and--credential 'x=vault:PATH#FIELD'reads Vault or OpenBao directly, so nothing long-lived sits in the task definition. This matters most for the heartbeat, whose token would otherwise be minted at container start and expire before morning. Per-platform resolvers and a compose fragment for OpenBao:cookbook.md§19.
deployment-profile.md— the reviewed production shape these containers render: auth, TLS, principals, the Postgres connection contracttriggers.md— the trigger model the heartbeat evaluatessecurity-model.md— trust boundaries of the consolequickstart.md— every other install path