diff --git a/Dockerfile.deploy b/Dockerfile.deploy
deleted file mode 100644
index f75a00956..000000000
--- a/Dockerfile.deploy
+++ /dev/null
@@ -1,12 +0,0 @@
-# syntax=docker/dockerfile:1.9
-#
-# Thin deploy image for PaaS providers that build from a repo Dockerfile
-# (Railway, Render, Fly.io source mode). Pulls the published multi-arch
-# image from GHCR instead of rebuilding fabro from source on every
-# deploy, which keeps cold starts short and avoids needing the release
-# workflow's pre-built binary context on the PaaS runner.
-#
-# The upstream image already configures entrypoint, CMD (binds
-# 0.0.0.0:${PORT:-32276}), volumes, and the unprivileged fabro user.
-
-FROM ghcr.io/fabro-sh/fabro:nightly
diff --git a/README.md b/README.md
index d2d663b6e..0a3caeec9 100644
--- a/README.md
+++ b/README.md
@@ -144,15 +144,18 @@ For headless or scripted environments, `fabro install` runs the same setup as a
---
-## Self-host the Fabro server
+## Running Fabro
-Running Fabro as an HTTP server with the web UI lets a team share one instance. The repository ships a `Dockerfile` that serves the API (with the embedded web UI) on `$PORT` (default `32276`) and persists state to `/storage`.
+Fabro runs as a server. You choose where it runs:
-[](https://railway.com/new/template?template=https%3A%2F%2Fgithub.com%2Ffabro-sh%2Ffabro) [](https://render.com/deploy?repo=https://github.com/fabro-sh/fabro)
+- **On your laptop** — install the CLI (above) and run `fabro server start`. Workflows pause when your laptop sleeps.
+- **On a host (self-hosted)** — deploy the Docker image with `docker compose` or any cloud container service (ECS, Cloud Run, Kubernetes). See [Self-host with Docker](https://docs.fabro.sh/administration/self-host-docker).
-Click a button to provision a Fabro service from this repository. On Railway, attach a Volume at `/storage` so your runs and checkpoints survive redeploys; on Render, the `render.yaml` blueprint provisions a 1 GB disk at `/storage` automatically. The [Railway](https://docs.fabro.sh/administration/deploy-railway) and [Render](https://docs.fabro.sh/administration/deploy-render) deploy guides walk through env vars, accessing the dev token, and pointing the CLI at your deployment.
+One-click managed alternative for the same Docker image:
-Prefer to run Fabro elsewhere? See [Running the Fabro Server](https://docs.fabro.sh/administration/deploy-server) for generic Docker guidance and the companion guides for [Fly.io](https://docs.fabro.sh/administration/deploy-fly-io) and [DigitalOcean](https://docs.fabro.sh/administration/deploy-digital-ocean).
+[](https://railway.com/deploy/UcEy5m?referralCode=E5TucU&utm_medium=integration&utm_source=template&utm_campaign=generic)
+
+See the [deployment overview](https://docs.fabro.sh/administration/deployment) for the full picture.
---
diff --git a/docker-compose.yaml b/docker-compose.yaml
index efd89d286..e3ee394c9 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -7,6 +7,9 @@ services:
volumes:
- fabro-storage:/storage
- /var/run/docker.sock:/var/run/docker.sock
+ env_file:
+ - path: .env
+ required: false
volumes:
fabro-storage:
diff --git a/docs/public/administration/deploy-digital-ocean.mdx b/docs/public/administration/deploy-digital-ocean.mdx
deleted file mode 100644
index 6096bc30a..000000000
--- a/docs/public/administration/deploy-digital-ocean.mdx
+++ /dev/null
@@ -1,137 +0,0 @@
----
-title: "DigitalOcean"
-description: "Deploy Fabro to a DigitalOcean Droplet with docker compose and Caddy for automatic TLS"
----
-
-
- The server interface is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it.
-
-
-DigitalOcean is a good fit for self-hosting Fabro on a Droplet — a plain Linux VPS running `docker compose`. The repo ships everything you need:
-
-- `docker-compose.yaml` pulls the pre-built image from GHCR and declares a named volume for `/storage`
-- `docker-compose.prod.yaml` adds a [Caddy](https://caddyserver.com) sidecar that terminates TLS and auto-provisions Let's Encrypt certificates
-- `docker/Caddyfile` reverse-proxies HTTPS traffic to the Fabro server
-
-
- **Why not App Platform?** DigitalOcean App Platform has no persistent volumes — it is designed for stateless workloads that offload state to managed Postgres or Spaces. Fabro writes runs, checkpoints, sessions, and JWT keys to `/storage`, so a Droplet (or any VPS) is the right fit. If you prefer Kubernetes, DOKS works too — that path is not documented here.
-
-
-## Prerequisites
-
-- A DigitalOcean account and (optionally) the [`doctl`](https://docs.digitalocean.com/reference/doctl/how-to/install/) CLI
-- A domain name with DNS you can edit (required for Caddy to issue a Let's Encrypt cert)
-- LLM provider API keys (Anthropic, OpenAI, etc.) and any secrets you want set via `.env`
-
-## 1. Create a Droplet
-
-Pick the **Docker on Ubuntu** image from the DigitalOcean Marketplace — it ships with Docker Engine and the Compose plugin preinstalled, so there's no manual Docker install step.
-
-From the control panel, or with `doctl`:
-
-```bash
-doctl compute droplet create fabro \
- --image docker-20-04 \
- --size s-2vcpu-2gb \
- --region nyc3 \
- --ssh-keys
-```
-
-`s-2vcpu-2gb` is a reasonable starting size for light usage; grow later as your workflow load increases. Any region works — pick the one closest to you.
-
-## 2. Point DNS at the Droplet
-
-Caddy needs the domain to resolve to the Droplet's public IP **before** you start the stack, or the first cert issuance will fail. Create an `A` record for your chosen hostname (e.g. `fabro.example.com`) pointing at the Droplet's IPv4 address. Wait for propagation before continuing.
-
-## 3. Configure and launch
-
-SSH into the Droplet and pull the repo:
-
-```bash
-ssh root@
-git clone https://github.com/fabro-sh/fabro
-cd fabro
-cp .env.example .env
-```
-
-Edit `.env`. At minimum:
-
-```
-FABRO_DOMAIN=fabro.example.com
-ANTHROPIC_API_KEY=...
-SESSION_SECRET=...
-```
-
-The [Server Configuration](/administration/server-configuration) reference has the full list of variables; the minimum useful set is:
-
-| Variable | Purpose |
-|---|---|
-| `FABRO_DOMAIN` | Public hostname Caddy serves. Must resolve to this Droplet for Let's Encrypt to issue a cert. |
-| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / ... | At least one LLM provider key for the models you'll run |
-| `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot |
-| `SESSION_SECRET` | 64-character hex string; required when the web UI is enabled |
-| `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, `GITHUB_APP_PRIVATE_KEY` | Only if you enable GitHub OAuth or the GitHub App integration |
-
-Bring up the stack:
-
-```bash
-docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d
-```
-
-This starts two containers: `fabro` (the server, state in the `fabro-storage` named volume) and `caddy` (listening on ports 80/443, cert state in the `caddy_data` named volume). Caddy requests a Let's Encrypt cert on first boot; watch progress with `docker compose logs -f caddy`.
-
-## Accessing your Fabro server
-
-Once `https:///health` returns `ok`, two things to grab:
-
-1. **The dev token** — on first boot, Fabro writes one to `/var/fabro/dev-token` and logs it:
-
- ```bash
- docker compose exec fabro cat /var/fabro/dev-token
- ```
-
-2. **Point your local CLI at the server** — add the URL to `~/.fabro/settings.toml`:
-
- ```toml title="~/.fabro/settings.toml"
- [cli.target]
- type = "http"
- url = "https://fabro.example.com/api/v1"
- ```
-
- Then commands like `fabro model list --server ` will hit your Droplet.
-
-See [Running the Fabro Server](/administration/deploy-server) for the full auth and CLI-pointing story.
-
-## Updates
-
-To pull the latest nightly image and restart:
-
-```bash
-cd /root/fabro
-git pull
-docker compose -f docker-compose.yaml -f docker-compose.prod.yaml pull
-docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d
-```
-
-The `fabro-storage` and `caddy_data` named volumes survive `pull` and `up`, so runs, checkpoints, and TLS certs persist.
-
-To pin a specific version instead of `:nightly`, edit `docker-compose.yaml` and change `image: ghcr.io/fabro-sh/fabro:nightly` to the tag you want.
-
-## Caveats
-
-- **DNS must resolve before first `up`.** Caddy will retry Let's Encrypt failures, but an obviously bad DNS config will lock you into the staging CA's low rate limits. Verify with `dig +short fabro.example.com` before starting.
-- **Firewall.** The Docker Marketplace image opens 22, 80, and 443 by default — good. Keep port 32276 **closed** on the public interface; Caddy fronts the Fabro server on the private Docker network. Use `ufw status` to verify.
-- **State lives in named volumes.** `fabro-storage` and `caddy_data` are the load-bearing pieces. Back them up (for example, via `docker run --rm -v fabro_fabro-storage:/src -v $(pwd):/dst alpine tar czf /dst/backup.tgz -C /src .`) before destructive operations.
-- **Single-host deploy.** This setup assumes one Droplet owns the data. For HA you'd need a different architecture (external block storage, managed DB, etc.) — Fabro's server currently assumes a single writer on `/storage`.
-- **Architecture.** The compose file pins `platform: linux/amd64`; the `:nightly` tag is multi-arch but the arm64 variant is not currently usable. Use x86_64 Droplets.
-
-## Next steps
-
-
-
- Auth, dev tokens, submitting runs, and pointing the CLI at your deployment.
-
-
- Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more.
-
-
diff --git a/docs/public/administration/deploy-fly-io.mdx b/docs/public/administration/deploy-fly-io.mdx
deleted file mode 100644
index 51bc1d21f..000000000
--- a/docs/public/administration/deploy-fly-io.mdx
+++ /dev/null
@@ -1,108 +0,0 @@
----
-title: "Fly.io"
-description: "Deploy Fabro to Fly.io from the prebuilt GHCR image, with a Fly Volume for state"
----
-
-
- The server interface is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it.
-
-
-[Fly.io](https://fly.io) can host the Fabro server by pulling the pre-built image published to GHCR. The repo ships a `fly.toml` that points Fly directly at the image — no build step on Fly's builders, no `Dockerfile` evaluation — and declares a Volume mount at `/storage` so your runs, checkpoints, and sessions survive redeploys.
-
-Fly.io is CLI-first; there is no one-click deploy button. The workflow below uses [`flyctl`](https://fly.io/docs/flyctl/install/).
-
-## First-deploy checklist
-
-### 1. Adopt `fly.toml` in a new app
-
-```bash
-git clone https://github.com/fabro-sh/fabro
-cd fabro
-fly launch --copy-config --no-deploy
-```
-
-`fly launch --copy-config` keeps the repo's `fly.toml` instead of generating a new one; `--no-deploy` lets you finish wiring secrets and volumes before the first deploy. You'll be prompted for an **app name** (must be globally unique on Fly) and a **primary region** — update `fly.toml` in place if you change the defaults.
-
-### 2. Create the persistent Volume
-
-Fabro writes all persistent state — run history, checkpoints, sessions, the default token, and JWT keys — under `/storage`. Fly containers have ephemeral filesystems, so without a Volume that directory is wiped on every redeploy. `fly.toml` declares the mount but **cannot create the volume itself** — provision it with `flyctl`:
-
-```bash
-fly volumes create storage --size 1 --region
-```
-
-Grow later with `fly volumes extend` if needed. The volume name (`storage`) must match `[[mounts]].source` in `fly.toml`.
-
-### 3. Set required environment variables
-
-Fly stores env vars as encrypted secrets:
-
-```bash
-fly secrets set \
- ANTHROPIC_API_KEY=... \
- SESSION_SECRET=...
-```
-
-The [Server Configuration](/administration/server-configuration) reference has the full list; the minimum useful set:
-
-| Variable | Purpose |
-|---|---|
-| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / ... | At least one LLM provider key for the models you'll run |
-| `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot |
-| `SESSION_SECRET` | 64-character hex string; required when the web UI is enabled |
-| `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, `GITHUB_APP_PRIVATE_KEY` | Only if you enable GitHub OAuth or the GitHub App integration |
-
-### 4. Deploy
-
-```bash
-fly deploy
-```
-
-Fly pulls `ghcr.io/fabro-sh/fabro:nightly`, attaches the volume, and starts the Machine. The health check on `/health` must pass before traffic is routed.
-
-## Accessing your Fabro server
-
-Once the deploy is healthy, Fly exposes a `.fly.dev` URL (or your custom domain). Two things to grab:
-
-1. **The dev token** — on first boot, Fabro writes one to `/var/fabro/dev-token` and logs it. Read it via the Machine's shell:
-
- ```bash
- fly ssh console -C "cat /var/fabro/dev-token"
- ```
-
- Or tail the startup logs with `fly logs`.
-
-2. **Point your local CLI at the server** — add the Fly URL to `~/.fabro/settings.toml`:
-
- ```toml title="~/.fabro/settings.toml"
- [cli.target]
- type = "http"
- url = "https://.fly.dev/api/v1"
- ```
-
- Then commands like `fabro model list --server ` will hit your Fly instance.
-
-See [Running the Fabro Server](/administration/deploy-server) for the full auth and CLI-pointing story.
-
-## Redeploys and updates
-
-`fly deploy` re-pulls the GHCR image on every run. `fly.toml` references the `:nightly` tag by default, so redeploying picks up the latest nightly automatically. To pin a specific version, edit `fly.toml` to reference `ghcr.io/fabro-sh/fabro:` and redeploy. The `/storage` Volume survives redeploys, so runs and checkpoints persist.
-
-## Caveats
-
-- **`$PORT` is not injected.** Unlike Railway and Render, Fly does not set a `PORT` environment variable. The Fabro image binds to `$PORT` if set, otherwise `32276` — `fly.toml` pins `internal_port = 32276` so the default works. If you change `internal_port`, also `fly secrets set PORT=` to match.
-- **Volume is load-bearing and not replicated.** Fly's docs recommend at least two Volumes per app for redundancy, but Fabro's server is single-replica by design — one Machine owns `/storage`. Treat this like a traditional VPS: hardware failure means restoring from [Fly's volume snapshots](https://fly.io/docs/volumes/snapshots/) or a backup you manage.
-- **Single Machine.** Don't `fly scale count` above 1 — a second Machine can't mount the same Volume, and the server assumes a single writer.
-- **Architecture.** Fly Machines run x86_64 (amd64) by default. The `:nightly` tag is multi-arch, but the arm64 variant is not currently usable — stay on amd64.
-- **Autostop is disabled.** `fly.toml` sets `auto_stop_machines = "off"` so the Machine stays up for the run queue. Leaving autostop enabled would pause Fabro when there's no HTTP traffic, stalling any in-flight runs.
-
-## Next steps
-
-
-
- Auth, dev tokens, submitting runs, and pointing the CLI at your deployment.
-
-
- Full `settings.toml` reference — TLS, auth methods, concurrency, and more.
-
-
diff --git a/docs/public/administration/deploy-railway.mdx b/docs/public/administration/deploy-railway.mdx
index c932246fb..a6082f107 100644
--- a/docs/public/administration/deploy-railway.mdx
+++ b/docs/public/administration/deploy-railway.mdx
@@ -1,6 +1,6 @@
---
title: "Railway"
-description: "Deploy Fabro to Railway from the Dockerfile, with a persistent Volume for state"
+description: "Deploy Fabro to Railway from the prebuilt GHCR image, with a persistent Volume for state"
---
@@ -11,9 +11,9 @@ description: "Deploy Fabro to Railway from the Dockerfile, with a persistent Vol
## One-click deploy
-[](https://railway.com/new/template?template=https%3A%2F%2Fgithub.com%2Ffabro-sh%2Ffabro)
+[](https://railway.com/deploy/UcEy5m?referralCode=E5TucU&utm_medium=integration&utm_source=template&utm_campaign=generic)
-The button launches Railway's new-project flow pointed at this repo. `railway.toml` instructs Railway to build `Dockerfile.deploy`, which is a thin wrapper that pulls `ghcr.io/fabro-sh/fabro:nightly` — no Rust compilation on Railway's builder, so deploys complete in seconds.
+The button launches Railway's template flow, which deploys the pre-built `ghcr.io/fabro-sh/fabro:nightly` image directly from GHCR — no Rust compilation on Railway's builder, so deploys complete in seconds.
## First-deploy checklist
@@ -59,11 +59,11 @@ Once the deploy is healthy, Railway exposes a `*.up.railway.app` URL (or your cu
Then commands like `fabro model list --server ` will hit your Railway instance.
-See [Running the Fabro Server](/administration/deploy-server) for the full auth and CLI-pointing story.
+See [Server Operations](/reference/server-operations) for the full auth and CLI-pointing story.
## Redeploys and updates
-Railway re-pulls the GHCR image on every deploy. `Dockerfile.deploy` references the `:nightly` tag by default, so redeploying picks up the latest nightly automatically. To pin a specific version, edit `Dockerfile.deploy` to reference `ghcr.io/fabro-sh/fabro:` and redeploy. The `/storage` Volume survives redeploys, so runs and checkpoints persist.
+Railway re-pulls the GHCR image on every deploy. The template uses the `:nightly` tag by default, so redeploying picks up the latest nightly automatically. To pin a specific version, change the image in **Service → Settings → Source** to `ghcr.io/fabro-sh/fabro:` and redeploy. The `/storage` Volume survives redeploys, so runs and checkpoints persist.
## Caveats
@@ -74,7 +74,7 @@ Railway re-pulls the GHCR image on every deploy. `Dockerfile.deploy` references
## Next steps
-
+
Auth, dev tokens, submitting runs, and pointing the CLI at your deployment.
diff --git a/docs/public/administration/deploy-render.mdx b/docs/public/administration/deploy-render.mdx
deleted file mode 100644
index ff8837365..000000000
--- a/docs/public/administration/deploy-render.mdx
+++ /dev/null
@@ -1,89 +0,0 @@
----
-title: "Render"
-description: "Deploy Fabro to Render via the render.yaml blueprint, with a persistent disk for state"
----
-
-
- The server interface is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it.
-
-
-[Render](https://render.com) can host the Fabro server by pulling the pre-built image published to GHCR. The repo ships a `render.yaml` blueprint that provisions the service and a persistent disk for `/storage` in one click, so your runs, checkpoints, and sessions survive redeploys.
-
-## One-click deploy
-
-[](https://render.com/deploy?repo=https://github.com/fabro-sh/fabro)
-
-The button opens Render's blueprint flow pointed at this repo. `render.yaml` instructs Render to build `Dockerfile.deploy`, which is a thin wrapper that pulls `ghcr.io/fabro-sh/fabro:nightly` — no Rust compilation on Render's builder, so deploys complete in seconds.
-
-The blueprint declares:
-
-- A `web` service (Docker runtime) on Render's `starter` plan
-- A 1 GB persistent disk mounted at `/storage`
-- A health check on `/health`
-
-## First-deploy checklist
-
-The blueprint covers the infrastructure; a few pieces still need to be wired up after the first deploy.
-
-### 1. Confirm the disk is attached
-
-Fabro writes all persistent state — run history, checkpoints, sessions, the default token, and JWT keys — under `/storage`. The blueprint provisions a 1 GB disk at that path automatically. Verify in **Service → Disks** that `fabro-storage` is present and mounted at `/storage`. Grow the size later from the same page if needed.
-
-
- Render persistent disks require a paid plan (`starter` or higher). The free tier does not support disks, so the blueprint selects `starter` by default.
-
-
-### 2. Confirm the target port
-
-Render sets `$PORT` automatically and expects the container to bind to it. The Fabro image honors `$PORT` and falls back to `32276`, so Render's default HTTP routing works without any manual port configuration.
-
-### 3. Set required environment variables
-
-Add variables in **Service → Environment** as needed. The [Server Configuration](/administration/server-configuration) reference has the full list; the minimum useful set:
-
-| Variable | Purpose |
-|---|---|
-| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / ... | At least one LLM provider key for the models you'll run |
-| `FABRO_DEV_TOKEN` | Optional — pre-set the dev token instead of reading the one written to `/storage` on first boot |
-| `SESSION_SECRET` | 64-character hex string; required when the web UI is enabled |
-| `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, `GITHUB_APP_PRIVATE_KEY` | Only if you enable GitHub OAuth or the GitHub App integration |
-
-No `.env` file is auto-loaded inside the container; everything comes from Render's environment.
-
-## Accessing your Fabro server
-
-Once the deploy is healthy, Render exposes a `*.onrender.com` URL (or your custom domain). Two things to grab:
-
-1. **The dev token** — on first boot, Fabro writes one to `/var/fabro/dev-token` and logs it. Find it in **Service → Logs** in the Render dashboard, or open **Service → Shell** and run `cat /var/fabro/dev-token`.
-2. **Point your local CLI at the server** — add the Render URL to `~/.fabro/settings.toml`:
-
- ```toml title="~/.fabro/settings.toml"
- [cli.target]
- type = "http"
- url = "https://.onrender.com/api/v1"
- ```
-
- Then commands like `fabro model list --server ` will hit your Render instance.
-
-See [Running the Fabro Server](/administration/deploy-server) for the full auth and CLI-pointing story.
-
-## Redeploys and updates
-
-Render re-pulls the GHCR image on every deploy. `Dockerfile.deploy` references the `:nightly` tag by default, so redeploying picks up the latest nightly automatically. To pin a specific version, edit `Dockerfile.deploy` to reference `ghcr.io/fabro-sh/fabro:` and redeploy. The `/storage` disk survives redeploys, so runs and checkpoints persist.
-
-## Caveats
-
-- **Disk is load-bearing.** `/storage` holds the dev token and JWT signing keys in addition to runs and checkpoints. Do not detach or resize it destructively once the service is in use.
-- **Single replica.** Fabro's server currently assumes one process owns `/storage`. Don't scale the service to multiple instances.
-- **Architecture.** Render runs x86_64 (amd64) containers. The `:nightly` tag is multi-arch, but the arm64 variant is not currently usable — stay on amd64, which is Render's default.
-
-## Next steps
-
-
-
- Auth, dev tokens, submitting runs, and pointing the CLI at your deployment.
-
-
- Full `settings.toml` reference — reverse-proxy TLS, auth methods, concurrency, and more.
-
-
diff --git a/docs/public/administration/deployment.mdx b/docs/public/administration/deployment.mdx
new file mode 100644
index 000000000..fd88c037d
--- /dev/null
+++ b/docs/public/administration/deployment.mdx
@@ -0,0 +1,42 @@
+---
+title: "Deployment"
+description: "Choose where the Fabro server runs: on your laptop or self-hosted on a server"
+---
+
+
+ The server interface is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it.
+
+
+Fabro runs as a server. The CLI authenticates and communicates with that server, whether the server is running on your laptop or on a remote host. Choose where the server runs based on how you use Fabro.
+
+## Two deployment modes
+
+| | Local | Self-hosted |
+|---|---|---|
+| **Where the server runs** | Your laptop | A host you operate (Docker container) |
+| **How to start it** | `fabro server start` | `docker compose up -d` |
+| **Best for** | Solo use, getting started, learning | Teams, production, 24/7 workflows |
+| **Trade-off** | Workflows pause when your laptop sleeps or shuts off | You operate the host |
+
+Both modes use the same image, the same workflow engine, and the same CLI. The only difference is where the server process lives.
+
+## Local
+
+Run `fabro server start` on your machine. State persists under `~/.fabro/`. The CLI talks to it over a Unix socket by default. This is the right mode for solo use and getting started — no deployment required.
+
+When your laptop sleeps or shuts off, in-flight workflows pause until the laptop wakes again. For workflows that need to run 24/7 or for teams sharing a single instance, self-host.
+
+See [Server Operations](/reference/server-operations) for starting the server, the install wizard, authentication, and pointing the CLI at it.
+
+## Self-hosted
+
+For team use, production workflows, or running 24/7, self-host the server as a Docker container. The recommended approach is `docker compose` for a single host, or any cloud container service (ECS, Cloud Run, Kubernetes) using the same image with the same requirements.
+
+
+
+ Compose-first walkthrough. Same image works on ECS, Cloud Run, and Kubernetes.
+
+
+ One-click managed shortcut for the same Docker image.
+
+
diff --git a/docs/public/administration/self-host-docker.mdx b/docs/public/administration/self-host-docker.mdx
new file mode 100644
index 000000000..ebdc8c180
--- /dev/null
+++ b/docs/public/administration/self-host-docker.mdx
@@ -0,0 +1,143 @@
+---
+title: "Self-host with Docker"
+description: "Run the Fabro server as a Docker container with docker compose, ECS, or any cloud container service"
+---
+
+
+ The server interface is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it.
+
+
+The supported deployment artifact is the official Fabro image at `ghcr.io/fabro-sh/fabro`. Everything else — `docker compose`, ECS, Cloud Run, Kubernetes, Railway — is just running this image somewhere with the right requirements.
+
+## Requirements
+
+| Requirement | Value |
+|---|---|
+| **Image** | `ghcr.io/fabro-sh/fabro:nightly` (multi-arch; pin a version for production) |
+| **Persistent volume** | Mount at `/storage`. Stores run history, checkpoints, sessions, the dev token, and JWT keys. |
+| **Port** | The container binds to `$PORT` (default `32276`). Expose it. |
+| **LLM provider key** | At least one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, ... |
+| **Replicas** | One. The server expects exclusive ownership of `/storage`. |
+
+## Quickstart with docker compose
+
+The repo ships a `docker-compose.yaml` at the root. Clone the repo (or copy the file), create a `.env` with at least one provider key, and start it:
+
+```bash
+git clone https://github.com/fabro-sh/fabro.git
+cd fabro
+cp .env.example .env
+# edit .env and set at least ANTHROPIC_API_KEY (or another provider key)
+docker compose up -d
+```
+
+The compose file:
+
+- Pulls `ghcr.io/fabro-sh/fabro:nightly`
+- Creates a named volume `fabro-storage` mounted at `/storage`
+- Mounts `/var/run/docker.sock` so Fabro can spawn sandbox containers on the host daemon
+- Exposes port `32276`
+- Loads environment from `.env` if present
+
+
+ Mounting `/var/run/docker.sock` gives the container host-root-equivalent access. Only use the bundled compose service in trusted, single-tenant deployments. See [Sandboxing](/administration/sandboxing) for the threat model.
+
+
+After the container is healthy, finish setup in your browser following the [install wizard](/reference/server-operations#first-run-web-install-wizard).
+
+### Adding a reverse proxy with TLS
+
+For a production deployment exposed to the internet, layer the `docker-compose.prod.yaml` overlay on top. It adds a [Caddy](https://caddyserver.com) reverse proxy that terminates TLS (auto-provisioning a Let's Encrypt certificate) and forwards to Fabro:
+
+```bash
+# In .env
+FABRO_DOMAIN=fabro.example.com
+
+# Then bring the stack up
+docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d
+```
+
+Leave `FABRO_DOMAIN` unset to serve plain HTTP on `localhost`.
+
+## Required environment variables
+
+At minimum, set one LLM provider key in `.env`:
+
+```bash title=".env"
+ANTHROPIC_API_KEY=sk-ant-...
+```
+
+For the web UI you also need a session secret:
+
+```bash
+SESSION_SECRET=<64-character hex string>
+```
+
+Generate one with `openssl rand -hex 32`.
+
+Optional:
+
+| Variable | Purpose |
+|---|---|
+| `FABRO_DEV_TOKEN` | Pre-set the dev token instead of reading the one written to `/storage` on first boot |
+| `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, `GITHUB_APP_PRIVATE_KEY` | Only if you enable GitHub OAuth or the GitHub App integration |
+| `FABRO_DOMAIN` | Public hostname when using the Caddy reverse-proxy overlay |
+
+See [Server Configuration](/administration/server-configuration) for the full settings reference, and [`.env.example`](https://github.com/fabro-sh/fabro/blob/main/.env.example) for the complete list.
+
+## Cloud container services
+
+The same image works on any container orchestrator that supports the requirements above. Common patterns:
+
+- **AWS ECS / Fargate** — Task definition referencing `ghcr.io/fabro-sh/fabro:nightly`, EFS volume mounted at `/storage`, port `32276` published, environment variables for keys.
+- **Google Cloud Run** — Cloud Run with a backed volume mount at `/storage`. Pin minimum instances to 1; scale-to-zero interrupts running workflows.
+- **Kubernetes** — One-replica `StatefulSet` (not Deployment) with a `PersistentVolumeClaim` mounted at `/storage`. Expose via Service + Ingress.
+
+In all cases: single replica, persistent `/storage`, expose `$PORT`, set provider keys.
+
+## Pinning a version
+
+`docker-compose.yaml` uses `:nightly` by default, so `docker compose pull && docker compose up -d` picks up the latest nightly. To pin a specific version, change the `image:` line to `ghcr.io/fabro-sh/fabro:`.
+
+Release artifacts ship with [SLSA Build Provenance](/reference/verifying-releases) attestations you can verify with `gh attestation verify`.
+
+## Pointing the CLI at your server
+
+Once the container is running, install the CLI on your local machine and point it at the server:
+
+```toml title="~/.fabro/settings.toml"
+[cli.target]
+type = "http"
+url = "https://fabro.example.com/api/v1"
+```
+
+For dev-token auth, save the token in the CLI auth store:
+
+```bash
+fabro auth login --server https://fabro.example.com/api/v1 --dev-token fabro_dev_...
+```
+
+See [Server Operations](/reference/server-operations#pointing-the-cli-at-a-server) for the full CLI-target options.
+
+## Caveats
+
+- **Volume is load-bearing.** Without a persistent volume at `/storage`, redeploys silently wipe all state — including the dev token and JWT signing keys.
+- **Single replica.** The server expects exclusive ownership of `/storage`. Don't scale to multiple replicas.
+- **Architecture.** The `:nightly` tag is multi-arch. The amd64 variant is the most heavily tested.
+
+## Next steps
+
+
+
+ Install wizard, web UI, authentication, demo mode, and pointing the CLI at the server.
+
+
+ Full settings.toml reference — auth, reverse-proxy TLS, run defaults, and more.
+
+
+ One-click managed shortcut for the same Docker image.
+
+
+ The Docker sandbox provider's security model and trust assumptions.
+
+
diff --git a/docs/public/administration/troubleshooting.mdx b/docs/public/administration/troubleshooting.mdx
index adae24e83..1bd5d9b61 100644
--- a/docs/public/administration/troubleshooting.mdx
+++ b/docs/public/administration/troubleshooting.mdx
@@ -33,7 +33,7 @@ It checks:
**Port already in use** — Change the port with `fabro server start --port 3001` or stop the conflicting process.
-**SSE streams disconnecting** — If using a reverse proxy, ensure buffering is disabled and the connection timeout is long enough for workflow runs. See the [DigitalOcean reverse-proxy example](/administration/deploy-digital-ocean).
+**SSE streams disconnecting** — If using a reverse proxy, ensure buffering is disabled and the connection timeout is long enough for workflow runs.
**Run config validation errors** — Use `fabro preflight` to validate without executing:
diff --git a/docs/public/core-concepts/how-fabro-works.mdx b/docs/public/core-concepts/how-fabro-works.mdx
index 6b724d182..de33adec5 100644
--- a/docs/public/core-concepts/how-fabro-works.mdx
+++ b/docs/public/core-concepts/how-fabro-works.mdx
@@ -16,7 +16,7 @@ Fabro has two interfaces, both backed by the same workflow engine:
- **Direct CLI runs** (`fabro run`) — Run a single workflow synchronously in your terminal. Best for local development, one-off runs, and CI/CD.
- **Server interface** (`fabro server start`) — Start an HTTP API server with a web UI, concurrent run scheduling, and team access. Best for production use and running at scale.
-Both interfaces parse the same Graphviz files, use the same execution engine, and support the same sandbox providers. See [Running the Fabro Server](/administration/deploy-server) for a detailed comparison and setup guide, or [Architecture](/reference/architecture) for internals.
+Both interfaces parse the same Graphviz files, use the same execution engine, and support the same sandbox providers. See [Deployment](/administration/deployment) for where the server runs and [Server Operations](/reference/server-operations) for operating it, or [Architecture](/reference/architecture) for internals.
## Author time
diff --git a/docs/public/docs.json b/docs/public/docs.json
index 869f98d63..f69811618 100644
--- a/docs/public/docs.json
+++ b/docs/public/docs.json
@@ -108,6 +108,7 @@
"reference/run-directory",
"reference/sdk",
"reference/architecture",
+ "reference/server-operations",
"reference/verifying-releases",
"administration/server-configuration",
"administration/troubleshooting",
@@ -156,11 +157,9 @@
"group": "Deployment",
"icon": "server",
"pages": [
- "administration/deploy-server",
- "administration/deploy-railway",
- "administration/deploy-render",
- "administration/deploy-fly-io",
- "administration/deploy-digital-ocean"
+ "administration/deployment",
+ "administration/self-host-docker",
+ "administration/deploy-railway"
]
}
]
diff --git a/docs/public/getting-started/quick-start.mdx b/docs/public/getting-started/quick-start.mdx
index 6445e64b2..9de4f2f06 100644
--- a/docs/public/getting-started/quick-start.mdx
+++ b/docs/public/getting-started/quick-start.mdx
@@ -4,10 +4,7 @@ description: "Get up and running with Fabro"
---
-Fabro has two modes:
-
-- **Standalone mode** — Run workflows directly from the CLI. This is what the quick start covers below.
-- **Server mode** — An API server with a web UI for launching and managing workflow runs at scale. See [Server Mode](/administration/deploy-server) for details.
+Fabro runs as a server. This quickstart runs everything locally on your laptop — no deployment required. To self-host the server for a team or 24/7 workflows, see [Deployment](/administration/deployment).
## Supported platforms
@@ -54,7 +51,7 @@ Release binaries and the multi-arch Docker image ship with [SLSA Build Provenanc
-Setting up server mode instead? Run `fabro server start` to finish setup in a browser-based wizard. See [Running the Fabro Server](/administration/deploy-server) for the full flow.
+Self-hosting the server? See [Self-host with Docker](/administration/self-host-docker) and [Server Operations](/reference/server-operations) for the install wizard, auth, and CLI-pointing.
## Initialize your project
diff --git a/docs/public/reference/architecture.mdx b/docs/public/reference/architecture.mdx
index 32ee05428..bc7e2de04 100644
--- a/docs/public/reference/architecture.mdx
+++ b/docs/public/reference/architecture.mdx
@@ -89,4 +89,4 @@ The UI provides:
## Comparison
-See [Server Mode](/administration/deploy-server#standalone-vs-server-mode) for a full feature comparison between standalone and server mode.
+See [Deployment](/administration/deployment) for where the Fabro server runs and the trade-offs between local and self-hosted modes.
diff --git a/docs/public/administration/deploy-server.mdx b/docs/public/reference/server-operations.mdx
similarity index 78%
rename from docs/public/administration/deploy-server.mdx
rename to docs/public/reference/server-operations.mdx
index 684ec2832..94b4c4dab 100644
--- a/docs/public/administration/deploy-server.mdx
+++ b/docs/public/reference/server-operations.mdx
@@ -1,28 +1,13 @@
---
-title: "Running the Fabro Server"
-description: "Run Fabro as an API server with a web UI, concurrent runs, and team access"
+title: "Server Operations"
+description: "Operate the Fabro server: starting, install wizard, auth, web UI, and pointing the CLI at it"
---
The server interface is in private early access. Contact [bryan@qlty.sh](mailto:bryan@qlty.sh) if you're interested in trying it.
-Fabro has two interfaces to the same workflow engine. You can run a workflow directly in the CLI with `fabro run`, or start the HTTP server with `fabro server start` to queue runs, stream events, and serve the web UI.
-
-Both interfaces use the same workflow engine, the same Graphviz files, and the same sandbox providers. The difference is how you interact with them.
-
-## Direct CLI Runs vs. Server Interface
-
-| | Direct CLI runs | Server interface |
-|---|---|---|
-| **Command** | `fabro run workflow.fabro` | `fabro server start` |
-| **Best for** | Local development, one-off runs, CI/CD | Production, team use, running at scale |
-| **Execution** | Synchronous, one run per process | Asynchronous, queued with configurable concurrency |
-| **Human-in-the-loop** | Terminal prompts | Web UI or HTTP endpoints |
-| **Events** | Printed to stderr | Streamed via SSE |
-| **Persistence** | Checkpoint files only | Persistent run store + checkpoint files |
-| **Web UI** | Not available | Full React interface |
-| **Authentication** | None | Dev token and/or GitHub OAuth |
+This page covers operating the Fabro server once it's running, whether locally on your laptop or self-hosted in a container. For where to run it, see [Deployment](/administration/deployment).
## Starting the server
@@ -68,7 +53,7 @@ See [Server Configuration](/administration/server-configuration) for the full `s
## Submitting runs
-In the server interface, workflows are submitted via the REST API and executed in the background. The exact request body is documented in the API reference:
+Workflows are submitted via the REST API and executed in the background. The exact request body is documented in the API reference:
```bash
curl -X POST http://localhost:3000/api/v1/runs
@@ -110,7 +95,7 @@ The API streams run events via [Server-Sent Events (SSE)](/api-reference/runs/st
## Human-in-the-loop
-In the server interface, human-in-the-loop questions are served over HTTP instead of terminal prompts. The engine blocks the current stage until an answer is submitted, then continues execution. See the [list questions](/api-reference/human-in-the-loop/list-run-questions) and [submit answer](/api-reference/human-in-the-loop/submit-run-answer) API reference pages.
+Human-in-the-loop questions are served over HTTP. The engine blocks the current stage until an answer is submitted, then continues execution. See the [list questions](/api-reference/human-in-the-loop/list-run-questions) and [submit answer](/api-reference/human-in-the-loop/submit-run-answer) API reference pages.
## Authentication
@@ -162,16 +147,16 @@ See [User Configuration](/reference/user-configuration#cli-target-section) for t
## Next steps
+
+ Choose where the server runs: laptop or self-hosted Docker container.
+
Full settings.toml reference — authentication, reverse-proxy TLS, run defaults, and more.
-
- Step-by-step guide for deploying Fabro on Railway.
-
REST API for submitting runs, streaming events, and managing resources.
- The workflow engine that powers both interfaces.
+ The workflow engine and architecture.
diff --git a/fly.toml b/fly.toml
deleted file mode 100644
index 58a9f8fdd..000000000
--- a/fly.toml
+++ /dev/null
@@ -1,35 +0,0 @@
-# Fly.io deployment config for the Fabro server.
-#
-# Pulls the multi-arch image published to GHCR instead of building from
-# source on every deploy. Persists state to a Fly Volume mounted at
-# /storage. The Fabro binary binds to $PORT or 32276, so matching
-# internal_port to 32276 avoids needing to wire PORT as a secret.
-#
-# First-time setup:
-# fly launch --copy-config --no-deploy # adopts this file; sets app name/region
-# fly volumes create storage --size 1 # required — fly.toml cannot create volumes
-# fly secrets set ANTHROPIC_API_KEY=... SESSION_SECRET=...
-# fly deploy
-
-app = "fabro"
-primary_region = "ord"
-
-[build]
- image = "ghcr.io/fabro-sh/fabro:nightly"
-
-[[mounts]]
- source = "storage"
- destination = "/storage"
-
-[http_service]
- internal_port = 32276
- force_https = true
- auto_stop_machines = "off"
- auto_start_machines = false
-
- [[http_service.checks]]
- path = "/health"
- method = "GET"
- interval = "30s"
- timeout = "5s"
- grace_period = "10s"
diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs
index 1fc600583..adad75bf2 100644
--- a/lib/crates/fabro-sandbox/src/lib.rs
+++ b/lib/crates/fabro-sandbox/src/lib.rs
@@ -36,9 +36,10 @@ pub use error::{Error, Result};
pub use local::LocalSandbox;
pub use read_guard::ReadBeforeWriteSandbox;
pub use sandbox::{
- CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GitRunInfo, GitSetupIntent,
- GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, format_lines_numbered,
- git_push_via_exec, setup_git_via_exec, shell_quote,
+ CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
+ ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions, Sandbox, SandboxEvent,
+ SandboxEventCallback, format_lines_numbered, git_push_via_exec, redacted_output_tail,
+ setup_git_via_exec, shell_quote,
};
pub use sandbox_provider::SandboxProvider;
pub use sandbox_record::SandboxRecord;
diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs
index 0abe33235..f14dc1629 100644
--- a/lib/crates/fabro-sandbox/src/sandbox.rs
+++ b/lib/crates/fabro-sandbox/src/sandbox.rs
@@ -451,15 +451,7 @@ impl ExecResult {
&self,
max_bytes_per_stream: usize,
) -> Option {
- let (stdout, stdout_truncated) = redacted_tail(&self.stdout, max_bytes_per_stream);
- let (stderr, stderr_truncated) = redacted_tail(&self.stderr, max_bytes_per_stream);
- let tail = fabro_types::ExecOutputTail {
- stdout,
- stderr,
- stdout_truncated,
- stderr_truncated,
- };
- (!tail.is_empty()).then_some(tail)
+ redacted_output_tail(&self.stdout, &self.stderr, max_bytes_per_stream)
}
pub fn default_redacted_output_tail(&self) -> Option {
@@ -488,6 +480,26 @@ impl ExecResult {
}
}
+/// Build a redacted `ExecOutputTail` from raw stdout/stderr without
+/// fabricating a synthetic `ExecResult`. Pass `""` for either stream that
+/// isn't relevant. Returns `None` when both streams are empty.
+#[must_use]
+pub fn redacted_output_tail(
+ stdout: &str,
+ stderr: &str,
+ max_bytes_per_stream: usize,
+) -> Option {
+ let (stdout, stdout_truncated) = redacted_tail(stdout, max_bytes_per_stream);
+ let (stderr, stderr_truncated) = redacted_tail(stderr, max_bytes_per_stream);
+ let tail = fabro_types::ExecOutputTail {
+ stdout,
+ stderr,
+ stdout_truncated,
+ stderr_truncated,
+ };
+ (!tail.is_empty()).then_some(tail)
+}
+
fn redacted_tail(text: &str, max_bytes: usize) -> (Option, bool) {
if text.is_empty() || max_bytes == 0 {
return (None, !text.is_empty());
diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/crates/fabro-server/tests/it/api/docs.rs
index b8a120f82..54eae87bb 100644
--- a/lib/crates/fabro-server/tests/it/api/docs.rs
+++ b/lib/crates/fabro-server/tests/it/api/docs.rs
@@ -33,11 +33,11 @@ fn security_doc_does_not_require_jwt_keys_for_the_current_web_flow() {
}
#[test]
-fn deploy_server_doc_links_to_the_cli_target_section_slug() {
- let deploy_server = read_doc("docs/public/administration/deploy-server.mdx");
+fn server_operations_doc_links_to_the_cli_target_section_slug() {
+ let server_operations = read_doc("docs/public/reference/server-operations.mdx");
assert!(
- deploy_server.contains("/reference/user-configuration#cli-target-section"),
- "deploy-server doc should link to the Mintlify slug for the [cli.target] section"
+ server_operations.contains("/reference/user-configuration#cli-target-section"),
+ "server-operations doc should link to the Mintlify slug for the [cli.target] section"
);
}
diff --git a/lib/crates/fabro-workflow/src/sandbox_metadata.rs b/lib/crates/fabro-workflow/src/sandbox_metadata.rs
index eca510284..c022970ba 100644
--- a/lib/crates/fabro-workflow/src/sandbox_metadata.rs
+++ b/lib/crates/fabro-workflow/src/sandbox_metadata.rs
@@ -326,21 +326,14 @@ fn parse_fast_import_mark(stdout: &str) -> Result
"git fast-import did not report imported commit mark (stdout_bytes={})",
stdout.len()
),
- exec_output_tail: stdout_output_tail(stdout),
+ exec_output_tail: fabro_sandbox::redacted_output_tail(
+ stdout,
+ "",
+ fabro_sandbox::DEFAULT_EXEC_OUTPUT_TAIL_BYTES,
+ ),
})
}
-fn stdout_output_tail(stdout: &str) -> Option {
- fabro_sandbox::ExecResult {
- stdout: stdout.to_string(),
- stderr: String::new(),
- exit_code: Some(0),
- termination: fabro_types::CommandTermination::Exited,
- duration_ms: 0,
- }
- .default_redacted_output_tail()
-}
-
fn fast_import_ident(author: &GitAuthor) -> String {
let name = author
.name
diff --git a/railway.toml b/railway.toml
deleted file mode 100644
index 185d7486a..000000000
--- a/railway.toml
+++ /dev/null
@@ -1,8 +0,0 @@
-[build]
-builder = "dockerfile"
-dockerfilePath = "Dockerfile.deploy"
-
-[deploy]
-healthcheckPath = "/health"
-restartPolicyType = "ALWAYS"
-restartPolicyMaxRetries = 5
diff --git a/render.yaml b/render.yaml
deleted file mode 100644
index be4c88043..000000000
--- a/render.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-services:
- - type: web
- name: fabro
- runtime: docker
- dockerfilePath: ./Dockerfile.deploy
- plan: starter
- healthCheckPath: /health
- disk:
- name: fabro-storage
- mountPath: /storage
- sizeGB: 1