Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apps/docs/docs/self-host/bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
title: Compose bundle
---

# Compose bundle

The bundle is the adoption path: one `docker compose up` brings up the whole control plane — API
with embedded MCP and webhooks, worker, web UI, PostgreSQL, and the workspace runner image — from
the repository's `docker-compose.yml`. It needs no cloud account and no Terraform.

Use the [quickstart](quickstart.md) instead when working on the Facility source, and the
[AWS reference deployment](aws.md) when a hosted control plane is the goal. The bundle runs every
service on one host with one Docker daemon, so it is an evaluation and small-team shape, not a
resilient deployment.

## Prerequisites

Docker with Compose v2, a running daemon the current user can reach, and a GitHub organization
whose repositories Facility may automate. Story workspaces hold repository checkouts, dependencies,
nested images, and persistent volumes; keep several gigabytes of disk free.

## Start the control plane

The master key encrypts every stored credential. Generate it once and keep it: an instance that
loses its key cannot decrypt the project secrets it already holds.

```bash
git clone https://github.com/theam/facility.git
cd facility
printf 'SECRET_MASTER_KEY=%s\n' "$(openssl rand -base64 32)" >> .env
docker compose up -d
```

The first run builds the API, web, and runner images. `migrate` applies the schema and must exit
zero before the API and worker start; `runner-image` builds the workspace image the worker later
hands to stories. Both are one-shot services, so `docker compose ps` showing them as exited is the
expected steady state.

Check the control plane:

```bash
curl --fail http://localhost:4400/health
curl --fail http://localhost:4400/readyz
```

| Surface | URL |
| --- | --- |
| Web UI | `http://localhost:3400` |
| API, MCP, webhooks, OpenAPI | `http://localhost:4400` |
| Story previews | `http://preview.localhost:4400` |

The preview origin is a separate security surface because it serves code an agent wrote. It stays a
registered site of its own even here; `preview.localhost` resolves to loopback in modern browsers.

Both host ports are bound to `127.0.0.1`, so the bundle is reachable from the machine running it and
nowhere else. That binding is what lets it run without TLS: a `localhost` URL constrains nothing on
its own, and a port published on every interface would serve plaintext sessions and preview traffic
to the network segment. Reaching the instance from another machine therefore means changing the
binding, and changing it means putting TLS and the HTTPS requirements described in the
[production guide](production.md) in front of it first. A tunnel or reverse proxy is the same
decision.

## Connect GitHub

The bundle ships no identity, so sign-in and repository automation both have to be configured
before the first story. This is the longest step; the rest of the page takes minutes.

1. Create a **GitHub OAuth App** for browser sign-in with callback
`http://localhost:3400/api/auth/callback`, and put its credentials in `.env`:

```dotenv
AUTH_IDENTITY_PROVIDER=github
GITHUB_OAUTH_CLIENT_ID=...
GITHUB_OAUTH_CLIENT_SECRET=...
```

Without them the login page offers GitHub sign-in and the API answers `auth_unconfigured`. See
[Authentication](authentication.md) for the OIDC alternative and for the organization
restriction.

2. Create and install the **GitHub App** that Facility uses for clone and push credentials,
kickstart pull requests, and webhook-driven agents, then add `GITHUB_APP_ID`,
`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_WEBHOOK_SECRET` and `GITHUB_APP_SLUG` to `.env`. The
permission table and event subscriptions are in the [GitHub App guide](github-app.md).

3. Apply the new configuration:

```bash
docker compose up -d
```

GitHub cannot deliver webhooks to `localhost`. Agents triggered from the UI or MCP work without a
tunnel; issue, comment, and pull-request triggers need an HTTPS tunnel whose payload URL is
`https://<tunnel-host>/webhooks/github`, and `PUBLIC_URL` must match it. Treat that tunnel as
public.

## Bind the first owner

Migrations create the schema but no organization. Bind one owner to the installed GitHub App with
the operator CLI, which ships inside the API image:

```bash
docker compose exec api facility instance bootstrap \
--org-name "Acme" --org-slug acme \
--owner-email owner@acme.example --owner-name "Owner" \
--github-user-id 1 --github-login owner \
--github-account-id 2 --github-account-login acme \
--github-installation-id 3
```

Read the account, user, and installation identifiers from the GitHub App installation. Repeating
the exact binding is safe; a different binding against a populated instance is refused rather than
applied.

## First story

Sign in at `http://localhost:3400`, create a project, choose a repository, and open its kickstart
pull request. After merging that configuration pull request, sync the project on the Pipeline page
and start a small disposable story. The [story operations guide](../guides/operate-story.md) covers
normal work, and the [end-to-end validation](../guides/validate-workspace-loop.md) is worth running
before connecting code that matters.

## Operate

```bash
docker compose logs -f api worker # follow the control plane
docker compose up -d --build # apply a new checkout
docker compose stop # stop; volumes and stories remain
```

Facility does not delete worktrees or session volumes by age, so watch disk usage. Story workspace
volumes are managed by Facility and outlive the API and worker containers: remove a story through
the product, not with broad Docker volume pruning. Before reusing a database from an earlier
release, read the [0.12 upgrade guide](../reference/upgrade-012.md).
5 changes: 3 additions & 2 deletions apps/docs/docs/self-host/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ generic scheduler and GitHub reconciliation, PostgreSQL, the web UI, and a works
accounting, budgets, observability, audit events, analytics summaries, and pipeline state live in
those services. They do not require sidecars or separate control-plane applications.

The single-host Compose file uses Docker named volumes. The [AWS reference deployment](aws.md)
runs the control plane on ECS and RDS while Vercel Sandbox runs and retains story workspaces.
The single-host [Compose bundle](bundle.md) uses Docker named volumes. The [AWS reference
deployment](aws.md) runs the control plane on ECS and RDS while Vercel Sandbox runs and retains
story workspaces.

Run at least one API, one worker, and one web process. API and worker must use the same release,
database, master key, GitHub App configuration, workspace provider configuration, and project value
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/docs/self-host/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ title: Quickstart

# Self-host quickstart

This path runs Facility from source for local evaluation and development. Use the [production
This path runs Facility from source for local evaluation and development. To run the product
without a source toolchain, use the [Compose bundle](bundle.md) instead. Use the [production
guide](production.md) before exposing an instance to other users or repositories.

## Prerequisites
Expand Down
1 change: 1 addition & 0 deletions apps/docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const sidebars: SidebarsConfig = {
label: "self-hosting",
collapsed: false,
items: [
"self-host/bundle",
"self-host/quickstart",
"self-host/local-development",
"self-host/production",
Expand Down
12 changes: 10 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ services:
GITHUB_CLONE_TOKEN: ${GITHUB_CLONE_TOKEN:-}
FACILITY_WORKSPACE_DRIVER: docker
FACILITY_WORKSPACE_IMAGE: ${FACILITY_WORKSPACE_IMAGE:-facility-runner:dev}
ports: ["4400:4400"]
# Bound to loopback, not published on every interface. The bundle runs
# without TLS, and a localhost URL constrains nothing about who can reach
# the port: `4400:4400` would serve plaintext sessions and preview traffic
# to the whole network segment. Fronting this with TLS means changing the
# binding deliberately, not discovering it was already open.
ports: ["127.0.0.1:4400:4400"]
volumes:
# The API inspects workspace state through the host Docker daemon.
- /var/run/docker.sock:/var/run/docker.sock
Expand Down Expand Up @@ -125,7 +130,10 @@ services:
restart: unless-stopped
environment:
FACILITY_API_URL: http://api:4400
ports: ["3400:3400"]
# Loopback for the same reason as the api port. Container-to-container
# traffic uses the Compose network (`http://api:4400`), so this publishes
# nothing the stack needs internally.
ports: ["127.0.0.1:3400:3400"]
healthcheck:
test:
[
Expand Down
28 changes: 24 additions & 4 deletions services/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,24 @@ const EnvSchema = z
} else {
const preview = new URL(env.FACILITY_PREVIEW_URL);
const previewSite = registeredSite(preview.hostname);
const controlSites = [env.PUBLIC_URL, env.WEB_URL ?? env.PUBLIC_URL, env.MCP_PUBLIC_URL]
const controlOrigins = [env.PUBLIC_URL, env.WEB_URL ?? env.PUBLIC_URL, env.MCP_PUBLIC_URL]
.filter((value): value is string => Boolean(value))
.map((value) => registeredSite(new URL(value).hostname));
if (preview.protocol !== "https:") {
.map((value) => new URL(value));
const controlSites = controlOrigins.map((url) => registeredSite(url.hostname));
// The single-host bundle serves the whole instance over loopback, where
// there is no name to obtain a certificate for and nothing is reachable
// off the machine. Requiring HTTPS there refuses a configuration that
// has no transport to protect, so exempt an instance whose every origin
// — preview included — is loopback HTTP. Any origin that leaves the
// machine puts the whole set back under the HTTPS requirement. This is
// the carve-out the interactive OAuth block below already makes.
const loopbackInstance =
preview.protocol === "http:" &&
isLoopbackHostname(preview.hostname) &&
controlOrigins.every(
(url) => url.protocol === "http:" && isLoopbackHostname(url.hostname),
);
if (preview.protocol !== "https:" && !loopbackInstance) {
ctx.addIssue({
code: "custom",
path: ["FACILITY_PREVIEW_URL"],
Expand Down Expand Up @@ -430,7 +444,13 @@ function isExactAuthCallbackUrl(url: URL, webOrigin: string) {
);
}

// RFC 6761 section 6.3 reserves `localhost` and every name under `.localhost`
// for the loopback interface. Facility needs more than the bare name because
// the preview origin must stay a registered site of its own; `preview.localhost`
// satisfies both, and resolvers are required not to send it to the network.
function isLoopbackHostname(hostname: string) {
const normalized = hostname.toLowerCase();
return ["localhost", "127.0.0.1", "[::1]"].includes(normalized);
return (
["localhost", "127.0.0.1", "[::1]"].includes(normalized) || normalized.endsWith(".localhost")
);
}
96 changes: 96 additions & 0 deletions services/api/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseDotenv } from "dotenv";
import { describe, expect, it } from "vitest";
import { parse as parseYaml } from "yaml";
import { readConfig } from "../src/config.js";

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..");

const validEnv = {
DATABASE_URL: "postgres://facility:facility@localhost:5432/facility",
SECRET_MASTER_KEY: Buffer.alloc(32, 9).toString("base64"),
Expand Down Expand Up @@ -82,6 +89,95 @@ describe("Facility 0.12 configuration", () => {
).toMatchObject({ previewUrl: "https://preview.example.net" });
});

it("boots from the shipped .env.example once the master key is supplied", () => {
// dotenv delivers a bare `KEY=` as an empty string rather than omitting the
// key, so every blank line in the template reaches validation as a present
// value. The template is the documented first step of self-hosting: it has
// to parse as written, with only the master key it tells the operator to
// generate filled in.
const template = parseDotenv(readFileSync(join(repoRoot, ".env.example"), "utf8"));
expect(template.SECRET_MASTER_KEY).toBe("");
expect(() =>
readConfig({ ...template, SECRET_MASTER_KEY: validEnv.SECRET_MASTER_KEY }),
).not.toThrow();
});

it("keeps the bundle's own ports on loopback, which is what the carve-out rests on", () => {
// Allowing plain HTTP because every origin is a loopback URL is only sound
// while the bundle is actually reachable from loopback alone. A host port
// published as `4400:4400` listens on every interface, and the URL says
// nothing about the binding, so the premise lives in a different file from
// the rule. Pin it next to the rule that depends on it.
const compose = parseYaml(readFileSync(join(repoRoot, "docker-compose.yml"), "utf8")) as {
services: Record<string, { ports?: string[] }>;
};
const published = Object.entries(compose.services).flatMap(([service, definition]) =>
(definition.ports ?? []).map((port) => ({ service, port })),
);

// Not vacuous: the bundle does publish ports, and these are the ones.
expect(published.map(({ service }) => service)).toEqual(["api", "web"]);
expect(published.filter(({ port }) => !port.startsWith("127.0.0.1:"))).toEqual([]);
});

it("boots the single-host bundle on loopback origins", () => {
// The defaults docker-compose.yml hands the api container, which runs the
// `api` image and therefore NODE_ENV=production. Keep this aligned with the
// Compose file: it is the configuration the bundle actually starts with.
const bundleEnv = {
...validEnv,
NODE_ENV: "production",
PUBLIC_URL: "http://localhost:4400",
WEB_URL: "http://localhost:3400",
FACILITY_PREVIEW_URL: "http://preview.localhost:4400",
MCP_PUBLIC_URL: "http://localhost:4400/mcp",
};
expect(readConfig(bundleEnv)).toMatchObject({
previewUrl: "http://preview.localhost:4400",
});

// The preview origin stays a separate registered site even on loopback, so
// the bare control hostname is still refused as a preview origin.
expect(() =>
readConfig({ ...bundleEnv, FACILITY_PREVIEW_URL: "http://localhost:4400" }),
).toThrow("must use a registered site separate");
});

it("keeps the production HTTPS requirement for any origin that leaves the machine", () => {
const loopbackControl = {
...validEnv,
NODE_ENV: "production",
PUBLIC_URL: "http://localhost:4400",
WEB_URL: "http://localhost:3400",
};
// A preview origin off the machine is not covered by the loopback carve-out.
expect(() =>
readConfig({ ...loopbackControl, FACILITY_PREVIEW_URL: "http://preview.example.net" }),
).toThrow("FACILITY_PREVIEW_URL must use HTTPS in production");

// Neither is a loopback preview whose control plane is published.
expect(() =>
readConfig({
...validEnv,
NODE_ENV: "production",
PUBLIC_URL: "https://api.example.com",
WEB_URL: "https://app.example.com",
FACILITY_PREVIEW_URL: "http://preview.localhost:4400",
}),
).toThrow("FACILITY_PREVIEW_URL must use HTTPS in production");

// A published deployment keeps the requirement it always had.
expect(() =>
readConfig({
...validEnv,
NODE_ENV: "production",
PUBLIC_URL: "https://api.example.com",
WEB_URL: "https://app.example.com",
FACILITY_PREVIEW_URL: "http://preview.example.net",
}),
).toThrow("FACILITY_PREVIEW_URL must use HTTPS in production");
});

it("rejects preview URLs with credentials, paths, queries, or fragments", () => {
expect(() =>
readConfig({
Expand Down
Loading