fix(deploy): pass SES config through to the app stack

The stack env is assembled from Gitea repo secrets by the deploy
workflows' `env_data` block — there is no .env file on the host for the
app stack. SES was in neither, so `MailService` came up unconfigured on
every deployment and, with NODE_ENV=production killing the stdout dev
fallback, every notification and renewal aviso failed.

Wire SES_REGION / SES_FROM / SES_FROM_NAME / SES_ACCESS_KEY /
SES_SECRET_KEY / SES_CONFIGURATION_SET / NOTIFICATION_ADMIN_EMAILS
through both galactus and cubex. No `_GALACTUS` suffix: one SES identity
serves every deployment.

Kept out of the required-secrets preflight — mail is not needed to boot,
and failing a deploy over it would be wrong. Preflight warns instead,
since the failure is otherwise invisible until someone clicks "Ejecutar".

Also corrects the comments added in the previous commit, which claimed
these belonged in a host env file rather than in CI secrets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 10:59:45 -07:00
co-authored by Claude Opus 5
parent 33833c3af9
commit f4b92fa7a5
5 changed files with 74 additions and 14 deletions
+41 -1
View File
@@ -40,6 +40,18 @@
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32) # SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD # MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD # MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
# Optional — outbound mail. Not needed to deploy; needed for
# /notificaciones to send anything at all (the image sets
# NODE_ENV=production, which disables MailService's stdout fallback, so
# a blank config fails every send loudly):
# SES_REGION e.g. us-west-2
# SES_FROM a VERIFIED SES sending identity
# SES_FROM_NAME display name, optional
# SES_ACCESS_KEY / SES_SECRET_KEY
# SES_CONFIGURATION_SET optional, for bounce/complaint events
# NOTIFICATION_ADMIN_EMAILS comma-separated summary recipients
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
# serves every deployment.
# - The runner (which lives on cubex) must be able to reach BOTH # - The runner (which lives on cubex) must be able to reach BOTH
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy). # galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
# If it cannot reach 3306, run the migration by hand from a host that can # If it cannot reach 3306, run the migration by hand from a host that can
@@ -116,6 +128,13 @@ jobs:
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
# Not required — the app boots fine without mail. Warned about below,
# because the failure mode is remote: everything looks healthy until
# someone clicks "Ejecutar" and every send fails.
SES_REGION: ${{ secrets.SES_REGION }}
SES_FROM: ${{ secrets.SES_FROM }}
SES_ACCESS_KEY: ${{ secrets.SES_ACCESS_KEY }}
SES_SECRET_KEY: ${{ secrets.SES_SECRET_KEY }}
SCOPE: ${{ github.event.inputs.scope }} SCOPE: ${{ github.event.inputs.scope }}
run: | run: |
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
@@ -140,6 +159,20 @@ jobs:
fi fi
echo "all required secrets present for scope=$SCOPE" echo "all required secrets present for scope=$SCOPE"
# Mail is optional to deploy but not optional to work. Say so loudly
# rather than letting /notificaciones fail one send at a time.
mail_missing=""
for name in SES_REGION SES_FROM SES_ACCESS_KEY SES_SECRET_KEY; do
eval "value=\${$name}"
[ -z "$value" ] && mail_missing="$mail_missing $name"
done
if [ -n "$mail_missing" ]; then
echo "::warning::outbound mail is NOT configured, missing:$mail_missing"
echo "::warning::the deploy will succeed, but every notification and"
echo "::warning::renewal aviso will fail with 'El envío de correo no"
echo "::warning::está configurado.' See docs/MASS_EMAIL_NOTIFICATIONS.md"
fi
# --- full only: database --------------------------------------------- # --- full only: database ---------------------------------------------
- name: Deploy database stack - name: Deploy database stack
if: ${{ github.event.inputs.scope == 'full' }} if: ${{ github.event.inputs.scope == 'full' }}
@@ -260,7 +293,14 @@ jobs:
"OPS_DB_ADMIN_USER": "root", "OPS_DB_ADMIN_USER": "root",
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}", "OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
"SES_REGION": "${{ secrets.SES_REGION }}",
"SES_FROM": "${{ secrets.SES_FROM }}",
"SES_FROM_NAME": "${{ secrets.SES_FROM_NAME }}",
"SES_ACCESS_KEY": "${{ secrets.SES_ACCESS_KEY }}",
"SES_SECRET_KEY": "${{ secrets.SES_SECRET_KEY }}",
"SES_CONFIGURATION_SET": "${{ secrets.SES_CONFIGURATION_SET }}",
"NOTIFICATION_ADMIN_EMAILS": "${{ secrets.NOTIFICATION_ADMIN_EMAILS }}"
} }
# --- prove it ---------------------------------------------------------- # --- prove it ----------------------------------------------------------
+8 -1
View File
@@ -267,7 +267,14 @@ jobs:
"OPS_DB_ADMIN_USER": "root", "OPS_DB_ADMIN_USER": "root",
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}", "OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
"SES_REGION": "${{ secrets.SES_REGION }}",
"SES_FROM": "${{ secrets.SES_FROM }}",
"SES_FROM_NAME": "${{ secrets.SES_FROM_NAME }}",
"SES_ACCESS_KEY": "${{ secrets.SES_ACCESS_KEY }}",
"SES_SECRET_KEY": "${{ secrets.SES_SECRET_KEY }}",
"SES_CONFIGURATION_SET": "${{ secrets.SES_CONFIGURATION_SET }}",
"NOTIFICATION_ADMIN_EMAILS": "${{ secrets.NOTIFICATION_ADMIN_EMAILS }}"
} }
# --- prove it ---------------------------------------------------------- # --- prove it ----------------------------------------------------------
+7 -4
View File
@@ -73,13 +73,16 @@ services:
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents} S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set} MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
# Outbound mail (SES). Runtime config, never baked into the image and # Outbound mail (SES). Runtime config — read at container boot, never
# never a CI secret — the build does not send mail, this container does. # 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 # The image sets NODE_ENV=production, which disables MailService's
# stdout dev fallback: leave these blank and every notification and # stdout dev fallback: leave these blank and every notification and
# renewal aviso fails with "El envío de correo no está configurado." # renewal aviso fails with "El envío de correo no está configurado."
# rather than silently going nowhere. Values live in this stack's env # rather than silently going nowhere.
# file on galactus (deploy/.env.prod), same as DATABASE_URL.
SES_REGION: ${SES_REGION:-} SES_REGION: ${SES_REGION:-}
SES_FROM: ${SES_FROM:-} SES_FROM: ${SES_FROM:-}
SES_FROM_NAME: ${SES_FROM_NAME:-} SES_FROM_NAME: ${SES_FROM_NAME:-}
+7 -3
View File
@@ -34,9 +34,13 @@ MINIO_ROOT_USER=jc_minio
MINIO_ROOT_PASSWORD=CHANGE_ME MINIO_ROOT_PASSWORD=CHANGE_ME
# --- Outbound mail (Amazon SES) ---------------------------------------------- # --- Outbound mail (Amazon SES) ----------------------------------------------
# Belongs HERE, in the stack's env file on the host — not in Gitea Actions # NOTE: for the Portainer-deployed stacks these do NOT come from a file on the
# secrets. The build never sends mail; the running container does, and it reads # host — the deploy workflows build the stack env from Gitea repo secrets (see
# these at boot (apps/api/src/mail/mail.service.ts). # the `env_data` blocks in .gitea/workflows/deploy*.yml). This file documents
# the full variable set and is what you fill in for a hand-run stack.
#
# Either way they are RUNTIME config, read at container boot
# (apps/api/src/mail/mail.service.ts) — never baked into the image.
# #
# The production image sets NODE_ENV=production, which turns OFF the stdout dev # The production image sets NODE_ENV=production, which turns OFF the stdout dev
# fallback. Leaving these blank does not silently swallow mail — every send # fallback. Leaving these blank does not silently swallow mail — every send
+11 -5
View File
@@ -138,11 +138,17 @@ Without SES_* the API still boots and `MailService` falls back to stdout
in dev (`NODE_ENV !== "production"`). In production every send throws in dev (`NODE_ENV !== "production"`). In production every send throws
`ServiceUnavailableException` and the row is recorded as `FAILED`. `ServiceUnavailableException` and the row is recorded as `FAILED`.
These are **runtime** config, set in the deployed stack's env file on the These are **runtime** config — read at container boot, never baked into the
host (`deploy/jorgecuadros-app.env.example` documents the full set) — not image. For the Portainer deployments they are set as **Gitea repo secrets**
Gitea Actions secrets. The build never sends mail; only the running and injected into the stack env by the `env_data` block of
container does, and the production image sets `NODE_ENV=production`, so a `.gitea/workflows/deploy-galactus.yml` (and `deploy.yml`), exactly like
blank SES config fails loudly rather than falling back to stdout. `DATABASE_URL` and `SESSION_SECRET`. Unlike most secrets there they carry no
`_GALACTUS` suffix: one SES identity serves every deployment.
They are optional to *deploy* — the preflight only warns — but the
production image sets `NODE_ENV=production`, which disables the stdout dev
fallback, so a blank SES config makes every send fail loudly rather than
quietly going nowhere.
## UI ## UI