Deployment¶
Self-hosting¶
The backend ships as a single container image run in different modes, backed by
one Postgres database. The distribution compose file
docker-compose.dist.yml
brings up Postgres, server, scheduler, and worker together.
docker compose -f docker-compose.dist.yml up -d
docker compose -f docker-compose.dist.yml run --rm server -mode=provision -name=me
Configuration¶
Backend environment variables are all prefixed BOUNDFLOW_:
| Variable | Applies to | Purpose |
|---|---|---|
DATABASE_URL |
all | Postgres connection string. |
GRPC_PORT |
server | Client-facing gRPC port (default 50051). |
WORKER_GRPC_PORT |
worker | Worker-facing gRPC port (default 50052). |
NUM_PARTITIONS |
scheduler | Scheduler partition count. |
JOB_TIMEOUT_SECS |
scheduler | Default job timeout. |
LOG_LEVEL / DEBUG |
all | Logging. |
SDK-side: BOUNDFLOW_API_KEY, BOUNDFLOW_SERVER_ADDRESS /
BOUNDFLOW_WORKER_ADDRESS (default to localhost), and ANTHROPIC_API_KEY for
real agents.
Secrets — the .env file¶
docker compose automatically reads a .env file next to the compose file, so
that's where deployment secrets go. Copy the template and set your values:
cp .env.example .env
# BOUNDFLOW_DB_PASSWORD is required — the stack won't start without it.
# Generate a strong one: echo "BOUNDFLOW_DB_PASSWORD=$(openssl rand -hex 16)" >> .env
.env is gitignored; never commit real secrets. BOUNDFLOW_DB_PASSWORD feeds
both the bundled Postgres container and the backend's connection string.
docker-compose.dist.yml ships no default for it — a deployment must set its own,
so it can't accidentally run on a known password. (The dev compose,
docker-compose.yml, keeps a local default for tests.)
Production database — bring your own¶
For anything beyond a local trial, don't rely on the bundled postgres container —
point the backend at a managed Postgres (RDS / Cloud SQL / Azure DB) over TLS.
Set BOUNDFLOW_DATABASE_URL in .env; it overrides the bundled URL entirely:
Then remove the bundled postgres service (and the depends_on: postgres entries)
from your compose file — or override them in a docker-compose.override.yml — and
run -mode=migrate once against your database to create the schema.
Warning
Don't publish the Postgres port. The bundled Postgres isn't published to the
host. If you expose it, put it behind your network's controls — and set
BOUNDFLOW_DB_PASSWORD to a strong secret (required; see above).
TLS¶
The Go server speaks plaintext gRPC; TLS is expected to be terminated at the edge — a reverse proxy or load balancer (Caddy, nginx, Envoy, or a cloud LB) in front of the server that presents the certificate and forwards plaintext to the backend. This is a standard gRPC deployment pattern.
The SDK selects TLS by URL scheme: an https:// endpoint uses a secure channel
(validated against system root CAs); anything else is insecure. So point the SDK
at your terminating proxy over https:// in production:
export BOUNDFLOW_SERVER_ADDRESS=https://boundflow.example.com:443
export BOUNDFLOW_WORKER_ADDRESS=https://boundflow.example.com:8443
Note
Custom CA / self-signed certs. The SDK currently validates against system root CAs only. End-to-end TLS to a private-CA or self-signed certificate (e.g. on localhost) is not yet configurable from the SDK — front the server with a publicly-trusted cert for now.
A minimal Caddy terminator, for reference:
Continuous deployment (BoundFlow's own test cloud)¶
This section describes how we run the shared test environment. It isn't required for self-hosting.
Every green Tests run on main builds an image, runs migrations, and rolls the
three container apps. Nothing is tagged and no version is bumped — a tag still means
a deliberate stable SDK release, and nothing else.
Deploying continuously also keeps the server ahead of every published SDK, which is the only safe direction: a client calling an RPC its server lacks fails, while a server ahead of its client is invisible.
Why migrations run as a job¶
The database has public network access disabled and sits on a delegated subnet, so a CI runner cannot reach it. Migrations run as a Container Apps job inside the same managed environment, sharing the apps' connection-string secret. The deploy fails closed: if the migration doesn't succeed, the apps stay on the previous image rather than starting a binary against a schema it doesn't match.
Create the job once:
RG=boundflow_test
ENV=$(az containerapp show -n boundflow-server-test -g $RG \
--query properties.managedEnvironmentId -o tsv)
CONN=$(az containerapp secret show -n boundflow-server-test -g $RG \
--secret-name boundflow-app-db-conn --query value -o tsv)
az containerapp job create \
-n boundflow-migrate-test -g $RG --environment "$ENV" \
--trigger-type Manual --replica-timeout 600 --replica-retry-limit 0 \
--image ghcr.io/boundflow/boundflow:latest \
--cpu 0.5 --memory 1Gi \
--args "-mode=migrate" \
--secrets "boundflow-app-db-conn=$CONN" \
--env-vars "BOUNDFLOW_DATABASE_URL=secretref:boundflow-app-db-conn" \
"BOUNDFLOW_NUM_PARTITIONS=10"
BOUNDFLOW_NUM_PARTITIONS is required even in migrate mode, and must match what the
scheduler runs with.
Azure credentials for CI¶
The workflow authenticates with a federated credential — no secret is stored.
APP_ID=$(az ad app create --display-name boundflow-ci --query appId -o tsv)
az ad sp create --id "$APP_ID"
az role assignment create --assignee "$APP_ID" --role Contributor \
--scope /subscriptions/<subscription-id>/resourceGroups/boundflow_test
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:boundflow/boundflow:environment:test-cloud",
"audiences": ["api://AzureADTokenExchange"]
}'
Then set AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID as
repository variables, and create a test-cloud environment (the subject above
must match it).
The SDK tracks main too¶
Every green main also publishes a PEP 440 dev release, numbered against the next
minor — 0.7.0.dev12 while 0.6.0 is the stable release. Dev releases sort before
the version they name and after the previous stable one, so:
pip install boundflow # newest stable — unaffected by merges
pip install --pre boundflow # whatever main is running
Nothing pushes an update to anyone's machine, so this makes main available rather than applied. The console is part of the SDK and runs on the operator's machine, so someone on the stable release sees the console from that release, not from main.