Prod pipeline
How schema changes flow from a PR through review to a production database. Why `voltro db apply` runs as an explicit deploy step, never on boot. Per-env fingerprint check + the refuse-to-boot behaviour.
The framework's hard rule: voltro start (the production runtime) NEVER auto-applies migrations. The dev-mode behaviour where voltro dev boots refuse-to-start on a blocked plan + auto-apply otherwise is intentionally not extended to prod. Schema changes mid-rolling-deploy without review are the largest data-risk class the framework could create; we don't.
What prod boot DOES is compare the declared-schema fingerprint against the latest _voltro_migration_plans.fingerprint. Match → serve traffic. Mismatch → refuse to boot with a structured error.
The end-to-end flow
┌─────────────────────────────────┐
│ 1. Developer edits schema in PR │
└────────────────┬────────────────┘
│
▼
┌──────────────────────────────────┐
│ 2. CI runs `voltro db plan` │
│ against a staging-style │
│ snapshot. The printed plan │
│ (classes + fingerprints) goes │
│ into the PR for review │
└────────────────┬─────────────────┘
│
▼
┌──────────────────────────────────┐
│ 3. Reviewer reads the plan │
│ classification, fingerprints │
│ + DSL annotations │
│ Approves PR │
└────────────────┬─────────────────┘
│
▼
┌──────────────────────────────────┐
│ 4. Merge to main │
│ CI/CD deploys new image │
│ BEFORE traffic switch a │
│ one-shot job runs │
│ `voltro db apply` against │
│ the prod DB (re-diffs live) │
└────────────────┬─────────────────┘
│
▼
┌───────────────────────────────────┐
│ 5. Traffic switches │
│ Prod boot: fingerprint match │
│ → serves │
└───────────────────────────────────┘There's a slot for the apply step in every common deploy tool (k8s init container, ECS task pre-deploy hook, Heroku release phase, Cloud Run job-on-deploy, Fly.io release_command). The shape is identical: run a one-shot container/process that holds the migration credentials + executes voltro db apply. It re-diffs the live DB against the deployed code's declared schema and applies the resulting plan — there is no pre-serialised plan file to pass; the apply re-computes the diff at run time. The serving process never gets the migration-grade credentials.
Per-env fingerprint check
voltro start (prod runtime) does this on every boot:
const declaredFp = fingerprintSchema(declaredSnapshot(tables))
const lastApplied = await sql`
SELECT fingerprint FROM _voltro_migration_plans
ORDER BY appliedAt DESC LIMIT 1
`
if (lastApplied?.fingerprint !== declaredFp) {
// PROD-MISMATCH outcome → refuse-to-boot.
process.exit(1)
}The structured error on mismatch:
[voltro:start] auto-migrate: SCHEMA FINGERPRINT MISMATCH —
declared = a8f2c9d10b3f4e62
live = 8f507ba1e1aadad5
Run `voltro db apply --plan plan.json` from the deploy pipeline before serving.
exit 1This propagates as a non-zero exit code, k8s + ECS + Cloud Run mark the pod/task CrashLoopBackOff / failed deployment → automatic rollback to the previous image. Operators see the loud crash + know to run the apply step.
VOLTRO_AUTO_MIGRATE=0 skips this check entirely — useful when migrations are handled by a separate ops process + the boot doesn't need to verify. Tradeoff: a drift goes undetected until the next manual voltro db drift run.
Previewing the plan in CI
Run voltro db plan against a snapshot of the prod schema (NOT prod
itself — never expose prod credentials to CI). Add --json to emit the
plan as a machine-readable artifact you can save and commit:
DB_URL=<staging-snapshot-url> voltro db plan --json > plan.jsonThe reviewer reads either the printed plan or the JSON. The saved
plan.json then becomes the input to voltro db apply --plan plan.json
(see below), applying the EXACT reviewed diff. A bare voltro db apply
(no --plan) re-diffs live at deploy time instead — both flows are
supported; the plan-file flow is the one the prod refuse-to-boot message
points you to.
# CI step — runs in staging or a snapshot-replica env, against a real
# connection:
DB_URL=<staging-snapshot-url> voltro db planThe printed plan goes into the PR for the reviewer:
schema diff: 4 operations, 0 blocked
✓ ALTER TABLE users ADD COLUMN bio text # safe
⊕ ALTER TABLE users ADD COLUMN email text # backfill: sql`...`
⊕ UPDATE users SET email = 'unknown-' || id || '@local'
⊕ ALTER TABLE users ALTER COLUMN email SET NOT NULL
✓ CREATE INDEX users_email_idx ON users(email) # safe
safe: 2 needs-default: 0 needs-backfill: 1
rename: 0 lossy: 0 blocked: 0
fingerprint: 8f507ba1e1aadad5 → a8f2c9d10b3f4e62A reviewer reads the SQL the applier will emit + checks the
fingerprints. Blocked ops (red ✗) MUST be resolved in the PR before
merge — voltro db apply refuses any plan with a blocked op (exit 2).
Applying against prod
The deploy job runs voltro db apply (re-diffing the deployed code's
declared schema against the live prod DB):
voltro db apply --note 'PR #1234 — add user emails'The applier:
- Introspects the live DB and plans the diff fresh (it does NOT ingest a plan file — the diff is computed against live at apply time)
- Refuses (exit 2) if any op is blocked, or refuses (exit 3) if
NODE_ENV=production— so the apply runs in a one-shot job withNODE_ENVunset /staging, holding migration credentials, NOT in the serving process - Acquires the advisory lock + executes the plan
- Records the result in
_voltro_migration_planswithsource: 'auto-diff'+notes: 'PR #1234 — add user emails'
Applying a reviewed plan (--plan, works under NODE_ENV=production)
voltro db apply --plan plan.json applies a plan saved by voltro db plan --json. Unlike a bare apply it is allowed when
NODE_ENV=production — because it re-introspects live and refuses
unless BOTH fingerprints still match the saved plan:
fromFingerprint— the live schema the plan was generated against. Live drifted since? → aborts (exit 2, "live schema has drifted").toFingerprint— the declared schema the plan targets. Schema files changed since? → aborts (exit 2).
So --plan can only ever apply the exact diff that was reviewed — never
a stale or drifted one. That safety is what lets it run directly on the
prod runtime, with no NODE_ENV-unset dance. It still refuses any plan
with a blocked op (exit 2) and records the result with source: 'file'.
voltro db apply --plan plan.json --note 'PR #1234 — add user emails'Because the diff is recomputed against live, an apply on an already-
up-to-date DB is a clean no-op (schema is up to date — nothing to apply). That's what makes the apply safe to run in every pod of a
stateless deploy.
Apply timing relative to deploy
Two orderings, both common:
Apply before image swap (recommended): the new image is deployed but not serving yet. The apply runs against the live DB. Then traffic switches.
- New schema is in place when the new code starts serving → no version mismatches
- Old code is still serving until the swap → it must tolerate the new schema for a brief window
- Constraints: every migration must be backward-compatible with the OLD code for the swap window. ADD columns (the OLD code ignores them) ✓. DROP columns (the OLD code might still write to them) ✗ → requires a 2-deploy dance (deploy 1: stop writing to col, deploy 2: drop col).
Apply after image swap: traffic is on the new code, the apply runs after. The new code must tolerate the OLD schema until the apply finishes.
- New code is in place during apply → mid-apply rollback is easier (just rollback the apply, the new code can still talk to the old shape if you designed for it)
- Migration is the LAST step → if it fails, the new code is already serving + needs the new schema. Outage.
For most teams the first is safer (the framework's _voltro_migration_plans.environment tracking expects this pattern). For specific workloads where a partial migration would be catastrophic (massive backfills, multi-hour rewrites), the apply runs first as a one-shot job, the deploy follows when it's done.
Multi-instance prod
The advisory lock around voltro db apply serialises concurrent applies. Two instances of the apply job racing the same plan → one acquires the lock, the other blocks until the first finishes + observes the post-apply fingerprint matches (it's a no-op now), exits 0.
This is the same mechanism that lets you run the apply in EVERY pod of a stateless deploy (the second-through-Nth no-op out fast) — useful when the deploy pipeline can't single out a designated migration runner.
Rollback paths
If the apply itself fails partway:
- Postgres / MSSQL / SQLite: the transaction rolled back → live DB unchanged → fix the migration + re-run
voltro db apply(it re-diffs from the unchanged state) - MySQL / MariaDB: DDL is implicit-commit, so completed ops stayed. Re-run
voltro db apply— it re-diffs against the half-applied live shape and emits only the remaining ops. See multi-dialect.
If the apply succeeded but the new code is broken + needs to be rolled back:
- The new-code rollback ≠ the schema rollback. The image deploys can revert via your normal CI/CD path; the schema stays at the new fingerprint.
- There is no
voltro db rollback <plan-id>for a planner plan. To back out asafechange, ship a schema PR that re-declares the old shape and apply it as a new forward plan; forlossychanges the old data is gone — restore from backup. See Rollback.
This is why the migration story is conservative + the prod refuse-to-boot is strict: once data is gone, no automated reversal brings it back.
What about staging?
The apply records environment: 'staging' when NODE_ENV=staging,
else dev — each env's rows are tracked in the same
_voltro_migration_plans table, tagged by environment. The CI flow
typically applies to staging first, runs smoke tests against the new
code, then to prod. The apply is idempotent against re-runs: because it
re-diffs live each time, a second run against an already-migrated DB
no-ops out.
The cloud dashboard surfaces per-env state with a multi-env tab in the cloud UI.