The pipeline triggers the deploy, not the commit
Most hosting platforms offer to watch your repository and build on every push. It is one checkbox, it removes a step, and for a static site it is the right answer. For an application with a database behind it, that checkbox is a race condition with a friendly name.
The problem is ordering. A release that changes the schema is two actions, not one: run the migration, then serve the code that depends on it. Push-triggered builds start the second action the moment the commit lands, which means the build is already racing whatever runs the first. Lose that race and you are serving code against a schema that does not exist yet.
sequenceDiagram
accTitle: A push-triggered build racing the migration
participant Repo as Repository
participant Host
participant DB as Database
Repo->>Host: push triggers a build
Repo->>DB: the migration starts
Host->>Host: serves the new code
Note over Repo,DB: serving code against a schema that does not exist yet
DB-->>Repo: migration applied, too lateThe deploy is the last step of a release, not a reaction to a commit.
So on the ERP we built for a custom car shop, the host's own Git auto-deploy is switched off for both branches, and the pipeline owns the order instead. One workflow per branch, and both run the same ordered steps:
- Checks. Lint at zero warnings, types, unit tests.
- Migration. Ordered, recorded, applied to the target.
- Type regeneration from the live schema.
- SQL tests, run against a real database rather than a mock.
- Build.
- Deploy, triggered by the pipeline as its final act.
Nothing is served until the schema it depends on is in place, because the only thing that can trigger a deploy is a workflow that has already applied the migration. That is the whole decision, and it cost one checkbox.
Two environments, copies in one direction
There are two: production, and a disposable clone of it for development, schema and data. Copies flow one way only, so development data never reaches the live database. The clone stays out of search results, which matters more than it sounds: a preview on a real domain that search engines can reach will compete with the live site for its own terms.
Schema changes go to development first, always. Migrations are ordered and recorded, and the live schema is never edited by hand. The moment someone fixes something directly in a console, the recorded history stops describing the database, and every later migration is applying to a shape nobody has written down.
The gate that earns its keep
Types are generated from the schema and ship in the same commit as the migration that changed it. CI fails the build when the two disagree.
# Fail the build if the committed types are stale.
npm run db:types
git diff --exit-code -- src/db/types.tsThis is the cheapest useful gate in the pipeline. It does not test behaviour and it does not need a fixture. It asserts one thing: that the shape the code believes in is the shape the database has. A migration that lands without its regenerated types is caught before it reaches a branch anyone else is working on, which is the difference between a red build and an afternoon spent reading runtime errors that all have the same cause.
Promotion is fast-forward only. A merge that cannot fast-forward means the branches have diverged, so it gets rebased rather than merged — and production therefore never receives a commit that development has not already exercised.
Configuration counts as schema
Auth settings, the redirect allow-list and the email templates are pushed from the repository by both workflows. A push makes the remote match the file, which means a change made by hand in a dashboard is reverted on the next release.
That sounds hostile until the first time someone fixes a redirect in a console at 19:00 and the fix silently disappears a week later. A configuration diff deserves the same attention as a migration, for exactly the same reason: it changes behaviour and nothing in the repository records that it happened.
Destructive scripts refuse
Seed and teardown scripts abort when the target is production, and there is a test asserting that they do. The test is the part worth copying. A guard nobody exercises is a guard that works until someone refactors the environment check.
What this post leaves out. Vendors are deliberately generic here, the same convention the case studies use — the decisions above hold whichever CI, host and Postgres provider you run. Project identifiers, hostnames, credential names and connection strings are not published anywhere on these sites.