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 1
This 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 → a8f2c9d10b3f4e62
A 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 a bare apply runs in a one-shot job withNODE_ENV=staging, holding migration credentials, NOT in the serving process
NODE_ENVunset is no longer "not production". Everyvoltro db …andvoltro migrateinvocation resolves an unsetNODE_ENVtoproduction, exactly asvoltro serveandvoltro startdo — so a barevoltro db applyin a pipeline that forgot the variable now refuses (exit 3) instead of silently applying an un-reviewed diff to production. SetNODE_ENV=developmentfor a local database; use the--planpath below for a real one.This is not only about the refusal.
_voltro_tracesand_voltro_undo_logare created only when tracing / undo capture are on, and both are on unless production — so an apply withNODE_ENVunset used to DECLARE two tables the serving container did not. The declared set is what the schema fingerprint hashes, so the apply recorded a fingerprint the container could not reproduce andvoltro serverefused to boot withprod-mismatch, telling you to run the apply you had just run.
- 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.
Both spellings of the flag work: --plan plan.json and --plan=plan.json.
The FIRST deploy, against an empty database
Nothing special is required, and the plan you review is the whole story: on a
database with no tables, voltro db plan --json includes the framework's own
_voltro_* tables (the migration ledger, api keys, kv, outbox, traces …) plus
actors, alongside your own. They are part of the declared schema, so they are
planned, classified and applied by exactly the same code as your tables — expect
a first-deploy plan to be ~20 operations larger than the diff you wrote.
Two consequences worth knowing:
- The reviewed plan is complete.
db apply --plancreates nothing beside it, so the fingerprint the plan was generated against is still the live schema when the guard checks it. (It did not used to be: the ledger tables were created before the fingerprint was taken, so the first deploy of every new database refused withthe live schema has driftedone second after the plan was generated. Fixed.) - One table is deliberately absent from the plan:
_voltro_migration_ops, the crash-resume ledger. It has to exist before the very first plan runs — the plan that creates everything else — sovoltro db applycreates it itself, under the migration lock. A live_voltro_*table your schema does not declare is never planned for a drop, so it does not show up in the next diff either.
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.
Rehearsing a migration against real data
The strongest check on this pipeline is not that each command exits 0 — it is that no row moved that you did not ask to move. This loop was built for a MariaDB cutover and caught three defects the framework's own suite did not, which is why it is written up here.
Restore a backup into a throwaway database.
voltro data backup ./rehearsal . DB_URL=$SCRATCH_URL voltro data restore ./rehearsalUse
data backup/data restore— the NATIVE path — not the logicaldata export. The logical exporter re-shapes rows through the current declared schema, and the state a rehearsal exists to migrate from is precisely the one that does not match it.Take an exact census, before.
SELECT table_name, COUNT(*) FROM ... -- one COUNT(*) per tableIt must be
COUNT(*).information_schema.TABLE_ROWSis an estimate on InnoDB — routinely off by thousands, and it is what a fast version of this check would reach for. The slowness is the point.Run the exact production command sequence — the same one your deploy Job runs, in the same order:
voltro db files . voltro db plan --json > plan.json voltro db apply --plan plan.jsonTake the census again, and require the difference to be explainable.
A healthy run moves one row: the
_voltro_migration_plansledger entry. Anything else is a question, not a result.
Two things the census must get right, both learned by using it:
- A soft drop is not a loss. With
VOLTRO_SOFT_DROP=1a dropped table reappears as<name>__dropped_<YYYYMMDDHHMMSS>with its rows intact. Reporting that as a vanished table trains people to ignore the check; reporting it as clean hides a real drop. Give it its own category. - A new table is not a discrepancy. A migration that adds one produces a table with no "before" count. Say so explicitly rather than letting a zero read as data loss.
To rehearse a schema several months old — the realistic case — craft the backup deliberately: a table a later migration added, a column a later one narrowed, a column the schema no longer declares. The planner is state-based, so it diffs live against declared and never replays a history; a six-month-old dump costs exactly one diff.
File-based migrations in this pipeline
voltro db apply runs pending migrations/*.ts first, then diffs — the same order the boot path uses.
voltro db apply --plan plan.json does not run them. It refuses when any are pending:
db apply --plan: refusing — 2 pending file-based migration(s).
20260714_090000_split_full_name
20260721_143000_backfill_slug
These perform the changes a state diff cannot infer, so they change the shape
this plan was computed against. Apply them and regenerate the plan:
voltro db files .
voltro db plan --json > plan.json
voltro db apply --plan plan.jsonThat is not caution for its own sake. A saved plan was computed and reviewed against an earlier state; a file migration performs exactly the kind of change (a table split, a cross-table data move) that makes the plan stale. Running the migrations first would trip the fingerprint guard immediately afterwards and leave a half-applied deploy; running them after would apply a plan reviewed against a state that no longer exists.
So a pipeline that uses the saved-plan form needs voltro db files as its own step, before the plan is generated:
voltro db files . # authored data steps
voltro db plan --json > plan.json # diff, now against the corrected shape
voltro db apply --plan plan.json # reviewed, fingerprint-guardedIf you use plain voltro db apply instead, the first step is already included.