Files
jorgecuadros-platform/deploy/galactus/jorgecuadros-app.compose.yml
T
rmancinasandClaude Opus 5 30dfc7dc3e fix(ops): run backups as an admin login, and stop recording failed dumps as good
The Operaciones panel (backup, restore, sync, re-import) shelled out to
mysqldump as the application user, parsed straight out of DATABASE_URL.
`--single-transaction` issues FLUSH TABLES, which needs the global RELOAD
privilege, and the app user is granted only ALL ON jorgecuadros.* plus
USAGE ON *.*. BACKUP failed outright; SYNC and REIMPORT failed with it,
since both take a safety backup first.

An admin credential is now supplied out of band via OPS_DB_ADMIN_USER /
OPS_DB_ADMIN_PASSWORD, mirroring what deploy/scripts/pre-migrate-backup.mjs
already does, rather than permanently elevating the user the API serves
requests as. Host, port and database still come from DATABASE_URL, so the
override can only change who logs in, never which server. Unset, it falls
back to the DATABASE_URL credentials and warns — local development is
unaffected.

Two defects in the dumps themselves, both shared with the deploy backup
before it was rewritten:

- No --set-gtid-purged=OFF. The production server is the replication source
  with GTID on, so every dump embedded SET @@GLOBAL.GTID_PURGED and was
  unrestorable onto the server it came from — the one thing the restore
  screen is for.

- The pipeline's exit status was gzip's, and gzip succeeded. A mysqldump
  that died on its first statement left a small, perfectly valid archive
  that the job recorded as SUCCESS and the restore screen listed as an
  ordinary restore point. Dumps now run under `set -o pipefail`, assert a
  CREATE TABLE count, and delete their own output on failure. Verified with
  a stubbed mysqldump: a failing dump exits 1, surfaces the real error,
  removes the partial file, and — critically — stops SYNC/REIMPORT before
  the ETL touches anything.

Restores gained pipefail too: a corrupt archive made gunzip fail while
mysql, fed a truncated stream, could still exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:21:00 -07:00

127 lines
6.0 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
# 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}
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}
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:
# Public API URL the browser calls (injected at runtime, see layout.tsx).
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
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: