aboutsummaryrefslogtreecommitdiff

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 <sha>: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 <forge@example.com>",
  "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": "<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://<account>.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.