> ## Documentation index
> Fetch the complete documentation index at: https://haarness.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](https://haarness.ai/docs/cloud/overview.md) 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](https://haarness.ai/docs/cloud/compose-install.md#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:

  ```text
  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.

```sh
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.

```sh
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

```sh
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

```sh
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

```sh
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:

```sh
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](https://haarness.ai/docs/cloud/compose-install.md#back-up-and-restore-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:

```sh
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:

```sh
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` |

```sh
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:

```sh
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.
