Ctrl K
ComunidadGuías…

SELF_HOSTING.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Self-hosting Goldroad

**The hosted service at [trygoldroad.com](https://trygoldroad.com) is the
recommended way to use Goldroad.** Self-hosting is possible and this document is the
one blessed path — but it is **community-supported and unsupported beyond this
document**. There is no guarantee of upgrade tooling, migration help, or backwards
compatibility for self-hosted instances. If you need something dependable, use the
hosted service.

Goldroad is AGPL-3.0-only; you are free to run your own instance. If your instance
serves other people over a network, AGPL §13 requires you to offer them the
corresponding source of the exact version you run.

## What you need

- A **Cloudflare account** (the free plan is enough to start).
- **Node.js** and **pnpm** (see `package.json` → `packageManager` for the version).
- **Wrangler** (installed as a dev dependency; commands below use `pnpm exec wrangler`).
- Your **own atproto OAuth client** — you cannot reuse ours. Every instance is its
  own confidential OAuth client, identified by *your* origin.

## One-click deploy

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/peterkibuchi/goldroad)

The button clones the repo into your Cloudflare account, provisions a D1 database
from `wrangler.jsonc`, rewrites the `database_id`, and prompts you for the secrets
below. It doesn't apply the database migrations, and `VITE_PUBLIC_ORIGIN` is read at
build time. So after it finishes, run `pnpm db:migrate:selfhost` from a clone, set
`VITE_PUBLIC_ORIGIN` as a build variable, generate your OAuth key (below), and
redeploy. Sign-in fails until all of that is done.

## Manual deploy

1. **Install and configure.**

   ```sh
   pnpm install
   cp .env.example .env
   cp .dev.vars.example .dev.vars
   ```

2. **Provision D1** and put its id in `wrangler.jsonc` (top-level `d1_databases[0].database_id`,
   which ships as a placeholder):

   ```sh
   pnpm exec wrangler d1 create goldroad-db
   pnpm db:migrate:selfhost    # applies drizzle/ migrations to your remote DB
   ```

   `db:migrate:selfhost` is the one to use: it carries no `--env`, so it resolves
   the top-level `d1_databases` block — the one whose id you just replaced.
   `db:migrate:prod` and `db:migrate:staging` target the hosted project's own
   environments and are not yours to run.

3. **Set your build variables.** These are `VITE_`-prefixed, which means they are
   baked into the client bundle at build time — put them in `.env` for local work
   and in your Workers Builds **build variables** for deploys, and never put a
   secret in one. The schema is `src/env.ts`; every one of them is optional there,
   but the origin is only optional in the sense that it falls back to the hosted
   instance, which is wrong for you.

   | Variable | Required | What it is |
   |----------|----------|------------|
   | `VITE_PUBLIC_ORIGIN` | yes, in practice | The origin your instance serves from, e.g. `https://your-worker.your-subdomain.workers.dev` or your own domain. Every minted URL and your OAuth `client_id` derive from it, so it must be right before the first sign-in. Left empty it defaults to the hosted origin. |
   | `VITE_PUBLIC_POSTHOG_KEY` | optional | PostHog project key. Empty (the default) means analytics never initialise at all — no SDK load, no events. |
   | `VITE_PUBLIC_POSTHOG_HOST` | optional | PostHog ingestion host. Defaults to `https://us.i.posthog.com`; set it for EU or a self-hosted PostHog. |
   | `VITE_PUBLIC_TURNSTILE_SITE_KEY` | optional | Cloudflare Turnstile sitekey. Set it and the waitlist and report forms render the widget; empty means no widget. Pair it with `TURNSTILE_SECRET` below — the sitekey alone renders a challenge nobody checks. |
   | `VITE_APP_TITLE` | optional | Overrides the application title. |

4. **Set secrets** (values documented in `.dev.vars.example`). Everything here is
   server-side and never reaches the browser:

   | Secret | Required | What it is |
   |--------|----------|------------|
   | `COOKIE_SECRET` | yes | HMAC key for the session-cookie signature. Generate with `openssl rand -hex 32`. |
   | `OAUTH_PRIVATE_KEY_JWK` | yes (prod) | ES256 private JWK for `private_key_jwt` client assertions — your instance's OAuth key. Generate it per the comment in `.dev.vars.example`. |
   | `TURNSTILE_SECRET` | optional | Turnstile secret paired with `VITE_PUBLIC_TURNSTILE_SITE_KEY`. Unset, `/api/waitlist` and `/api/report` skip verification entirely and behave as they do without Turnstile; set, a solved challenge is required. |
   | `WEBHOOK_URL` | optional | Alert webhook for the hourly cron: self-check failures, and new abuse reports awaiting triage. Absent = silent no-op: reports stay queued and unnotified, and once it is set the backlog goes out oldest-first at up to 50 per hourly tick. |
   | `POSTHOG_QUERY_API_KEY` | optional | PostHog personal API key with Query Read scope, for the writer-stats endpoint. |
   | `POSTHOG_PROJECT_ID` | optional | Numeric PostHog project id the pageviews live in. Both PostHog secrets are needed together — with either missing, `/api/stats` answers `{ enabled: false }` and never calls upstream. |
   | `TAKEDOWN_PURGE_TOKEN` | optional | Bearer token for `/api/cache-purge`, which evicts a taken-down page from the read cache. On its own it evicts in the data center that answers; the others keep serving their copy for up to 300 seconds, and the response says so. Unset, the endpoint answers 404. Generate with `openssl rand -hex 32`. |
   | `CF_PURGE_ZONE_ID` | optional | The id of the Cloudflare zone serving your origin. With `CF_PURGE_API_TOKEN`, `/api/cache-purge` also asks Cloudflare's purge API to drop the pages zone-wide; a whole-author takedown purges the entire zone. Needs your origin on a Cloudflare zone (not a `workers.dev` address) and `TAKEDOWN_PURGE_TOKEN` set. |
   | `CF_PURGE_API_TOKEN` | optional | An API token scoped to that zone with cache-purge permission. With only one of the two `CF_PURGE_*` secrets set, purges stay local. |

   Set each as a Worker secret, e.g.:

   ```sh
   echo "$(openssl rand -hex 32)" | pnpm exec wrangler secret put COOKIE_SECRET
   ```

5. **Deploy.**

   ```sh
   pnpm deploy
   ```

## The hourly cron is not optional

`wrangler.jsonc` declares one cron trigger (`0 * * * *`), and every scheduled job
in the app runs off it — including **scheduled publishing**. Remove the trigger
and a writer can still schedule a post; nothing will ever publish it, which is
the one failure this app works hardest to avoid. Keep the trigger, and add jobs
to `runScheduled()` in `src/lib/scheduled.ts` rather than adding triggers (the
free plan allows five per account, and one is enough).

The hour is the resolution: a post due at 09:20 goes out on the 10:00 tick. Each
tick publishes up to five due posts and leaves the rest for the next one, saying
so in its log line.

## Configuration boundaries

- The **top level** of `wrangler.jsonc` is the generic self-host target: your
  worker name, `workers_dev` on, a placeholder D1 id, and **no custom routes**. Deploy
  the top level and you get a standalone instance on your own subdomain.
- The `env.staging` / `env.production` blocks are the hosted project's own
  environments (custom domain route, specific databases). You do **not** need them;
  deploy the top level.
- **Preview URLs are off** at the top level (`preview_urls: false`). With them on,
  Cloudflare gives every uploaded version its own public URL, and each one runs
  against your live database. To review pull requests on them, set it to `true`.
  They only serve when `VITE_PUBLIC_ORIGIN` is the Worker's own `workers.dev`
  hostname; with a custom domain as the origin, a preview URL redirects there.
  An instance deployed before this default changed loses its preview URLs on its
  next deploy.

## Backups

Your D1 database already has 30-day point-in-time restore (Time Travel) with no
setup, which covers a bad migration or a bad delete. It does **not** survive the
database being deleted, and it cannot get a copy of your data off Cloudflare.

[`docs/BACKUPS.md`](docs/BACKUPS.md) covers both: the Time Travel commands, and
the optional nightly encrypted export that closes the rest. The export needs a
keypair and two repository secrets, so it stays off until you set it up — the
hourly cron will tell you it has no backup until then, which is the honest
answer.

## Analytics (optional)

Analytics are off unless you set `VITE_PUBLIC_POSTHOG_KEY` (PostHog). Leaving it empty
disables analytics entirely — the SDK is never even loaded.

The per-post view counts a writer sees are a separate switch: they come from PostHog's
Query API and need `POSTHOG_QUERY_API_KEY` and `POSTHOG_PROJECT_ID` as Worker secrets.
Without both, `/api/stats` reports itself disabled and the counts simply do not appear.