Most CI/CD pipelines follow a shape close to commit, build, test, deploy, monitor, as laid out in Microsoft's pipeline guide. Migrations do not fit cleanly inside any of those five, which is why teams either bury them in application startup or run them by hand. Both are ways to lose.
The rules that matter:
- Migrations run after the build, not during it. The build compiles code and produces an artifact (binary, container image, package). Migrations need the artifact version recorded so you know which schema the code expects. Running them as the first thing after build, in their own stage, keeps that link.
- Migrations run before the new code receives traffic. A rolling deploy brings new instances up one at a time while old instances still serve. If the schema is missing when the first new instance starts, that instance fails health checks and the rollout stalls half applied.
- Migrations are artifacts too. Store each migration file in version control, checksum it, and record what ran in a migrations table. A rerun of the pipeline must be a no-op, not a second execution.
- One pipeline, one migration set. Apply the same set to staging and production, the way GitLab's CI guidance recommends keeping the test environment close to production. Drift between environments is how a migration passes staging and drops a column in production.
A stage ordering that works, expressed as a sketch rather than a vendor config:
stages: [build, migrate, deploy, verify]
migrate:
needs: [build] # artifact exists and is tagged
environment: production # credentials scoped here, not to build
run: migrate up --to $ARTIFACT_VERSION # idempotent, exits 0 if already applied
deploy:
needs: [migrate]
strategy: rolling # old and new instances overlap by design
Why this order and not "migrate inside deploy"? Because the deploy step is the one that scales to many instances and gets retried. A migration embedded in an application's startup script runs once per instance, which is a race you did not ask for. Keeping it in a single pipeline job means one execution, one log, one place to look when it fails.
There is a second reason, grounded in how deployment failures get handled. Pipeline tools model rollback as a failure strategy on stages and steps, as Harness documents for its pipelines. A dedicated migration stage can carry its own failure policy (alert, stop, roll forward) separately from the deploy stage's policy (rollback traffic to the previous version). If migration and deploy are one stage, you cannot tell the pipeline "on migration failure, halt before any traffic moves." You get an all-or-nothing action that is wrong for at least one of the two operations.
The decision rule you actually need
Before writing any migration, ask one question: can the previous version of the code run correctly against the new schema?
- If yes, the migration is backward compatible. It can ship in the same release as the code that depends on it.
- If no, split it. The schema change ships in one release, the code that needs it in a later one, and the removal of the old shape later still.
That single test, applied to every migration in every pull request, prevents nearly all the incidents this article is about. Everything below is machinery for the "no" case.



