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— in production the same sequence is spelledvoltro db deploy(below), in a one-shot job 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'
The deploy step (voltro db deploy, works under NODE_ENV=production)
voltro db deploy . is the production migrate step in one command:
- the pending
before-schemafile migrations, - the plan — computed against live after them, printed to the job log and
recorded in
_voltro_migration_plans, - the same gates as every apply: a blocked operation refuses (exit 2), a lossy
one needs its declaration (
dropped(),VOLTRO_DESTRUCTIVE_OK), a rolling-deploy-unsafe one needs its flag, - the apply,
- the pending
after-schemafile migrations — on the shape the apply produced.
All of it runs in the deploy transaction on Postgres and SQL Server, and under
the deploy journal (undo on failure) on MySQL, MariaDB, SQLite and Turso, where
the run asks before its first statement — pass --yes in a job. Any failure
exits non-zero.
voltro db deploy . --yesIt is bare db apply with the production refusal lifted by name. That refusal
stays for db apply: a hand-typed apply against production, or a job whose
unset NODE_ENV resolved to production, must not apply a diff by accident. A
job that says deploy has said what it means. db deploy refuses --plan — it
computes the plan it applies; to apply a plan reviewed earlier, use the form
below.
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 deploy . --yesor, if the job applies a saved plan:
voltro db files . --phase before-schema voltro db plan --json > plan.json voltro db apply --plan plan.json --yesTake 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 and voltro db deploy run the pending before-schema files
first, then diff and apply, then run the after-schema files — the order the
boot path uses.
voltro db apply --plan plan.json runs the after-schema files after the plan
it applies. It refuses when a before-schema file is pending:
db apply --plan: refusing — 1 pending before-schema file migration(s).
pending: 20260714_090000_split_full_name
why: a before-schema file migration performs changes a state diff cannot
infer, so it changes the shape this plan was computed against
fix: run them first and plan again: `voltro db files . --phase before-schema
&& voltro db plan --json > plan.json && voltro db apply --plan plan.json`
— or `voltro db deploy .`, which does all of it in one stepThat is not caution for its own sake. A saved plan was computed and reviewed against an earlier state; a before-schema migration performs exactly the kind of change (a table split, a cross-table data move) that makes the plan stale. Running it first would trip the fingerprint guard immediately afterwards and leave a half-applied deploy; running it after would apply a plan reviewed against a state that no longer exists.
So a pipeline that uses the saved-plan form runs the before-schema files as their own step, before the plan is generated:
voltro db files . --phase before-schema # authored steps the plan must not compute against
voltro db plan --json > plan.json # diff, now against the corrected shape
voltro db apply --plan plan.json --yes # reviewed, fingerprint-guarded; runs the after-schema files after itvoltro db files . without --phase does the same here: while a schema plan is
pending it runs only the before-schema files and reports the after-schema
ones as waiting for the plan (exit 0). It runs an after-schema file only once
the plan is empty, because that file reads the shape the plan produces — a
back-fill into a table this deploy creates would otherwise meet a table that
does not exist yet.
voltro db migrate is not part of this. It runs defineMigration
*.migration.ts steps, a separate system with its own ledger; over pending
migrations/<id>.ts files it exits 1 and names them rather than reporting that
there is nothing to do.