Deploy and update Githome
How Githome is deployed to Cloudflare from its private repository with scripts/deploy.ts and scripts/setup.sh, how it updates, and local development.
Githome runs on Cloudflare, with copies kept off Cloudflare, and the operator deploys it from its private repository. Nobody else deploys a copy: this page describes how the instance at git.pkgforge.dev is deployed, so that you can reason about what a deploy creates, changes and never touches. The commands run from a checkout of the repository; the repository is private and has no public clone URL.
There are two entry points, and both run the same script:
| Way | What it does |
|---|---|
node scripts/deploy.ts render | plan | apply | The deployment itself. render writes the config, plan reads the account, apply creates and deploys. |
scripts/setup.sh | Checks the toolchain, installs the workspace and starts scripts/deploy.ts. See Deploy from your own machine. |
For what each Cloudflare product does in Githome, see Architecture. For every setting, see Configuration.
What you need
| Requirement | Why |
|---|---|
| A Cloudflare account on the Workers Paid plan, with R2 turned on | Githome uses Workers, Durable Objects with SQLite, D1, R2, Queues and Workflows. The Worker asks for 300,000 ms of CPU time per request. |
The zone pkgforge.dev, with the hosts of the instance | git.pkgforge.dev, uploads.git.pkgforge.dev, dash.git.pkgforge.dev and, once a module is deployed, its NAME.git.pkgforge.dev are Custom Domains of their Workers. |
| Node.js 26.7.0 and pnpm 12.11.2 | The Node of .node-version and the pnpm of packageManager in package.json. scripts/setup.sh refuses any other version. |
| A minter credential for Cloudflare | A file kept outside git (.env/CF_TOKEN_MINTER.txt). The deploy never uses a login or a token pasted into a command. See The Cloudflare door. |
Nobody who uses the instance needs a Cloudflare account. Only the deployment uses one.
The resources
One file of the repository, deploy/resources.jsonc, lists the Cloudflare resources of each Worker, and wrangler.jsonc of each Worker holds the bindings; a test keeps the two in agreement. Each resource name starts with githome-, except the core Worker and its D1 database, which are named githome.
| Worker | Hosts | Resources |
|---|---|---|
githome (the core, apps/githome) | git.pkgforge.dev, uploads.git.pkgforge.dev | D1 githome; R2 bucket githome-data; queues githome-events, githome-events-dlq, githome-mail, githome-mail-dlq and githome-mail-events; Workflows githome-run, githome-import and githome-account; the Durable Object migrations v1 to v8; a cron every 5 minutes |
githome-dash (the dash, apps/dash) | dash.git.pkgforge.dev | D1 githome-dash, and a service binding to the core |
each module (modules/) | NAME.git.pkgforge.dev | Its own Worker |
The deploy keeps a fixed list of secrets for each Worker. It generates MASTER_KEY and OIDC_PRIVATE_KEY the first time and never rotates them. The others come from the operator and are never generated: the mail, mirror and backup credentials, the Turnstile secret and, for the dash, the token that registers the first passkey. See Worker secrets.
The Cloudflare door
The deploy reaches Cloudflare through one door, scripts/cf-token.ts with scripts/lib/cf-auth.ts. The operator keeps a minter credential in a file outside git. For each run the door mints a child token that holds only the permission groups of the run's purpose (the read-only wrangler-read for plan), hands it to wrangler from node_modules as CLOUDFLARE_API_TOKEN in that process only, and revokes it when the process ends, also on Ctrl-C. A token value is never printed, logged, put in an argument or written to a file, and the door refuses a command that deletes a resource. The account id comes from the door, never from the tree.
node scripts/cf-token.ts verify checks that the minter is active, and node scripts/cf-token.ts check mints a child token, makes two reads with it and revokes it.
How a deployment works
renderis local and makes no network call. For each Worker of the manifest it writeswrangler.deploy.jsoncin the Worker's folder (a generated file that git ignores; don't edit it) fromwrangler.jsonc: noenvsection, a Custom Domain for each host, the D1 names of the manifest, no container, and the variableGITHOME_CI=off, so the forge CI stays dormant. Each Durable Object binding and migration stays.planis read only. It lists each resource of the manifest as present, missing or different: the Worker, each D1 database, R2 bucket, queue and Workflow, each Custom Domain, the crons and each secret thatapplygenerates. It exits3on a missing or a different resource.applycreates each missing D1 database, R2 bucket and queue, renders the config with the real D1 ids, applies the D1 migrations, deploys withwrangler, sets each missing generated secret from a value made in the process (never printed), and plans again. It stops on a resource that it cannot read or that differs, and it sets no secret when a read after the deploy fails, so a live key is never replaced. It never deletes or replaces a resource.MASTER_KEYis read from the operator's own copy when that copy exists; otherwise it is generated and written there before it is set, so a copy exists for a rebuild.
--worker NAME limits a command to one Worker. --target NAME makes a deployment of its own beside the live one, for a rebuild or a drill: each resource takes a name under githome-NAME-, the Worker has no Custom Domain and serves its workers.dev host, and it runs only the jobs of a rebuild.
Exit codes: 0 done, 1 a Cloudflare call or a step failed, 2 it could not run (bad arguments, no manifest, the door refused), 3 a resource is missing or differs.
Deploy from your own machine
scripts/setup.sh runs from a checkout of the repository, for the first deploy and for each update. It checks node, pnpm and git, refuses a Node other than the one in .node-version or a pnpm other than the one in packageManager, runs pnpm install --frozen-lockfile, and starts the deploy script. It takes the Cloudflare token from the door, never from an argument or a prompt.
scripts/setup.sh # plan: what the account lacks (read only)
scripts/setup.sh --dry-run # build and dry-run each Worker: no Cloudflare call
scripts/setup.sh --apply # deploy
scripts/setup.sh --target NAME # with the plan or --apply: a deployment of its own
pnpm setup:cloudflare runs the same script. Every step creates only what is missing, so run it again with --apply to update; with no flag it plans and changes nothing.
The first sign-in
Githome has no passwords, no Cloudflare Access sign-in and no open sign-up. The dash registers its first passkey with a bootstrap token that the operator sets as the Worker secret DASH_BOOTSTRAP_TOKEN (at least 32 characters). The dash records the one use of the token and refuses it afterwards. From then on the superadmin signs in on https://dash.git.pkgforge.dev with that passkey, invites each account and sends the one-time invitation link by hand. Githome sends no email for an invitation. A person who lost their passkeys asks the superadmin for a sign-in link.
DNS
Each host is a Custom Domain of its Worker, which Cloudflare creates with the DNS record and the certificate. A new host can take a while to get its certificate. When wrangler is run without a terminal, it replaces an existing DNS record of a host that becomes a Custom Domain.
Updating
Update by running scripts/setup.sh --apply (or node scripts/deploy.ts apply) again from the updated checkout. Each run:
- Creates nothing that already exists and keeps the D1 data, the R2 data and the secrets.
- Applies new D1 migrations before the new Worker goes live. Versions only move forward: D1 migrations can't be undone, so don't deploy an older version over a newer one.
Keep MASTER_KEY safe and don't simply replace it: it decrypts every stored secret, and links already sent are signed with it. To change it, keep the old key as MASTER_KEY_PREV as described in Rotating MASTER_KEY. If OIDC_PRIVATE_KEY is deleted, the next deploy generates a new key with a new key ID; systems that trust the OIDC tokens fetch the new public key from /.well-known/jwks.
Mirrors and backups
Push mirrors copy each repository, with its forge state in refs/githome/meta, to GitHub, Cloudflare Artifacts, GitLab, Forgejo or Codeberg. Backups start once their bucket and credentials are set (they are not set yet, so no backup runs); then they run each day, are sealed with their own key (BACKUP_KEY, used for nothing else) and go to an S3-compatible bucket off Cloudflare. A deployment made with --target is the place where a rebuild or a drill runs, beside the live one.
Local development
You can run the core on your machine with wrangler dev, which simulates D1, R2, Queues, Durable Objects and Workflows locally.
pnpm install
pnpm -r build
pnpm --filter @githome/app db:migrate:local
pnpm dev
pnpm dev runs wrangler dev -e dev in apps/githome and serves http://localhost:8787. The dev environment differs from the deployment:
| Deployment | dev environment | |
|---|---|---|
PUBLIC_URL | https://git.pkgforge.dev | http://localhost:8787 |
| Sign-in | Passkeys and tokens | Any request without credentials acts as dev@githome.local (DEV_AUTH). Tokens still work. |
| D1, R2, queues, Workflows | githome, githome-data, githome-events, githome-mail, githome-run, githome-import | The same names with -dev, all local |
| Containers | None | None |
| Forge CI | Off | Not switched off, as in the tests |
| Sent when email sending is set up | Captured by Miniflare, not delivered | |
MASTER_KEY | Set by the deploy | Not set: storing secrets fails until you add one |
To store secrets locally, put a key in apps/githome/.dev.vars (ignored by git): MASTER_KEY=<base64 of 32 random bytes>. The dash has its own dev environment (wrangler dev -e dev --port 8788).
Troubleshooting
| Message or exit | Where | What to do |
|---|---|---|
Node X is needed (.node-version); this is Node Y or the same for pnpm | setup.sh | Install the version that the file or packageManager names and run it again. |
Exit 2, the door refused | deploy.ts | The minter credential is missing or not active. Run node scripts/cf-token.ts verify. |
Exit 3 after plan | deploy.ts | A resource of the manifest is missing or differs. Read the list; apply creates what is missing and stops on a difference. |
apply stops on a resource that differs | deploy.ts | apply never replaces or deletes a resource. Settle the difference by hand, then run it again. |
Exit 1 after the Worker is deployed and the read-back fails | deploy.ts | No secret was set, so a live key was not replaced. Run plan, then apply again. |
| A host answers with a certificate error | Browser | A new Custom Domain can take a while to get its certificate. |