# 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: