From 5783e3f5e8402393db416948b22fe02b8a97e011 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:25:47 -0500 Subject: [PATCH 1/3] fix(config): boot the single-host bundle on loopback origins The Compose bundle cannot start. Its api container runs the `api` image, which sets NODE_ENV=production, and docker-compose.yml defaults FACILITY_PREVIEW_URL to http://preview.localhost:4400. Production validation refuses any preview origin that is not HTTPS, so readConfig throws before the API listens: FACILITY_PREVIEW_URL must use HTTPS in production The command in the Compose file's own header comment therefore never brings up an instance. Requiring HTTPS on loopback refuses a configuration that has no transport to protect: there is no name to obtain a certificate for and nothing is reachable off the machine. Exempt an instance whose every origin -- preview included -- is loopback HTTP, the same carve-out the interactive OAuth block already makes for its own URL set. Any origin that leaves the machine puts the whole set back under the HTTPS requirement, and the separate-registered-site rule is untouched: previews stay isolated from the control plane on loopback too. isLoopbackHostname now accepts names under `.localhost`, which RFC 6761 section 6.3 reserves for the loopback interface. Facility needs more than the bare name because the preview origin must be a registered site of its own. This widens two other call sites: FACILITY_INSECURE_DEV may be enabled on a `.localhost` control origin, and MCP_PUBLIC_URL may be HTTP there. Both already accepted `localhost` itself, and neither leaves the host. Regression coverage pins the shipped defaults rather than a paraphrase of them: the Compose environment boots, a published control plane with a loopback preview does not, a loopback control plane with a published preview does not, and the bare control hostname is still refused as a preview origin. The shipped .env.example is also parsed as dotenv delivers it and asserted to boot with only the master key filled in. --- services/api/src/config.ts | 28 ++++++++++-- services/api/test/config.test.ts | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/services/api/src/config.ts b/services/api/src/config.ts index 10eb87f4..ccce80ae 100644 --- a/services/api/src/config.ts +++ b/services/api/src/config.ts @@ -131,10 +131,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"], @@ -393,7 +407,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") + ); } diff --git a/services/api/test/config.test.ts b/services/api/test/config.test.ts index 29a9fbe4..7a7c38d7 100644 --- a/services/api/test/config.test.ts +++ b/services/api/test/config.test.ts @@ -1,6 +1,12 @@ +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 { 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"), @@ -61,6 +67,77 @@ 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("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({ From 5d95a7f42cf98bf9a1af69d5f01400188134c6fd Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:26:03 -0500 Subject: [PATCH 2/3] docs(self-host): document the Compose bundle as the adoption path docker-compose.yml has carried the whole 0.12 control plane since #289, but no page describes it: the quickstart is the from-source path (corepack, pnpm install, pnpm dev) and production mentions the file in one clause. Someone evaluating Facility with Docker has nothing to follow. The new page is the single documentation page issue #19 asks for. It covers what the bundle is and is not, the one-shot migrate and runner-image services and why they read as exited, the surface table including the separate preview origin, the two GitHub applications -- OAuth App for sign-in, GitHub App for repository automation -- and binding the first owner with the operator CLI that already ships inside the api image. It also states the limits rather than leaving them to be discovered: GitHub cannot deliver webhooks to localhost, so UI and MCP triggers work untunnelled and repository triggers do not; the bundle runs TLS-free only while every origin stays loopback; and Facility never reclaims workspace volumes by age. --- apps/docs/docs/self-host/bundle.md | 129 +++++++++++++++++++++++++ apps/docs/docs/self-host/production.md | 5 +- apps/docs/docs/self-host/quickstart.md | 3 +- apps/docs/sidebars.ts | 1 + 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 apps/docs/docs/self-host/bundle.md diff --git a/apps/docs/docs/self-host/bundle.md b/apps/docs/docs/self-host/bundle.md new file mode 100644 index 00000000..93a9c78c --- /dev/null +++ b/apps/docs/docs/self-host/bundle.md @@ -0,0 +1,129 @@ +--- +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. +A bundle whose origins are all loopback runs without TLS. Publishing any origin — a tunnel, a +reverse proxy, a LAN address — puts the whole set back under the HTTPS requirement described in the +[production guide](production.md). + +## 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:///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). diff --git a/apps/docs/docs/self-host/production.md b/apps/docs/docs/self-host/production.md index 00e5073d..1ed430cc 100644 --- a/apps/docs/docs/self-host/production.md +++ b/apps/docs/docs/self-host/production.md @@ -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 diff --git a/apps/docs/docs/self-host/quickstart.md b/apps/docs/docs/self-host/quickstart.md index 04828f4a..0eaaab59 100644 --- a/apps/docs/docs/self-host/quickstart.md +++ b/apps/docs/docs/self-host/quickstart.md @@ -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 diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index be7218ed..10952758 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -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", From aa4bf30d60afac75e634ab286cf854a634ce3414 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:15:22 -0500 Subject: [PATCH 3/3] fix(compose): bind the bundle's host ports to loopback The HTTP carve-out this PR adds rests on every origin being loopback, and that premise was false. `ports: ["4400:4400"]` and `["3400:3400"]` publish on every interface, so a `localhost` URL constrained nothing: the bundle served control-plane sessions and preview traffic to the whole network segment in plaintext, and the PR description claimed the opposite. Both host ports are now bound to `127.0.0.1`. Container-to-container traffic uses the Compose network, so nothing the stack needs internally was published in the first place. Reaching the instance from another machine now requires changing the binding, which is the moment to put TLS in front of it rather than a property to discover afterwards. The regression lives beside the rule that depends on it, in `config.test.ts`: the premise and the carve-out were in different files, which is how the premise rotted unnoticed. It parses the shipped Compose file, fails on any published port that is not loopback-bound, and asserts which services publish at all so it cannot pass vacuously. --- apps/docs/docs/self-host/bundle.md | 11 ++++++++--- docker-compose.yml | 12 ++++++++++-- services/api/test/config.test.ts | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/docs/docs/self-host/bundle.md b/apps/docs/docs/self-host/bundle.md index 93a9c78c..1a67fa59 100644 --- a/apps/docs/docs/self-host/bundle.md +++ b/apps/docs/docs/self-host/bundle.md @@ -51,9 +51,14 @@ curl --fail http://localhost:4400/readyz 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. -A bundle whose origins are all loopback runs without TLS. Publishing any origin — a tunnel, a -reverse proxy, a LAN address — puts the whole set back under the HTTPS requirement described in the -[production guide](production.md). + +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 diff --git a/docker-compose.yml b/docker-compose.yml index 3429ee50..37cb68d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -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: [ diff --git a/services/api/test/config.test.ts b/services/api/test/config.test.ts index 7a7c38d7..9b0b11d9 100644 --- a/services/api/test/config.test.ts +++ b/services/api/test/config.test.ts @@ -3,6 +3,7 @@ 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)), "../../.."); @@ -80,6 +81,24 @@ describe("Facility 0.12 configuration", () => { ).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; + }; + 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