pr_01m47d15m3e54sn21z27rpy5n9/docs/SELF_HOSTING.md
| 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()`, `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: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. |