g1t/docs/SELF_HOSTING.md

492 lines36,848 bytesCodeBlame
1# Self-hosting g1t
2
3The goal (2026-10-05): g1t should not be locked to Cloudflare. Anyone should
4be able to run it on their own machine with `docker compose up`. The free
5core is MIT and self-hostable; managed hosting at g1t.sh is the paid
6product, and it stays on Cloudflare. Self-hosting must never make hosted
7g1t worse, so hosted code paths do not change to make room for it.
8
9This document covers:
10
111. An inventory of every Cloudflare dependency in the code.
122. The design: ports and adapters, with the runtime choice weighed.
133. What phase 1 ships today: `deploy/self-host/`, and what was verified.
144. The phased plan, with estimates.
155. The risks.
16
17The 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
53The sources are every `wrangler.jsonc` plus a grep of the code. Coupling
54is 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()`, `abuse()`), `egress.ts` (`sandboxHosts`, `buildHosts`, `EGRESS_CA = /etc/cloudflare/certs/cloudflare-containers-ca.crt`), `AttemptSandbox.outboundHandlers`, `interceptHttps = true`, `setOutboundHandler("egress", { hosts })`, `setOutboundByHost("sandbox.g1t.internal", "abuse")` (a sandbox reporting it stopped itself for mining; `crates/runner/src/abuse.rs`) | 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` (`modelEnv`), `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
91The deployable units, their stage and what each is built from are listed in
92`deploy/stack.jsonc` (see `docs/DEPLOYING.md`); its `self_host` field is
93what `deploy/self-host/configs.mjs` runs, turns off or leaves out. A test
94checks this table names every unit.
95
96| Service | Runs on | Cloudflare dependencies beyond Workers and D1 | Phase 1 self-hosted |
97| --- | --- | --- | --- |
98| `apps/web` | TS Worker plus assets | KV (`BLOBS`, `AVATARS`), Cache API, `cloudflare:workers` `env`, RPC to `RUNNER` | Runs unchanged |
99| `apps/api` | Rust Worker | KV `BLOBS`; hard-coded `api.g1t.sh`/`mcp.g1t.sh` issuer | Not started yet (phase 2) |
100| `apps/sudo` | TS Worker plus assets | Access JWT | Not run |
101| `apps/docs` | Static | — | Not run (docs.g1t.sh serves them) |
102| `apps/status` | TS Worker | Email Sending, cron; bound only to billing | Runs in a process of its own (`status.sh`), so it stays up when the site does not |
103| `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim |
104| `services/repos` | Rust | **Artifacts**, Cache API, optional KV `GIT_CACHE` with `REPOS_KEY` | Runs unchanged; `ARTIFACTS` goes to the git store. Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only |
105| `services/work` | Rust | Queue consumer | Runs unchanged |
106| `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to |
107| `services/projects` | TS | Queue consumer | Runs unchanged |
108| `services/search` | Rust | Queues (events and its own jobs); FTS5 | Runs unchanged |
109| `services/billing` | Rust | Cron, Cloudflare REST API (keeper), Stripe | Runs with `FREE_WHILE_BUILDING=true` and no Stripe key: nothing is charged |
110| `services/security` | Rust | Queue, cron | Runs unchanged (cron not fired) |
111| `services/actions` | Rust | Queue, cron, `ACTIONS_KEY` | Runs; jobs need the runner, which is off |
112| `services/webhooks` | Rust | Queue, cron, `WEBHOOKS_KEY` | Runs; first delivery works, retries need cron |
113| `services/integrations` | Rust | Queue, `INTEGRATIONS_KEY` | Runs unchanged |
114| `services/deployments` | TS | Workers for Platforms, REST API, KV `DOMAINS`, cron | Runs with no API token: nothing deploys |
115| `services/runner` | TS | **Containers**, Durable Objects, outbound interception, AI Gateway, cron | Off: bound to the off Worker |
116| `services/context` | TS | **Vectorize**, **Workers AI**, Queues | Off: bound to the off Worker |
117| `services/models` | TS | AI Gateway; public at `models.g1t.sh` | Not run (only sandboxes call it) |
118| `services/pages` | TS | Dispatch namespace, wildcard routes, KV | Not run |
119| `services/og` | TS | Cache API | Not run (social cards are optional) |
120| `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 |
121| `crates/sshd` | native | Not deployed; calls `/_internal/ssh/*` endpoints that do not exist yet | Phase 3 |
122
123### Hard-coded hosted addresses
124
125Self-hosting needs one setting, `PUBLIC_URL`, in place of these. Phase 1
126gets around the ones on its path: the mail shim rewrites `https://g1t.sh`
127links in mail, and `configs.mjs` rewrites `SITE_URL`/`API_URL`/`SITE`
128variables. The rest are listed here so phase 2 can make them settings:
129
130- `apps/web/app/lib/meta.ts` (`SITE`, `OG`); `clone-box.tsx` (clone URL,
131 `mcp.g1t.sh`); `workers/app.ts` (`DOCS`).
132- `apps/api/src/lib.rs` (`API`); `oauth.rs` (issuer, MCP resource).
133- `services/identity/src/email.rs` (`SITE`, `FROM`).
134- `services/runner/src/index.ts` (`G1T_API`, `GIT_REMOTE` and the other
135 remotes handed to sandboxes, in 12 places).
136- `services/billing` (`stripe.rs`, `accounts.rs`, `limits.rs`).
137- `crates/sshd` (`G1T_API` default).
138
139There are about 150 occurrences of `g1t.sh` in non-test code. Most are
140docs links and copy, and need no change.
141
142## 2. Design
143
144### Principles
145
1461. **Hosted is the reference.** Hosted code does not change behaviour to
147 make room for self-hosting. A self-hosted adapter is added beside the
148 hosted one, and the hosted one stays the default.
1492. **Swap at the narrowest seam that exists.** A binding-shaped seam (a
150 Worker that offers the same methods as the Cloudflare binding) needs no
151 code change, and is how phase 1 works. A code-level port (a trait or
152 interface with two adapters) is cleaner and testable, and is the long-term
153 shape. Each primitive moves from the first to the second when it is next
154 touched.
1553. **One runtime, two hosts.** The Workers stay Workers. workerd runs them
156 self-hosted, so one build serves both and there is no second code path
157 to keep correct.
1584. **Off is a real mode.** Every optional subsystem (agents, context,
159 deployments, billing) has an "off" answer that pages already handle.
160
161### The ports
162
163| Port | Hosted adapter | Self-hosted adapter | Lives in | Status |
164| --- | --- | --- | --- | --- |
165| `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) |
166| `Mailer` | Email Sending binding | SMTP (through Mailpit relay now; a direct SMTP adapter later) | `g1t_kit::mail` | **Built** (binding level) |
167| `Sandbox` | `AttemptSandbox` (Containers, DO) | `DockerSandbox`: a supervisor that starts the runner image through the Docker API | `services/runner` (TS) | Phase 2 |
168| `Egress` | Containers outbound handler | Allow-list HTTP(S) proxy on an internal network | `services/runner` | Phase 2 |
169| `ModelRoute` | AI Gateway, or direct | Any Anthropic/OpenAI-compatible base URL | `services/models`, `runner/model-env.ts` | Exists (env-switchable) |
170| `Embedder` | Workers AI | OpenAI-compatible `/v1/embeddings` | `services/context` | Phase 3 |
171| `VectorIndex` | Vectorize | sqlite-vec / pgvector / Qdrant | `services/context` | Phase 3 |
172| `AppHost` | Workers for Platforms plus REST API | workerd app host (Worker Loader) | `services/deployments` | Phase 3 |
173| `Domains` | Cloudflare for SaaS | Caddy on-demand TLS | `services/deployments` | Phase 3 |
174| `AdminAuth` | Cloudflare Access | `G1T_ADMINS` plus the normal session | `apps/sudo` | Phase 4 |
175| `Bus` | Queues | Miniflare Queues now; SQLite outbox later | `services/events`, `g1t_kit` | Works (runtime) |
176| `Database` | D1 | SQLite files through workerd | — | Works (runtime) |
177| `Blobs` | KV | Miniflare KV now; filesystem/S3 later | — | Works (runtime) |
178| `Scheduler` | Cron Triggers | A ticker that calls `scheduled()` | `deploy/self-host` | Phase 2 |
179| `UsageKeeper` | Cloudflare bill plus AI Gateway logs | None (billing off) | `services/billing` | Off |
180
181For Rust, the code-level ports go in `crates/kit` as traits (`g1t_kit::ports`),
182with the Cloudflare adapters next to them. For TypeScript, they go in a new
183`packages/platform` package, with each adapter in its own module so a
184hosted bundle never pulls in a self-hosted adapter. The adapter is chosen at
185startup from one setting, `G1T_MODE=hosted|self`, never per request.
186
187### The runtime: workerd, or native binaries
188
189| | **workerd (phase 1: `wrangler dev`; later a plain workerd config)** | **Native: Rust on axum/hyper, TS on Node** |
190| --- | --- | --- |
191| 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. |
192| 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. |
193| 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. |
194| 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. |
195| Scale-out | One process, one disk. D1-on-SQLite is single-writer. | Could use Postgres and many processes. |
196| 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. |
197
198**Recommendation.**
199
200- **Phase 1:** workerd under `wrangler dev`, as built in `deploy/self-host/`.
201 It needs no change to any service and is proven end to end.
202- **Phase 2:** replace `wrangler dev` with a launcher that drives Miniflare's
203 API directly, or a generated `workerd` config. It should expose only the
204 site and the API, with no dev endpoints, run cron, and run as a proper
205 service.
206- **Long term:** keep workerd as the runtime and push the remaining
207 Cloudflare-only bindings behind code-level ports. Compile to native only
208 what already is native (the runner binary, sshd), or a service where
209 workerd is a real limit, case by case.
210
211A full native port is not worth it. It would double the maintenance of
212every feature, which is the opposite of what makes self-hosting
213sustainable for a small team.
214
215### Sandboxes (phase 2)
216
217- **Engine.** Docker or Podman through the socket, mounted into a small
218 supervisor (`g1t-sandboxd`), not into the workerd container. The
219 supervisor exposes the runner's `Sandbox` port over HTTP: start with
220 env, stop, status, and the exit report that becomes `onStop`. It uses
221 the **same image** (`services/runner/Dockerfile`).
222- **Runner.** The runner Worker runs in workerd with `AttemptSandbox`
223 replaced by a `DockerSandbox` adapter. Run state that lives in
224 `ctx.storage` moves to the supervisor's SQLite; `schedule()`/`timeUp`
225 becomes a supervisor timer.
226- **Egress guardrails.** Each restricted sandbox joins an internal Docker
227 network with no default route. Its `HTTP_PROXY`/`HTTPS_PROXY` point at an
228 allow-list proxy (for example a small Go or Node `CONNECT` proxy) that
229 admits the hosts `sandboxHosts()` computes and reports refusals.
230 Nothing is intercepted, so no CA is injected. `EGRESS=off` puts the
231 sandbox on a normal network.
232- **Addresses.** `G1T_API`, `GIT_REMOTE` and the rest become
233 `PUBLIC_URL`-derived settings. Sandboxes reach the site and API on the
234 compose network.
235- **Shortcut to evaluate first.** Wrangler can already run Containers
236 classes locally through Docker. If its local Containers support
237 `setOutboundHandler`, the runner could run nearly unchanged. Test this
238 before building the supervisor.
239
240### Git storage
241
242Artifacts gives g1t: named repositories, scoped short-lived tokens, a smart
243HTTP remote, typed reads (commit, tree, blob, file, log), copy-on-write
244forks, and jurisdictions. g1t uses everything except jurisdictions.
245
246The self-hosted git store (`deploy/self-host/gitstore/server.mjs`, about 450
247lines of Node with no dependencies) provides the same:
248
249| Artifacts | Git store |
250| --- | --- |
251| `create(name, { setDefaultBranch, description })` | `git init --bare --initial-branch`, plus `g1t.json` beside it for metadata |
252| `get(name)` then `info()` | Metadata, `HEAD`, and the last push time; `remote` is `GITSTORE_URL/git/<name>.git` |
253| `createToken(scope, ttl)` | HMAC-SHA256 over `{ key, scope, expiry }` with the shared secret |
254| Smart HTTP remote | `git http-backend`; a read token cannot push |
255| `log`, `readCommit`, `readTree`, `readBlob`, `readFile` | `git rev-list --first-parent`, `cat-file`, `ls-tree` |
256| `fork(name, { defaultBranchOnly })` | `git clone --bare [--single-branch]` with hard-linked objects |
257
258Hosted is untouched: `ArtifactsStore` is still the only adapter compiled
259into `services/repos`. Self-hosted, the `ARTIFACTS` binding is a service
260binding to `deploy/self-host/workers/artifacts`, which offers Artifacts'
261methods and calls the git store. Long term, a `LocalGitStore` adapter in
262Rust should call the git store's API directly, which removes the shim. The
263git store can later gain `git gc` scheduling and object-store-backed packs
264for large installations.
265
266### Search and context
267
268- **Site search** (`services/search`) is D1 FTS5. It works self-hosted as
269 is (verified: `/search?q=hello` answers 200).
270- **Context hub** (`services/context`) needs an `Embedder` and a
271 `VectorIndex`:
272 - Default: **sqlite-vec** in the context service's own SQLite, with
273 embeddings from an **OpenAI-compatible endpoint**. Ollama's
274 `nomic-embed-text` has the same 768 dimensions as today's
275 `bge-base-en-v1.5`.
276 - Alternatives: pgvector or Qdrant, for installations that already run
277 them.
278 - Off: the service already degrades to keyword search when `AI` or
279 `VECTORS` is missing (`index.ts:919`), so phase 3 can first run context
280 with neither bound.
281
282### Models
283
284Already portable. The model proxy (`services/models`) and the runner's
285model environment take any Anthropic-compatible base URL, and an empty
286`AI_GATEWAY_ID` skips AI Gateway. Self-hosted:
287
288- a workspace connects its own provider under Integrations (Anthropic, or
289 any Anthropic-compatible endpoint, including a local gateway in front of
290 OpenAI-compatible models);
291- "hosted models" are off, because there is no g1t key to spend.
292
293### Deployments
294
295- **Phase 1–2: off.** `services/deployments` runs, but with no
296 `CLOUDFLARE_API_TOKEN` nothing deploys. Its pages and settings still
297 render.
298- **Phase 3: an app host on workerd.** The self-hosted `pages` dispatcher
299 uses workerd's **Worker Loader** binding to load each uploaded app's
300 modules on demand, keyed by deployment id, from the blob store. Static
301 assets are served from the same store. Builds run in Docker sandboxes
302 (phase 2), which already bundle with `wrangler deploy --dry-run`.
303 Wildcard hosts (`*.apps.example.com`) and custom domains go through
304 Caddy with on-demand TLS. The alternative, one workerd process per app,
305 is simpler to isolate but heavier.
306
307### Billing and the feature map
308
309Self-hosted billing is **off by default**: no Stripe, no usage limits,
310no keeper. Billing still runs, because the shell reads `billing.account`
311on every page, but with `FREE_WHILE_BUILDING=true` and no Stripe key.
312
313| Feature | Hosted (g1t.sh) | Self-hosted default | Self-hosted, when turned on |
314| --- | --- | --- | --- |
315| Accounts, workspaces, repos, git over HTTP | On | On | — |
316| Issues, pull requests, review, merge queue | On | On | — |
317| Site search (FTS5) | On | On | — |
318| Email | Email Sending | Mailpit, logged | SMTP relay |
319| Webhooks, integrations | On | On (no scheduled retries yet) | — |
320| g1t agents | On | Off | Phase 2: Docker sandboxes plus your own model provider |
321| Guardrails egress | Containers interception | n/a | Phase 2: allow-list proxy |
322| Hosted models (g1t's key) | On (billed) | Off | Never: bring your own |
323| Context hub semantic search | Vectorize plus Workers AI | Off | Phase 3: sqlite-vec plus an OpenAI-compatible embedder |
324| Deployments | Workers for Platforms | Off | Phase 3: workerd app host |
325| Custom domains | Cloudflare for SaaS | Off | Phase 3: Caddy on-demand TLS |
326| Billing, limits, Stripe, keeper | On | Off | Not planned |
327| sudo (staff console) | Access | Off | Phase 4: `G1T_ADMINS` |
328| Git over SSH | Not yet | Off | Phase 3 (`crates/sshd`, which is native already) |
329| REST API, MCP, CLI | On | Off | Phase 2 |
330
331### Auth, Access and email
332
333- `apps/sudo` checks Cloudflare Access. Self-hosted, it should check a
334 normal g1t session against `G1T_ADMINS`. That needs an `AdminAuth` port in
335 `workers/app.ts` with two adapters. It is phase 4, because sudo is about
336 billing.
337- Sessions are random tokens stored hashed in D1, with no signing key, so
338 nothing to configure. The cookie is `Secure`: fine on `localhost`, but any
339 other address needs HTTPS. The compose file should gain an optional Caddy
340 service in phase 2.
341- **Email verification is required** before creating anything, and there is
342 no bypass in code. That is why the proof ships a working mail path
343 (Mailpit) rather than "email off". An admin "mark verified" or
344 `G1T_SKIP_EMAIL_VERIFICATION` belongs with `G1T_ADMINS`.
345
346### Configuration, upgrades and backups
347
348- **Today:** environment variables in the compose file (`PUBLIC_URL`,
349 `G1T_PORT`, `MAIL_URL`, `MAIL_FROM`). Keys (`ACTIONS_KEY`,
350 `INTEGRATIONS_KEY`, `WEBHOOKS_KEY`, the git store secret) are generated on
351 first start and kept on volumes.
352- **Phase 2:** one `g1t.toml`, read by the launcher and turned into
353 bindings and variables:
354
355 ```toml
356 public_url = "https://git.example.com"
357
358 [mail]
359 smtp = "smtp://user:pass@smtp.example.com:587"
360 from = "g1t <git@example.com>"
361
362 [agents] # off when absent
363 docker = "unix:///var/run/docker.sock"
364 egress = "enforce"
365
366 [context] # off when absent
367 embeddings = "http://ollama:11434/v1"
368 model = "nomic-embed-text"
369
370 [admins]
371 usernames = ["alice"]
372 ```
373
374- **Upgrades.** The container applies every service's D1 migrations to its
375 SQLite file on start (`wrangler d1 migrations apply --local`). Applied
376 migrations are recorded in `d1_migrations`, so this is idempotent. Hosted
377 and self-hosted run the same migration files, which keeps them
378 forward-compatible. Rule to keep: migrations stay additive, or come with
379 a backfill a self-hoster's start can run.
380- **Backups.** Phase 1: stop, then tar the `g1t-data` and `g1t-git`
381 volumes (documented in the guide). Phase 2: online backups with
382 `sqlite3 .backup` per database and `git bundle` or rsync of the bare
383 repositories, or Litestream for continuous replication.
384
385## 3. Phase 1: what works today
386
387Everything is in `deploy/self-host/`:
388
389| File | What it is |
390| --- | --- |
391| `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`. |
392| `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. |
393| `Dockerfile.dockerignore` | Build-context rules for this image only (the root `.dockerignore` leaves out the site). |
394| `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`. |
395| `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. |
396| `gitstore/server.mjs`, `gitstore/Dockerfile` | The git store. |
397| `workers/artifacts/index.js` | The `ARTIFACTS` binding, implemented against the git store. |
398| `workers/mail/index.js` | The `EMAIL` binding: logs, then sends to Mailpit. |
399| `workers/off/index.js` | The runner and the context hub when they are off. |
400| `smoke.sh` | The end-to-end check. |
401
402Workers running: the site; identity, repos, work, events, projects, search,
403billing, security, actions, webhooks, integrations and deployments; and the
404artifacts, mail and two off stand-ins.
405
406### Verified
407
408On this machine (Windows 11, Docker Desktop 29.8, engine on Linux):
409
4101. **Without Docker, with local processes.** The git store ran under Node
411 on Windows and every Worker ran under `wrangler dev`, using the configs
412 from `configs.mjs` and the existing Rust builds. `smoke.sh` passed every
413 step: sign-up, confirmation through the logged link, workspace, repo,
414 `git push` and `git clone` over HTTP, issue, and the code, blob and
415 commits pages. Seventeen other pages answered 200: home, workspace,
416 repo overview, issues, pulls, settings, agents, actions, deployments,
417 security, people, usage, explore, search, account settings and tree.
418 The workspace context page answered 403 from the off stand-in, as
419 intended.
4202. **With Docker Compose.** See the [Docker run](#docker-run) section below.
421
422### Not verified, or not working yet
423
424- Pull requests between branches and forks, the merge queue and catch-up.
425 They use `fork` and smart-HTTP pushes, which the git store implements,
426 but they have not been exercised end to end.
427- Cron work: webhook retries, Actions schedules, security sweeps.
428- The REST API, MCP, the CLI, and git over SSH.
429- Anything on an address other than `localhost` without HTTPS (the
430 session cookie is `Secure`).
431- Restart durability beyond one restart, upgrades across schema changes,
432 and backup and restore.
433
434## 4. Phased plan
435
436Estimates are for one engineer who knows the codebase, working with
437agents. They include docs and tests.
438
439| Phase | Scope | Estimate |
440| --- | --- | --- |
441| **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 |
442| **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 |
443| **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 |
444| **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 |
445
446Total to parity: about 10–13 weeks. Phase 1 alone is already a credible
447"run it yourself" for the core forge.
448
449## 5. Risks
450
451- **`wrangler dev` is a development tool.** It exposes Miniflare's dev
452 endpoints (`/cdn-cgi/...`, including a local data explorer) on the same
453 port as the site. Treat phase 1 as **localhost or a trusted private
454 network only** until the launcher in phase 2 replaces it. Its flags and
455 behaviour can also change between Wrangler releases. Pin the Wrangler
456 version, as the lockfile already does.
457- **No cron yet.** Webhook retries, Actions schedules, security sweeps and
458 deployments' sweeps do not run. Anything that relies on a sweep to
459 recover from a missed event stays stuck until phase 2 adds a ticker.
460- **New Cloudflare-only bindings break self-hosting silently.**
461 `configs.mjs` passes unknown keys through untouched. A new binding type
462 could make `wrangler dev` reach for a remote resource (for example
463 `remote: true`, AI or Vectorize) and prompt for a login. Mitigation: CI
464 that builds the compose stack and runs `smoke.sh` on every change, and an
465 allow-list in `configs.mjs` that fails on unknown binding types.
466- **Artifacts semantics drift.** The shim copies the Artifacts methods g1t
467 uses today. If repos starts using another method (`import`,
468 `listTokens`, `revokeToken`), self-hosted fails at runtime. Mitigation: a
469 contract test that runs `services/repos` against the git store, and the
470 long-term `LocalGitStore` port.
471- **Error codes over RPC.** `ArtifactsStore::create` and `fork` tolerate
472 `ALREADY_EXISTS` by reading the thrown error's `code`. Workers RPC may not
473 carry custom error properties across a service binding. If it does not,
474 retrying a half-finished create fails self-hosted where it would succeed
475 hosted. This was not seen in testing, because creates were never retried.
476- **Single node, single writer.** SQLite (D1 local) and one workerd
477 process suit a team, not a large organisation. Scaling out means
478 Postgres behind a `Database` port, which is a large change and is not
479 planned.
480- **The `Secure` cookie.** It needs HTTPS anywhere but `localhost`. A LAN
481 install over plain HTTP cannot sign in.
482- **Building from a working tree that is mid-change.** The image compiles
483 every Rust service, including ones other work is changing. A service that
484 does not compile breaks the whole image. Released images (phase 4) fix
485 this.
486- **Image size and build time.** The first build compiles ten Rust crates
487 to WebAssembly and installs the site's dependencies. Expect minutes and
488 several GB. Published images remove this for users.
489- **Hard-coded hosted URLs.** About a dozen code paths name `g1t.sh` or
490 `api.g1t.sh`. Until they read `PUBLIC_URL`, some links and redirects
491 (OG images, the docs link, the clone box's MCP line) point at the hosted
492 service.