SELF_HOSTING.md
Rama main · réplica de lectura del repositorio original.
# 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
[](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.