Skip to content

Runbook: Deploy

How a deploy works end to end, and how to roll back. Architecture: ADR 0009 (CI/CD), ADR 0008 (IaC), ADR 0006 (Cloudflare), ADR 0014 (data tier), ADR 0013 (staging + previews).

One Worker codebase, two environments + ephemeral previews (ADR 0013):

Environment Host Gate (repo variable) Data tier
staging staging.quarry.team (noindex, behind Cloudflare Access — @quarry.team OTP) STAGING_DEPLOY_ENABLED=true D1 marketing-site-form-submissions-staging + own KV
production quarry.team / www DEPLOY_ENABLED=true D1 marketing-site-form-submissions + own KV
PR previews ephemeral *.workers.dev URL follows the staging gate staging’s (versions upload — no deploy, no traffic promotion)

The environment is selected at build time: the @astrojs/cloudflare adapter reads apps/web/wrangler.toml for the environment named by CLOUDFLARE_ENV (unset = production, staging = [env.staging]) and emits the fully-resolved deployable config to dist/server/wrangler.json. All wrangler commands must target the emitted config (-c dist/server/wrangler.json) — the source toml has no main/assets.

Infrastructure and the application ship on separate, independently triggered tracks. wrangler deploy ships the Worker on push to main; Terraform owns the durable Cloudflare config plane (zone, D1, R2, Turnstile, WAF/cache) and is reconciled on its own cadence: terraform plan on infra PRs for review, a weekly drift check, and a deliberate, gated manual apply. Keeping apply off the push path is intentional — an auto-applied plan could destroy stateful resources (a D1 database, an R2 bucket), so an infra change is applied by a human who has read the plan, not on every merge. Nothing is deployed by hand to dashboards.

  1. Open a PR. CI runs the validate suite: pnpm typecheck, pnpm lint, pnpm format:check, pnpm test (Vitest), pnpm test:e2e (Playwright), axe a11y, Lighthouse CI budgets, the token-freshness check, and terraform plan for any changed environment. All required checks must pass.
  2. Review the preview. The Preview Web (PR) workflow uploads a Worker version of the staging environment (wrangler versions upload) and posts its ephemeral preview URL as a sticky PR comment. Reviewers inspect the actual deployed result against staging’s D1/KV.
  3. Merge to main. On push to main, the Deploy Web workflow (.github/workflows/deploy-web.yml) ships the site — there is no Terraform step in this path:
    • pnpm build (builds @quarry/ui tokens then @quarry/web), then wrangler deploy -c dist/server/wrangler.json ships the Worker and its static assets after applying any pending D1 migrations (apps/web/migrations/). Staging deploys on every push to main once STAGING_DEPLOY_ENABLED=true; the production job runs the same steps behind its own DEPLOY_ENABLED gate.
      • The @quarry/web postbuild step runs scripts/inject-csp-hashes.mjs, which pins the SHA-256 hashes of every inline script and style the site emits into script-src/style-src of dist/client/_headers (replacing the __CSP_SCRIPT_HASHES__/__CSP_STYLE_HASHES__ tokens in public/_headers) so the shipped CSP is strict — no 'unsafe-*' anywhere (ADR 0015). It fails the build if a token is missing or no inline scripts/styles are found, and the e2e suite (tests/e2e/csp.spec.ts) loads every backbone route under the built policy and fails on any violation. Edit the CSP in public/_headers, never the built copy.
    • Infrastructure is a separate track, not triggered by the merge. terraform apply runs only by gated manual dispatch (.github/workflows/infra.ymlworkflow_dispatchapply), with terraform plan on infra PRs and a weekly drift check. State is in Cloudflare R2 (S3-compatible). Reconcile infra deliberately — ahead of a deploy that depends on new resources — rather than on every push.
  4. Post-deploy verification (see checklist).

The Starlight docs app (apps/docs) deploys separately from the marketing site, via the Deploy Docs workflow (.github/workflows/deploy-docs.yml):

  • The site is a generated mirror of docs/. apps/docs/scripts/sync-docs.mjs projects the canonical Markdown under docs/ (ADRs, BRD, guides, runbooks) into apps/docs/src/content/docs/**, rewriting cross-doc links to site routes and everything else to GitHub URLs. It runs as prebuild/predev, so any build reflects the current docs/. Never hand-edit the generated sections — edit docs/, run pnpm --filter @quarry/docs sync, and commit. CI (ci.yml) regenerates and fails on drift, so the mirror can’t fall out of step with the source.
  • It is a pure-static, assets-only Worker named quarry-docs (apps/docs/wrangler.toml): no main, no D1/KV, no secrets, no build-time env — just astro build output served on docs.quarry.team. Cloudflare creates the custom-domain DNS record on deploy, so there is no Terraform record for the docs host (same pattern as apex/www).
  • The job triggers on pushes to main touching apps/docs/** or docs/** (an edit to the canonical docs must redeploy the mirror) and is gated on the repo variable DOCS_DEPLOY_ENABLED=true (unset until the first deliberate go-live). It needs the same CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID secrets and a docs environment.
  • PR previews: Preview Docs (PR) (.github/workflows/preview-docs.yml) uploads a Worker version of the docs site (wrangler versions upload, preview_urls = true) on PRs touching apps/docs/** or docs/** and posts its ephemeral URL as a sticky comment under a marker (quarry-docs-preview-url) distinct from the web preview’s — so the two never collide. Same gate/guards as the web preview (DOCS_DEPLOY_ENABLED, same-repo human PRs only).
  • Manual deploy: pnpm deploy:docs (builds then wrangler deploy from apps/docs).
  • First-time go-live: set DOCS_DEPLOY_ENABLED=true, ensure the Cloudflare token may create the docs.quarry.team custom domain, then run the workflow (workflow_dispatch) or push a docs change.
  • Rollback: wrangler rollback in apps/docs, or revert the commit and let the workflow redeploy.
  • Validate suite green on the PR (types, lint, format, unit, e2e, axe, Lighthouse, token freshness).
  • terraform plan reviewed — no unexpected resource destruction.
  • Content frontmatter valid (build would fail otherwise); no unintended draft: true/false flips.
  • Secrets present in GitHub Actions / provider stores; none added to the repo.
  • Redirect map for /cases/* → /work/* current (ADR 0011).
  • Production loads; home, a /work/<slug>, and a /blog/<slug> render correctly.
  • A sample legacy /cases/<slug> returns 301 to /work/<slug>.
  • Contact and careers forms submit end to end (Turnstile passes, record persists).
  • curl -sI https://quarry.team | grep -i content-security-policy shows hash-pinned script-src and style-src and no unsafe- anywhere in the policy (strict CSP intact; ADR 0015).
  • The contact page’s “Book a 30-min meet & greet” dialog opens and the Calendly booking page loads inside it (the iframe is our only Calendly surface; their widget JS must stay gone).
  • sitemap.xml, robots.txt, llms.txt resolve.
  • Cloudflare Web Analytics receiving data; no console errors (other than the intentional easter egg).
  • Lighthouse spot-check within budget.

The Worker and its data tier roll back independently.

Site (Cloudflare Worker + assets):

  • Roll back to the previous Worker version with wrangler rollback (or promote a prior deployment from the Cloudflare dashboard), or
  • Revert the offending commit and let the pipeline redeploy the prior build via wrangler deploy.

Infrastructure (Terraform):

  • Revert the infrastructure change and terraform apply to reconcile back to the prior known-good state (single Cloudflare provider). Inspect plan carefully for destructive changes before applying.

Database (Cloudflare D1):

  • Schema issues are handled by rolling forward a corrective migration; data issues are recovered via D1 Time Travel (point-in-time restore). Treat data-tier rollback as an incident — see incident-response.md.
Symptom First action
Bad content/markup, site up Roll back to previous Worker version (wrangler rollback)
Form submissions failing Roll back the Worker; check Turnstile/secrets
Infra misconfiguration (DNS, WAF, redirects) Revert infra change, terraform apply
Data corruption / bad migration Invoke incident response; corrective migration / D1 Time Travel