fix: update to ts0.org zone, R2 custom domain, live infra state

- Zone stack now uses ts0.org (token has DNS:Edit there, not dodwell.us)
- R2 stack uses cloudflare_r2_bucket_domain instead of Workers (no Workers:Edit)
- Custom domain spa.ts0.org attached to spaoftheday R2 bucket (bootstrapped)
- Buckets spaoftheday + spaoftheday-tfstate created in R2
- DNS records added: SPF, DMARC, DKIM for spa.ts0.org
- Gitea Actions secrets configured: CLOUDFLARE_API_TOKEN, ACCOUNT_ID, R2 keys
- Placeholder SPA uploaded to R2 at 2026/03/09/index.html
- CLAUDE.md updated to reflect spa.ts0.org and current live state
This commit is contained in:
Release Manager
2026-03-09 12:19:40 +11:00
parent 133af0540a
commit 77f2c5b6bd
6 changed files with 78 additions and 72 deletions

View File

@@ -14,8 +14,8 @@ spaoftheday/
│ └── deploy.yml # Runs on merge to master: apply infra + upload SPAs
├── stacks/
│ └── cloudflare/
│ ├── zone/ # Terramate stack: DNS zone for spa.dodwell.us
│ └── r2/ # Terramate stack: R2 bucket + Worker router
│ ├── zone/ # Terramate stack: DNS records for spa.ts0.org (→ spaoftheday.com)
│ └── r2/ # Terramate stack: R2 bucket + custom domain
├── spas/
│ └── YYYY/
│ └── MM/
@@ -50,7 +50,8 @@ spas/
- SPAs must be self-contained (no external CDN dependencies that could break)
- Keep file sizes reasonable; R2 serves them directly
**Live URL pattern:** `https://spa.dodwell.us/YYYY/MM/DD/`
**Live URL pattern:** `https://spa.ts0.org/YYYY/MM/DD/`
_(Will become `https://spaoftheday.com/YYYY/MM/DD/` once the domain is registered)_
---
@@ -93,49 +94,57 @@ export R2_SECRET_ACCESS_KEY=...
---
## Required GitHub Secrets
Set these in the repository's GitHub Actions secrets:
| Secret | Description |
|---|---|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (Zone:Edit, DNS:Edit, R2:Edit, Workers:Edit) |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID |
| `R2_ACCESS_KEY_ID` | R2 S3-compatible access key |
| `R2_SECRET_ACCESS_KEY` | R2 S3-compatible secret key |
---
## Infrastructure
Managed with **Terramate + Terraform**.
| Stack | Path | Purpose |
|---|---|---|
| `cloudflare-zone` | `stacks/cloudflare/zone/` | DNS records for `spa.dodwell.us` (CNAME → R2, SPF, DMARC, DKIM) |
| `cloudflare-r2` | `stacks/cloudflare/r2/` | R2 bucket + Cloudflare Worker router |
| `cloudflare-zone` | `stacks/cloudflare/zone/` | DNS records for `spa.ts0.org` (SPF, DMARC, DKIM spam control) |
| `cloudflare-r2` | `stacks/cloudflare/r2/` | R2 bucket `spaoftheday` + custom domain `spa.ts0.org` |
**Terraform state** is stored in a separate R2 bucket: `spaoftheday-tfstate`.
**To plan/apply manually:**
**Current live state (bootstrapped manually):**
- R2 bucket `spaoftheday` — created ✓
- R2 bucket `spaoftheday-tfstate` — created ✓
- Custom domain `spa.ts0.org` — attached to bucket ✓
- DNS: SPF, DMARC, DKIM records for `spa.ts0.org` — created ✓
**To plan/apply manually (after bootstrapping Terraform state import):**
```bash
# Install terramate: https://terramate.io/docs/cli/installation
cd stacks/cloudflare/zone
terraform init
terraform plan -var="cloudflare_api_token=$CLOUDFLARE_API_TOKEN" -var="cloudflare_account_id=$CLOUDFLARE_ACCOUNT_ID"
terraform plan \
-var="cloudflare_api_token=$CLOUDFLARE_API_TOKEN" \
-var="cloudflare_account_id=$CLOUDFLARE_ACCOUNT_ID"
```
---
## Required GitHub / Gitea Secrets
All 4 secrets are configured in Gitea Actions:
| Secret | Description |
|---|---|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (R2:Edit, DNS:Edit on ts0.org) |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID |
| `R2_ACCESS_KEY_ID` | R2 S3-compatible access key |
| `R2_SECRET_ACCESS_KEY` | R2 S3-compatible secret key |
---
## Domain Migration
Currently hosted at `spa.dodwell.us`. When `spaoftheday.com` is registered:
Currently hosted at `spa.ts0.org`. When `spaoftheday.com` is registered:
1. Create a new zone in `stacks/cloudflare/zone/main.tf` for `spaoftheday.com`
2. Add CNAME, SPF, DMARC records for the new domain
3. Update the Worker route to serve from `spaoftheday.com`
4. All existing URLs remain valid (or add redirects)
5. Notify Release Manager so they can verify the migration
1. Add the new zone to Cloudflare and verify ownership.
2. Update `stacks/cloudflare/zone/variables.tf` → `zone_name = "spaoftheday.com"` and `subdomain = "spa.spaoftheday.com"` (or just `spaoftheday.com`).
3. Update `stacks/cloudflare/r2/variables.tf` → `spa_domain` and `zone_id`.
4. Apply the stacks — Cloudflare will migrate the custom domain.
5. Notify Release Manager to verify all URLs load correctly.
---

View File

@@ -1,5 +1,7 @@
# Cloudflare R2 bucket for SPA of the Day static hosting
# SPAs are stored at: /<year>/<month>/<day>/index.html
# Served at: https://spa.ts0.org/<year>/<month>/<day>/
# (Will migrate to spaoftheday.com once registered)
resource "cloudflare_r2_bucket" "spaoftheday" {
account_id = var.cloudflare_account_id
@@ -7,22 +9,19 @@ resource "cloudflare_r2_bucket" "spaoftheday" {
location = "WNAM"
}
# Custom domain for the R2 bucket (requires zone to exist first)
resource "cloudflare_r2_bucket" "tfstate" {
account_id = var.cloudflare_account_id
name = var.tfstate_bucket_name
location = "WNAM"
}
# Cloudflare Pages / Workers for SPA serving
# Using a Workers Route to serve R2 content from spa.dodwell.us
resource "cloudflare_worker_script" "spa_router" {
account_id = var.cloudflare_account_id
name = "spa-router"
content = file("${path.module}/worker.js")
r2_bucket_binding {
name = "SPA_BUCKET"
bucket_name = cloudflare_r2_bucket.spaoftheday.name
}
# Attach spa.ts0.org as a custom domain on the R2 bucket.
# Cloudflare auto-creates the CNAME record (spa.ts0.org -> public.r2.dev, proxied).
# Requires: R2 bucket exists + ts0.org zone is in the same account.
resource "cloudflare_r2_bucket_domain" "spa" {
account_id = var.cloudflare_account_id
bucket_name = cloudflare_r2_bucket.spaoftheday.name
domain = var.spa_domain
zone_id = var.zone_id
enabled = true
}

View File

@@ -20,3 +20,15 @@ variable "tfstate_bucket_name" {
type = string
default = "spaoftheday-tfstate"
}
variable "spa_domain" {
description = "Custom domain for SPA hosting (attached to R2 bucket)"
type = string
default = "spa.ts0.org"
}
variable "zone_id" {
description = "Cloudflare zone ID for the SPA domain"
type = string
default = "de9200e86be412df6d0ed8fab5d345ef"
}

View File

@@ -1,51 +1,37 @@
# Cloudflare Zone configuration for spa.dodwell.us
# Note: dodwell.us zone is pre-existing; we add records to it.
# When migrating to spaoftheday.com, create a new zone resource there.
# Cloudflare Zone configuration for spa.ts0.org
# Note: ts0.org zone is in Cloudflare. We add spa.* records to it.
# When spaoftheday.com is registered, create a new zone resource there and migrate.
data "cloudflare_zone" "dodwell" {
data "cloudflare_zone" "ts0" {
name = var.zone_name
}
# --- SPA subdomain CNAME pointing to R2 public bucket ---
resource "cloudflare_record" "spa_root" {
zone_id = data.cloudflare_zone.dodwell.id
name = "spa"
type = "CNAME"
value = "${var.cloudflare_account_id}.r2.cloudflarestorage.com"
proxied = true
comment = "SPA of the Day - R2 static hosting"
}
# --- R2 custom domain is managed via cloudflare_r2_bucket_domain resource ---
# The CNAME (spa.ts0.org -> public.r2.dev) is auto-created by Cloudflare
# when the custom domain is attached to the bucket.
# We manage it here for Terraform state only (import after first manual apply).
resource "cloudflare_record" "spa_www" {
zone_id = data.cloudflare_zone.dodwell.id
name = "www.spa"
type = "CNAME"
value = "spa.dodwell.us"
proxied = true
comment = "SPA of the Day - www redirect"
}
# --- Email spam control (SPF, DMARC, DKIM placeholder) ---
# --- Email spam control (SPF, DMARC, DKIM) ---
resource "cloudflare_record" "spa_spf" {
zone_id = data.cloudflare_zone.dodwell.id
zone_id = data.cloudflare_zone.ts0.id
name = "spa"
type = "TXT"
value = "v=spf1 -all"
comment = "SPA subdomain - no mail sent, reject all"
comment = "spa.ts0.org - no mail sent, reject all"
}
resource "cloudflare_record" "spa_dmarc" {
zone_id = data.cloudflare_zone.dodwell.id
zone_id = data.cloudflare_zone.ts0.id
name = "_dmarc.spa"
type = "TXT"
value = "v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;"
comment = "DMARC policy for spa subdomain"
comment = "DMARC reject policy for spa subdomain"
}
resource "cloudflare_record" "spa_dkim" {
zone_id = data.cloudflare_zone.dodwell.id
zone_id = data.cloudflare_zone.ts0.id
name = "*._domainkey.spa"
type = "TXT"
value = "v=DKIM1; p="
comment = "DKIM null record - no mail signing"
comment = "DKIM null record - no mail signing for spa subdomain"
}

View File

@@ -1,9 +1,9 @@
output "zone_id" {
description = "The Cloudflare zone ID for dodwell.us"
value = data.cloudflare_zone.dodwell.id
description = "The Cloudflare zone ID for ts0.org"
value = data.cloudflare_zone.ts0.id
}
output "spa_hostname" {
description = "The spa subdomain hostname"
value = "spa.dodwell.us"
value = "spa.ts0.org"
}

View File

@@ -12,11 +12,11 @@ variable "cloudflare_account_id" {
variable "zone_name" {
description = "The DNS zone name"
type = string
default = "dodwell.us"
default = "ts0.org"
}
variable "subdomain" {
description = "Subdomain for SPA hosting"
type = string
default = "spa.dodwell.us"
default = "spa.ts0.org"
}