You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

7.4 KiB

pages Infrastructure

OpenTofu configuration for the Cloudflare resources backing pages.elijah.run. Mirrors the style of ../../quotesdb/infra.

Scope split (see ../CLAUDE.md "Deviations from repo defaults" and docs/plans/2026-07-12-pages-design.md §3): the Cloudflare Terraform provider (v4) cannot upload ES module Workers that import wasm files, so wrangler deploy owns the Worker script and its route. OpenTofu (this directory) owns everything else: the R2 bucket, the D1 database, the DNS record, and the rate limiting rule.

Resources provisioned

Resource Description
cloudflare_r2_bucket.pages R2 bucket storing hosted page content (<slug>/<path> keys)
cloudflare_d1_database.pages D1 database for users/sessions/page metadata
cloudflare_record.pages Proxied placeholder DNS record so the Worker route can intercept pages.elijah.run/*
cloudflare_ruleset.pages_rate_limits Per-IP rate limits on /api/pages (uploads) and /auth/

Required credentials

Variable Description
cloudflare_api_token Cloudflare API token with Workers, D1, R2, and DNS edit permissions
cloudflare_account_id Cloudflare account ID
cloudflare_zone_id Zone ID for elijah.run

Set these in terraform.tfvars (gitignored) or as TF_VAR_* environment variables:

cloudflare_api_token  = "..."
cloudflare_account_id = "..."
cloudflare_zone_id    = "..."

1. Prerequisites

Cloudflare account

  • An active Cloudflare account with the elijah.run zone.
  • An API token (User Profile → API Tokens → Create Token) scoped to:
    • Account → Workers R2 Storage → Edit
    • Account → D1 → Edit
    • Zone → DNS → Edit (for elijah.run)
    • Zone → WAF (or "Ruleset") → Edit (for the rate limiting ruleset)
    • Note the account ID (right sidebar of the Cloudflare dashboard) and the zone ID (elijah.run overview page, right sidebar).

GitHub OAuth App

pages authenticates users via GitHub OAuth (design §2.4). Create the app before deploying the Worker, since its client ID/secret are Worker config:

  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
  2. Application name: pages.elijah.run (or similar).
  3. Homepage URL: https://pages.elijah.run.
  4. Authorization callback URL: https://pages.elijah.run/auth/callback — must match BASE_URL + /auth/callback exactly, since the Worker validates GitHub's redirect against this.
  5. Click Register application.
  6. Copy the generated Client ID — this becomes GITHUB_CLIENT_ID in ../wrangler.toml.
  7. Click Generate a new client secret and copy it immediately (it's only shown once) — this becomes the GITHUB_CLIENT_SECRET Worker secret (§5 below), never committed to the repo.

If you also want a local dev flow (wrangler dev on http://localhost:8787), create a second OAuth App (GitHub doesn't allow multiple callback URLs per app in the classic OAuth App flow) with callback http://localhost:8787/auth/callback, and put its client ID/secret in .dev.vars (copy .dev.vars.example, git-ignored).

2. tofu init / plan / apply

D1 must exist before the Worker script binds to it (chicken-and-egg), same as quotesdb:

cd infra/
tofu init
tofu apply -target=cloudflare_d1_database.pages \
  -var cloudflare_api_token="..." \
  -var cloudflare_account_id="..." \
  -var cloudflare_zone_id="..."

Then apply everything else (R2 bucket, DNS record, rate limits):

tofu plan \
  -var cloudflare_api_token="..." \
  -var cloudflare_account_id="..." \
  -var cloudflare_zone_id="..."
tofu apply \
  -var cloudflare_api_token="..." \
  -var cloudflare_account_id="..." \
  -var cloudflare_zone_id="..."

(Omit the -var flags if you've set the equivalent TF_VAR_* environment variables or a terraform.tfvars file instead.)

Zone ruleset caveat: Cloudflare allows only one zone-level http_ratelimit ruleset per zone, on every plan. quotesdb already declares one on the shared elijah.run zone — if both projects' rulesets are applied, the later apply clobbers the earlier one. If quotesdb's rate limits are live, merge this file's two rules blocks into that single shared ruleset instead of applying cloudflare_ruleset.pages_rate_limits standalone (a paid plan raises the rule count allowed inside the one ruleset; the Free plan caps it at 1 rule). See the comment in rate-limits.tf.

After apply, note the d1_database_id output:

tofu output d1_database_id

3. Update wrangler.toml

From the pages/ project root (not infra/):

  1. Replace the placeholder database_id in [[d1_databases]] with the real ID from tofu output d1_database_id.

  2. Uncomment the [[routes]] block now that the DNS record exists:

    [[routes]]
    pattern   = "pages.elijah.run/*"
    zone_name = "elijah.run"
    

4. Apply the D1 schema

From pages/ (schema lives at migrations/schema.sql):

# Production (remote) database
wrangler d1 execute pages --remote --file migrations/schema.sql

# Local dev database (used by `wrangler dev`)
wrangler d1 execute pages --local --file migrations/schema.sql

The schema uses CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS throughout, so re-running either command against an already-migrated database is safe.

5. Secrets and vars

# From pages/ — prompts for the value, never stored in the repo.
wrangler secret put GITHUB_CLIENT_SECRET

In ../wrangler.toml, set the two plaintext vars under [vars]:

[vars]
BASE_URL           = "https://pages.elijah.run"
GITHUB_CLIENT_ID   = "<client id from the GitHub OAuth App above>"
ADMIN_GITHUB_LOGIN = "<your GitHub login — auto-promoted to admin on first login>"

6. Deploy

From pages/:

worker-build --release   # or let [build] in wrangler.toml run it
wrangler deploy

7. Verify

  • https://pages.elijah.run/ loads the management UI.
  • "Log in with GitHub" completes the OAuth round trip and sets a session cookie (__Host-session).
  • The account matching ADMIN_GITHUB_LOGIN shows is_admin / can_upload true via GET /api/me.
  • Uploading a small .html file (POST /api/pages?name=<slug>) as an approved user returns 201 and the page is listed at GET /api/pages.
  • GET /<slug>/ serves the uploaded content with Content-Security-Policy: sandbox … and X-Content-Type-Options: nosniff response headers.
  • Deleting the page (DELETE /api/pages/:slug) as the owner or an admin removes it from both the page list and R2 (a subsequent GET /<slug>/ 404s).
  • Repeating an upload or auth request past the configured rate limit gets blocked (HTTP 429-equivalent Managed Challenge/block page).

State

State is stored locally in terraform.tfstate (gitignored). For a team setup, migrate to a remote backend (S3-compatible bucket, Terraform Cloud, etc.).

Files

File Purpose
providers.tf Terraform block (provider version pin) + Cloudflare provider configuration
variables.tf Input variable declarations
r2.tf Cloudflare R2 bucket for hosted page content
d1.tf Cloudflare D1 database + d1_database_id output
dns.tf Placeholder proxied DNS record enabling the Worker route
rate-limits.tf Per-IP rate limiting ruleset for uploads and auth
.gitignore Ignores state, lock, and credential files