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>
109 lines
4.8 KiB
YAML
109 lines
4.8 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:
|
|
- ${TAILSCALE_DNS:-100.100.100.100}
|
|
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
|
|
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:
|
|
- ${TAILSCALE_DNS:-100.100.100.100}
|
|
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:
|