Skip to content

Per-PR preview deployments on Fly.io#894

Merged
edwh merged 7 commits into
developfrom
fly-pr-previews
Jul 14, 2026
Merged

Per-PR preview deployments on Fly.io#894
edwh merged 7 commits into
developfrom
fly-pr-previews

Conversation

@edwh

@edwh edwh commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Per-PR preview deployments on Fly.io: label any PR with preview (or dispatch the PR preview workflow with a PR number), approve the run, and get a self-contained test site at https://restarters-pr-<N>.fly.dev running that PR's code against a copy of the live database. Replaces the single-slot preview flow (preview-deploy.yml + fly.preview.toml), which clobbered restarters-dev and never ran migrations.

How it works

  • The deploy builds the PR's merge result with its base (like CI tests): a stale branch is previewed as it would actually land, the code is never older than the schema in the restored backup, and conflicted PRs fail fast with "resolve conflicts first". The PR comment notes how far the branch is behind, including migration-touching commits.
  • Reuses the proven restarters-yesterday pattern: same Dockerfile.fly build, embedded local MariaDB, restore from the most recent hourly production backup at cold boot. New docker/preview-startup.sh additionally:
    • serves a static auto-refreshing "warming up" page on port 80 within seconds of boot, so the health check passes immediately and visitors never see a proxy error during the restore;
    • truncates the restored jobs/failed_jobs tables — the backup captures production's in-flight queue jobs, and the supervisord worker is hardcoded to queue:work database, so these must never execute on a preview;
    • runs php artisan migrate --force, so a PR's migrations are exercised against real data before merge, and reports the outcome at /_preview_status (no PII) — the workflow polls it and posts the result on the PR.
  • fly.pr.toml uses auto_stop_machines = "suspend" with min_machines_running = 0 and no swap (swap silently disables suspend): after ~5 min idle the whole VM is snapshotted with MariaDB still loaded, and the next request resumes it sub-second. Cold boots (deploys, discarded snapshots) go through the warming page instead.
  • Cost: ~$0.09/h only while someone is browsing; rootfs storage only (well under $1/month) while idle; nothing after the sweep destroys the app (PR closed or label removed; runs 4x daily).

Security design

Previews run arbitrary PR code while holding a full live-DB copy, so they get no production-capable credentials:

  • Fresh APP_KEY generated per preview app — never production's (that key could forge production session cookies).
  • Read-only Tigris and read-only Google Drive credentials (setup documented in docs/fly-deployment.md).
  • New FEATURE__IMAGE_UPLOAD flag (default on) gates both upload paths (FixometerFile::upload() and NetworkController::update(), which bypassed the helper) so previews can't write to the shared production image bucket.
  • New FEATURE__WORDPRESS_INTEGRATION flag, enforced centrally in Group::eventsShouldPushToWordpress() — previously the WordPress listeners were only safe because the secret happened to be absent. sync:discourseusernames also gains the discourse feature-flag check it was missing.
  • Every deploy requires approval via the preview GitHub Environment (which holds the secrets), so a PR that modifies the workflow can't reach them unreviewed. Cleanup deliberately never runs on pull_request events — the sweep runs default-branch code via the preview-cleanup environment, restricted to develop.
  • /_login (the site gate) now has an nginx rate limit keyed on Fly-Client-IP.

Also in this PR

  • Dockerfile.fly: MariaDB is installed for any non-production startup script (the guard was hard-coded to yesterday-startup.sh).
  • Branch banner: previews show the red PR-<N> badge automatically (APP_SHOW_BRANCH), with a new MAILPIT_URL override so its mail link points at the shared Mailpit.
  • Old branches are previewable without rebasing: merging with the base brings in current infra; a fallback overlay covers PRs whose base branch predates preview support.

One-time setup required before first use

Documented in docs/fly-deployment.mdPR PreviewsOne-time setup: two GitHub environments (preview with required reviewers, preview-cleanup restricted to develop), FLY_ORG_TOKEN (org-scoped), FLY_PREVIEW_SECRETS (fresh gate password, read-only Tigris key, read-only Drive service account), FLY_ORG repo variable, and the preview label. After merge + setup, the plan is to canary on PR #887.

Testing

  • New UploadsDisabledTest (5 tests) and WordpressIntegrationFlagTest (4 tests), written first, red → green; existing WordPress push and group edit tests still pass.
  • preview-startup.sh: bash -n + shellcheck; both nginx configs validated with nginx -t; fly.pr.toml TOML-parsed; workflow validated with actionlint.

edwh and others added 2 commits July 13, 2026 18:46
Label a PR 'preview' (or dispatch the workflow) to get a test site at
restarters-pr-<N>.fly.dev running that PR's code against a copy of the
live DB, restored from the latest hourly backup at cold boot. Machines
suspend after ~5 min idle (sub-second resume, storage-only cost) and a
scheduled sweep destroys apps once the PR closes or loses the label.

- fly.pr.toml: per-PR app template (suspend requires no swap; embedded
  MariaDB; integrations off; cookie gate on; branch banner on)
- docker/preview-startup.sh: warming page on :80 within seconds, restore
  latest backup, truncate restored jobs/failed_jobs (supervisord's worker
  is hardcoded to queue:work database), run the PR's migrations against
  real data, report outcome at /_preview_status
- Dockerfile.fly: install MariaDB for any non-production startup script
- nginx: rate-limit /_login (keyed on Fly-Client-IP); serve
  /_preview_status gate-exempt
- FEATURE__IMAGE_UPLOAD flag gating FixometerFile::upload() and the
  NetworkController logo path, so previews can't write to the shared
  production image bucket (their Tigris creds are read-only anyway)
- FEATURE__WORDPRESS_INTEGRATION flag enforced centrally in
  Group::eventsShouldPushToWordpress(); previously WP listeners were only
  safe because the secret was absent. sync:discourseusernames gains the
  discourse flag check it was missing
- pr-preview.yml: deploys gated by the 'preview' GitHub Environment
  (required reviewer); workflow_dispatch verifies the PR is not from a
  fork; cleanup never runs on pull_request events (PR-modified workflow
  code must not reach the org token) - a 6-hourly sweep via the
  develop-restricted 'preview-cleanup' environment destroys stale apps
- navbar: MAILPIT_URL override so preview banners link the shared Mailpit
- Replaces the single-slot preview flow (preview-deploy.yml +
  fly.preview.toml), which clobbered restarters-dev and never ran
  migrations

One-time setup (environments, org token, read-only Tigris/Drive
credentials, FLY_ORG variable, label) is documented in
docs/fly-deployment.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
A stale branch previewed as-is runs old code against a newer live schema.
Deploying refs/pull/N/merge (PR + current base, same as CI) means the
code is never older than develop, and production schema always lags
develop, so the restored backup can't be ahead of the code. Conflicted
PRs fail fast with a clear message; the PR comment now notes how many
commits (and migration-touching commits) the branch is behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
Comment thread .github/workflows/pr-preview.yml Fixed
Comment thread .github/workflows/pr-preview.yml Fixed
Comment thread .github/workflows/pr-preview.yml Fixed
Comment thread .github/workflows/pr-preview.yml Fixed
edwh and others added 5 commits July 13, 2026 20:35
- Pin superfly/flyctl-actions to a full commit SHA: @master would let a
  compromised upstream repo ship code straight into the job that holds
  the org token
- Move permissions from workflow level to job level (cleanup only needs
  read access)
- Deduplicate UploadsDisabledTest setup into helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
The workflow now strips comment/blank lines before flyctl secrets import,
so the filled-in template can be pasted as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
Records the concrete setup (environments, org, restore identity, label)
alongside day-to-day usage, expectations, and the security model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
Previews currently reuse production's read-write Tigris key (owner-
accepted 2026-07-13); the image-upload flag is the only upload block
until it is swapped for a read-only key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
Closes the interim noted in the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
@sonarqubecloud

Copy link
Copy Markdown

@edwh
edwh merged commit d21830f into develop Jul 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants