On this page
Install with Docker Compose
The Compose stack builds Haarness Cloud from source and runs it on one machine, behind one local address: the API and web app, the worker, the gateway, the managed model router, PostgreSQL and a Caddy reverse proxy. Use it to try the services or to work on them. If you only want to use Cloud, get started instead.
This page is for people who have the Haarness Cloud source. That source, and the router's, are not public.
What works, and what doesn't
| Part | In this stack |
|---|---|
| API, web app, worker, gateway, router | Containers on this machine |
| Database | A PostgreSQL container with its own volume |
| Sign-in | Email links through your SendGrid account, Google through your OAuth client; no Apple |
| Payments | Your Stripe account |
| Exports, Marketplace packages and other files | Your S3-compatible bucket, such as Cloudflare R2 |
| Cloud workspaces | Not in this stack (below) |
- Cloud workspaces do not run. Workspaces only run as Fly.io Machines, and this stack gives them the router's
address inside the Compose network (
http://router:18082), which a Fly Machine cannot reach. The images contain no Haarness runtime to run workspaces on this machine instead. - It is not a production install. With
HAARNESS_CLOUD_ENV=prodthe services refuse to start, because that same router address is nothttps://(see What production refuses). Run it in the defaultdevmode onhttp://addresses, or instagingbehind HTTPS. - Only this machine can reach it. Every published port is bound to
127.0.0.1. To serve it to others, put your own HTTPS proxy in front of127.0.0.1:18000, setHAARNESS_CLOUD_COMPOSE_PUBLIC_URLto the proxy'shttps://address, and setHAARNESS_CLOUD_ENV=stagingandHAARNESS_CLOUD_COOKIE_SECURE=true. Indevthe API refuses anhttps://address, becausedevaccepts the internal token that every copy of the source contains.
Before you start
Docker with Compose v2, and roughly 8 GB of RAM and 20 GB of disk for the first build.
The Haarness Cloud and router sources, side by side, in folders named exactly
haarness-cloudandhaarness-router. The images are built from the folder that holds both and copy them by those names:haarness-cloud/ haarness-router/Accounts the stack needs before it starts: Stripe (test mode is fine), an S3-compatible bucket, a Fly.io API token and app, and a way to sign in: SendGrid for email links, a Google OAuth client, or both.
Configure it
In haarness-cloud, copy the example file. It already points the services at .env and turns the fixtures off.
.env is ignored by git; never commit a filled-in copy.
cd haarness-cloud
cp .env.example .envThen edit .env:
HAARNESS_CLOUD_COMPOSE_PUBLIC_URL: add this line, set to the one address people use,http://127.0.0.1:18000unless you put a proxy in front. It replaces the four public URLs in.env.example, which point at each service's own port instead.HAARNESS_CLOUD_INTERNAL_TOKEN: a random string of at least 16 characters, such as the output ofopenssl rand -hex 32. It guards the internal endpoints. Outsidedev, the placeholder is refused.HAARNESS_CLOUD_METRICS_TOKEN: a second, different random string, for Prometheus only. Never give Prometheus the internal token.HAARNESS_CLOUD_AGENT_SECRETS_KEY: the output ofopenssl rand -base64 32. It encrypts two-factor secrets, agent credentials and router provider keys, so keep it stable and back it up.HAARNESS_CLOUD_STRIPE_SECRET_KEYandHAARNESS_CLOUD_STRIPE_WEBHOOK_SECRET: required to start. Stripe test-mode values are fine outsideprod. Add theHAARNESS_CLOUD_STRIPE_PRICE_*ids only if you sell plans.HAARNESS_CLOUD_S3_ENDPOINT,_PUBLIC_ENDPOINT,_BUCKET,_ACCESS_KEYand_SECRET_KEY: your object storage, required to start. With R2, both endpoints are the samehttps://<account>.r2.cloudflarestorage.comaddress. Create the bucket first; the services never create one.HAARNESS_CLOUD_FLY_API_TOKENandHAARNESS_CLOUD_FLY_APP: the worker refuses to start without them, even though workspaces do not run in this stack.
Set up at least one way to sign in:
- Email links:
HAARNESS_CLOUD_SENDGRID_API_KEY, andHAARNESS_CLOUD_EMAIL_FROMset to a sender you verified in SendGrid. Without SendGrid, setHAARNESS_CLOUD_EMAIL_PROVIDER=log: the stack starts, but offers no email sign-in. - Google:
HAARNESS_CLOUD_OIDC_CLIENT_IDandHAARNESS_CLOUD_OIDC_CLIENT_SECRETfrom your own Google OAuth client. Add your public URL followed by/auth/google/callbackto the client's authorized redirect URIs, such ashttp://127.0.0.1:18000/auth/google/callback. - Sign in with Apple is always off in this stack:
compose.yamlclears its settings.
To offer models paid with Cloud credits, list the managed router's providers and put their keys in the
variables they name. With the default [], those routers have no models.
HAARNESS_CLOUD_ROUTER_PROVIDERS='[{"name":"openai","kind":"openai","api_key_env":"OPENAI_API_KEY"}]'
OPENAI_API_KEY=…The stack always uses its own PostgreSQL container: database haarness_cloud, user and password haarness,
published on 127.0.0.1:55432 only. HAARNESS_CLOUD_DATABASE_URL in .env is not used.
Bring it up
docker compose --env-file .env -f deploy/compose/compose.yaml \
--profile web-dev run --rm --build web-dist
docker compose --env-file .env -f deploy/compose/compose.yaml \
--profile core up -d --build --scale fixtures=0
curl http://127.0.0.1:18000/readyzThe first command builds the web app into a volume the API serves; without it, the app's pages answer 404. The
second builds the services and starts them; the first build can take 10 to 25 minutes. When /readyz answers
200, open http://127.0.0.1:18000 in a browser on the same machine and sign in.
Pass --env-file .env to every command on this page, and --scale fixtures=0 to every up. Without
--env-file .env, Compose does not read your .env: the services fall back to the development file .env.e2e,
the fixtures are on, and up starts the fake sign-in service, which issues an identity for any email address.
Check that it's healthy
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core ps
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core logs -f api
curl http://127.0.0.1:18000/readyz/healthz says the process is alive. /readyz answers 200 once the database is reachable and its migrations are
current, and 503 otherwise. Its status reads degraded while Google sign-in discovery or the plan catalogue is
unavailable. If a service keeps restarting, read its log the same way (logs worker, for example): it names the
setting it is missing.
Back up and restore the database
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core \
exec -T postgres pg_dump -U haarness haarness_cloud | gzip > backup-$(date +%F).sql.gzBack up .env with it: without the same HAARNESS_CLOUD_AGENT_SECRETS_KEY, the encrypted secrets in the database
cannot be read. Back up your S3 bucket with your storage provider's tools at the same time, because each refers to
the other.
To restore, start from a stack with no volumes, start only PostgreSQL, load the backup, then bring up the rest:
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core up -d postgres
gunzip -c backup-2026-10-24.sql.gz | docker compose --env-file .env -f deploy/compose/compose.yaml \
--profile core exec -T postgres psql -v ON_ERROR_STOP=1 -U haarness haarness_cloud
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core up -d --scale fixtures=0Try a restore before you need one.
Upgrade
Back up the database first: to go back to an older version, you restore that backup. Then pull both sources and rebuild the web app and the services:
git -C ../haarness-router pull
git pull
docker compose --env-file .env -f deploy/compose/compose.yaml \
--profile web-dev run --rm --build web-dist
docker compose --env-file .env -f deploy/compose/compose.yaml \
--profile core up -d --build --scale fixtures=0up runs the new database migrations before it starts the new services; running them again applies nothing. To
run the migrations on their own:
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core run --rm migrateProfiles
| Profile | Starts |
|---|---|
core |
PostgreSQL, migrations, API, worker, gateway, router and Caddy, plus the fixtures unless you pass --scale fixtures=0 |
core-deps |
PostgreSQL only, for running the services natively while you develop |
fixtures |
Fake sign-in, Stripe and S3 services, for development and tests only |
web-dev |
The Vite dev server on 127.0.0.1:18173, and the one-shot web app build |
observability |
Prometheus on 127.0.0.1:19090 and Grafana on 127.0.0.1:13000 |
docker compose --env-file .env -f deploy/compose/compose.yaml \
--profile core --profile observability up -d --scale fixtures=0The fixtures are never appropriate for real users. The fake identity provider issues an identity for any email address you ask it for.
Grafana opens without a sign-in, as an admin, so keep it on loopback. Prometheus reads the router's metrics with
the development metrics token; if you set your own HAARNESS_CLOUD_METRICS_TOKEN, put the same value in the
credentials line of deploy/compose/observability/prometheus.yml.
What production refuses
With HAARNESS_CLOUD_ENV=prod, the services refuse to start unless:
- the fixtures are off and workspaces use Fly.io, not the
localprovider; - object storage is not the in-memory test store (
HAARNESS_CLOUD_S3_ENDPOINT=memory); HAARNESS_CLOUD_COOKIE_SECURE=trueand rate limits are on;- Google sign-in, if configured, uses a real client and an
https://issuer; - email goes through SendGrid over HTTPS;
- pushes go through Apple's or Google's push service (
HAARNESS_CLOUD_PUSH_PROVIDER=apns,fcmorapns,fcm), or are turned off on purpose withoff; - Stripe uses a live secret key, and every plan and top-up pack has its price id;
HAARNESS_CLOUD_AGENT_SECRETS_KEYholds 32 random bytes in base64;- every public URL, and the router address workspaces use, starts with
https://.
This Compose file fixes the router address workspaces use to http://router:18082, so a production start is refused
today.
Remove it
Add the same --profile flags you started with:
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core down # keep the data
docker compose --env-file .env -f deploy/compose/compose.yaml --profile core down -v # delete the volumes-v deletes the database and the other named volumes. There is no undo. Your S3 bucket is not touched.