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