git-forge-cloudflare ==================== Runs git-forge on Cloudflare Containers with R2 for durable storage. A Worker routes every request to a single container through one Durable Object. Inside the container, syncd mirrors the data directory to an R2 bucket and restores it at boot. git-forge is neither modified nor vendored here. The image builds it from its own repository, at the commit named in `forge.rev`. Container disk is erased whenever the instance sleeps or its host is recycled, and git-forge needs a real filesystem and one running process. Cloudflare supplies the second: a Durable Object name resolves to one instance globally. syncd supplies the first: the container keeps its filesystem on ephemeral disk, R2 holds the durable copy, and git-forge is unaware of either. Architecture ------------ Worker -> Durable Object "forge" -> Container :8080 git-forge, unmodified :8081 syncd, in this repo | Outbound -> R2, logs, mail worker.js routes requests and answers the intercepted hosts syncd mirrors and restores the datadir, makes writes durable Containerfile git-forge, syncd, git, cgit, highlight, tini wrangler.jsonc the bindings The Worker intercepts three hostnames, none of which resolve in DNS: r2.forge.internal objects, served from the R2 binding logs.forge.internal stdout, logged where `wrangler tail` can see it mail.forge.internal Mailgun's api shape, answered by whatever sends syncd carries no R2 credential: it sends plain HTTP to a hostname the Worker intercepts on the same machine. Anything in the container that can make a request has the whole bucket, cgit included; a binding has no narrower form. Logs travel the same path because Cloudflare does not forward container stdout to Workers observability. Storage ------- manifest.json which repositories exist. Authoritative stale.json directories the manifest stopped listing, and when config/... the config directory, file by file config/git/objects/... its audit log config/REFS its refs repos/{path}.git/objects/... immutable, uploaded once, never rewritten repos/{path}.git/REFS `git show-ref` output, one PUT Git objects are content addressed and append only, so only refs change, and syncd stores them as state rather than files: `gc --auto` moves loose refs into `packed-refs`, and a restored loose ref would override it and resurrect an old head. Objects go up first and refs last, the order git pushes in, so a crash within a flush leaves the state from before the write. The manifest is what makes deletion work: restore creates only the directories it names, so rewriting it deletes. The objects of a repository the manifest no longer lists stay in the bucket for seven days, reported at every boot and once a day while the container runs, and are removed by the first check after that. The delay is the window to put back a manifest restored from a backup or written short: a boot that believed one would otherwise remove every repository it omits. A bucket holding objects under `repos/` and no manifest is fatal rather than empty. A forge that never made a repository writes no manifest and starts normally. Boot restores `config/` in full, then creates one empty directory per repository in the manifest, which is all git-forge's startup scan looks at. syncd hydrates a repository on its first request: `git init --bare`, copy the objects, replay the refs. The issue and wiki siblings hydrate with it. A repository that should have hydrated and did not fails loudly: git refuses an empty directory, and git-forge does not treat that as an empty repository. Durability ---------- syncd puts a write in R2 before reporting success. It forwards the request to git-forge and, for anything other than a known read, buffers the response, flushes, and returns once the flush completes. A mutex serialises flushes and each one rescans the whole datadir, so every request covers its own writes whatever URL it had: held everything that is not a GET or HEAD, except git-upload-pack streamed every GET and HEAD, except /confirm-email and /oidc/... A held response is capped at 8 MiB and answered 503 past it; the cap is for cgit, which answers a snapshot to POST as well. One write is asynchronous: the date a push token was last used, which git-forge treats as telemetry. A sweep every 15 seconds carries it up, and backstops any misclassification above. tini is PID 1 and syncd its child, with git-forge as syncd's child, so the container dies if syncd dies. On SIGTERM syncd signals git-forge, waits for it to drain, flushes, and exits. Limits ------ Cloudflare caps a push at 100 MB of new objects at the edge, more on Business and Enterprise, and nothing here can raise it. Clones are unbounded. git-forge separately caps a wiki push at 16 MiB. Import an existing repository in ranges, so that no single pack exceeds the cap: git push origin :refs/heads/master Local disk bounds the datadir. Instance type `basic` has 4 GB, and `git gc` needs room to repack on top of it; `wrangler.jsonc` picks the type, and 20 GB is the largest. Boot costs one round trip per object under `config/`. The sweep packs the audit log at five hundred loose objects. A flush walks the whole datadir and forks `git show-ref` per directory, so its cost, and every held write's latency, grows with the number of repositories. Test ---- ./test Builds the image and exercises it end to end: create the first admin, create a repository, push, destroy the container, then start a new one with only the bucket to work from, and again after the audit log is packed. It needs docker, go, git, curl and openssl on the host, and the git-forge checkout `fetch-forge` reads. `fake-r2.go` stands in for the object handler in `worker.js`, so no Cloudflare account is needed. `go test` covers syncd against a bucket: a flush racing a hydration, a gc, a push, and a deletion, and the prune waiting out its grace. The rest of `worker.js` has no coverage: it runs only in a deployment. Deploy ------ Needs a Cloudflare account on Workers Paid with R2 enabled, wrangler, and docker. ./fetch-forge wrangler r2 bucket create git-forge wrangler secret put GIT_FORGE_COOKIE_KEY at least 32 characters wrangler deploy `fetch-forge` copies git-forge's source into `forge/` for the image build, from `../git-forge` at the commit in `forge.rev`, or from `GIT_FORGE_SRC` and `GIT_FORGE_REV`. `forge/` is derived and uncommitted. Upgrading means editing `forge.rev`; the pin exists because this repository asserts which of git-forge's routes write, which one removes a repository, and what a repository holds besides objects and refs. syncd logs the revision at startup. `GIT_FORGE_COOKIE_KEY` must be a secret: the datadir is ephemeral, so a generated key would sign everybody out on every restart. syncd refuses to start without one. A custom domain is one route, with workers.dev switched off: "routes": [{ "pattern": "git.example.com", "custom_domain": true }], "workers_dev": false, Switching workers.dev off matters: base_url is decided by the request that starts the container, so two hostnames on one worker means whichever wakes it wins and every form posted to the other returns 403 until it restarts. Leave `base_url` unset. worker.js supplies it from the origin of the request that started the container, always as https. Set it in `wrangler.jsonc` only when a custom domain fronts a hostname requests do not arrive on, and then exactly. Put nothing else in `vars`: an environment value pins what the admin page could otherwise change. worker.js sets `GIT_FORGE_CLIENT_IP_HEADER` to `CF-Connecting-IP`, which the edge overwrites, so rate limits count real addresses rather than syncd's. Against an empty bucket git-forge prints a one time setup token and serves `/setup`. The container starts on the first request and prints the token then, so start `wrangler tail` beforehand. A restart before setup issues a new token; `wrangler deploy` forces a restart. The container stops after ten idle minutes and starts again on the next request. A Durable Object alarm stops it with SIGTERM once nothing is in flight; a request arriving mid-drain receives an error. Retry. Mail ---- git-forge's Mailgun driver issues one form-encoded POST, and `worker.js` answers that shape, so any sender serves git-forge without changing it and every key stays outside the container. The binding is declared and sends nothing until git-forge's config points at the intercepted host; until then git-forge's default driver drops each message with a log line naming the recipient and subject. Edit the copy of `forge.json` in the bucket, since the web interface does not expose mail settings, then redeploy: wrangler r2 object get git-forge/config/forge.json --remote --pipe >f "$EDITOR" f wrangler r2 object put git-forge/config/forge.json --remote --file=f wrangler deploy `forge.json` reaches the bucket after the container has run once. git-forge requires `domain` to be non-empty though nothing uses it: "mail": { "driver": "mailgun", "from": "git-forge ", "mailgun": { "domain": "forge", "base_url": "https://mail.forge.internal/v3" } } Without a sending domain onboarded to Email Service, a binding reaches only addresses verified in the account. With no binding declared, `worker.js` logs each message and reports success. The spool lives on container disk and is not mirrored. Sign in with a provider ----------------------- git-forge implements generic OpenID Connect: discovery, authorization code with PKCE, and a signed id_token carrying a verified address. Google works, as does anything that publishes a discovery document; GitHub does not, since its only OIDC issuer mints workload tokens. Declare the provider in the bucket's copy of `forge.json`, as with mail: "oidc": [{ "key": "google", "label": "Google", "issuer": "https://accounts.google.com", "client_id": ".apps.googleusercontent.com", "allow": ["someone@gmail.com", "@example.com"] }] Supply the secret through the environment, never the file: wrangler secret put GIT_FORGE_OIDC_GOOGLE_CLIENT_SECRET git-forge derives the variable name from the key, upper-cased. Register the redirect uri `{base_url}/oidc/{key}/callback` with the provider exactly. A secret added while a container runs takes effect after a restart; both sides report what they hold, without its value, in `wrangler tail`. An account minted this way is named `{key}:{name}` and holds no grants; the admins receive mail when one first appears. Set `allow`, or every account the provider vouches for can mint one. The container contacts the provider directly. This is the only feature that depends on `enableInternet` in `worker.js` and on the ca bundle `main.go` assembles; a forge with no provider can set it false. Operations ---------- R2 holds everything that has to survive. Two backups: every repository, cloned readable without any of this the bucket, mirrored to another bucket exact, and restores as is Cloning omits the account table, so keep `users.json` beside it, and treat it as credential material in full: wrangler r2 object get git-forge/config/users.json --remote --pipe R2 speaks the S3 api at `https://.r2.cloudflarestorage.com`, so any S3 tool mirrors the bucket. A repository deleted through the web is reported at every boot, and daily while the container runs, for seven days: stale: notes.git, 412 objects no manifest lists, removed after 2026-09-18 To bring it back within that window, put `manifest.json` and `repos.json` back as they were and redeploy; after it, a clone is the only recovery. Reverting any other mistake means editing the bucket and restarting, as with mail. A restore reads the working tree, not the audit log, so putting the old content back is the revert: wrangler r2 object get git-forge/config/repos.json --remote --pipe >f "$EDITOR" f wrangler r2 object put git-forge/config/repos.json --remote --file=f wrangler deploy Do this while the forge is idle: syncd leaves a change made behind its back alone, but a simultaneous change through the web wins. What the log means when something is wrong: restore: ... fatal, deliberately: an empty datadir would be flushed over the real one the change could not be made a flush failed, so the write is on a disk durable that does not survive: a 503 on purpose. The next flush carries it up Not a git repository: hydration did not fire for a repository /var/lib/git-forge/repos/... the matcher missed. The objects are still in the bucket config: audit log not the bucket is missing an object the log's replayed refs name. This costs history and nothing else no sender bound mail is configured but the binding is not GIT_FORGE_COOKIE_KEY must be the secret is unset set Memory and disk are billed on the instance type while the container is awake; a sleeping container costs nothing. Debugging --------- wrangler tail logs, both processes wrangler r2 object get git-forge/manifest.json --remote --pipe wrangler r2 object get git-forge/repos/foo.git/REFS --remote --pipe `--remote` is mandatory: without it wrangler reads a local simulated bucket and finds nothing. `REFS` is `git show-ref` output. syncd logs one line per flush that uploaded anything and one per hydration.