Skip to content

Deployment & CI

Automated pipelines keep BlindProof running:

  1. tests.yml — client + backend + repo-script tests on every push and PR.
  2. deploy.yml — ships the backend to Fly when backend/** changes land on main, gated on green tests (see Deploying).
  3. ots-daily.yml — the daily OpenTimestamps maintenance cron.
  4. docs-drift.yml — the docs-drift check (see Keeping docs in sync).
  5. publish-client.yml — mirrors the open-source client to tomdyson/blindproof-client as one squashed snapshot per publish (see Open source).

Plus the backend itself, which is Docker-built and deployed to Fly.io, and this docs site, which is built and deployed by Cloudflare Pages on every push to main.

Backend on Fly.io

Configuration lives in backend/fly.toml. Summary:

  • App: blindproof
  • Region: lhr (London — single-region for the POC)
  • VM: shared-cpu-1x, 256 MB RAM
  • Volume: data, 1 GB, mounted at /app/data
  • Storage: SQLite at /app/data/db.sqlite3, blob store at /app/data/blobs/
  • TLS: force HTTPS; Let's Encrypt cert via Fly
  • Custom domain: blindproof.co.uk (CNAME + ACME challenge via Cloudflare, grey-clouded)

Deploying

Backend changes deploy automatically: when a push to main touches backend/** (or client/verify.py — the verifier ships inside every bundle from the image, so a verifier-only change must redeploy too), deploy.yml runs the backend test suite against that exact commit and — only if it's green — ships the image to Fly with flyctl deploy --remote-only. It's path-filtered (docs/client/mac pushes don't trigger a redundant redeploy) and workflow_dispatch-able from the Actions tab for on-demand runs. The commit that adds the workflow won't itself deploy (it doesn't touch backend/**), so the first automated deploy lands on the next backend change — trigger it manually if you want to ship sooner.

To deploy by hand (first run, or a manual intervention):

just deploy

The recipe exists because the Docker build context is backend/ but the verifier's canonical source is client/verify.py (see Proof bundle format): both just deploy and deploy.yml stage a copy at backend/client/verify.py (gitignored) before running fly deploy --remote-only, and a RUN test -f /app/client/verify.py guard in the Dockerfile fails the build — rather than bundle generation failing in production — if the staging step is ever skipped. A bare cd backend && fly deploy will therefore fail the image build; use the recipe.

Either path uses the repo's Dockerfile (Python 3.12-slim, uv for deps, non-root user) and entrypoint.sh (runs manage.py migrate --noinput at container start, then execs gunicorn on 0.0.0.0:8000).

Migrations run at container start, not as a Fly release command. Release machines don't mount volumes, so a release-command migration would silently operate on an ephemeral empty DB. Running inside entrypoint.sh means migrations apply to the real, mounted /app/data/db.sqlite3.

Don't rename or squash a migration once it's been deployed. Production records applied migrations by name. Renaming one in code — e.g. to resolve a makemigrations number clash — makes Django treat the renamed migration as unapplied and re-run it against a schema that already has its objects. That fails the start-up migrate and crash-loops the deploy (the migration only succeeds against the empty DBs that CI builds, so CI can't catch it). Resolve number clashes with python manage.py makemigrations --merge, which adds a new merge migration and leaves existing names intact. If a rename has already shipped, reconcile the django_migrations table on the volume by hand (fly ssh consolemanage.py shell) before the next deploy.

Secrets

Managed via fly secrets set. Production currently holds:

Secret Purpose
DJANGO_SECRET_KEY Django session signing.
BLINDPROOF_SIGNING_KEY Ed25519 private key for proof-bundle signatures.
BLIND_OTS_MODE real.
DJANGO_ALLOWED_HOSTS blindproof.fly.dev,blindproof.co.uk.

Everything else is baked into fly.toml as non-secret env.

Observability

fly logs --app blindproof is the main signal. A single VM means there's no aggregation concern to worry about.

The daily OTS cron

.github/workflows/ots-daily.yml runs at 02:00 UTC daily (plus workflow_dispatch for on-demand runs) and executes three management commands against the Fly machine via flyctl ssh:

sequenceDiagram
    participant GA as GitHub Actions<br/>02:00 UTC daily
    participant Fly as flyctl ssh
    participant App as Fly machine

    GA->>Fly: authenticate (FLY_API_TOKEN)
    Fly->>App: manage.py upgrade_ots_receipts
    App-->>Fly: yesterday's pending → confirmed
    Fly->>App: manage.py aggregate_day (backlog sweep)
    App-->>Fly: new Merkle roots
    Fly->>App: manage.py submit_ots_receipts
    App-->>Fly: roots handed to calendars
    Fly-->>GA: exit 0

Order matters:

  1. upgrade_ots_receipts first. Promotes yesterday's (and any still-pending earlier) receipts to Bitcoin-confirmed once the calendars have anchored them. This is what flips verify.py from PENDING to PASS.
  2. aggregate_day (no arguments — backlog sweep). Builds a Merkle root for every past day that still has unanchored snapshots — not just yesterday, and one root per capture-day batch of currently-unanchored snapshots. This makes the step self-healing: a missed run or a late-synced save (client offline over a weekend, a second device) is picked up on the next pass instead of being orphaned from anchoring and the proof bundle (issue #57). Late arrivals for a day that was already sealed get a separate backfill root — the existing root is immutable — so a capture-day can carry more than one root. Today is left unsealed so more saves can still arrive. Pass --date YYYY-MM-DD to aggregate one specific day manually. If any snapshot older than 48h is still unanchored after the sweep, the command exits non-zero so the scheduled run fails loudly rather than hiding the orphan in a green log.
  3. submit_ots_receipts. Hands new roots to the three default public calendars (alice.btc, bob.btc, finney).

Auth

A Fly deploy token scoped to the blindproof app only, stored as the FLY_API_TOKEN repo secret. Not a general-purpose Fly account token — the blast radius is limited to this one app.

Why GitHub Actions and not a Fly scheduled machine

Simpler ops: one cron in a YAML file, version-controlled alongside the code, manually triggerable via workflow_dispatch. The lag tolerance is hours (submit at 02:00, anchor 2–6 hours later, upgrade next day), so GitHub Actions' 5–15 min cron variance is a non-issue. Fly's scheduler would work too but adds a dedicated machine to the ops surface with nothing to offset it.

If sub-hour guarantees ever become a requirement, revisit.

This docs site

Built and deployed by Cloudflare Pages on every push to main. The Pages project pulls the repo via the Cloudflare Pages GitHub App, runs pip install zensical && zensical build --clean, and publishes the site/ directory. No workflow file — build config lives in the Pages project settings.

Published at https://docs.blindproof.co.uk/.

Moved off GitHub Pages so the source repo can stay private without requiring GitHub Enterprise.

Keeping docs in sync

Because the docs site auto-deploys on every push to main, a stale page ships to production as fast as a stale API response. A drift check enforces the correspondence between code changes and doc updates.

The mapping is data at scripts/docs-drift-mapping.toml — a small TOML file listing areas, each with a set of code paths (fnmatch patterns) and the docs pages that should be updated alongside them. When adding a new public-surface area or a new doc page, extend the mapping in the same commit.

The checker is scripts/check_docs_drift.py — pure stdlib Python, ~130 lines. Given a diff range, it fails if any area has code changes without matching doc changes. Escape hatches: [skip-docs-check] on its own line in a commit message, or DOCS_DRIFT_SKIP=1 in the environment (CI still checks).

Local enforcement via prek, wired through .pre-commit-config.yaml as a pre-push hook. One-time setup per clone:

uvx prek install --hook-type pre-push

After that, git push runs the check automatically. A failure blocks the push with a readable report of what changed and what else needed to change.

CI backstop at .github/workflows/docs-drift.yml — runs the same prek hook on every push to main (and on PRs, reserved for future use). Computes the diff range from github.event.before/after, passes it to the checker via the DOCS_DRIFT_RANGE env var. A failure leaves a red status check on the commit, so direct-to-main pushes that skipped the local hook (git push --no-verify, or a clone where prek install hadn't been run) still surface the drift post-hoc.

Production housekeeping

Demo/test accounts in the production SQLite. [email protected] carries seeded plausible snapshots for dashboard visuals; the "snapshots" are metadata-only (their ciphertext blobs are random bytes — they decrypt to nothing useful). Remove via fly ssh console --app blindproof + python manage.py shell when no longer wanted.

SQLite backups. Fly volumes are not automatically backed up. For the POC the data is low-stakes (demo accounts); for V1, wire up periodic snapshots to B2.

Fly machine restarts are routine and non-disruptive — migrations re-run on start and are idempotent, and gunicorn rebinds cleanly.

Rollback

Not automated. If a deploy breaks:

fly releases --app blindproof
fly deploy --image <previous-release-image>

Releases are image-tagged, so rolling back is a single command once the target tag is known. Migrations are additive — rollback does not un-migrate. If a schema change needs undoing, write a forward migration that restores the previous shape.

See also