# Production Deployment Runbook Moving the CCR Platform from the personal dev setup to the lab's own accounts, and putting it on a custom domain. Written for a one-sitting migration: the code is already unified in the lab GitHub repo, the app is feature-complete, and data already lives on managed services (Postgres + object storage). This runbook carries all of it over to lab-owned accounts. Terminology: "the Space" is the Hugging Face Space that runs the app. "Custom domain" is whatever the PI picks (examples below use `ccr.culturemoralitylab.org`; swap in the chosen domain everywhere). There are no em dashes in this file on purpose (project rule). --- ## 0. What is already done - Code is unified in `Culture-and-Morality-Lab/ccr-platform` (public), with clean commit history. - License texts are staged in the repo root (`LICENSE-MIT`, `LICENSE-APACHE`); one gets promoted to `LICENSE` once the PI picks. - The app is one portable container (`Dockerfile`); the same image runs on any host, so "deploy" is a git push to the Space. - Data services are already in use on the dev instance (Supabase Postgres via `DATABASE_URL`, Cloudflare R2 via the `CCR_S3_*` secrets), so this is a copy-to-lab-accounts job, not new infrastructure. ## 1. Accounts to create (lab email, free) | Service | Purpose | Cost | | --- | --- | --- | | Hugging Face (account or org) | Hosts the Space | Free (cpu-basic) | | Supabase | Postgres database + Google sign-in | Free tier | | Cloudflare | Domain registrar + DNS + R2 storage + the reverse-proxy Worker | Free (domain ~20/yr; R2 needs a card on file but is free at this scale) | | Groq | AI item-drafting key | Free tier | | GitHub | Already done (lab org repo exists) | Free | Save every password and key in a password manager as you go. These are the lab's institutional credentials, not personal logins. ## 2. Secrets sheet (fill privately, never commit) The new Space needs these. Collect the values as you complete each section below, then paste them into the Space in section 7. | Name | Store | Value source | | --- | --- | --- | | `CCR_SESSION_SECRET` | Secret | Generate fresh: `python3 -c "import secrets; print(secrets.token_hex(32))"` (do NOT reuse the dev value) | | `DATABASE_URL` | Secret | New Supabase project, Session pooler URI, with the DB password in it (section 3) | | `SUPABASE_URL` | Secret | New Supabase project > Settings > API | | `SUPABASE_ANON_KEY` | Secret | Same page (anon public key, NOT service_role) | | `CCR_STORAGE` | Variable | `s3` | | `CCR_S3_ENDPOINT` | Secret | New R2: `https://.r2.cloudflarestorage.com` (section 5) | | `CCR_S3_BUCKET` | Secret | New R2 bucket name | | `CCR_S3_ACCESS_KEY_ID` | Secret | New R2 API token | | `CCR_S3_SECRET_ACCESS_KEY` | Secret | New R2 API token | | `GROQ_API_KEY` | Secret | New lab Groq key | | `CCR_APP_URL` | Variable | `https://` (section 8) | | `CCR_COOKIE_SECURE` | Variable | `1` | | `CCR_ANON_TTL_HOURS` | Variable | `24` | | `ADMIN_EMAILS` | Variable | Comma-separated admin emails (confirm with PI) | | `CCR_MAX_ROWS` | Variable | `50000` (matches the dev instance) | Hugging Face keeps Variables and Secrets in two separate stores. A name defined in BOTH puts the Space into CONFIG_ERROR before it builds. Put each name in one store only, per the table. ## 3. Supabase project (database + sign-in) 1. New project under the lab's Supabase org. Match the region to the current dev project (read it from the dev `DATABASE_URL`: the pooler host contains it, e.g. `aws-0-us-east-1...` means US East). Generate a strong DB password and save it. 2. Turn OFF "Enable Data API" (the app talks to Postgres directly and never uses the REST API; leaving it off closes that surface). Leave "Enable automatic RLS" on. Do NOT connect GitHub (the app manages its own schema). 3. Settings > API: copy the Project URL and the anon key into the secrets sheet (`SUPABASE_URL`, `SUPABASE_ANON_KEY`). 4. Connect > Session pooler: copy the URI, insert the DB password, into `DATABASE_URL`. 5. Google sign-in (do this before launch; the button is hidden without it): - Google Cloud console (lab Google account) > OAuth consent screen (External) > Credentials > Create OAuth client ID (Web application). - Supabase > Authentication > Providers > Google > Enable. Copy the shown callback (`https://.supabase.co/auth/v1/callback`) and add it as an authorized redirect URI in the Google client. Paste the Google client id and secret back into the Supabase Google form. - Supabase > Authentication > URL Configuration: add the redirect URLs `http://127.0.0.1:8000/api/auth/google/callback` and, once the domain is live, `https:///api/auth/google/callback`. ## 4. Migrate the database (carry accounts and data over) Run BEFORE the new Space boots for the first time, so the app finds the data already present rather than creating empty tables. ```sh # Dump the app's tables from the current (dev) database. Use the dev # DATABASE_URL from your local .env. --schema=public keeps it to the app's # tables; --no-owner --no-acl avoids role mismatches between projects. pg_dump "$DEV_DATABASE_URL" --schema=public --no-owner --no-acl -Fc -f ccr_public.dump # Restore into the NEW Supabase project. pg_restore --no-owner --no-acl --clean --if-exists \ -d "$NEW_DATABASE_URL" ccr_public.dump ``` If `pg_dump` version-mismatches against Supabase, use the Postgres client that matches the server major version (or the `supabase db dump` CLI). The app's startup auto-migration adds any missing columns after this, so a slightly older dump still boots cleanly. ## 5. R2 object storage (uploaded files) 1. Cloudflare > R2 > create a bucket under the lab account (this is when R2 asks for a card on file; the 10 GB free tier covers this use). 2. Create an R2 API token (Object Read and Write) scoped to that bucket. Put the access key id, secret, endpoint, and bucket name into the secrets sheet. 3. Copy the existing files over (R2 is S3-compatible): ```sh # From the dev R2 to the lab R2. Fill both endpoint/keys from each account. aws s3 sync \ --endpoint-url "$DEV_S3_ENDPOINT" "s3://$DEV_BUCKET" ./r2-migrate-tmp aws s3 sync \ --endpoint-url "$LAB_S3_ENDPOINT" ./r2-migrate-tmp "s3://$LAB_BUCKET" # (or use rclone with two remotes: rclone sync dev:bucket lab:bucket) ``` ## 6. Domain purchase (on the PI call, card needed) 1. Cloudflare > Domain Registration > register the chosen domain (recommended: buy at Cloudflare so domain, DNS, R2, and the Worker share one account). Availability was last checked recently and can change daily, so register the same day it is chosen. 2. If the domain is bought elsewhere, add it to Cloudflare and point its nameservers at Cloudflare, so the Worker route in section 8 can attach. ## 7. Create the lab Space and add secrets 1. New Space under the lab Hugging Face account: SDK = Docker, hardware = cpu-basic (free, same as dev). Give it the same repo. 2. Point the local repo's Space remote at the new Space and push: ```sh git remote set-url hf https://huggingface.co/spaces// git push hf main # use the lab HF username + a WRITE token when prompted ``` 3. Space > Settings > Variables and secrets: enter every row from the secrets sheet (section 2), each in the store the table specifies. 4. The Space builds and boots. Because the database was restored in section 4, accounts and data are already there. ## 8. Wire the custom domain (reverse proxy) Hugging Face Spaces cannot serve a custom domain directly, so a small Cloudflare Worker serves the Space at the lab domain and keeps the domain in the address bar. 1. Get the Space direct host: Space > Settings > Embed this Space > Direct URL, of the form `https://-.hf.space`. 2. Open `deploy/reverse-proxy-worker.js`, set `UPSTREAM_HOST` to that host (without the `https://`). 3. Cloudflare > Workers & Pages > Create Worker > paste the file > Deploy. 4. That Worker > Settings > Domains & Routes > Add Custom Domain > `ccr.` (Cloudflare provisions TLS automatically). 5. Set the Space Variable `CCR_APP_URL=https://ccr.` and confirm the Supabase redirect URL from section 3.5 uses the same domain. Restart the Space so it picks up `CCR_APP_URL`. ## 9. Launch checklist (verify before announcing) - Site loads on the custom domain; the address bar stays on the domain while navigating. - Google sign-in completes and returns to the custom domain (not hf.space). - Password sign-in works; an account created on the dev instance is present (confirms the data migration). - Upload a small corpus, pick a construct, run it, open results. - AI drafting: "Draft with AI" tab works with the lab Groq key; the drafted items appear, and a saved AI construct shows the "AI-generated, not validated" label through picker, results, and the metadata download. - `/guide` loads; `/admin` is reachable for the emails in `ADMIN_EMAILS`. - Promote the license: `git mv LICENSE-MIT LICENSE && rm LICENSE-APACHE` (or the reverse), commit, push. Needed because the site publicly says the tool is open-source. ## 10. Cut over and retire the old instance - Pause (do not delete) the old personal Space once the new one is verified, so it is a fallback for a day or two. - Keep the dev database and R2 until the new instance has run cleanly for a few days, then retire them. --- ## Optional, post-launch ### Keep the Space awake (free) Free Spaces sleep after about 48 hours idle (first visitor then waits about a minute). A free uptime pinger (for example UptimeRobot) hitting the domain every few hours keeps it warm without paying. The durable fix is an always-on host (next item). ### Always-on host (later) The same container runs on any host (Fly, Railway, Hetzner, a UMass VM). Moving there removes the sleep behavior AND gives native custom-domain support, so the Worker in section 8 can be dropped. Not a launch blocker. ### One-push deploys (GitHub Action) Auto-deploy the Space on every merge to `main`, so nobody needs the Space remote. Add a GitHub Actions workflow that mirrors `main` to the Space, with an HF write token stored as a GitHub Actions secret (`HF_TOKEN`): ```yaml # .github/workflows/deploy-hf.yml name: Deploy to Hugging Face Space on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - name: Push to Space env: HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | git push "https://lab:${HF_TOKEN}@huggingface.co/spaces//" main ``` ### Switch AI drafting to Claude Haiku (later) Add `ANTHROPIC_API_KEY` as a Space secret and the app auto-selects Anthropic over Groq (`CCR_GENERATION_PROVIDER` can force either). Groq stays as the free fallback. The provenance stamp records which model drafted each construct, so constructs drafted during the Groq period stay traceable.