`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>
129 lines
5.3 KiB
YAML
129 lines
5.3 KiB
YAML
# Application stack for the Jorge Cuadros platform: the NestJS API + the Next.js
|
|
# web front-end. The two images are built + pushed by .gitea/workflows/build.yml:
|
|
# git.mancinas.io/rmancinas/jorgecuadros-api
|
|
# git.mancinas.io/rmancinas/jorgecuadros-web
|
|
#
|
|
# This stack does NOT ship MySQL or MinIO — those are their own stacks
|
|
# (deploy/jorgecuadros-db.stack.yml, deploy/jorgecuadros-minio.stack.yml). The
|
|
# API reaches them over the network via DATABASE_URL / S3_ENDPOINT, which point
|
|
# at the db + minio stacks' published ingress ports on the swarm host.
|
|
#
|
|
# Target: Portainer local endpoint on cubex (3-node Swarm). PROD only.
|
|
# Deploy with a stack env that supplies every ${VAR:?...} below — see
|
|
# deploy/jorgecuadros-app.env.example for the full list.
|
|
#
|
|
# Statefulness: the API keeps uploaded Access files (ingest) and DB backups on
|
|
# named volumes, which are node-local. So the API is pinned to the same node as
|
|
# the db/minio stacks (node label jorgecuadros_db == true) — a reschedule would
|
|
# otherwise start against empty ingest/backup volumes. The web tier is
|
|
# stateless and floats freely.
|
|
#
|
|
# The web image is NOT URL-baked: the browser's API origin is injected at
|
|
# runtime from API_ORIGIN (see apps/web/src/app/layout.tsx), so this same image
|
|
# works for any deployment — set the URL here, not at build time.
|
|
|
|
version: "3.8"
|
|
|
|
services:
|
|
api:
|
|
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
|
# Container label (not `deploy.labels`, which labels the swarm SERVICE).
|
|
# deploy/scripts/pre-migrate-backup.mjs finds the container by this label to
|
|
# run its pre-migrate mysqldump into the backup volume.
|
|
labels:
|
|
io.jorgecuadros.role: "api"
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
|
# CORS: the public origin the browser loads the web app from.
|
|
WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set}
|
|
PORT: "3001"
|
|
INGEST_DIR: /data/ingest
|
|
BACKUP_DIR: /data/backups
|
|
MIGRATION_ENV: prod
|
|
# The API applies pending Prisma migrations at container start, before
|
|
# Nest listens, and refuses to start if they fail (docker/api-entrypoint.sh).
|
|
# Set false ONLY when the schema is being moved by hand — the app will
|
|
# then boot against whatever schema it finds.
|
|
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
|
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
|
# application user: --single-transaction needs the global RELOAD privilege
|
|
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
|
# and re-import fails without this. Host/port/database still come from
|
|
# DATABASE_URL — this only changes who logs in. See opsConn() in
|
|
# apps/api/src/ops/ops.service.ts.
|
|
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
|
|
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
|
|
# Object storage — internal endpoint the API (server-side) uses to reach
|
|
# the minio stack. Not browser-facing (downloads proxy through the API).
|
|
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
|
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
|
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
|
# Outbound mail (SES) — runtime config, not a build-time CI secret.
|
|
SES_REGION: ${SES_REGION:-}
|
|
SES_FROM: ${SES_FROM:-}
|
|
SES_FROM_NAME: ${SES_FROM_NAME:-}
|
|
SES_ACCESS_KEY: ${SES_ACCESS_KEY:-}
|
|
SES_SECRET_KEY: ${SES_SECRET_KEY:-}
|
|
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET:-}
|
|
NOTIFICATION_ADMIN_EMAILS: ${NOTIFICATION_ADMIN_EMAILS:-}
|
|
ports:
|
|
- target: 3001
|
|
published: ${API_PORT:-3001}
|
|
protocol: tcp
|
|
mode: ingress
|
|
volumes:
|
|
- ingest_data:/data/ingest
|
|
- backup_data:/data/backups
|
|
deploy:
|
|
replicas: 1
|
|
placement:
|
|
constraints:
|
|
- node.labels.jorgecuadros_db == true
|
|
restart_policy:
|
|
condition: any
|
|
update_config:
|
|
order: stop-first
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
|
|
web:
|
|
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
|
labels:
|
|
io.jorgecuadros.role: "web"
|
|
environment:
|
|
# OPTIONAL override of the API URL the browser calls (injected at runtime,
|
|
# see layout.tsx). Leave it unset: the browser then derives the origin
|
|
# from the page it loaded — same host on port 3001 over plain HTTP, or
|
|
# /api behind a TLS-terminating proxy. Set it only when the API really
|
|
# lives on a different host than the web app.
|
|
API_ORIGIN: ${API_ORIGIN:-}
|
|
ports:
|
|
- target: 3000
|
|
published: ${WEB_PORT:-3000}
|
|
protocol: tcp
|
|
mode: ingress
|
|
depends_on:
|
|
- api
|
|
deploy:
|
|
replicas: 1
|
|
restart_policy:
|
|
condition: any
|
|
update_config:
|
|
order: start-first
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ >/dev/null 2>&1 || exit 1"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
|
|
volumes:
|
|
ingest_data:
|
|
backup_data:
|