Prod came up with nobody able to log in, in two separate ways.
1. No sign-in account exists. `prisma migrate deploy` creates tables, never
rows, and nothing in the deploy path seeds one — deliberately, since making
an administrator should not be a side effect of shipping code. But
apps/api/scripts was not in the runtime image either, so the only way to
create the first account was to run the script from a developer machine
against a production DATABASE_URL. Ship scripts/ in the image so it can be
run on the host with docker exec. Still never run automatically.
2. Login could not establish a session at all. cookie.secure followed NODE_ENV,
the image sets NODE_ENV=production, and the app is served over plain HTTP —
express-session then silently emits NO Set-Cookie header. POST /auth/login
still answered 200 with the full user object, no session was created, every
later request 403'd, and the UI would have looped back to /login. It reads
as an auth bug and is really a transport mismatch.
The flag is now driven by SESSION_COOKIE_SECURE, still defaulting to
NODE_ENV. An EMPTY value counts as unset rather than false, because compose
turns an absent `${SESSION_COOKIE_SECURE:-}` into the empty string and the
naive check would have quietly dropped Secure on any deployment that merely
passed the variable through.
galactus sets it to "false". That is acceptable ONLY because the host is
reachable exclusively over Tailscale, so WireGuard already encrypts the
wire. It must go back to "true" when the app is served over TLS or exposed
off-tailnet; behind a TLS-terminating proxy, set trust proxy instead.
Verified against live prod: seeded an admin, POST /auth/login returns 200 with
full ADMIN abilities, a wrong password is rejected with 401, and no Set-Cookie
was present before this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
15 KiB
YAML
314 lines
15 KiB
YAML
# Manual PROD deploy to galactus — the office server, Portainer endpoint 3.
|
|
#
|
|
# galactus is STANDALONE Docker (`swarm: inactive`), so this workflow applies
|
|
# the compose files under deploy/galactus/, NOT the Swarm files in deploy/.
|
|
# .gitea/workflows/deploy.yml is the cubex/Swarm equivalent; the two are kept
|
|
# separate on purpose because plain compose silently ignores Swarm's `deploy:`
|
|
# keys rather than failing on them.
|
|
#
|
|
# This does NOT build. build.yml already built + pushed both images from one
|
|
# matrix run, so api and web at the same tag are always in step.
|
|
#
|
|
# Order of operations, and why:
|
|
# 1. db + minio (scope=full only) — the API depends on both.
|
|
# 2. pre-migrate backup dumped INSIDE the still-running OLD api container,
|
|
# so the file lands in the volume the Operaciones
|
|
# restore screen reads. Must precede the migration.
|
|
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
|
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
|
# the rule, the backup is the emergency lever.
|
|
# 4. app (api + web) the new images.
|
|
# 5. verify ask the running API what it actually is.
|
|
#
|
|
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
|
# schema stays forward. This is exactly why every schema change must be
|
|
# backward-compatible with the previous release.
|
|
#
|
|
# Prereqs (once):
|
|
# - Gitea repo secrets, galactus-specific (suffix _GALACTUS so the cubex
|
|
# secrets keep working side by side):
|
|
# PORTAINER_URL_GALACTUS https://100.103.77.46:9443
|
|
# PORTAINER_API_KEY_GALACTUS Portainer access token for galactus
|
|
# PORTAINER_ENDPOINT_ID_GALACTUS 3
|
|
# PORTAINER_APP_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-app
|
|
# PORTAINER_DB_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-db
|
|
# PORTAINER_MINIO_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-minio
|
|
# DATABASE_URL_GALACTUS mysql://jorgecuadros:<pass>@<galactus>:3306/jorgecuadros
|
|
# APP_API_ORIGIN_GALACTUS browser-facing API URL
|
|
# APP_WEB_ORIGIN_GALACTUS web public origin (API CORS)
|
|
# APP_S3_ENDPOINT_GALACTUS server-side minio URL
|
|
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
|
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
|
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
|
# - The runner (which lives on cubex) must be able to reach BOTH
|
|
# 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
|
|
# and dispatch with skip_migrate=true.
|
|
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
|
# with `prisma db push`): baseline it before the first run, or step 3 fails
|
|
# with P3005 "database schema is not empty":
|
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
|
# --schema packages/database/prisma/schema.prisma
|
|
|
|
name: Deploy to galactus
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Image tag to deploy (1.2.3 — no leading v — or sha-<short>, or latest)"
|
|
required: true
|
|
default: "latest"
|
|
scope:
|
|
description: "What to deploy"
|
|
type: choice
|
|
required: true
|
|
default: "app"
|
|
options:
|
|
- app
|
|
- full
|
|
bootstrap:
|
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
|
type: boolean
|
|
required: false
|
|
default: false
|
|
skip_migrate:
|
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
|
type: boolean
|
|
required: false
|
|
default: false
|
|
|
|
env:
|
|
REGISTRY: git.mancinas.io
|
|
|
|
jobs:
|
|
deploy:
|
|
name: Deploy ${{ github.event.inputs.tag }} (${{ github.event.inputs.scope }})
|
|
runs-on: docker
|
|
container:
|
|
image: node:20-alpine
|
|
steps:
|
|
- name: Install tools
|
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
|
# runtime and cannot resolve one without it.
|
|
run: apk add --no-cache openssl ca-certificates git
|
|
|
|
- uses: actions/checkout@v4
|
|
|
|
# An unset secret arrives as an empty string, and the deploy action then
|
|
# fails with "Input required and not supplied: token" — which names the
|
|
# action's input, not the secret you forgot. Check them up front and say
|
|
# exactly which ones are missing.
|
|
- name: Preflight — required secrets
|
|
env:
|
|
PORTAINER_URL_GALACTUS: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
PORTAINER_API_KEY_GALACTUS: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
PORTAINER_ENDPOINT_ID_GALACTUS: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
PORTAINER_APP_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
|
PORTAINER_DB_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
|
PORTAINER_MINIO_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
|
DATABASE_URL_GALACTUS: ${{ secrets.DATABASE_URL_GALACTUS }}
|
|
SESSION_SECRET_GALACTUS: ${{ secrets.SESSION_SECRET_GALACTUS }}
|
|
APP_API_ORIGIN_GALACTUS: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
|
APP_WEB_ORIGIN_GALACTUS: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
|
APP_S3_ENDPOINT_GALACTUS: ${{ secrets.APP_S3_ENDPOINT_GALACTUS }}
|
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
|
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
|
SCOPE: ${{ github.event.inputs.scope }}
|
|
run: |
|
|
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
|
|
PORTAINER_ENDPOINT_ID_GALACTUS PORTAINER_APP_STACK_NAME_GALACTUS
|
|
DATABASE_URL_GALACTUS SESSION_SECRET_GALACTUS
|
|
APP_API_ORIGIN_GALACTUS APP_WEB_ORIGIN_GALACTUS
|
|
APP_S3_ENDPOINT_GALACTUS MINIO_ROOT_USER MINIO_ROOT_PASSWORD"
|
|
if [ "$SCOPE" = "full" ]; then
|
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME_GALACTUS
|
|
PORTAINER_MINIO_STACK_NAME_GALACTUS
|
|
MYSQL_PASSWORD MYSQL_ROOT_PASSWORD"
|
|
fi
|
|
missing=""
|
|
for name in $REQUIRED; do
|
|
eval "value=\${$name}"
|
|
[ -z "$value" ] && missing="$missing $name"
|
|
done
|
|
if [ -n "$missing" ]; then
|
|
echo "::error::missing repo secrets:$missing"
|
|
echo "::error::set them under Settings > Actions > Secrets"
|
|
exit 1
|
|
fi
|
|
echo "all required secrets present for scope=$SCOPE"
|
|
|
|
# --- full only: database ---------------------------------------------
|
|
- name: Deploy database stack
|
|
if: ${{ github.event.inputs.scope == 'full' }}
|
|
uses: cssnr/portainer-stack-deploy-action@v1
|
|
with:
|
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
|
file: deploy/galactus/jorgecuadros-db.compose.yml
|
|
type: file
|
|
standalone: true
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
env_data: |
|
|
{
|
|
"MYSQL_SERVER_ID": "1",
|
|
"MYSQL_PORT": "3306",
|
|
"MYSQL_DATABASE": "jorgecuadros",
|
|
"MYSQL_USER": "jorgecuadros",
|
|
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
|
|
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
|
|
}
|
|
|
|
# --- full only: object storage ---------------------------------------
|
|
- name: Deploy minio stack
|
|
if: ${{ github.event.inputs.scope == 'full' }}
|
|
uses: cssnr/portainer-stack-deploy-action@v1
|
|
with:
|
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
|
file: deploy/galactus/jorgecuadros-minio.compose.yml
|
|
type: file
|
|
standalone: true
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
env_data: |
|
|
{
|
|
"MINIO_API_PORT": "9000",
|
|
"MINIO_CONSOLE_PORT": "9001",
|
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
|
}
|
|
|
|
# --- restore point, taken while the OLD api container is still up ------
|
|
- name: Pre-migrate backup
|
|
env:
|
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
|
# Portainer serves a self-signed certificate. Scoped to this step
|
|
# only, which does nothing but talk to Portainer.
|
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
|
|
|
# --- schema, forward-only ---------------------------------------------
|
|
- name: Apply database migrations
|
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
|
env:
|
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
|
run: |
|
|
set -e
|
|
SCHEMA=packages/database/prisma/schema.prisma
|
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
|
echo "::error::the database predates migration history — baseline it once with:"
|
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
|
exit 1
|
|
fi
|
|
|
|
# --- make sure the host actually has the images ------------------------
|
|
# The deploy action's `pull: true` does not reliably refresh an already
|
|
# cached moving tag. Pull explicitly, or a "successful" deploy can leave
|
|
# the host serving an older build of the same tag.
|
|
- name: Pull images
|
|
env:
|
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
REGISTRY: ${{ env.REGISTRY }}
|
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
|
|
TAG: ${{ github.event.inputs.tag }}
|
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
|
run: node deploy/scripts/pull-images.mjs
|
|
|
|
# --- always: the app (web + api) -------------------------------------
|
|
- name: Deploy app stack
|
|
uses: cssnr/portainer-stack-deploy-action@v1
|
|
with:
|
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
|
file: deploy/galactus/jorgecuadros-app.compose.yml
|
|
type: file
|
|
standalone: true
|
|
pull: true
|
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
|
env_data: |
|
|
{
|
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
|
"API_PORT": "3001",
|
|
"WEB_PORT": "3000",
|
|
"S3_BUCKET": "jorgecuadros-documents",
|
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
|
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}",
|
|
"SESSION_COOKIE_SECURE": "false",
|
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
|
}
|
|
|
|
# --- prove it ----------------------------------------------------------
|
|
- name: Verify running version
|
|
env:
|
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
|
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
|
WANT: ${{ github.event.inputs.tag }}
|
|
# A stack naming a tag is not proof the containers run it. Ask BOTH
|
|
# tiers what they are, and require them to be the same commit: api and
|
|
# web are built from one matrix run, so a difference can only mean one
|
|
# of them did not actually get replaced.
|
|
run: |
|
|
set -e
|
|
apk add --no-cache curl >/dev/null
|
|
fetch_version() {
|
|
for i in $(seq 1 30); do
|
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
|
echo "waiting for $1 ($i/30)..."
|
|
sleep 5
|
|
done
|
|
echo "::error::$1/version never answered"
|
|
return 1
|
|
}
|
|
fetch_version "$API_ORIGIN" /tmp/api.json
|
|
fetch_version "$WEB_ORIGIN" /tmp/web.json
|
|
cat /tmp/api.json; echo; cat /tmp/web.json; echo
|
|
|
|
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
|
|
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
|
|
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
|
|
|
|
# Compare the COMMIT, not the version string: on a branch build both
|
|
# tiers report "master", so version equality proves nothing.
|
|
if [ "$API_SHA" != "$WEB_SHA" ]; then
|
|
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
|
|
echo "::error::one of the images was not replaced; check the Pull images step"
|
|
exit 1
|
|
fi
|
|
echo "api and web agree: $API_SHA"
|
|
|
|
# A semver dispatch is additionally comparable to the tag itself:
|
|
# metadata-action's {{version}} turns tag v1.2.3 into image 1.2.3,
|
|
# while `latest` and `sha-*` report the branch or short sha instead.
|
|
case "$WANT" in
|
|
[0-9]*.[0-9]*.[0-9]*)
|
|
if [ "$API_VER" != "$WANT" ]; then
|
|
echo "::error::deployed $WANT but the API reports $API_VER"
|
|
exit 1
|
|
fi
|
|
echo "verified: running $API_VER"
|
|
;;
|
|
*)
|
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
|
;;
|
|
esac
|