README.md

gitfor

A git forge built entirely on Cloudflare: repositories live in Artifacts, and everything else runs on Workers, D1, KV, R2, Queues, Workflows, Durable Objects and Containers.

Features

  • Repositories: create, import from HTTPS remotes, fork, archive, delete, public/private visibility
  • Browsing: trees, syntax-highlighted files, raw files, README rendering, commit history, commit diffs, branches, tags, .tar.gz archives
  • Git over HTTPS: clone/fetch/push through a Worker proxy authenticated with personal access tokens
  • Web editing: create, edit, rename and delete files, optionally on a new branch
  • Branch protection: required pull requests, approvals and status checks, deletion protection, admin bypass
  • Issues: labels, assignees, comments, locking, closing keywords (fixes #12), full-text search
  • Pull requests: cross-fork PRs, inline review threads, approvals and change requests, review requests, drafts, merge/squash/rebase, mergeability checks
  • Actions: GitHub-Actions-style workflows in .gitfor/workflows/*.yml with needs, matrices, if: expressions, secrets, artifacts and live logs
  • Runners: Cloudflare Containers by default, plus self-hosted runners (gfd runner, a container per job)
  • Releases with R2-backed assets, webhooks with HMAC signatures and redelivery, notifications, activity feeds, organizations

Architecture

Concern Cloudflare primitive
Git objects and refs Artifacts (Workers binding + smart HTTP)
Users, repos, issues, PRs, CI state D1 (with FTS5 search)
Sessions, refs/object/diff caches, token cache KV
Logs, CI artifacts, release assets R2
Webhook fan-out and delivery, notifications Queues (with retries and a DLQ)
Post-push pipeline, CI run orchestration Workflows (PushWorkflow, CiRunWorkflow)
Job execution, log streaming, merges Durable Object Runner with a Container
Push events from Artifacts triggers.events (cf.artifacts.repo.pushed)

Key code paths:

  • src/server.ts — Worker entry: git proxy, runner API, raw/archive/asset routes, queue and cron handlers
  • src/server/git/ — pkt-line, refs, packfile writer, receive-pack client, tree diff, merge-base
  • src/server/ci/ — workflow schema, expressions, compiler, bash job script, runner DO, scheduler
  • src/fns/ — TanStack Start server functions used by the UI
  • migrations/ — D1 schema

Setup

Requirements: Bun, Docker (for building the runner image), a Cloudflare account on the Workers Paid plan with Artifacts enabled.

bun install

wrangler.jsonc is the production config; bun run dev and bun run preview layer dev overrides from vite.config.ts on top of it (PUBLIC_URL=http://localhost:3000, GITFOR_LOCAL=true, and every Artifacts namespace and trigger filter renamed to <namespace>-dev), so local development never touches the deployed repositories. Workers AI and send_email stay remote: true, so dev still uses your account's AI quota and sends real mail unless EMAIL_DEV_SINK is set.

Production setup

bun run deploy first runs bun run deploy:check, which fails while any D1/KV/Secrets Store id is still the all-zero placeholder or PUBLIC_URL/USERCONTENT_ORIGIN are empty. A deployed Worker also refuses to serve (503, with the reasons logged) if PUBLIC_URL is loopback or plain http, if USERCONTENT_ORIGIN is missing or unsafe, if SECRETS_KEY is shorter than 32 characters, or if ARTIFACTS_SHARDS names a missing binding.

Create each resource and put its id in wrangler.jsonc (also set account_id if your login has several accounts):

  • [ ] D1: bunx wrangler d1 create gitfor → d1_databases[0].database_id
  • [ ] KV: bunx wrangler kv namespace create gitfor → kv_namespaces[0].id
  • [ ] R2: bunx wrangler r2 bucket create gitfor
  • [ ] Queues: bunx wrangler queues create <name> for gitfor-events, gitfor-security, gitfor-integrations
  • [ ] Dead-letter queues: gitfor-events-dlq, gitfor-security-dlq, gitfor-integrations-dlq
  • [ ] Artifacts namespace stig (the artifacts binding and both trigger filters); check it with bunx wrangler artifacts namespaces get stig. Development uses stig-dev; never share a namespace between deployments.
  • [ ] Analytics Engine dataset gitfor_usage (created on first write; nothing to run)
  • [ ] Secrets Store: bunx wrangler secrets-store store create gitfor → secrets_store_secrets[0].store_id, then add the gitfor-oidc-signing-key secret to it
  • [ ] Secret SECRETS_KEY, at least 32 random characters: openssl rand -base64 48 | bunx wrangler secret put SECRETS_KEY
  • [ ] Vars: PUBLIC_URL (your https forge URL) and USERCONTENT_ORIGIN (an https origin on a separate registrable domain, for example https://gitforusercontent.dev)
  • [ ] Routes or custom domains for the forge host and the user-content host (and SITES_DOMAIN, EDITOR_ORIGIN hosts if you use them), all pointing at this Worker
  • [ ] First admin: set ADMIN_EMAIL; the account whose verified primary email matches becomes admin (at verification or when it is made primary), while the instance has no admin. Alternatively bunx wrangler secret put BOOTSTRAP_TOKEN (at least 16 characters): open /signup?bootstrap=<token> and the signup becomes the first admin (the only signup allowed while SIGNUPS_ENABLED=false and the instance is empty); delete the secret afterwards. Only local dev makes the first signup an admin automatically.
  • [ ] Optional: GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET secrets enable GitHub login.
  • [ ] Operator task before opening Sites to the public: get SITES_DOMAIN (for example gitforstatic.dev) into the private section of the Public Suffix List. Until it ships in browsers, every site and scratch site shares one cookie and storage site. See docs/sites-design.md "PSL submission".
  • [ ] Hosting cost controls (vars, safe defaults): SITES_ENABLED ("true"; "false" stops all Sites traffic), SITES_DAILY_CPU_MS_LIMIT and SITES_DAILY_REQUEST_LIMIT ("0" = unlimited; past either, dynamic sites answer 503 for the rest of the UTC day and admins are notified). See docs/sites-design.md "Cost controls".
  • [ ] Support address: set SUPPORT_EMAIL (falls back to LEGAL_CONTACT_EMAIL; the deploy check warns when both are empty). A Stripe chargeback disables the billed account and tells the customer to email it; winning the dispute does not re-enable the account, an admin does that from /admin (docs/billing.md "Chargebacks").
  • [ ] Optional, custom domains with billing: create a recurring $5/month Stripe price (lookup key gitfor_custom_domain_monthly, metadata.addon = "custom_domains") and set it as the STRIPE_PRICE_CUSTOM_DOMAIN secret. Without it, the plan's customDomains flag or an admin grant allows custom domains (docs/billing.md).

Set USERCONTENT_ORIGIN to a separate registrable domain routed to the same Worker, the way GitHub serves user files from githubusercontent.com. Raw files, archives, release assets, attachments, avatars and proxied markdown images are then served only from that origin, which never receives the forge's __Host- session cookie; private content gets a short-lived signed grant on redirect. The value must not be the forge host, the editor origin, or under SITES_DOMAIN. Local development maps localhost to usercontent.localhost automatically.

Develop

cp .dev.vars.example .dev.vars
bun run db:migrate
bun run dev

The Artifacts binding uses remote: true, so local development talks to the real Artifacts service, in the <namespace>-dev namespace. Local data is disposable: rows in the local D1 point at repositories in that namespace, so after switching namespaces (or whenever local state is broken) reset it with rm -rf .wrangler/state/v3/d1 && bun run db:migrate. Repositories created by local development before the -dev split live in the deployed namespace; the orphan sweep reports them there. Review that report before ever enabling deletion.

Artifacts namespaces

Artifacts rate-limits control-plane calls per namespace (2,000 per 10 seconds), so a large instance can spread repositories over several namespaces. Each extra namespace needs its own binding, named ARTIFACTS_1, ARTIFACTS_2, … (the first stays ARTIFACTS), and its own cf.artifacts.repo.pushed and cf.artifacts.repo.forked trigger entries in wrangler.jsonc; without them, pushes and forks in that namespace never reach the push and provisioning Workflows. Then set ARTIFACTS_SHARDS to the total count. Only new repositories are spread out; the shard is part of each repository's Artifacts name, so never remove a binding that still holds repositories.

A cron finds Artifacts repositories with no matching row, which keep incurring storage charges. ARTIFACTS_ORPHAN_SWEEP controls it: report (default) only logs them, off disables it, and delete removes them. delete only takes effect with ARTIFACTS_NAMESPACE_EXCLUSIVE=true, a non-localhost PUBLIC_URL and outside local dev; otherwise it logs an error and keeps reporting. It also halts the run without deleting, logging ARTIFACTS ORPHAN SWEEP HALTED, when more than 20 orphans turn up in one run or more than 5% of the repositories on a listing page look orphaned, which usually means D1 and the namespace belong to different deployments. Set that flag only when no other deployment, local development included, uses the namespace.

CI jobs fetch just the commit they run on (depth 1). Set GITFOR_FETCH_DEPTH in a workflow's env to fetch more history, or to 0 for a full fetch.

Deploy

bun run deploy

The ADMIN_EMAIL account, or the signup presenting BOOTSTRAP_TOKEN, becomes the instance administrator (see Production setup). With SIGNUPS_ENABLED set to "false", only that bootstrap account can register while the instance is empty.

Using git

Create a token under Settings → Access tokens, then use your username and the token as the password:

git clone https://gitfor.dev/alice/project.git

Workflows

# .gitfor/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
jobs:
  test:
    runs-on: cloudflare
    strategy:
      matrix:
        node: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - run: bun install && bun test
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.node }}
          path: coverage
  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: [self-hosted, linux]
    steps:
      - run: ./deploy.sh
        env:
          TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Supported triggers: push (branches, tags, paths filters), pull_request (types, branches, paths), release, workflow_dispatch (with inputs). Steps are run scripts (bash, sh, python, node, bun), the built-in checkout, cache and upload-artifact, setup-node/setup-bun (built in unless a version is requested), and JavaScript actions from public github.com repositories or the repository itself (uses: ./path).

Third-party actions:

  • A tag or branch is resolved to its commit when the run is scheduled; that SHA is stored with the run, shown in the log ("Download action repository … (SHA: …)") and used for every retry.
  • A full commit SHA is accepted only if a branch or tag of the named repository reaches it, so a commit that only exists in a fork can't pose as the repository. Short SHAs are refused.
  • Code is fetched over git and every object is checked against its SHA; the archive is stored once by commit and its sha256 is verified by the runner before extracting. Actions run only inside the job's runner, never in the Worker.
  • Repository settings → Allowed actions chooses between any public action, only SHA-pinned actions, or only built-in ones.
  • ${{ github.token }} written in a step's with: reads the GITHUB_COM_TOKEN secret (a github.com token, for actions that call the GitHub API). An action's own ${{ github.token }} defaults get an empty value, so no action receives that secret unless the workflow passes it. Fork pull request runs get no secrets.
  • Composite and Docker actions are not supported yet. A pin covers the action's code, not what it downloads while it runs. Job status is published as commit statuses named <workflow> / <job>, which branch protection can require.

Self-hosted runners

Create a registration token under Repository settings → Runners (or /orgs/<owner>/runners for owner-wide runners), choosing the labels and the isolation, then on the host (needs Docker or Podman):

gfd runner register --url https://gitfor.dev --token gitfor_reg_...
gfd runner run

Each job runs in a fresh, non-root container with a read-only root filesystem and an egress firewall that blocks cloud metadata and private networks. Fork pull request jobs never reach self-hosted runners unless a repository admin opts in, and then only runners whose container isolation the admin declared on the token. See the gfd README for what is and isn't isolated. The old runner/gitfor-runner.ts is retired.

Sites custom domains

Sites (docs/sites-design.md) are served under SITES_DOMAIN. Custom domains use Cloudflare for SaaS: set CUSTOM_DOMAINS_ZONE_ID to the SaaS zone (with a fallback origin and a */* Worker route) and add a CF_API_TOKEN secret with "SSL and Certificates: Edit" on that zone. Leaving it empty turns custom domains off.

Scripts

Command Purpose
bun run dev Local dev server
bun run build Production build and type-check
bun run deploy Apply D1 migrations and deploy
bun run cf-typegen Regenerate worker-configuration.d.ts
bun run lint / bun run format oxlint / oxfmt

License

Released under the 0BSD license.