Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Running g1t yourself: the design, a docker compose proof, and a guide to what works today | 1 | # Self-hosting g1t |
| 2 | ||
| 3 | The goal (2026-10-05): g1t should not be locked to Cloudflare. Anyone should | |
| 4 | be able to run it on their own machine with `docker compose up`. The free | |
| 5 | core is MIT and self-hostable; managed hosting at g1t.sh is the paid | |
| 6 | product, and it stays on Cloudflare. Self-hosting must never make hosted | |
| 7 | g1t worse, so hosted code paths do not change to make room for it. | |
| 8 | ||
| 9 | This document covers: | |
| 10 | ||
| 11 | 1. An inventory of every Cloudflare dependency in the code. | |
| 12 | 2. The design: ports and adapters, with the runtime choice weighed. | |
| 13 | 3. What phase 1 ships today: `deploy/self-host/`, and what was verified. | |
| 14 | 4. The phased plan, with estimates. | |
| 15 | 5. The risks. | |
| 16 | ||
| 17 | The user guide is `apps/docs/src/content/docs/guides/self-hosting.md`. | |
| 18 | ||
| 19 | ## The short version | |
| 20 | ||
| 21 | - **g1t is already shaped for this.** Every service talks to every other | |
| 22 | over plain HTTP: `POST /rpc/<method>` with a JSON body (`crates/kit`, | |
| 23 | `packages/contracts/src/clients.ts`). Every database is SQLite (D1). | |
| 24 | Git storage sits behind a `GitStore` port (`services/repos/src/store.rs`), | |
| 25 | and everything past that port speaks git's smart HTTP to a remote URL | |
| 26 | with a bearer token. | |
| 27 | - **The fastest credible path is to run the Workers themselves in | |
| 28 | workerd**, the open-source Workers runtime, not to port them. Phase 1 | |
| 29 | runs every core Worker unchanged (the same WebAssembly and the same | |
| 30 | bundles that deploy to Cloudflare) in one workerd process under | |
| 31 | `wrangler dev`. D1, KV and Queues are kept on a volume as SQLite files. | |
| 32 | - **Three things replace Cloudflare-only bindings**, as small Workers bound | |
| 33 | in their place, with no change to the services: | |
| 34 | - `ARTIFACTS` becomes an Artifacts-compatible shim in front of a git | |
| 35 | store, which keeps plain bare repositories on disk and serves them | |
| 36 | with `git http-backend`. | |
| 37 | - `EMAIL` becomes a shim that logs each message and hands it to Mailpit, | |
| 38 | which can relay to any SMTP server. | |
| 39 | - The runner and the context hub are bound to an "off" Worker, which | |
| 40 | answers "agents are off" instead of failing. | |
| 41 | - **Proven on this machine with Docker:** sign up, confirm the email | |
| 42 | through Mailpit, create a workspace and a repository, push and clone over | |
| 43 | HTTP, open an issue, and browse code, commits and files in the site. All | |
| 44 | of it runs against local storage. See [Phase 1: what works today](#phase-1-what-works-today). | |
| 45 | - **Long term:** keep workerd as the runtime, because it is what hosted | |
| 46 | runs. Replace `wrangler dev` with a production workerd configuration. | |
| 47 | Move the binding shims into code-level ports in `g1t_kit` and a TS | |
| 48 | `@g1t/platform` package, so each primitive has a hosted and a | |
| 49 | self-hosted adapter behind one interface. | |
| 50 | ||
| 51 | ## 1. Inventory | |
| 52 | ||
| 53 | The sources are every `wrangler.jsonc` plus a grep of the code. Coupling | |
| 54 | is graded: | |
| 55 | ||
| 56 | - **thin**: one call site or a config switch; | |
| 57 | - **adapter**: already behind a port, or easy to put behind one; | |
| 58 | - **woven**: the logic is shaped around the product. | |
| 59 | ||
| 60 | ### By primitive | |
| 61 | ||
| 62 | | Primitive | Where | Coupling | Self-hosted equivalent | | |
| 63 | | --- | --- | --- | --- | | |
| 64 | | **Workers runtime**, service bindings | Every service. Rust through `worker` 0.8 (`#[event(fetch\|queue\|scheduled)]`, `Env`, `Fetcher`); TS as `export default { fetch, queue, scheduled }` | woven (as a runtime), thin (as an API) | **workerd**: the same runtime, open source. Service bindings work as they do hosted. Calls are HTTP (`POST /rpc/<method>`), so a native port could use plain HTTP clients. | | |
| 65 | | **Workers RPC** (JS methods across a binding) | Only `RUNNER`: `RunnerService extends WorkerEntrypoint` (`services/runner/src/index.ts:626`). `apps/web` calls `env.RUNNER.enabled/run/plan/...` directly in 11 routes. | thin | workerd supports it. A native port needs these on `/rpc/*` as well; the runner already has a `fetch` shim for Rust callers. | | |
| 66 | | **D1** | System of record for 13 services. Rust: `env.d1("DB")`; TS: `D1Database`; `db.batch()` in `crates/kit` `rename` | woven (SQL), thin (API) | **SQLite files**. workerd/Miniflare implements D1 on SQLite, and the same `migrations/` apply with `wrangler d1 migrations apply --local`. A native port would need a `Database` port over `rusqlite`/`better-sqlite3`; the SQL is already SQLite, including FTS5. | | |
| 67 | | **KV** | `BLOBS`: Actions artifacts and cache (`apps/api/src/blobs.rs`, `apps/web/app/lib/artifacts.server.ts`). `AVATARS`: `services/identity/src/avatars.rs`, `apps/web/workers/app.ts`, `services/og`. `DOMAINS`: `services/deployments/src/domains.ts`, `services/pages` | thin | Miniflare KV on disk (SQLite plus blob files). Natively: a `BlobStore` port on the filesystem or S3/MinIO. | | |
| 68 | | **Queues**: the event bus | Producer: `services/events` `BUS.sendBatch` (`lib.rs:67`). The consumer writes the log, then fans out to every binding named `SUBSCRIBER_*` (`lib.rs:161`). Twelve consumers, one queue each. Private job queues in search (`g1t-search-jobs`) and context (`g1t-context-jobs`); consumers branch on the queue name. | woven | Miniflare Queues: in-process and persisted, which works today. Natively: a `Bus` port with a SQLite outbox and a poller per subscriber, or NATS/Redis Streams. At-least-once delivery and idempotent consumers are already the contract. | | |
| 69 | | **Durable Objects** | Only `AttemptSandbox` (runner), as the containers library's base class. Uses `ctx.storage.get/put/delete`, `schedule()` (alarm), `idFromName`/`idFromString`, DO RPC (`run`, `destroy`, `noteBlocked`). **Not used:** WebSocket hibernation, raw `alarm()`, `ctx.storage.sql`, `ctx.exports`. | woven, in the runner only | workerd supports Durable Objects (on-disk SQLite). Runner state can move to the sandbox supervisor (phase 2). | | |
| 70 | | **Containers** (`@cloudflare/containers`) | `services/runner`: one sandbox per agent run, Actions job and deploy build. `sleepAfter`, `start({ envVars, enableInternet })`, `onStop`. Image: `services/runner/Dockerfile` (node 24, git, toolchains, Claude Code, `g1t-runner`). | woven | **Docker or Podman** through the socket, with the same image. Wrangler can already run Containers locally through Docker; whether that covers outbound interception has to be tested. | | |
| 71 | | **Outbound interception** (guardrails egress) | `services/runner/src/guard.ts` (`egress()`), `egress.ts` (`sandboxHosts`, `EGRESS_CA = /etc/cloudflare/certs/cloudflare-containers-ca.crt`), `AttemptSandbox.outboundHandlers`, `interceptHttps = true`, `setOutboundHandler("egress", { hosts })` | woven | The sandbox joins an internal network with no route out, and gets `HTTP(S)_PROXY` pointing at an allow-list proxy that checks the `CONNECT` host. The CA bundle is then not needed, because nothing is re-signed. Blocked hosts are reported to the runner as `noteBlocked` does today. | | |
| 72 | | **Artifacts** (git storage) | Only `services/repos/src/store.rs` (`ArtifactsStore`, behind the `GitStore`/`GitRepo` traits). Methods used: `create`, `get`; then `info`, `createToken`, `log`, `readCommit`, `readTree`, `readBlob`, `readFile`, `fork`, and `[Symbol.dispose]`. Everything else (push, fetch, landing, catch-up, import, ref listing) is smart HTTP to `info().remote` with `Bearer <token>`: `land.rs`, `catch_up.rs`, `refs.rs`, `import.rs`, `git_http.rs`. | adapter | **Bare repositories on disk plus `git http-backend`.** Built in phase 1: `deploy/self-host/gitstore`. Forks are local clones with hard links. Tokens are HMAC-signed, scoped, and expire. | | |
| 73 | | **Cache API** | `services/repos/src/store.rs` (trees and blobs up to 1 MiB, by hash), `apps/web/workers/app.ts` (avatars), `services/og` | thin, optional | Miniflare's cache, or none. Every use tolerates a miss. | | |
| 74 | | **Vectorize** | `services/context/src/index.ts` (`VECTORS.upsert/deleteByIds/query`); optional, guarded by `if (!AI \|\| !VECTORS)` | thin | **sqlite-vec** (default: one file, next to D1), pgvector or Qdrant behind a `VectorIndex` port; or off, which already degrades to keyword search. | | |
| 75 | | **Workers AI** | `services/context` only: `@cf/baai/bge-base-en-v1.5` embeddings (768 dims) | thin | An OpenAI-compatible `/v1/embeddings` endpoint (Ollama, vLLM, LM Studio, or a hosted API) behind an `Embedder` port. Changing models means re-embedding (a backfill job already exists). | | |
| 76 | | **AI Gateway** | `services/runner/src/model-env.ts:56`, `services/models/src/route.ts:70` (gateway URL, `cf-aig-*` headers), `services/billing/src/keeper.rs` (reads gateway logs to settle) | thin | Optional already: an empty `AI_GATEWAY_ID` goes straight to the provider. Any Anthropic- or OpenAI-compatible base URL works for a workspace's own provider. | | |
| 77 | | **Workers for Platforms** | `services/pages` (dispatcher: `env.APPS.get(script).fetch`), `services/deployments/src/cloudflare.ts` (script and asset upload through the REST API) | woven | Phase 1: off. Later: a self-hosted app host in workerd, using the Worker Loader binding to load uploaded scripts, or a workerd per app (see [Deployments](#deployments)). | | |
| 78 | | **Cloudflare for SaaS** (custom hostnames) | `services/deployments/src/custom-hostnames.ts` (`/zones/{id}/custom_hostnames`) | thin | Caddy with on-demand TLS, asking g1t whether a hostname is allowed. | | |
| 79 | | **Cloudflare REST API** | deployments: script upload, list, delete, assets, GraphQL usage. Billing keeper: AI Gateway logs, `billable-usage`, GraphQL container usage. Ops scripts in `scripts/`. | thin (deployments), woven (keeper pricing) | Deployments: the app-host adapter. Keeper: off when self-hosted, because there is no bill to reconcile. | | |
| 80 | | **Email Sending** | `services/identity/src/email.rs` (`EMAIL.send({to, from, subject, text, html})`); callers: verification, password reset, `admin.rs` limit warnings | thin | Built in phase 1: a shim that logs and hands mail to Mailpit, which relays over SMTP. Later: a `Mailer` port with an SMTP adapter. | | |
| 81 | | **Cloudflare Access** | `apps/sudo/app/lib/access.ts` (verifies `Cf-Access-Jwt-Assertion` against `/cdn-cgi/access/certs`, `ACCESS_AUD`, `STAFF_EMAILS`) | woven, in sudo only | A local admin flag: `G1T_ADMINS` usernames checked against the normal g1t session. Self-hosters rarely need sudo, which is about billing. | | |
| 82 | | **Cron Triggers** | actions (every minute), webhooks (every minute), security (`*/30`), billing (`*/15` and daily), deployments (`*/10`), runner (`*/5`) | thin | workerd runs `scheduled()` when asked. Phase 1 does not yet fire them; phase 2 adds a scheduler that does (see the risks). | | |
| 83 | | **`cloudflare:workers` imports** | `apps/web` (`env` in 15 files), `apps/sudo`, `services/runner` (`WorkerEntrypoint`) | thin | Provided by workerd. A Node port would pass `env` through context instead. | | |
| 84 | | **Static Assets** | `apps/web` (Vite plugin build), `apps/docs`, `apps/sudo` (`run_worker_first`) | thin | workerd serves them. | | |
| 85 | | **`placement`, `observability`, routes, custom domains** | every `wrangler.jsonc` | config only | Dropped by `deploy/self-host/configs.mjs`. | | |
| 86 | | **`cf-ray`** | Used as an audit request id, with a fallback: `services/repos/src/run_access.rs:131`, `apps/api/src/audit.rs:37` | thin | Falls back already. | | |
| 87 | | **Not used** | R2, Hyperdrive, Workflows, Analytics Engine, Browser Rendering, Images, Turnstile, Secrets Store, `connect()`, HTMLRewriter, `request.cf` | — | — | | |
| 88 | ||
| 89 | ### By service | |
| 90 | ||
| 91 | | Service | Runs on | Cloudflare dependencies beyond Workers and D1 | Phase 1 self-hosted | | |
| 92 | | --- | --- | --- | --- | | |
| 93 | | `apps/web` | TS Worker plus assets | KV (`BLOBS`, `AVATARS`), Cache API, `cloudflare:workers` `env`, RPC to `RUNNER` | Runs unchanged | | |
| 94 | | `apps/api` | Rust Worker | KV `BLOBS`; hard-coded `api.g1t.sh`/`mcp.g1t.sh` issuer | Not started yet (phase 2) | | |
| 95 | | `apps/sudo` | TS Worker plus assets | Access JWT | Not run | | |
| 96 | | `apps/docs` | Static | — | Not run (docs.g1t.sh serves them) | | |
| 97 | | `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim | | |
| 98 | | `services/repos` | Rust | **Artifacts**, Cache API | Runs unchanged; `ARTIFACTS` goes to the git store | | |
| 99 | | `services/work` | Rust | Queue consumer | Runs unchanged | | |
| 100 | | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to | | |
| 101 | | `services/projects` | TS | Queue consumer | Runs unchanged | | |
| 102 | | `services/search` | Rust | Queues (events and its own jobs); FTS5 | Runs unchanged | | |
| 103 | | `services/billing` | Rust | Cron, Cloudflare REST API (keeper), Stripe | Runs with `FREE_WHILE_BUILDING=true` and no Stripe key: nothing is charged | | |
| 104 | | `services/security` | Rust | Queue, cron | Runs unchanged (cron not fired) | | |
| 105 | | `services/actions` | Rust | Queue, cron, `ACTIONS_KEY` | Runs; jobs need the runner, which is off | | |
| 106 | | `services/webhooks` | Rust | Queue, cron, `WEBHOOKS_KEY` | Runs; first delivery works, retries need cron | | |
| 107 | | `services/integrations` | Rust | Queue, `INTEGRATIONS_KEY` | Runs unchanged | | |
| 108 | | `services/deployments` | TS | Workers for Platforms, REST API, KV `DOMAINS`, cron | Runs with no API token: nothing deploys | | |
| 109 | | `services/runner` | TS | **Containers**, Durable Objects, outbound interception, AI Gateway, cron | Off: bound to the off Worker | | |
| 110 | | `services/context` | TS | **Vectorize**, **Workers AI**, Queues | Off: bound to the off Worker | | |
| 111 | | `services/models` | TS | AI Gateway; public at `models.g1t.sh` | Not run (only sandboxes call it) | | |
| 112 | | `services/pages` | TS | Dispatch namespace, wildcard routes, KV | Not run | | |
| 113 | | `services/og` | TS | Cache API | Not run (social cards are optional) | | |
| 114 | | `crates/runner` | native, in the sandbox | Talks to `https://api.g1t.sh` and `https://g1t.sh` (hard-coded in the runner Worker); `wrangler deploy --dry-run` for builds | Phase 2 | | |
| 115 | | `crates/sshd` | native | Not deployed; calls `/_internal/ssh/*` endpoints that do not exist yet | Phase 3 | | |
| 116 | ||
| 117 | ### Hard-coded hosted addresses | |
| 118 | ||
| 119 | Self-hosting needs one setting, `PUBLIC_URL`, in place of these. Phase 1 | |
| 120 | gets around the ones on its path: the mail shim rewrites `https://g1t.sh` | |
| 121 | links in mail, and `configs.mjs` rewrites `SITE_URL`/`API_URL`/`SITE` | |
| 122 | variables. The rest are listed here so phase 2 can make them settings: | |
| 123 | ||
| 124 | - `apps/web/app/lib/meta.ts` (`SITE`, `OG`); `clone-box.tsx` (clone URL, | |
| 125 | `mcp.g1t.sh`); `workers/app.ts` (`DOCS`). | |
| 126 | - `apps/api/src/lib.rs` (`API`); `oauth.rs` (issuer, MCP resource). | |
| 127 | - `services/identity/src/email.rs` (`SITE`, `FROM`). | |
| 128 | - `services/runner/src/index.ts` (`G1T_API`, `GIT_REMOTE` and the other | |
| 129 | remotes handed to sandboxes, in 12 places). | |
| 130 | - `services/billing` (`stripe.rs`, `accounts.rs`, `limits.rs`). | |
| 131 | - `crates/sshd` (`G1T_API` default). | |
| 132 | ||
| 133 | There are about 150 occurrences of `g1t.sh` in non-test code. Most are | |
| 134 | docs links and copy, and need no change. | |
| 135 | ||
| 136 | ## 2. Design | |
| 137 | ||
| 138 | ### Principles | |
| 139 | ||
| 140 | 1. **Hosted is the reference.** Hosted code does not change behaviour to | |
| 141 | make room for self-hosting. A self-hosted adapter is added beside the | |
| 142 | hosted one, and the hosted one stays the default. | |
| 143 | 2. **Swap at the narrowest seam that exists.** A binding-shaped seam (a | |
| 144 | Worker that offers the same methods as the Cloudflare binding) needs no | |
| 145 | code change, and is how phase 1 works. A code-level port (a trait or | |
| 146 | interface with two adapters) is cleaner and testable, and is the long-term | |
| 147 | shape. Each primitive moves from the first to the second when it is next | |
| 148 | touched. | |
| 149 | 3. **One runtime, two hosts.** The Workers stay Workers. workerd runs them | |
| 150 | self-hosted, so one build serves both and there is no second code path | |
| 151 | to keep correct. | |
| 152 | 4. **Off is a real mode.** Every optional subsystem (agents, context, | |
| 153 | deployments, billing) has an "off" answer that pages already handle. | |
| 154 | ||
| 155 | ### The ports | |
| 156 | ||
| 157 | | Port | Hosted adapter | Self-hosted adapter | Lives in | Status | | |
| 158 | | --- | --- | --- | --- | --- | | |
| 159 | | `GitStore` / `GitRepo` | `ArtifactsStore` | Git store (bare repos, `git http-backend`) through an Artifacts-compatible shim; later a `LocalGitStore` that calls the git store's HTTP API from Rust directly | `services/repos/src/store.rs` (exists) | **Built** (binding level) | | |
| 160 | | `Mailer` | Email Sending binding | SMTP (through Mailpit relay now; a direct SMTP adapter later) | `g1t_kit::mail` | **Built** (binding level) | | |
| 161 | | `Sandbox` | `AttemptSandbox` (Containers, DO) | `DockerSandbox`: a supervisor that starts the runner image through the Docker API | `services/runner` (TS) | Phase 2 | | |
| 162 | | `Egress` | Containers outbound handler | Allow-list HTTP(S) proxy on an internal network | `services/runner` | Phase 2 | | |
| 163 | | `ModelRoute` | AI Gateway, or direct | Any Anthropic/OpenAI-compatible base URL | `services/models`, `runner/model-env.ts` | Exists (env-switchable) | | |
| 164 | | `Embedder` | Workers AI | OpenAI-compatible `/v1/embeddings` | `services/context` | Phase 3 | | |
| 165 | | `VectorIndex` | Vectorize | sqlite-vec / pgvector / Qdrant | `services/context` | Phase 3 | | |
| 166 | | `AppHost` | Workers for Platforms plus REST API | workerd app host (Worker Loader) | `services/deployments` | Phase 3 | | |
| 167 | | `Domains` | Cloudflare for SaaS | Caddy on-demand TLS | `services/deployments` | Phase 3 | | |
| 168 | | `AdminAuth` | Cloudflare Access | `G1T_ADMINS` plus the normal session | `apps/sudo` | Phase 4 | | |
| 169 | | `Bus` | Queues | Miniflare Queues now; SQLite outbox later | `services/events`, `g1t_kit` | Works (runtime) | | |
| 170 | | `Database` | D1 | SQLite files through workerd | — | Works (runtime) | | |
| 171 | | `Blobs` | KV | Miniflare KV now; filesystem/S3 later | — | Works (runtime) | | |
| 172 | | `Scheduler` | Cron Triggers | A ticker that calls `scheduled()` | `deploy/self-host` | Phase 2 | | |
| 173 | | `UsageKeeper` | Cloudflare bill plus AI Gateway logs | None (billing off) | `services/billing` | Off | | |
| 174 | ||
| 175 | For Rust, the code-level ports go in `crates/kit` as traits (`g1t_kit::ports`), | |
| 176 | with the Cloudflare adapters next to them. For TypeScript, they go in a new | |
| 177 | `packages/platform` package, with each adapter in its own module so a | |
| 178 | hosted bundle never pulls in a self-hosted adapter. The adapter is chosen at | |
| 179 | startup from one setting, `G1T_MODE=hosted|self`, never per request. | |
| 180 | ||
| 181 | ### The runtime: workerd, or native binaries | |
| 182 | ||
| 183 | | | **workerd (phase 1: `wrangler dev`; later a plain workerd config)** | **Native: Rust on axum/hyper, TS on Node** | | |
| 184 | | --- | --- | --- | | |
| 185 | | Effort to first boot | **Done.** About a day, almost all of it shims and config. | Weeks. The Rust services use `worker::*` throughout: `Env`, `Fetcher`, `D1Database`, the `#[event]` macros and `js_sys` interop. Each needs an abstraction layer before it compiles natively. TS needs an `env` provider in place of `cloudflare:workers`, plus Queues, D1 and KV clients. | | |
| 186 | | Fidelity to hosted | **The same runtime and the same bundles.** A bug self-hosted is a bug hosted. | A second implementation of every platform API, with its own bugs. | | |
| 187 | | Performance | Good for one node: WebAssembly in V8, SQLite on local disk. Single-threaded per isolate; plenty for a team. | Better per core, and multi-threaded. That matters only at a scale where people use g1t.sh. | | |
| 188 | | Maintenance | Low. New hosted features run self-hosted for free, unless they add a new Cloudflare-only binding. The config generator then drops it or binds a stand-in. | High. Every feature is built twice, or behind a port that both sides keep honest. | | |
| 189 | | Scale-out | One process, one disk. D1-on-SQLite is single-writer. | Could use Postgres and many processes. | | |
| 190 | | Operational risk | `wrangler dev` is a development tool (see risks). Moving to `workerd serve` with a generated config, or a small Miniflare-API launcher, removes the dev-tool surface. | Conventional. | | |
| 191 | ||
| 192 | **Recommendation.** | |
| 193 | ||
| 194 | - **Phase 1:** workerd under `wrangler dev`, as built in `deploy/self-host/`. | |
| 195 | It needs no change to any service and is proven end to end. | |
| 196 | - **Phase 2:** replace `wrangler dev` with a launcher that drives Miniflare's | |
| 197 | API directly, or a generated `workerd` config. It should expose only the | |
| 198 | site and the API, with no dev endpoints, run cron, and run as a proper | |
| 199 | service. | |
| 200 | - **Long term:** keep workerd as the runtime and push the remaining | |
| 201 | Cloudflare-only bindings behind code-level ports. Compile to native only | |
| 202 | what already is native (the runner binary, sshd), or a service where | |
| 203 | workerd is a real limit, case by case. | |
| 204 | ||
| 205 | A full native port is not worth it. It would double the maintenance of | |
| 206 | every feature, which is the opposite of what makes self-hosting | |
| 207 | sustainable for a small team. | |
| 208 | ||
| 209 | ### Sandboxes (phase 2) | |
| 210 | ||
| 211 | - **Engine.** Docker or Podman through the socket, mounted into a small | |
| 212 | supervisor (`g1t-sandboxd`), not into the workerd container. The | |
| 213 | supervisor exposes the runner's `Sandbox` port over HTTP: start with | |
| 214 | env, stop, status, and the exit report that becomes `onStop`. It uses | |
| 215 | the **same image** (`services/runner/Dockerfile`). | |
| 216 | - **Runner.** The runner Worker runs in workerd with `AttemptSandbox` | |
| 217 | replaced by a `DockerSandbox` adapter. Run state that lives in | |
| 218 | `ctx.storage` moves to the supervisor's SQLite; `schedule()`/`timeUp` | |
| 219 | becomes a supervisor timer. | |
| 220 | - **Egress guardrails.** Each restricted sandbox joins an internal Docker | |
| 221 | network with no default route. Its `HTTP_PROXY`/`HTTPS_PROXY` point at an | |
| 222 | allow-list proxy (for example a small Go or Node `CONNECT` proxy) that | |
| 223 | admits the hosts `sandboxHosts()` computes and reports refusals. | |
| 224 | Nothing is intercepted, so no CA is injected. `EGRESS=off` puts the | |
| 225 | sandbox on a normal network. | |
| 226 | - **Addresses.** `G1T_API`, `GIT_REMOTE` and the rest become | |
| 227 | `PUBLIC_URL`-derived settings. Sandboxes reach the site and API on the | |
| 228 | compose network. | |
| 229 | - **Shortcut to evaluate first.** Wrangler can already run Containers | |
| 230 | classes locally through Docker. If its local Containers support | |
| 231 | `setOutboundHandler`, the runner could run nearly unchanged. Test this | |
| 232 | before building the supervisor. | |
| 233 | ||
| 234 | ### Git storage | |
| 235 | ||
| 236 | Artifacts gives g1t: named repositories, scoped short-lived tokens, a smart | |
| 237 | HTTP remote, typed reads (commit, tree, blob, file, log), copy-on-write | |
| 238 | forks, and jurisdictions. g1t uses everything except jurisdictions. | |
| 239 | ||
| 240 | The self-hosted git store (`deploy/self-host/gitstore/server.mjs`, about 450 | |
| 241 | lines of Node with no dependencies) provides the same: | |
| 242 | ||
| 243 | | Artifacts | Git store | | |
| 244 | | --- | --- | | |
| 245 | | `create(name, { setDefaultBranch, description })` | `git init --bare --initial-branch`, plus `g1t.json` beside it for metadata | | |
| 246 | | `get(name)` then `info()` | Metadata, `HEAD`, and the last push time; `remote` is `GITSTORE_URL/git/<name>.git` | | |
| 247 | | `createToken(scope, ttl)` | HMAC-SHA256 over `{ key, scope, expiry }` with the shared secret | | |
| 248 | | Smart HTTP remote | `git http-backend`; a read token cannot push | | |
| 249 | | `log`, `readCommit`, `readTree`, `readBlob`, `readFile` | `git rev-list --first-parent`, `cat-file`, `ls-tree` | | |
| 250 | | `fork(name, { defaultBranchOnly })` | `git clone --bare [--single-branch]` with hard-linked objects | | |
| 251 | ||
| 252 | Hosted is untouched: `ArtifactsStore` is still the only adapter compiled | |
| 253 | into `services/repos`. Self-hosted, the `ARTIFACTS` binding is a service | |
| 254 | binding to `deploy/self-host/workers/artifacts`, which offers Artifacts' | |
| 255 | methods and calls the git store. Long term, a `LocalGitStore` adapter in | |
| 256 | Rust should call the git store's API directly, which removes the shim. The | |
| 257 | git store can later gain `git gc` scheduling and object-store-backed packs | |
| 258 | for large installations. | |
| 259 | ||
| 260 | ### Search and context | |
| 261 | ||
| 262 | - **Site search** (`services/search`) is D1 FTS5. It works self-hosted as | |
| 263 | is (verified: `/search?q=hello` answers 200). | |
| 264 | - **Context hub** (`services/context`) needs an `Embedder` and a | |
| 265 | `VectorIndex`: | |
| 266 | - Default: **sqlite-vec** in the context service's own SQLite, with | |
| 267 | embeddings from an **OpenAI-compatible endpoint**. Ollama's | |
| 268 | `nomic-embed-text` has the same 768 dimensions as today's | |
| 269 | `bge-base-en-v1.5`. | |
| 270 | - Alternatives: pgvector or Qdrant, for installations that already run | |
| 271 | them. | |
| 272 | - Off: the service already degrades to keyword search when `AI` or | |
| 273 | `VECTORS` is missing (`index.ts:919`), so phase 3 can first run context | |
| 274 | with neither bound. | |
| 275 | ||
| 276 | ### Models | |
| 277 | ||
| 278 | Already portable. The model proxy (`services/models`) and the runner's | |
| 279 | model environment take any Anthropic-compatible base URL, and an empty | |
| 280 | `AI_GATEWAY_ID` skips AI Gateway. Self-hosted: | |
| 281 | ||
| 282 | - a workspace connects its own provider under Integrations (Anthropic, or | |
| 283 | any Anthropic-compatible endpoint, including a local gateway in front of | |
| 284 | OpenAI-compatible models); | |
| 285 | - "hosted models" are off, because there is no g1t key to spend. | |
| 286 | ||
| 287 | ### Deployments | |
| 288 | ||
| 289 | - **Phase 1–2: off.** `services/deployments` runs, but with no | |
| 290 | `CLOUDFLARE_API_TOKEN` nothing deploys. Its pages and settings still | |
| 291 | render. | |
| 292 | - **Phase 3: an app host on workerd.** The self-hosted `pages` dispatcher | |
| 293 | uses workerd's **Worker Loader** binding to load each uploaded app's | |
| 294 | modules on demand, keyed by deployment id, from the blob store. Static | |
| 295 | assets are served from the same store. Builds run in Docker sandboxes | |
| 296 | (phase 2), which already bundle with `wrangler deploy --dry-run`. | |
| 297 | Wildcard hosts (`*.apps.example.com`) and custom domains go through | |
| 298 | Caddy with on-demand TLS. The alternative, one workerd process per app, | |
| 299 | is simpler to isolate but heavier. | |
| 300 | ||
| 301 | ### Billing and the feature map | |
| 302 | ||
| 303 | Self-hosted billing is **off by default**: no Stripe, no usage limits, | |
| 304 | no keeper. Billing still runs, because the shell reads `billing.account` | |
| 305 | on every page, but with `FREE_WHILE_BUILDING=true` and no Stripe key. | |
| 306 | ||
| 307 | | Feature | Hosted (g1t.sh) | Self-hosted default | Self-hosted, when turned on | | |
| 308 | | --- | --- | --- | --- | | |
| 309 | | Accounts, workspaces, repos, git over HTTP | On | On | — | | |
| 310 | | Issues, pull requests, review, merge queue | On | On | — | | |
| 311 | | Site search (FTS5) | On | On | — | | |
| 312 | | Email | Email Sending | Mailpit, logged | SMTP relay | | |
| 313 | | Webhooks, integrations | On | On (no scheduled retries yet) | — | | |
| 314 | | g1t agents | On | Off | Phase 2: Docker sandboxes plus your own model provider | | |
| 315 | | Guardrails egress | Containers interception | n/a | Phase 2: allow-list proxy | | |
| 316 | | Hosted models (g1t's key) | On (billed) | Off | Never: bring your own | | |
| 317 | | Context hub semantic search | Vectorize plus Workers AI | Off | Phase 3: sqlite-vec plus an OpenAI-compatible embedder | | |
| 318 | | Deployments | Workers for Platforms | Off | Phase 3: workerd app host | | |
| 319 | | Custom domains | Cloudflare for SaaS | Off | Phase 3: Caddy on-demand TLS | | |
| 320 | | Billing, limits, Stripe, keeper | On | Off | Not planned | | |
| 321 | | sudo (staff console) | Access | Off | Phase 4: `G1T_ADMINS` | | |
| 322 | | Git over SSH | Not yet | Off | Phase 3 (`crates/sshd`, which is native already) | | |
| 323 | | REST API, MCP, CLI | On | Off | Phase 2 | | |
| 324 | ||
| 325 | ### Auth, Access and email | |
| 326 | ||
| 327 | - `apps/sudo` checks Cloudflare Access. Self-hosted, it should check a | |
| 328 | normal g1t session against `G1T_ADMINS`. That needs an `AdminAuth` port in | |
| 329 | `workers/app.ts` with two adapters. It is phase 4, because sudo is about | |
| 330 | billing. | |
| 331 | - Sessions are random tokens stored hashed in D1, with no signing key, so | |
| 332 | nothing to configure. The cookie is `Secure`: fine on `localhost`, but any | |
| 333 | other address needs HTTPS. The compose file should gain an optional Caddy | |
| 334 | service in phase 2. | |
| 335 | - **Email verification is required** before creating anything, and there is | |
| 336 | no bypass in code. That is why the proof ships a working mail path | |
| 337 | (Mailpit) rather than "email off". An admin "mark verified" or | |
| 338 | `G1T_SKIP_EMAIL_VERIFICATION` belongs with `G1T_ADMINS`. | |
| 339 | ||
| 340 | ### Configuration, upgrades and backups | |
| 341 | ||
| 342 | - **Today:** environment variables in the compose file (`PUBLIC_URL`, | |
| 343 | `G1T_PORT`, `MAIL_URL`, `MAIL_FROM`). Keys (`ACTIONS_KEY`, | |
| 344 | `INTEGRATIONS_KEY`, `WEBHOOKS_KEY`, the git store secret) are generated on | |
| 345 | first start and kept on volumes. | |
| 346 | - **Phase 2:** one `g1t.toml`, read by the launcher and turned into | |
| 347 | bindings and variables: | |
| 348 | ||
| 349 | ```toml | |
| 350 | public_url = "https://git.example.com" | |
| 351 | ||
| 352 | [mail] | |
| 353 | smtp = "smtp://user:pass@smtp.example.com:587" | |
| 354 | from = "g1t <git@example.com>" | |
| 355 | ||
| 356 | [agents] # off when absent | |
| 357 | docker = "unix:///var/run/docker.sock" | |
| 358 | egress = "enforce" | |
| 359 | ||
| 360 | [context] # off when absent | |
| 361 | embeddings = "http://ollama:11434/v1" | |
| 362 | model = "nomic-embed-text" | |
| 363 | ||
| 364 | [admins] | |
| 365 | usernames = ["alice"] | |
| 366 | ``` | |
| 367 | ||
| 368 | - **Upgrades.** The container applies every service's D1 migrations to its | |
| 369 | SQLite file on start (`wrangler d1 migrations apply --local`). Applied | |
| 370 | migrations are recorded in `d1_migrations`, so this is idempotent. Hosted | |
| 371 | and self-hosted run the same migration files, which keeps them | |
| 372 | forward-compatible. Rule to keep: migrations stay additive, or come with | |
| 373 | a backfill a self-hoster's start can run. | |
| 374 | - **Backups.** Phase 1: stop, then tar the `g1t-data` and `g1t-git` | |
| 375 | volumes (documented in the guide). Phase 2: online backups with | |
| 376 | `sqlite3 .backup` per database and `git bundle` or rsync of the bare | |
| 377 | repositories, or Litestream for continuous replication. | |
| 378 | ||
| 379 | ## 3. Phase 1: what works today | |
| 380 | ||
| 381 | Everything is in `deploy/self-host/`: | |
| 382 | ||
| 383 | | File | What it is | | |
| 384 | | --- | --- | | |
| 385 | | `docker-compose.yml` | Three services: `g1t` (every core Worker in one workerd), `gitstore` (bare repositories), and `mailpit` (mail). Volumes: `g1t-data`, `g1t-git`, `g1t-secrets`. | | |
| 386 | | `Dockerfile` | Compiles the ten Rust services to WebAssembly with `worker-build`, as hosted does. Builds the site with React Router. The runtime image has Node, Wrangler, workerd and the built Workers. | | |
| 387 | | `Dockerfile.dockerignore` | Build-context rules for this image only (the root `.dockerignore` leaves out the site). | | |
| 388 | | `start.sh` | Makes the sealing keys once, writes the configs, applies migrations, and runs `wrangler dev` with every config on `0.0.0.0:8787`, persisting to `/data/state`. | | |
| 389 | | `configs.mjs` | Derives each self-hosted Wrangler config from the hosted `wrangler.jsonc`. It drops routes, account and placement, rebinds `ARTIFACTS`/`EMAIL` and the off services, and rewrites hosted URLs. Derived, so it cannot drift. | | |
| 390 | | `gitstore/server.mjs`, `gitstore/Dockerfile` | The git store. | | |
| 391 | | `workers/artifacts/index.js` | The `ARTIFACTS` binding, implemented against the git store. | | |
| 392 | | `workers/mail/index.js` | The `EMAIL` binding: logs, then sends to Mailpit. | | |
| 393 | | `workers/off/index.js` | The runner and the context hub when they are off. | | |
| 394 | | `smoke.sh` | The end-to-end check. | | |
| 395 | ||
| 396 | Workers running: the site; identity, repos, work, events, projects, search, | |
| 397 | billing, security, actions, webhooks, integrations and deployments; and the | |
| 398 | artifacts, mail and two off stand-ins. | |
| 399 | ||
| 400 | ### Verified | |
| 401 | ||
| 402 | On this machine (Windows 11, Docker Desktop 29.8, engine on Linux): | |
| 403 | ||
| 404 | 1. **Without Docker, with local processes.** The git store ran under Node | |
| 405 | on Windows and every Worker ran under `wrangler dev`, using the configs | |
| 406 | from `configs.mjs` and the existing Rust builds. `smoke.sh` passed every | |
| 407 | step: sign-up, confirmation through the logged link, workspace, repo, | |
| 408 | `git push` and `git clone` over HTTP, issue, and the code, blob and | |
| 409 | commits pages. Seventeen other pages answered 200: home, workspace, | |
| 410 | repo overview, issues, pulls, settings, agents, actions, deployments, | |
| 411 | security, people, usage, explore, search, account settings and tree. | |
| 412 | The workspace context page answered 403 from the off stand-in, as | |
| 413 | intended. | |
| 414 | 2. **With Docker Compose.** See the [Docker run](#docker-run) section below. | |
| 415 | ||
| 416 | ### Not verified, or not working yet | |
| 417 | ||
| 418 | - Pull requests between branches and forks, the merge queue and catch-up. | |
| 419 | They use `fork` and smart-HTTP pushes, which the git store implements, | |
| 420 | but they have not been exercised end to end. | |
| 421 | - Cron work: webhook retries, Actions schedules, security sweeps. | |
| 422 | - The REST API, MCP, the CLI, and git over SSH. | |
| 423 | - Anything on an address other than `localhost` without HTTPS (the | |
| 424 | session cookie is `Secure`). | |
| 425 | - Restart durability beyond one restart, upgrades across schema changes, | |
| 426 | and backup and restore. | |
| 427 | ||
| 428 | ## 4. Phased plan | |
| 429 | ||
| 430 | Estimates are for one engineer who knows the codebase, working with | |
| 431 | agents. They include docs and tests. | |
| 432 | ||
| 433 | | Phase | Scope | Estimate | | |
| 434 | | --- | --- | --- | | |
| 435 | | **1. Core forge** | **Done in this change:** compose stack, git store, Artifacts/Email shims, off stand-ins, config generator, smoke test, guide. **Left:** run the API worker (REST, MCP, OAuth) on its own port with `PUBLIC_URL` issuer; make `PUBLIC_URL` a setting in identity mail, `meta.ts`, `clone-box.tsx` and the API; fire cron (a ticker calling each Worker's `scheduled`); exercise pull requests and the merge queue in `smoke.sh`; optional Caddy for HTTPS; CI job that builds the images and runs `smoke.sh`. | 1–1.5 weeks left | | |
| 436 | | **2. Agents with Docker sandboxes** | Test Wrangler's local Containers first. Otherwise: `g1t-sandboxd` supervisor, `DockerSandbox` adapter in the runner, egress allow-list proxy and internal network, runner addresses from `PUBLIC_URL`, models through a workspace's own provider, `g1t.toml` and a launcher that replaces `wrangler dev`. | 2–3 weeks | | |
| 437 | | **3. Search, context and deployments** | `Embedder` (OpenAI-compatible) and `VectorIndex` (sqlite-vec first) ports in context; app host on workerd with Worker Loader; Caddy on-demand TLS for app and custom domains; git over SSH through `crates/sshd` plus the missing `/_internal/ssh/*` endpoints. | 3–4 weeks | | |
| 438 | | **4. Parity and upgrade path** | Code-level ports in `g1t_kit` / `@g1t/platform` replacing the binding shims (`LocalGitStore` in Rust, `Mailer` with SMTP); `AdminAuth` for sudo; online backups (Litestream or `.backup`); versioned releases with published images; an upgrade test in CI that migrates a snapshot of the previous release; a self-host column in the docs for every feature. | 3–4 weeks | | |
| 439 | ||
| 440 | Total to parity: about 10–13 weeks. Phase 1 alone is already a credible | |
| 441 | "run it yourself" for the core forge. | |
| 442 | ||
| 443 | ## 5. Risks | |
| 444 | ||
| 445 | - **`wrangler dev` is a development tool.** It exposes Miniflare's dev | |
| 446 | endpoints (`/cdn-cgi/...`, including a local data explorer) on the same | |
| 447 | port as the site. Treat phase 1 as **localhost or a trusted private | |
| 448 | network only** until the launcher in phase 2 replaces it. Its flags and | |
| 449 | behaviour can also change between Wrangler releases. Pin the Wrangler | |
| 450 | version, as the lockfile already does. | |
| 451 | - **No cron yet.** Webhook retries, Actions schedules, security sweeps and | |
| 452 | deployments' sweeps do not run. Anything that relies on a sweep to | |
| 453 | recover from a missed event stays stuck until phase 2 adds a ticker. | |
| 454 | - **New Cloudflare-only bindings break self-hosting silently.** | |
| 455 | `configs.mjs` passes unknown keys through untouched. A new binding type | |
| 456 | could make `wrangler dev` reach for a remote resource (for example | |
| 457 | `remote: true`, AI or Vectorize) and prompt for a login. Mitigation: CI | |
| 458 | that builds the compose stack and runs `smoke.sh` on every change, and an | |
| 459 | allow-list in `configs.mjs` that fails on unknown binding types. | |
| 460 | - **Artifacts semantics drift.** The shim copies the Artifacts methods g1t | |
| 461 | uses today. If repos starts using another method (`import`, | |
| 462 | `listTokens`, `revokeToken`), self-hosted fails at runtime. Mitigation: a | |
| 463 | contract test that runs `services/repos` against the git store, and the | |
| 464 | long-term `LocalGitStore` port. | |
| 465 | - **Error codes over RPC.** `ArtifactsStore::create` and `fork` tolerate | |
| 466 | `ALREADY_EXISTS` by reading the thrown error's `code`. Workers RPC may not | |
| 467 | carry custom error properties across a service binding. If it does not, | |
| 468 | retrying a half-finished create fails self-hosted where it would succeed | |
| 469 | hosted. This was not seen in testing, because creates were never retried. | |
| 470 | - **Single node, single writer.** SQLite (D1 local) and one workerd | |
| 471 | process suit a team, not a large organisation. Scaling out means | |
| 472 | Postgres behind a `Database` port, which is a large change and is not | |
| 473 | planned. | |
| 474 | - **The `Secure` cookie.** It needs HTTPS anywhere but `localhost`. A LAN | |
| 475 | install over plain HTTP cannot sign in. | |
| 476 | - **Building from a working tree that is mid-change.** The image compiles | |
| 477 | every Rust service, including ones other work is changing. A service that | |
| 478 | does not compile breaks the whole image. Released images (phase 4) fix | |
| 479 | this. | |
| 480 | - **Image size and build time.** The first build compiles ten Rust crates | |
| 481 | to WebAssembly and installs the site's dependencies. Expect minutes and | |
| 482 | several GB. Published images remove this for users. | |
| 483 | - **Hard-coded hosted URLs.** About a dozen code paths name `g1t.sh` or | |
| 484 | `api.g1t.sh`. Until they read `PUBLIC_URL`, some links and redirects | |
| 485 | (OG images, the docs link, the clone box's MCP line) point at the hosted | |
| 486 | service. |