feat(deploy): apply migrations at api container start
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m3s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m32s

`prisma migrate deploy` ran in one place only: a workflow step on the Gitea
runner, which has to reach the target host's MySQL on 3306 directly. Two
paths went around it:

  - `skip_migrate=true`, the documented answer for when the runner cannot
    reach 3306, left the schema a release behind with nothing to catch it.
    The mismatch surfaced later as a column-not-found at runtime rather than
    as a failed deploy.
  - A container brought back by `restart: unless-stopped` after a host
    reboot, or a stack re-applied by hand in Portainer, never runs the
    workflow at all.

docker/api-entrypoint.sh becomes the api image's ENTRYPOINT: migrate, then
exec node. If the migration fails the container exits non-zero and the API
never listens — serving against a schema that does not match the code is
worse than being down, because the failures are partial and silent (a write
to a missing column breaks one feature while the rest looks healthy).

This does not replace the workflow step and is not a substitute for it. That
step still runs FIRST, while the old code is serving, which is the order
expand/contract migrations are designed around. `migrate deploy` is
idempotent, so on the normal path the container's run is a no-op query.

Behaviour:

  RUN_MIGRATIONS=false     skip and start anyway; plumbed through both app
                           stack files, for a schema moved by hand
  DATABASE_URL unset       refuse to start, and say why
  P1001 (unreachable)      retry, default 20 x 3s -- a cold db container, and
                           galactus's MagicDNS lookup right after a reboot
  anything else            exit at once; retrying a broken migration only
                           delays the same error. P3005 prints the
                           `migrate resolve --applied 0000_init` hint the
                           workflow step already printed.

Only P1001 retries, so a genuinely broken migration is not buried under a
minute of noise.

Both stacks are replicas: 1 and must stay so for an unrelated reason (the
servicios email sweep has no DB lock). The old comment claiming migrations
must not run per-container because "N replicas would race" is dropped: they
would not corrupt anything, since Prisma takes a database advisory lock and
the losers find nothing pending -- they would only each pay the wait.

The prisma CLI is already in the runtime layer (the image copies
/repo/node_modules wholesale), but which of the two plausible .bin paths
carries it is an implementation detail of pnpm's hoisted linker, so the
entrypoint accepts either and the Dockerfile asserts one exists at BUILD
time. A missing CLI breaks the image build, not a production boot.

Verified by running the entrypoint against stubbed prisma binaries: clean
run, P3005, P1001-to-exhaustion, P1001-then-recovery, RUN_MIGRATIONS=false,
missing DATABASE_URL, missing CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 01:17:01 -07:00
co-authored by Claude Opus 5
parent d645ba51d3
commit 5a277f4885
7 changed files with 212 additions and 15 deletions
+62 -6
View File
@@ -56,9 +56,11 @@ workflow's last step fails if the API does not report the tag you dispatched.
`pre-migrate-<tag>-<timestamp>.sql.gz`, which is exactly what the
**Operaciones** admin screen lists and can restore. A dump taken on the CI
runner would be unreachable by the only restore path the platform has.
3. **`prisma migrate deploy`** — as a workflow *step*, never the container
`CMD`. If it were the CMD, N replicas would race each other applying the
same migration.
3. **`prisma migrate deploy`** — as a workflow *step*, so the schema moves
while the OLD code is still serving, which is the order expand/contract is
designed around. **The api container repeats this at start** (below); the
command is idempotent, so on the normal path the container's run is a
no-op.
4. **app** — the new api + web images.
5. **Verify**`GET /version` on the running API must report the dispatched
tag.
@@ -141,6 +143,59 @@ Commit the generated `migrations/<timestamp>_add_foo/` directory. `db push` is
now a local-scratch tool only — using it against a database with history
desynchronises it from `_prisma_migrations`.
If you hand-write a migration instead of generating one, check it against what
Prisma would have produced before committing — a hand-written file that drifts
from `schema.prisma` fails on the *next* deploy, not this one:
```bash
prisma migrate diff \
--from-schema-datamodel <schema.prisma at the previous commit> \
--to-schema-datamodel packages/database/prisma/schema.prisma --script
```
## Migrations also run at container start
`docker/api-entrypoint.sh` is the api image's `ENTRYPOINT`. It runs
`prisma migrate deploy` and only then `exec`s the API. **If the migration
fails the container exits non-zero and the API never listens.**
That is the point. Serving against a schema that does not match the code is
worse than being down, because the failures are partial and silent — a write
to a column that does not exist yet breaks one feature while the rest of the
app looks healthy.
This does not replace the workflow step, which still runs first and against
the old code. It covers what that step cannot:
- **`skip_migrate: true`.** Previously that left the schema behind with no
further safety net, and the mismatch surfaced later as a runtime error. Now
it just moves the migration into the container, so it is a safe choice when
the runner cannot reach MySQL.
- **Restarts that never touch the workflow** — `restart: unless-stopped`
bringing the stack back after a host reboot, or a stack re-applied by hand
in Portainer.
Behaviour worth knowing:
| | |
|---|---|
| `RUN_MIGRATIONS=false` | Skip and start anyway. Plumbed through both app stack files. For when the schema is being moved by hand. |
| `DATABASE_URL` unset | Refuses to start (it would have failed at Nest boot anyway, but this says why). |
| Cannot reach the database (**P1001**) | Retries, default 20 × 3s. Covers a cold `db` container and galactus's MagicDNS lookup right after a reboot. `MIGRATE_MAX_ATTEMPTS` / `MIGRATE_RETRY_SECONDS` tune it. |
| Any other failure | Exits at once. Retrying a broken migration only delays the same error; **P3005** additionally prints the `migrate resolve --applied 0000_init` hint. |
**On replicas.** Both stacks are `replicas: 1` and must stay that way for an
unrelated reason (the servicios email sweep has no DB lock — see the caveats
below). If that ever changes, concurrent `migrate deploy` runs are safe on
their own: Prisma takes a database advisory lock, so the others block and then
find nothing pending. They would each pay the wait at startup, not corrupt
anything.
The prisma CLI has to be present in the runtime layer for any of this. The
image copies `/repo/node_modules` wholesale so it already is, and the
Dockerfile **asserts it at build time** — a missing CLI breaks the image
build rather than a production boot.
## galactus vs cubex
`galactus` is standalone Docker (Portainer endpoint **3**), `cubex` is a 3-node
@@ -319,9 +374,10 @@ backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
Portainer serves a self-signed certificate. It is scoped to that one step,
which talks to nothing but Portainer. Replacing the certificate and dropping
the flag is the real fix.
- The runner lives on cubex and must reach the target host's Portainer (9443)
**and** MySQL (3306). If it cannot reach 3306, run the migration by hand from
a host that can and dispatch with `skip_migrate: true`.
- The runner lives on cubex and must reach the target host's Portainer (9443).
It should also reach MySQL (3306) for the migrate step, but that is no longer
load-bearing — dispatch with `skip_migrate: true` and the api container
applies the migrations itself at start.
- `bootstrap: true` lets the pre-migrate backup be skipped when no API container
exists yet. Use it for a first-ever deploy only — it is the one switch that
lets a migration run with no restore point.