Files
jorgecuadros-platform/deploy/galactus/jorgecuadros-app.compose.yml
T
rmancinasandClaude Opus 5 5a277f4885
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m3s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m32s
feat(deploy): apply migrations at api container start
`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>
2026-08-15 01:17:01 -07:00

163 lines
8.2 KiB
YAML

# NestJS API + Next.js web on galactus (standalone Docker, Portainer endpoint 3).
#
# Standalone port of deploy/jorgecuadros-app.stack.yml — see the header of
# deploy/galactus/jorgecuadros-db.compose.yml for the Swarm keys plain compose
# silently ignores. The one that matters most here: without
# `restart: unless-stopped` neither service returns after a host reboot.
#
# Cross-stack traffic still goes over the HOST, not service DNS. db and minio
# are separate Portainer stacks, so they are on separate compose networks and
# their service names do not resolve from here. DATABASE_URL / S3_ENDPOINT must
# name galactus's own address and the published port — exactly as on cubex
# today. Do not "simplify" them to `mysql:3306`.
#
# ...which means these containers have to resolve galactus's MagicDNS name, and
# by default they CANNOT. The host runs systemd-resolved, whose 127.0.0.53 stub
# is unreachable from a container, so Docker falls back to the upstream resolver
# in /run/systemd/resolve/resolv.conf — the LAN router, which knows nothing
# about the tailnet. Routing to 100.x works fine; only the lookup fails, and the
# API dies with Prisma P1001 "can't reach database server". Pointing the
# containers at Tailscale's own resolver fixes it. 100.100.100.100 is Tailscale's
# fixed anycast MagicDNS address (identical on every tailnet); the search domain
# is this tailnet's suffix.
#
# The web image is NOT URL-baked: the browser's API origin is injected at
# runtime from API_ORIGIN (apps/web/src/app/layout.tsx), so the same image works
# for any deployment. APP_VERSION / GIT_SHA / BUILD_DATE come baked in from
# build.yml and are surfaced at GET /version (api) and in the web footer.
#
# Keep in sync with deploy/jorgecuadros-app.stack.yml when either changes.
services:
api:
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
restart: unless-stopped
# Stable handle for deploy/scripts/pre-migrate-backup.sh, which finds this
# container by label to run mysqldump into the backup volume. A label
# survives stack renames; the compose service name does not.
labels:
io.jorgecuadros.role: "api"
dns:
# MagicDNS first, then a public resolver. Listing ONLY 100.100.100.100
# costs the container public name resolution — apk/npm/any outbound
# hostname stops resolving — because MagicDNS does not forward to an
# upstream unless the tailnet is configured with global nameservers.
- ${TAILSCALE_DNS:-100.100.100.100}
- ${FALLBACK_DNS:-1.1.1.1}
dns_search:
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
# This deployment is HTTP, so a Secure session cookie would never be sent
# and login would silently never establish a session (express-session
# declines to emit a Secure cookie over a plain connection). Acceptable
# here ONLY because galactus is reachable exclusively over Tailscale, so
# WireGuard already encrypts the wire. Set this back to "true" the moment
# the app is served over TLS or exposed off-tailnet.
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
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}
# Read-only replica that my.jorgecuadros.com serves customers from. Used
# ONLY to report health on the Operaciones screen — the account holds
# REPLICATION CLIENT and nothing else, so it cannot read a single row.
# Unset is a supported state: the panel then says "no configurada"
# instead of erroring, which is correct before cutover and in dev.
REPLICA_DB_HOST: ${REPLICA_DB_HOST:-}
REPLICA_DB_USER: ${REPLICA_DB_USER:-}
REPLICA_DB_PASS: ${REPLICA_DB_PASS:-}
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 — read at container boot, never
# baked into the image; the build does not send mail, this container
# does. Values arrive the same way DATABASE_URL does: as Gitea repo
# secrets, injected into this stack's env by the `env_data` block of
# .gitea/workflows/deploy-galactus.yml.
#
# The image sets NODE_ENV=production, which disables MailService's
# stdout dev fallback: leave these blank and every notification and
# renewal aviso fails with "El envío de correo no está configurado."
# rather than silently going nowhere.
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:-}
# Who gets the per-job summary mail. Falls back to the two hardcoded
# defaults in NotificationsService when unset.
NOTIFICATION_ADMIN_EMAILS: ${NOTIFICATION_ADMIN_EMAILS:-}
ports:
- "${API_PORT:-3001}:3001"
volumes:
# Uploaded Access files and DB backups. Named, so they survive every
# redeploy — and so the pre-migrate dump the deploy takes is the same
# file the "Operaciones" restore screen lists.
- ingest_data:/data/ingest
- backup_data:/data/backups
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}
restart: unless-stopped
labels:
io.jorgecuadros.role: "web"
# Next server-side rendering can call the API by API_ORIGIN, which is the
# same MagicDNS name — so the web container needs the resolver too.
dns:
# MagicDNS first, then a public resolver. Listing ONLY 100.100.100.100
# costs the container public name resolution — apk/npm/any outbound
# hostname stops resolving — because MagicDNS does not forward to an
# upstream unless the tailnet is configured with global nameservers.
- ${TAILSCALE_DNS:-100.100.100.100}
- ${FALLBACK_DNS:-1.1.1.1}
dns_search:
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
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:
- "${WEB_PORT:-3000}:3000"
depends_on:
# Unlike Swarm — which ignores depends_on entirely — plain compose honours
# this, so web waits for the API to pass its healthcheck.
api:
condition: service_healthy
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: