Skip to content
DocsHaarness Cloud
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=prod the services refuse to start, because that same router address is not https:// (see What production refuses). Run it in the default dev mode on http:// addresses, or in staging behind 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 of 127.0.0.1:18000, set HAARNESS_CLOUD_COMPOSE_PUBLIC_URL to the proxy's https:// address, and set HAARNESS_CLOUD_ENV=staging and HAARNESS_CLOUD_COOKIE_SECURE=true. In dev the API refuses an https:// address, because dev accepts 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-cloud and haarness-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 .env

Then edit .env:

  • HAARNESS_CLOUD_COMPOSE_PUBLIC_URL: add this line, set to the one address people use, http://127.0.0.1:18000 unless 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 of openssl rand -hex 32. It guards the internal endpoints. Outside dev, 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 of openssl 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_KEY and HAARNESS_CLOUD_STRIPE_WEBHOOK_SECRET: required to start. Stripe test-mode values are fine outside prod. Add the HAARNESS_CLOUD_STRIPE_PRICE_* ids only if you sell plans.
  • HAARNESS_CLOUD_S3_ENDPOINT, _PUBLIC_ENDPOINT, _BUCKET, _ACCESS_KEY and _SECRET_KEY: your object storage, required to start. With R2, both endpoints are the same https://<account>.r2.cloudflarestorage.com address. Create the bucket first; the services never create one.
  • HAARNESS_CLOUD_FLY_API_TOKEN and HAARNESS_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, and HAARNESS_CLOUD_EMAIL_FROM set to a sender you verified in SendGrid. Without SendGrid, set HAARNESS_CLOUD_EMAIL_PROVIDER=log: the stack starts, but offers no email sign-in.
  • Google: HAARNESS_CLOUD_OIDC_CLIENT_ID and HAARNESS_CLOUD_OIDC_CLIENT_SECRET from your own Google OAuth client. Add your public URL followed by /auth/google/callback to the client's authorized redirect URIs, such as http://127.0.0.1:18000/auth/google/callback.
  • Sign in with Apple is always off in this stack: compose.yaml clears 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/readyz

The 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.gz

Back 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=0

Try 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=0

up 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 migrate

Profiles

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=0

The 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 local provider;
  • object storage is not the in-memory test store (HAARNESS_CLOUD_S3_ENDPOINT=memory);
  • HAARNESS_CLOUD_COOKIE_SECURE=true and 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, fcm or apns,fcm), or are turned off on purpose with off;
  • Stripe uses a live secret key, and every plan and top-up pack has its price id;
  • HAARNESS_CLOUD_AGENT_SECRETS_KEY holds 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.