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

> ## Agent Instructions
> Describe what the running code does. Do not invent hosted pricing, launch dates, or security guarantees. Share and viewer links are a configured base URL plus an artifact path. Show localhost and a placeholder origin such as https://library.example.com.

# Self-hosting

> Run Factrove with Docker Compose, or from source, including storage, HTTPS, the CLI, upgrades, and backups.

Factrove runs as your own install. The primary path is `docker-compose.yml`: PostgreSQL, Garage, a one-shot migration, the API, and the web app. That stack builds its images from this repository. GitHub Actions also publishes `linux/arm64` images for the API and the web app. See [Published images](#published-images).

Factrove is licensed under the GNU Affero General Public License v3.0 or later. If you run a modified version as a network service, offer its source to the people who use it. The web app includes a Source code link. Set `SOURCE_CODE_URL` on the web process to your fork. The default is `https://github.com/magnificode-ltd/factrove`.

`compose.yaml` is a different file. It starts PostgreSQL and Garage for [`pnpm dev`](https://docs.factrove.app/quickstart) and leaves the API and the web app on the host. `docker compose up` without `-f docker-compose.yml` selects that file.

## Prerequisites

For the Compose stack:

* Docker Engine and the Compose plugin. The command is `docker compose` (Compose v2). The file uses health checks, `service_completed_successfully`, and required `${VAR:?}` substitution.
* A clone of this repository, so Compose can build the images.
* Enough disk for the build and for the PostgreSQL and Garage volumes.

The stack pulls two pinned images and builds the other two:

| Image | Role |
| - | - |
| `postgres:17.11` | Metadata. Official multi-arch index |
| `dxflrs/garage:v2.3.0` | S3-compatible bytes for the bundled bucket. Official multi-arch index |
| `factrove-api:local` | API, built here with `docker build --target api` |
| `factrove-web:local` | Web app, built here with `docker build --target web` |

The API image uses Node.js 24.16.0 and pnpm 11.25.0, the same pins as `.nvmrc` and `packageManager` in `package.json`. You do not install Node on the host unless you also run the CLI or the [from-source](#run-from-source) path.

Garage creates the bucket named by `S3_BUCKET` because its command includes `--default-bucket`. The API does not create a bucket.

## Get the code

```bash theme={null}
git clone https://github.com/magnificode-ltd/factrove.git
cd factrove
```

`docker compose up --build` builds `factrove-api:local` and `factrove-web:local` from the `Dockerfile`. Pull requests build those same targets as `factrove-api:ci` and `factrove-web:ci` for that check. The images you can pull are in [Published images](#published-images).

## Published images

`.github/workflows/image.yml` publishes two `linux/arm64` images on every push to `main`, and when someone runs that workflow by hand. The runner is `ubuntu-24.04-arm`, which matches Graviton instances such as `t4g` in AWS `us-east-1`. Both images come from the existing `Dockerfile`.

| Image | Target | Role |
| - | - | - |
| `ghcr.io/magnificode-ltd/factrove-api` | `api` | API, and `prisma migrate deploy` for the migrate service |
| `ghcr.io/magnificode-ltd/factrove-web` | `web` | Web app |

Each image has five tags:

| Tag | Meaning |
| - | - |
| 40-character commit SHA | That commit |
| 7-character short SHA | That same commit |
| `sha-` and the 40-character commit SHA | That commit |
| `sha-` and the 7-character short SHA | That same commit |
| `latest` | The newest publish from the default branch |

A manual run from another branch publishes that commit's SHA tags, with and without the `sha-` prefix, and leaves `latest` where it is. Each image carries `org.opencontainers.image.source`, `org.opencontainers.image.revision` (the full commit SHA), and `org.opencontainers.image.licenses=AGPL-3.0-or-later`.

The packages stay private while this repository is private. That is the GitHub Container Registry default. When the repository is open-sourced, set `factrove-api` and `factrove-web` to public in each package's settings. Package visibility is separate from repository visibility. Making a package public is permanent.

Pull a private package with a GitHub account that can read it and a token that has the `read:packages` scope:

```bash theme={null}
echo "$GHCR_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
docker pull ghcr.io/magnificode-ltd/factrove-api:latest
docker pull ghcr.io/magnificode-ltd/factrove-web:latest
```

Replace `latest` with a full or short commit SHA, or with `sha-` and that SHA, to pin a build. `factrove-cloud` accepts an image tag when it is a release tag containing a dot, a tag starting with `sha-`, or a sha256 digest. The bare commit SHA tags stay so a reference that already uses them still resolves. The token AWS EC2 will use to pull these images will be stored in AWS Secrets Manager, created by `factrove-cloud`.

`docker-compose.yml` still builds `factrove-api:local` and `factrove-web:local` on the machine running Compose. Published tags are `linux/arm64` only. An `amd64` host keeps building from this checkout.

## Configure

Copy the example and replace every placeholder before the first start. `.env.docker` is gitignored. The values in the example are placeholders.

```bash theme={null}
cp .env.docker.example .env.docker
```

Generate a secret with:

```bash theme={null}
openssl rand -hex 32
```

Run that once for each value. The output is 64 hex characters. Use it as-is.

* `POSTGRES_PASSWORD` is inserted into `DATABASE_URL` without encoding. `@`, `:`, `/`, and `#` break that URL. The hex output avoids those characters.
* `BETTER_AUTH_SECRET` must be at least 32 characters. The hex output qualifies. It signs session cookies.
* `GARAGE_RPC_SECRET` must be 64 hex characters, which is the hex output above. The example text `replace-with-64-hex-characters-from-openssl-rand-hex-32` is not a valid Garage secret. Replace it or Garage exits.
* `S3_SECRET_ACCESS_KEY` is the Garage secret key. Use another hex output. `S3_ACCESS_KEY_ID` is the Garage access key id you choose. This pair is not an AWS key. Compose maps it to `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` on the API.

Compose reads `.env.docker` only when you pass `--env-file .env.docker`. A variable in that file reaches a container when `docker-compose.yml` interpolates it. `DATABASE_URL`, `S3_ENDPOINT`, and `INTERNAL_API_URL` are set in the compose file, so putting those names in `.env.docker` does not change them.

### Variables in `.env.docker`

| Variable | Required | Role |
| - | - | - |
| `POSTGRES_PASSWORD` | yes | Password for the database user `factrove`, database `factrove`, on the `postgres` service |
| `BETTER_AUTH_SECRET` | yes | Session signing secret, at least 32 characters |
| `GARAGE_RPC_SECRET` | yes with bundled Garage | 64 hex characters. Replace the placeholder before the first start |
| `S3_ACCESS_KEY_ID` | yes with bundled Garage | Garage access key id, passed to the API as `AWS_ACCESS_KEY_ID` |
| `S3_SECRET_ACCESS_KEY` | yes with bundled Garage | Garage secret key, passed to the API as `AWS_SECRET_ACCESS_KEY` |
| `S3_BUCKET` | no | Bucket name. Default `factrove`. Garage creates it on startup |
| `S3_REGION` | no | Region string. Default `us-east-1`, which matches `s3_region` in `infra/garage.toml` |
| `WEB_ORIGIN` | no | Browser origin of the web app. Default `http://localhost:3000` |
| `API_PUBLIC_ORIGIN` | no | Origin the API writes into absolute API URLs, and the Better Auth base URL. Default `http://localhost:3001` |
| `PUBLIC_BASE_URL` | set it whenever the web origin is not `http://localhost:3000` | Origin for viewer URLs and public share links. An empty value becomes `http://localhost:3000`. It does not follow `WEB_ORIGIN` |
| `BROWSER_API_URL` | no | Origin the browser uses to call the API. Default `http://localhost:3001`. Read when a page is rendered |
| `SOURCE_CODE_URL` | no | Source code link in the web app. Default `https://github.com/magnificode-ltd/factrove`. Read when a page is rendered |
| `WEB_PORT` | no | Host port published on `127.0.0.1` for the web app. Default `3000` |
| `API_PORT` | no | Host port published on `127.0.0.1` for the API. Default `3001` |
| `SHARE_TOKEN_KEY` | no | Share-token encryption key, at least 32 characters when set. Empty derives the key from `BETTER_AUTH_SECRET` with HKDF |
| `AUTH_COOKIE_DOMAIN` | no | Parent domain for the session cookie when the web app and API use different hosts. Leave empty on localhost |
| `TRUSTED_PROXIES` | no | Comma-separated proxy IPs or CIDRs that may set `X-Forwarded-For`. Empty keeps the socket peer |
| `EMAIL_TRANSPORT` | no | `log` (default), `smtp`, or `brevo`. `log` writes the invite to the API log and does not send it |
| `EMAIL_FROM_ADDRESS` | no | From address. Default `support@factrove.app` |
| `EMAIL_FROM_NAME` | no | From name. Default `Factrove` |
| `EMAIL_LOGO_URL` | no | Absolute http(s) logo for the invite. Empty uses the text wordmark |
| `SMTP_HOST` | when transport is `smtp` | Hostname or IP of any SMTP server |
| `SMTP_PORT` | when transport is `smtp` | Port from 1 to 65535. `587` is the usual STARTTLS port |
| `SMTP_SECURE` | when transport is `smtp` | `true` for implicit TLS, usually port 465. `false` for STARTTLS, usually port 587 |
| `SMTP_REQUIRE_TLS` | no | Unset requires STARTTLS when `SMTP_SECURE` is `false`. `false` allows plaintext. The password and the message are then visible on the network |
| `SMTP_USER` | no | SMTP username. Set it with `SMTP_PASSWORD`, or leave both empty when the server has no AUTH |
| `SMTP_PASSWORD` | no | SMTP password. Do not commit it. On Brevo's relay this is the SMTP key, which is not `BREVO_API_KEY` |
| `BREVO_API_KEY` | when transport is `brevo` | Brevo HTTP API key. Ignored for `log` and `smtp`. The API does not call `GET /v3/account` |
| `MARKDOWN_CONVERSION_CONCURRENCY` | no | Markdown workers at once. Default `2`, then clamped to 1–4 |
| `MARKDOWN_CONVERSION_QUEUE` | no | Conversions that may wait. Default `2`, then clamped to 0–8. A full queue is `503 CONVERSION_UNAVAILABLE` |

`PUBLIC_BASE_URL`, `WEB_ORIGIN`, `API_PUBLIC_ORIGIN`, and `BROWSER_API_URL` are absolute `http` or `https` origins. No userinfo, path, query, or hash. A trailing slash is dropped. Setting `PUBLIC_BASE_URL` does not start a server or publish the app.

`docker-compose.yml` sets `PUBLIC_BASE_URL` from `${PUBLIC_BASE_URL:-http://localhost:3000}`. An empty value in `.env.docker` becomes `http://localhost:3000` before the API process starts. On this stack the API does not fall back to `WEB_ORIGIN`. If you change the public origin or `WEB_PORT`, set `PUBLIC_BASE_URL` to that same origin. With `WEB_ORIGIN=http://localhost:13000` and `PUBLIC_BASE_URL` empty, viewer and share links still use port 3000. The API uses `WEB_ORIGIN` for those links only when `PUBLIC_BASE_URL` is unset, which is the [from-source](#run-from-source) process with the variable left out of `apps/api/.env`.

`MARKDOWN_CONVERSION_CONCURRENCY` and `MARKDOWN_CONVERSION_QUEUE` are read with `parseInt`. A leading integer is kept, so `1.5` becomes 1 and `2abc` becomes 2. A value with no leading integer, such as `abc`, uses the default 2. An empty value uses 2. The integer is then clamped.

`localhost` and `127.0.0.1` are different hosts. A cookie domain cannot cover either. Keep `WEB_ORIGIN` and `PUBLIC_BASE_URL` on the same host when either one is localhost or an IP address. When those hosts differ and both are domain names, set `AUTH_COOKIE_DOMAIN` to the narrowest parent of `WEB_ORIGIN`, `API_PUBLIC_ORIGIN`, and `PUBLIC_BASE_URL`. The API refuses to start when that domain is an IP, a public suffix, or a name that does not cover all three.

Rotating `SHARE_TOKEN_KEY`, or `BETTER_AUTH_SECRET` when `SHARE_TOKEN_KEY` is empty, makes existing public links return 404. Share the artifact again afterward.

On the published localhost ports the example is already aligned:

```text theme={null}
WEB_ORIGIN=http://localhost:3000
PUBLIC_BASE_URL=http://localhost:3000
API_PUBLIC_ORIGIN=http://localhost:3001
BROWSER_API_URL=http://localhost:3001
```

If you change `WEB_PORT` or `API_PORT`, set `WEB_ORIGIN`, `PUBLIC_BASE_URL`, `API_PUBLIC_ORIGIN`, and `BROWSER_API_URL` to those host ports. `PUBLIC_BASE_URL` still needs its own value.

### Values the compose file sets

These are not in `.env.docker`. They are the bundled-stack defaults on the API and web services:

| Variable | Value on the bundled stack |
| - | - |
| `DATABASE_URL` | `postgresql://factrove:<POSTGRES_PASSWORD>@postgres:5432/factrove` on `api` and `migrate` |
| `HOST` / `PORT` | API listens on `0.0.0.0:3001` inside the container |
| `S3_ENDPOINT` | `http://garage:3900` |
| `S3_FORCE_PATH_STYLE` | `true` |
| `S3_ALLOW_INSECURE_HTTP` | `true`, so the API accepts cleartext HTTP to the Garage hostname. The access key is sent in cleartext. Use this only on a private network |
| `INTERNAL_API_URL` | `http://api:3001`, the origin the Next.js server uses for its own requests |
| `HOSTNAME` / `PORT` | Web server listens on `0.0.0.0:3000` inside the container |

`FACTROVE_MIGRATE` is unset. See [Migrations](#migrations).

The web image reads `BROWSER_API_URL` when it renders a page, so changing that origin does not require a rebuild. Recreate the web container after you edit `.env.docker`. `INTERNAL_API_URL` stays `http://api:3001` on this network. `pnpm build` on the host still bakes `NEXT_PUBLIC_API_URL` for a process you start with `next start`. See [Run from source](#run-from-source).

## Build and run

From the repository root:

```bash theme={null}
docker compose --env-file .env.docker -f docker-compose.yml up --build -d
```

`--build` builds `factrove-api:local` and `factrove-web:local`. `-d` leaves the stack in the background. Omit `-d` to attach the terminal to the logs. The same images, built without Compose:

```bash theme={null}
docker build --target api --tag factrove-api:local .
docker build --target web --tag factrove-web:local .
```

Compose will not start until `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET`, `GARAGE_RPC_SECRET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY` are set. The `:?` markers in `docker-compose.yml` are what print that requirement.

| Service | Listens | Published on the host |
| - | - | - |
| `web` | `0.0.0.0:3000` in the container | `127.0.0.1:${WEB_PORT:-3000}` |
| `api` | `0.0.0.0:3001` in the container | `127.0.0.1:${API_PORT:-3001}` |
| `postgres` | `5432` on the Compose network | not published |
| `garage` | `3900` on the Compose network | not published |
| `migrate` | no listener | runs `prisma migrate deploy` and exits |

The project name is `factrove-selfhost`. Its volumes are separate from the dev project in `compose.yaml`, whose name is `factrove`.

Open the library at `http://localhost:3000` when `WEB_PORT` is `3000`. The API health check, with no credentials:

```bash theme={null}
curl -fsS http://127.0.0.1:3001/api/v1/health
```

A ready API returns `{"status":"ok"}`. The API image and the `api` service use that URL as their health check. The web service requests `http://127.0.0.1:3000/`.

```bash theme={null}
docker compose --env-file .env.docker -f docker-compose.yml ps
docker compose --env-file .env.docker -f docker-compose.yml logs migrate api web
```

`api` waits until `migrate` has exited successfully and until PostgreSQL and Garage are healthy. Garage creates the bucket during its start.

## Migrations

Startup of the API process does not migrate. The `migrate` service runs `prisma migrate deploy` from the migrations baked into `factrove-api:local`, then exits.

`docker compose up` creates that container and runs `prisma migrate deploy`. A later `up` or `up -d` starts the service again and runs the deploy again. The process exits when it finishes. `restart: "no"` stops Compose from bringing that exited container back by itself. The next `up` still starts the service. Repeating the deploy is safe: a database that already has the migrations reports that none are pending.

After you pull new migrations, apply them with the upgrade command before the new API serves:

```bash theme={null}
docker compose --env-file .env.docker -f docker-compose.yml run --rm migrate
```

`up -d` runs that same deploy again. The explicit `run --rm migrate` remains the upgrade step. It applies the migrations before you treat the new API as ready.

`FACTROVE_MIGRATE=1` is an opt-in on the API container: the entrypoint migrates, then serves. Any other value, including empty, serves without migrating. The committed compose file does not set it. Adding the name to `.env.docker` has no effect until the `api` service environment contains `FACTROVE_MIGRATE`. The `migrate` service is the path this file already runs.

There is no down migration. `pnpm db:seed` is a host command for local fixture identities. The Compose stack does not seed a user. Sign up in the web app.

## HTTPS and a reverse proxy

Published ports bind to `127.0.0.1`. A reverse proxy on the same machine is the way to serve HTTPS. Terminate TLS at the proxy. The containers speak HTTP.

Route the web origin to the web port and the API origin to the API port:

```text theme={null}
https://library.example.com  ->  127.0.0.1:3000
https://api.example.com      ->  127.0.0.1:3001
```

Set the public origins to those URLs. The host ports stay on loopback and do not appear in the public URLs.

```text theme={null}
WEB_ORIGIN=https://library.example.com
PUBLIC_BASE_URL=https://library.example.com
API_PUBLIC_ORIGIN=https://api.example.com
BROWSER_API_URL=https://api.example.com
AUTH_COOKIE_DOMAIN=example.com
```

`BROWSER_API_URL` and `API_PUBLIC_ORIGIN` are the origin the browser uses for the API. `WEB_ORIGIN` and `PUBLIC_BASE_URL` are the origin the browser uses for the library, the viewer, and share links. `INTERNAL_API_URL` stays `http://api:3001` so the Next.js server reaches the API on the Compose network.

Use the narrowest parent that still covers all three hosts as `AUTH_COOKIE_DOMAIN`. For `library.factrove.example.com` and `api.factrove.example.com`, that parent is `factrove.example.com`. The browser sends the session cookie to every subdomain of the value. On an `https` API origin the cookie is named `__Secure-better-auth.session_token`. On `http` it is `better-auth.session_token`.

Forward `X-Forwarded-For` from the proxy. The API trusts that header only when the TCP peer is listed in `TRUSTED_PROXIES`. Inside the container, a connection that arrived through the published port comes from the Compose bridge gateway, not from `127.0.0.1`. Read that gateway after the stack is up:

```bash theme={null}
docker network inspect factrove-selfhost_default --format '{{(index .IPAM.Config 0).Gateway}}'
```

Set `TRUSTED_PROXIES` to that address (or a CIDR that contains it). Leave `TRUSTED_PROXIES` empty when no proxy sends `X-Forwarded-For`. An empty value counts every visitor as the socket peer, so behind a proxy every visitor shares one rate-limit bucket. Sign-in and public views use that address. The limits are in memory, on each API process.

To publish a port beyond loopback, change the mapping in `docker-compose.yml` from `127.0.0.1:${API_PORT:-3001}:3001` to a wider bind. Keep TLS in front of any interface that is not loopback.

Do not log the `access` or `ticket` query on content URLs. `access` is the signed-in viewer grant. `ticket` is the grant for a password-protected public link. The viewer loads HTML from `API_PUBLIC_ORIGIN` in an iframe with `sandbox="allow-scripts"` and without `allow-same-origin`. There is no separate content hostname. `frame-ancestors` allows `PUBLIC_BASE_URL` and `WEB_ORIGIN`, so those origins must be the ones that frame the API response.

Recreate the containers after origin changes. `up -d` also starts `migrate` and runs `prisma migrate deploy` again, which exits once the database is already current:

```bash theme={null}
docker compose --env-file .env.docker -f docker-compose.yml up -d
```

## Object storage

The storage client calls `s3:PutObject`, `s3:GetObject`, and `s3:DeleteObject` on keys the API generates. New objects use `{artifactId}/{contentId}.html`. Older rows may still use `{artifactId}.html`. The public filename stays `{artifactId}.html`. HTTP responses and the CLI omit the key.

`S3_ENDPOINT` is the only custom endpoint the client reads. With that variable unset, `AWS_ENDPOINT_URL` is ignored. Plain `http` is accepted for `localhost`, `127.0.0.1`, and `::1`. Any other host needs `https`, unless `S3_ALLOW_INSECURE_HTTP=true`. That flag must be the string `true` or `false`.

The committed `docker-compose.yml` always starts Garage and points the API at `http://garage:3900`. A different bucket is a change to the `api` service environment. `.env.docker` cannot override `S3_ENDPOINT` while the compose file sets it.

### Bundled Garage

Keep the compose file's endpoint, path style, and `S3_ALLOW_INSECURE_HTTP=true`. Set `S3_BUCKET`, `S3_REGION`, and the Garage key pair as in [Configure](#configure). Garage creates `S3_BUCKET` on startup. The S3 port is not published on the host. Clients on the Compose network use `http://garage:3900`.

### AWS S3

The API does not create an AWS bucket. Create the bucket first, in `S3_REGION`.

On the `api` service:

* Set `S3_BUCKET` and `S3_REGION`.
* Remove `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE`, and `S3_ALLOW_INSECURE_HTTP`. The client then calls AWS in `S3_REGION`.
* Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` to the IAM key, or remove both. The client uses that pair whenever both are set, which is what the committed file does with the Garage key. Removing both lets the AWS SDK default chain run inside the container (environment, `AWS_PROFILE`, or a task role).
* When the key is temporary, set `AWS_SESSION_TOKEN` as well. The API sends the token only together with the access key and secret. The committed file does not pass a session token, so add it on the `api` service.

If you are not starting Garage, remove the `garage` service and the `garage` entry under `api.depends_on`. While that service remains, Compose still requires `GARAGE_RPC_SECRET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY`.

### MinIO and other S3-compatible stores

MinIO and any other S3-compatible store use the same client. The API does not create the bucket.

On the `api` service:

* Set `S3_BUCKET`, `S3_REGION`, and `S3_ENDPOINT` to that store's origin. No path, query, hash, or credentials in the URL.
* Set `S3_FORCE_PATH_STYLE` to `true` for path-style buckets (MinIO's usual mode). Virtual-hosted buckets use `false`. When the endpoint is set and this variable is unset, the client uses path style.
* Use `https` for a host other than loopback. `S3_ALLOW_INSECURE_HTTP=true` allows `http` to a private-network hostname. The access key is sent in cleartext.
* Set `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` to that store's key pair.

Remove the bundled `garage` service the same way as for AWS when you are not using it.

## External PostgreSQL

The committed file always starts `postgres:17.11` and sets `DATABASE_URL` on `migrate` and `api` to `postgresql://factrove:<POSTGRES_PASSWORD>@postgres:5432/factrove`. An external database is a change to those two `DATABASE_URL` values. A `DATABASE_URL` line in `.env.docker` does not replace them.

Point both services at the external URL. Encode reserved characters in the password. Remove the `postgres` service and the `postgres` entries under `depends_on`, or Compose will keep starting the bundled database. The migrations are the committed SQL in `apps/api/prisma/migrations/`, applied by the `migrate` service. The database user needs permission to create tables and indexes. This repository runs those migrations on PostgreSQL 17.

## Point the CLI at this install

Install the published CLI and point it at this API. Node.js must be at least 24.7.0 and below 25. The package name is `factrove`. `ftrv` is the same command.

```bash theme={null}
npm install --global factrove
factrove configure --api-url http://localhost:3001
```

`npx factrove configure --api-url http://localhost:3001` skips the global install. The built-in origin is `https://api.factrove.app`, so this install needs `--api-url` or a saved profile URL. `--endpoint-url` is the same option. `--api-key` prompts when you omit it, so the key stays out of shell history. Create the key in the web app at `/account/api-keys` after you sign up. The secret is shown once. The same session call is in [API overview](https://docs.factrove.app/api-reference/overview.md#create-an-api-key-from-a-session).

From a checkout, the same command is `pnpm factrove`. Node.js 24.16.0 and pnpm 11.25.0 match the pins in this repository.

```bash theme={null}
pnpm install --frozen-lockfile
pnpm --filter @factrove/cli... build
pnpm factrove configure --api-url http://localhost:3001
```

`factrove configure` writes `~/.factrove/credentials` and `~/.factrove/config` mode 0600 and stores the origin as `api_url`. It does not print the key. Later commands use that profile. Precedence is in [CLI](https://docs.factrove.app/guides/cli.md). `pnpm factrove` can also read `FACTROVE_API_URL` from `apps/cli/.env` when the process environment and the profile have no URL. The example sets that URL to `http://localhost:3001`. `node apps/cli/dist/main.js` and the published commands do not read that file.

An explicit `--profile` uses that profile's key and ignores `FACTROVE_API_KEY`.

## Check that it works

With the stack up and the origins left on localhost:

**1. Health**

```bash theme={null}
curl -fsS http://127.0.0.1:3001/api/v1/health
```

The body is `{"status":"ok"}`.

**2. Sign up**

Open `http://localhost:3000/signup` and create an account. Passwords are 8 to 128 characters. Sign-up also signs you in.

**3. API key and the CLI**

Create a key at `http://localhost:3000/account/api-keys`, then from the repository root:

```bash theme={null}
pnpm factrove configure --api-url http://localhost:3001
artifact_id=$(pnpm factrove upload ./fixtures/hello.html)
printf '%s\n' "$artifact_id"
```

Plain upload prints only the artifact id. pnpm writes the script line it runs to stderr, so the substitution above is the id.

**4. Library and viewer**

Open `http://localhost:3000`, choose **My artifacts**, and use Refresh if the new row is not listed yet. The viewer is `http://localhost:3000/artifacts/{id}`. An empty My artifacts page shows `npm i -g factrove`, `npx factrove`, the `ftrv` alias, `factrove configure` with the API origin, and `factrove upload`, plus a prompt for a coding agent. The commands on this page use `pnpm factrove` because they run from the checkout. Plain upload prints the artifact id. Add `--json` and open `viewerUrl` from that line.

**5. Share link**

```bash theme={null}
pnpm factrove share "$artifact_id"
```

The command prints a URL on `PUBLIC_BASE_URL`, shaped `http://localhost:3000/share/{id}/{token}`. Open that URL without an account. It serves the stored HTML until you revoke it.

Upload failures are in [Troubleshooting](https://docs.factrove.app/guides/troubleshooting.md). A wrong API origin is `NETWORK_ERROR`. A storage mismatch is `STORAGE_UNAVAILABLE`, and that failed upload leaves no artifact row.

## Upgrade

From the repository root, with the same `.env.docker`:

```bash theme={null}
git pull
docker compose --env-file .env.docker -f docker-compose.yml build
docker compose --env-file .env.docker -f docker-compose.yml run --rm migrate
docker compose --env-file .env.docker -f docker-compose.yml up -d
```

`build` refreshes `factrove-api:local` and `factrove-web:local`. `run --rm migrate` is the deliberate migration step. It applies committed migrations before the new API serves. `up -d` recreates the API and the web app and starts `migrate` again, which runs the same deploy. Both runs are safe to repeat. Keep `BETTER_AUTH_SECRET` and any `SHARE_TOKEN_KEY` stable across the upgrade, or existing sessions and share links stop resolving.

Stop writers before a migration that rewrites `artifact` if you are crossing `20261021170000_add_artifact_object_key` from an older build. That migration locks `artifact`. An API process from before it cannot insert or replace content once `object_key` is required. There is no down migration. Rolling back that cutover means stopping the API, copying each current object to `{artifactId}.html` in the same bucket, and restoring the previous schema and application. The previous application reads `{artifactId}.html`.

## Backups

Take the database, the object bytes, and the key material together. A successful replacement deletes the previous object after the new one is published, so a later database row no longer names the bytes you replaced.

Stop the API and the web app so a replacement cannot delete an object during the copy:

```bash theme={null}
docker compose --env-file .env.docker -f docker-compose.yml stop api web
```

Dump PostgreSQL from the bundled service. Inside that container, local connections use trust authentication, so `pg_dump` accepts user `factrove` without a password:

```bash theme={null}
docker compose --env-file .env.docker -f docker-compose.yml exec -T postgres \
  pg_dump -U factrove --no-owner --no-acl factrove > factrove.sql
```

The named volume for that data is `factrove-postgres` on project `factrove-selfhost` (Docker stores it as `factrove-selfhost_factrove-postgres`). A volume copy is a second copy of the same data. Stop `postgres` before you archive the volume, so PostgreSQL is not writing it. `pg_dump` is the copy you can restore into a new database.

For the bundled bucket, stop Garage and archive both of its volumes. Metadata is SQLite, so a copy taken while Garage is running can tear.

```bash theme={null}
docker compose --env-file .env.docker -f docker-compose.yml stop garage
```

The volume names in the compose file are `factrove-garage-meta` and `factrove-garage-data`. Docker stores them as `factrove-selfhost_factrove-garage-meta` and `factrove-selfhost_factrove-garage-data`. List them with:

```bash theme={null}
docker volume ls --filter label=com.docker.compose.project=factrove-selfhost
```

On AWS or MinIO, copy the bucket with that store's tools. Include every object named by the current rows:

```sql theme={null}
select id, object_key, size_bytes from artifact order by id;
```

Store `BETTER_AUTH_SECRET`, any `SHARE_TOKEN_KEY`, `GARAGE_RPC_SECRET`, and the S3 key pair with the backup, outside the dump. Sessions and share tokens cannot be read without the same auth material. When `SHARE_TOKEN_KEY` is unset, the share key is derived from `BETTER_AUTH_SECRET`. Keep `GARAGE_RPC_SECRET` with that backup.

Restore by loading that dump and those object bytes, then starting the stack with the same secrets. The dump does not include rows written after it, and it does not recreate objects a later replacement deleted.

Start the stack again with `docker compose --env-file .env.docker -f docker-compose.yml up -d`. That starts `migrate` again. `prisma migrate deploy` leaves a database that already contains those migrations unchanged.

## Run from source

Use this path when you run the Node processes yourself. PostgreSQL 17 and an S3-compatible bucket still have to be reachable from the API. Node.js must satisfy `>=24.7.0 <25` (24.16.0 is pinned). That floor is the first release with `crypto.argon2`, which share passwords use. pnpm must satisfy `>=11.25.0 <12` (11.25.0 is pinned). `engineStrict` in `pnpm-workspace.yaml` enforces both ranges.

`pnpm infra:up` can start the dev PostgreSQL and Garage from `compose.yaml` (host ports `127.0.0.1:5432` and `127.0.0.1:3900`, bucket `factrove`, local key `factrove-local`). A database and bucket you already run are configured only through `apps/api/.env`.

```bash theme={null}
cp apps/api/.env.example apps/api/.env
cp apps/cli/.env.example apps/cli/.env
pnpm install --frozen-lockfile
```

Edit `apps/api/.env`. The API loads that file on startup and keeps variables that are already set in the environment. The example matches `compose.yaml`.

| Variable | Role |
| - | - |
| `DATABASE_URL` | PostgreSQL URL. The example is `postgresql://factrove:factrove@127.0.0.1:5432/factrove` |
| `HOST` / `PORT` | API bind. Default `127.0.0.1` and `3001`. Use `0.0.0.0` when a proxy on another machine must connect |
| `WEB_ORIGIN`, `API_PUBLIC_ORIGIN`, `PUBLIC_BASE_URL` | Same origin shape as the Compose stack. An empty `PUBLIC_BASE_URL` uses `WEB_ORIGIN`. The Compose file substitutes `http://localhost:3000` instead |
| `NEXT_PUBLIC_API_URL` | Origin baked into the web client at `pnpm build`. Default `http://localhost:3001` |
| `BROWSER_API_URL` | Optional runtime override of the browser API origin. The web process reads it when rendering a page |
| `SOURCE_CODE_URL` | Optional URL for the Source code link. Default `https://github.com/magnificode-ltd/factrove`. The web process reads it when rendering a page |
| `INTERNAL_API_URL` | Optional origin the Next.js server uses to reach the API. Unset, it follows the browser origin |
| `BETTER_AUTH_SECRET` | Required, at least 32 characters. The example value is a local placeholder |
| `SHARE_TOKEN_KEY`, `AUTH_COOKIE_DOMAIN`, `TRUSTED_PROXIES` | Same as the Compose table. Leave the cookie domain unset on localhost |
| `EMAIL_TRANSPORT`, `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME`, `EMAIL_LOGO_URL` | Invite email. Unset transport is `log`. From defaults are `support@factrove.app` and `Factrove`. An empty logo URL uses the text wordmark |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_REQUIRE_TLS`, `SMTP_USER`, `SMTP_PASSWORD` | Required for `EMAIL_TRANSPORT=smtp`, except the user, password, and `SMTP_REQUIRE_TLS`. User and password are both set or both empty. Unset `SMTP_REQUIRE_TLS` requires STARTTLS when `SMTP_SECURE` is `false` |
| `BREVO_API_KEY` | Required for `EMAIL_TRANSPORT=brevo`. Ignored otherwise |
| `S3_BUCKET`, `S3_REGION` | Required. The API does not create an AWS bucket |
| `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE`, `S3_ALLOW_INSECURE_HTTP` | Garage on the host is `http://127.0.0.1:3900` with path style. Leave the insecure-HTTP flag unset for loopback and for AWS |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Set both, or neither. The Garage example pair is `factrove-local` / `factrove-local-secret` |
| `AWS_SESSION_TOKEN` | Sent only together with both keys |
| `AWS_PROFILE` | Used by the AWS SDK default chain when both keys are unset |
| `MARKDOWN_CONVERSION_CONCURRENCY`, `MARKDOWN_CONVERSION_QUEUE` | Same clamps as the Compose table |

`BASE_URL` is read by `pnpm db:seed` and by auth schema generation. The API process uses `API_PUBLIC_ORIGIN` instead.

`pnpm dev` does not copy `BROWSER_API_URL`, `INTERNAL_API_URL`, or `SOURCE_CODE_URL` out of `apps/api/.env`. For a production web process, export them in the environment. Set `NEXT_PUBLIC_API_URL` before the build when you are not using `BROWSER_API_URL`. `SOURCE_CODE_URL` is read when a page renders, including under `next start`.

> [!WARNING]
>
> `pnpm build` and `next build` fix `NEXT_PUBLIC_API_URL` into the client bundle. `next start` cannot change that baked origin. `BROWSER_API_URL` on the web process replaces it for pages rendered after you set the variable.

Apply migrations, build, and start. Startup does not migrate. Run each start command under a process supervisor. The working directory can be the repository root. The API reads `apps/api/.env` itself.

```bash theme={null}
pnpm db:migrate
pnpm build
pnpm --filter @factrove/api start
pnpm --filter @factrove/web start --hostname 127.0.0.1 --port 3000
```

`pnpm --filter @factrove/api start` runs `node apps/api/dist/main.js`. pnpm appends `--hostname` and `--port` to `next start`. An extra `--` before them makes Next.js treat `--hostname` as a directory and the process exits. Bind both to loopback when the proxy is on the same machine. Behind that proxy, `TRUSTED_PROXIES=127.0.0.1` matches a proxy that connects to the host loopback. That value is for this host process. The container path in [HTTPS and a reverse proxy](#https-and-a-reverse-proxy) sees the bridge gateway instead.

`pnpm db:seed` upserts the fixture user `local-user` and organization `local-org`. That user has no password. It is optional and it does not wipe artifacts. A real install signs up in the web app. That sign-up sends the invite email. Seed does not.

### Invite email

Creating an account sends one invite email to that address. The signup page and `POST /api/auth/sign-up/email` are the same path. The CLI does not create accounts. There is no organization-member invite route.

The invite depends on `EmailSender`. `EMAIL_TRANSPORT` picks the implementation. `log` writes the message and does not send it. `smtp` sends through any SMTP server. `brevo` posts `https://api.brevo.com/v3/smtp/email`. An unknown transport, or a missing setting for the transport you chose, stops the process before it listens. Startup does not open a network connection and does not call `GET /v3/account`.

`EMAIL_FROM_ADDRESS` defaults to `support@factrove.app`. `EMAIL_FROM_NAME` defaults to `Factrove`. `docker-compose.yml` passes the same names. An empty SMTP block with `EMAIL_TRANSPORT=log` is valid. A leftover `BREVO_API_KEY` is ignored unless the transport is `brevo`.

The message greets the person with "Hi {name}," or "Hi," when the name is blank, then says the Factrove account for that address is ready. A "Sign in to Factrove" button links to `WEB_ORIGIN/login`, with that same URL on its own line. The footer says why the message was sent, gives `EMAIL_FROM_ADDRESS` as the support contact, and links to `https://factrove.app`. The HTML and the plain-text part carry the same words. Nothing in the template adds a query parameter.

Set `WEB_ORIGIN` to the public URL of the web app before you send mail. The sign-in link is that origin plus `/login`. The default `http://localhost:3000` only works for a browser on the same machine.

Sign-up does not wait for the provider. The API sends at most four invites at once and keeps at most 100 more waiting. A sign-up past that limit still creates the account. The log says `Invite email skipped for <address>. The send queue is full.` and that invite is not sent. On shutdown the API waits up to 10 seconds for queued and in-flight invites, then logs `Invite email shutdown left N send(s) unfinished:` followed by those addresses. `pnpm dev` force-stops the API after 15 seconds, and the Compose API service sets `stop_grace_period: 15s`, so that drain can finish.

The HTML and plain-text layout live in `apps/api/src/mail/invite-email-template.ts`. Edit that file, then rebuild the API, to change the wording, colors, or layout. `EMAIL_LOGO_URL`, when set to an absolute `http` or `https` URL, replaces the text wordmark with that image. Leave it unset to keep the wordmark.

This install needs Node.js 24 (`>=24.7.0 <25`; 24.16.0 is pinned). A ready API answers `GET /api/v1/health`.

Plus-addresses are valid. The stored address is lower case and the plus tag is kept, so `Magnificode+Factrove@Mail.Grokbot.com` is the same account as `magnificode+factrove@mail.grokbot.com`. Send the body as JSON. A form body would turn `+` into a space.

Copy `apps/api/.env.example` to `apps/api/.env` when that file is missing. Put one of these blocks in that file before you start the API. `BETTER_AUTH_SECRET` in the example is long enough for local use. Do not commit `BREVO_API_KEY` or `SMTP_PASSWORD`.

Log, which is also the default when `EMAIL_TRANSPORT` is unset:

```bash theme={null}
EMAIL_TRANSPORT=log
EMAIL_FROM_ADDRESS=support@factrove.app
EMAIL_FROM_NAME=Factrove
```

Any SMTP server. Leave `SMTP_USER` and `SMTP_PASSWORD` empty together when that server has no AUTH. `SMTP_SECURE=true` is implicit TLS, usually port 465. `SMTP_SECURE=false` is STARTTLS, usually port 587. Leave `SMTP_REQUIRE_TLS` unset so a plain connection must upgrade. `SMTP_REQUIRE_TLS=false` allows plaintext: the password and the message are visible on the network. Use that only on a private network.

```bash theme={null}
EMAIL_TRANSPORT=smtp
EMAIL_FROM_ADDRESS=support@factrove.app
EMAIL_FROM_NAME=Factrove
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=mailer
SMTP_PASSWORD=replace-with-the-smtp-password
```

Brevo's SMTP relay. The login and the SMTP key come from the Brevo SMTP settings. They are not `BREVO_API_KEY`.

```bash theme={null}
EMAIL_TRANSPORT=smtp
EMAIL_FROM_ADDRESS=support@factrove.app
EMAIL_FROM_NAME=Factrove
SMTP_HOST=smtp-relay.brevo.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-brevo-smtp-login
SMTP_PASSWORD=replace-with-the-brevo-smtp-key
```

Brevo's HTTP API:

```bash theme={null}
EMAIL_TRANSPORT=brevo
EMAIL_FROM_ADDRESS=support@factrove.app
EMAIL_FROM_NAME=Factrove
BREVO_API_KEY=replace-with-a-transactional-key
```

From the repository root, with the API stopped:

```bash theme={null}
pnpm install --frozen-lockfile
pnpm infra:up
pnpm db:migrate
pnpm --filter "@factrove/api..." build
pnpm --filter @factrove/api start
```

`pnpm infra:up` starts PostgreSQL. `pnpm db:migrate` applies the committed migrations. The API listens on `http://127.0.0.1:3001`. In a second terminal, from the repository root, the invitee address is the first argument. `WEB_ORIGIN` is the Origin header and must be the same value the API was started with. The default matches `apps/api/.env.example`.

```bash theme={null}
send_factrove_invite() {
  invitee_email="${1:?Pass the invitee email. Example: magnificode+factrove@mail.grokbot.com}"
  invitee_password="${2:-local-invite-check-1}"
  web_origin="${WEB_ORIGIN:-http://localhost:3000}"
  payload=$(node --input-type=module -e 'process.stdout.write(JSON.stringify({name:"Ada Lovelace",email:process.argv[1],password:process.argv[2]}))' -- "$invitee_email" "$invitee_password")
  curl -sS -D - -o /tmp/factrove-signup.json \
    -X POST http://127.0.0.1:3001/api/auth/sign-up/email \
    -H 'Content-Type: application/json' \
    -H "Origin: ${web_origin}" \
    --data-binary "$payload"
  printf '\n'
}

send_factrove_invite 'magnificode+factrove@mail.grokbot.com'
```

A 200 response creates the account. The saved body is `/tmp/factrove-signup.json`. In the API log:

* `Invite email sent to <address> messageId=<id>` means the SMTP server or the Brevo HTTP API accepted the message. `messageId` is omitted when the provider id contains a secret or is not a normal id.
* `Invite email shutdown left N send(s) unfinished:` means the process stopped before those sends finished. The account still exists.
* `EMAIL_TRANSPORT is log. Invite email was not sent.` means the log transport is selected. The following lines are From, To, Subject, and the text.
* `Invite email failed for <address>.` means the send failed. The account still exists. The log does not include `BREVO_API_KEY`, `SMTP_USER`, or `SMTP_PASSWORD`. Sign-up does not wait for the provider.

A second request for the same address is 422 and does not send another email. Pass a different plus tag to try again, for example `send_factrove_invite 'magnificode+factrove2@mail.grokbot.com'`.

The signup form at `http://localhost:3000/signup` sends the same request. `pnpm dev` starts the web app and the API together. Use the curl above when you only need the API.

Point the CLI at `http://localhost:3001` as in [Point the CLI at this install](#point-the-cli-at-this-install). Health, library, and share checks are the same, against the host ports.

## Removing an install

`docker compose --env-file .env.docker -f docker-compose.yml down` stops the self-host containers and keeps the volumes. `down -v` also deletes `factrove-postgres`, `factrove-garage-meta`, and `factrove-garage-data` for this project. Objects in a remote bucket stay.

`pnpm infra:down` stops the dev PostgreSQL and Garage from `compose.yaml` and keeps their volumes. `pnpm infra:reset` deletes those dev volumes after you confirm. Neither command touches project `factrove-selfhost`.

CLI credentials in `~/.factrove` stay until you delete those files. There is no artifact-delete command. A private artifact can be shared with the current organization from the web app, or with `PATCH /api/v1/artifacts/{id}` and `{ "accessType": "org_shared", "organizationId": "<current organization id>" }`. The CLI does not change access on an existing artifact. See [Current limits](https://docs.factrove.app/concepts.md#current-limits).

Failures during startup are collected in [Troubleshooting](https://docs.factrove.app/guides/troubleshooting.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.